From c8dcaddc4bebab22f88d964c765264728f135ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omer=20=C5=A0abi=C4=87?= Date: Fri, 8 Aug 2025 21:25:08 +0200 Subject: [PATCH 01/20] feat(product-types): add migration and prototype model --- ...8_08_082024_create_product_types_table.php | 45 +++++++++++++++++++ src/Models/Product/Type.php | 18 ++++++++ 2 files changed, 63 insertions(+) create mode 100644 database/migrations/2025_08_08_082024_create_product_types_table.php create mode 100644 src/Models/Product/Type.php diff --git a/database/migrations/2025_08_08_082024_create_product_types_table.php b/database/migrations/2025_08_08_082024_create_product_types_table.php new file mode 100644 index 0000000..8030926 --- /dev/null +++ b/database/migrations/2025_08_08_082024_create_product_types_table.php @@ -0,0 +1,45 @@ +id(); + $table->string('name'); + $table->string('code')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('pim_product_type_data', function (Blueprint $table) { + $table->foreignId('product_type_id') + ->constrained('pim_product_types') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + + // 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->boolean('is_active')->default(true); + $table->boolean('is_default')->default(false); + }); + } + + public function down(): void + { + Schema::dropIfExists('pim_product_type_data'); + Schema::dropIfExists('pim_product_types'); + } +}; diff --git a/src/Models/Product/Type.php b/src/Models/Product/Type.php new file mode 100644 index 0000000..88b18d4 --- /dev/null +++ b/src/Models/Product/Type.php @@ -0,0 +1,18 @@ + Date: Tue, 19 Aug 2025 14:15:52 +0200 Subject: [PATCH 02/20] fix: fix migration --- .../migrations/2025_08_08_082024_create_product_types_table.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/database/migrations/2025_08_08_082024_create_product_types_table.php b/database/migrations/2025_08_08_082024_create_product_types_table.php index ce08dde..1010455 100644 --- a/database/migrations/2025_08_08_082024_create_product_types_table.php +++ b/database/migrations/2025_08_08_082024_create_product_types_table.php @@ -4,6 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +return new class extends Migration +{ public function up(): void { Schema::create('pim_product_types', function (Blueprint $table) { From 549bbedac41e3dc8fe7fbb75cc5d10643c1d249a Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Thu, 21 Aug 2025 09:46:38 +0200 Subject: [PATCH 03/20] feat(properties): implement product properties --- ...8_08_082024_create_product_types_table.php | 1 + ...08_19_171247_create_pim_property_table.php | 37 +++ ...172834_create_pim_property_value_table.php | 33 +++ ...te_pim_product_type_has_property_table.php | 30 +++ ...logue_product_has_property_value_table.php | 29 ++ ..._175841_add_indexes_to_property_tables.php | 42 +++ database/seeders/PropertySeeder.php | 117 ++++++++ src/Factories/PropertyFactory.php | 54 ++++ src/Factories/PropertyValueFactory.php | 30 +++ src/Filament/Resources/ProductResource.php | 91 +++++++ .../ProductResource/Pages/CreateProduct.php | 36 +++ .../ProductResource/Pages/EditProduct.php | 77 ++++++ .../Resources/ProductTypeResource.php | 8 + .../PropertiesRelationManager.php | 112 ++++++++ src/Filament/Resources/PropertyResource.php | 209 +++++++++++++++ .../PropertyResource/Pages/CreateProperty.php | 22 ++ .../PropertyResource/Pages/EditProperty.php | 24 ++ .../PropertyResource/Pages/ListProperties.php | 19 ++ .../ValuesRelationManager.php | 110 ++++++++ .../Resources/PropertyValueResource.php | 152 +++++++++++ .../Pages/CreatePropertyValue.php | 33 +++ .../Pages/EditPropertyValue.php | 28 ++ .../Pages/ListPropertyValues.php | 41 +++ src/Models/Product.php | 7 + src/Models/ProductType.php | 23 ++ src/Models/Property.php | 125 +++++++++ src/Models/PropertyValue.php | 75 ++++++ src/Policies/PropertyPolicy.php | 92 +++++++ tests/Feature/PropertyCrudTest.php | 189 +++++++++++++ tests/Feature/PropertyIntegrationTest.php | 239 +++++++++++++++++ tests/Feature/PropertyPermissionTest.php | 48 ++++ tests/Feature/PropertyValidationTest.php | 168 ++++++++++++ tests/Feature/PropertyValueCrudTest.php | 151 +++++++++++ tests/Unit/PropertyTest.php | 251 ++++++++++++++++++ tests/Unit/PropertyValueTest.php | 161 +++++++++++ 35 files changed, 2864 insertions(+) create mode 100644 database/migrations/2025_08_19_171247_create_pim_property_table.php create mode 100644 database/migrations/2025_08_19_172834_create_pim_property_value_table.php create mode 100644 database/migrations/2025_08_19_174519_create_pim_product_type_has_property_table.php create mode 100644 database/migrations/2025_08_19_175623_create_catalogue_product_has_property_value_table.php create mode 100644 database/migrations/2025_08_19_175841_add_indexes_to_property_tables.php create mode 100644 database/seeders/PropertySeeder.php create mode 100644 src/Factories/PropertyFactory.php create mode 100644 src/Factories/PropertyValueFactory.php create mode 100644 src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php create mode 100644 src/Filament/Resources/PropertyResource.php create mode 100644 src/Filament/Resources/PropertyResource/Pages/CreateProperty.php create mode 100644 src/Filament/Resources/PropertyResource/Pages/EditProperty.php create mode 100644 src/Filament/Resources/PropertyResource/Pages/ListProperties.php create mode 100644 src/Filament/Resources/PropertyResource/RelationManagers/ValuesRelationManager.php create mode 100644 src/Filament/Resources/PropertyValueResource.php create mode 100644 src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php create mode 100644 src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php create mode 100644 src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php create mode 100644 src/Models/Property.php create mode 100644 src/Models/PropertyValue.php create mode 100644 src/Policies/PropertyPolicy.php create mode 100644 tests/Feature/PropertyCrudTest.php create mode 100644 tests/Feature/PropertyIntegrationTest.php create mode 100644 tests/Feature/PropertyPermissionTest.php create mode 100644 tests/Feature/PropertyValidationTest.php create mode 100644 tests/Feature/PropertyValueCrudTest.php create mode 100644 tests/Unit/PropertyTest.php create mode 100644 tests/Unit/PropertyValueTest.php diff --git a/database/migrations/2025_08_08_082024_create_product_types_table.php b/database/migrations/2025_08_08_082024_create_product_types_table.php index 1010455..a1ed916 100644 --- a/database/migrations/2025_08_08_082024_create_product_types_table.php +++ b/database/migrations/2025_08_08_082024_create_product_types_table.php @@ -17,6 +17,7 @@ public function up(): void }); Schema::create('pim_product_type_data', function (Blueprint $table) { + $table->id(); $table->foreignId('product_type_id') ->constrained('pim_product_types') ->cascadeOnUpdate() diff --git a/database/migrations/2025_08_19_171247_create_pim_property_table.php b/database/migrations/2025_08_19_171247_create_pim_property_table.php new file mode 100644 index 0000000..836e2a5 --- /dev/null +++ b/database/migrations/2025_08_19_171247_create_pim_property_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('code', 50)->nullable()->unique(); + $table->string('name'); + $table->mediumText('description')->nullable(); + $table->string('internal_name')->nullable(); + $table->boolean('is_active')->default(true); + $table->boolean('is_global')->default(false); + $table->tinyInteger('max_values')->nullable(); + $table->boolean('enable_sorting')->default(false); + $table->boolean('is_filter')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pim_property'); + } +}; diff --git a/database/migrations/2025_08_19_172834_create_pim_property_value_table.php b/database/migrations/2025_08_19_172834_create_pim_property_value_table.php new file mode 100644 index 0000000..9c75ce4 --- /dev/null +++ b/database/migrations/2025_08_19_172834_create_pim_property_value_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('property_id')->constrained('pim_property')->onDelete('cascade'); + $table->string('value'); + $table->smallInteger('sort')->default(0); + $table->string('info_url')->nullable(); + $table->string('image')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pim_property_value'); + } +}; diff --git a/database/migrations/2025_08_19_174519_create_pim_product_type_has_property_table.php b/database/migrations/2025_08_19_174519_create_pim_product_type_has_property_table.php new file mode 100644 index 0000000..396f924 --- /dev/null +++ b/database/migrations/2025_08_19_174519_create_pim_product_type_has_property_table.php @@ -0,0 +1,30 @@ +foreignId('product_type_id')->constrained('pim_product_types')->onDelete('cascade'); + $table->foreignId('property_id')->constrained('pim_property')->onDelete('cascade'); + $table->smallInteger('sort')->nullable(); + $table->timestamps(); + $table->primary(['product_type_id', 'property_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pim_product_type_has_property'); + } +}; diff --git a/database/migrations/2025_08_19_175623_create_catalogue_product_has_property_value_table.php b/database/migrations/2025_08_19_175623_create_catalogue_product_has_property_value_table.php new file mode 100644 index 0000000..05017a9 --- /dev/null +++ b/database/migrations/2025_08_19_175623_create_catalogue_product_has_property_value_table.php @@ -0,0 +1,29 @@ +foreignId('product_id')->constrained('catalogue_products')->onDelete('cascade'); + $table->foreignId('property_value_id')->constrained('pim_property_value')->onDelete('cascade'); + $table->timestamps(); + $table->unique(['product_id', 'property_value_id'], 'product_property_value_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('catalogue_product_has_property_value'); + } +}; diff --git a/database/migrations/2025_08_19_175841_add_indexes_to_property_tables.php b/database/migrations/2025_08_19_175841_add_indexes_to_property_tables.php new file mode 100644 index 0000000..15a08fa --- /dev/null +++ b/database/migrations/2025_08_19_175841_add_indexes_to_property_tables.php @@ -0,0 +1,42 @@ +index(['is_active', 'is_global']); + $table->index('is_filter'); + }); + Schema::table('pim_property_value', function (Blueprint $table) { + $table->index(['property_id', 'sort']); + }); + Schema::table('pim_product_type_has_property', function (Blueprint $table) { + $table->index(['product_type_id', 'sort']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('pim_property', function (Blueprint $table) { + $table->dropIndex('pim_property_is_active_is_global_index'); + $table->dropIndex('pim_property_is_filter_index'); + }); + Schema::table('pim_property_value', function (Blueprint $table) { + $table->dropIndex('pim_property_value_property_id_sort_index'); + }); + Schema::table('pim_product_type_has_property', function (Blueprint $table) { + $table->dropIndex('pim_product_type_has_property_product_type_id_sort_index'); + }); + } +}; diff --git a/database/seeders/PropertySeeder.php b/database/seeders/PropertySeeder.php new file mode 100644 index 0000000..4820cfb --- /dev/null +++ b/database/seeders/PropertySeeder.php @@ -0,0 +1,117 @@ + 'brand', + 'name' => ['en' => 'Brand'], + 'description' => ['en' => 'Product brand or manufacturer'], + 'internal_name' => 'Brand/Manufacturer', + 'is_active' => true, + 'is_global' => true, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => true, + ]); + + // Create brand values + $brands = ['Nike', 'Adidas', 'Apple', 'Samsung', 'Sony']; + foreach ($brands as $index => $brand) { + PropertyValue::create([ + 'property_id' => $brandProperty->id, + 'value' => ['en' => $brand], + 'sort' => $index * 10, + ]); + } + + // Create Color property (global) + $colorProperty = Property::create([ + 'code' => 'color', + 'name' => ['en' => 'Color'], + 'description' => ['en' => 'Product color'], + 'is_active' => true, + 'is_global' => true, + 'max_values' => 3, // Allow multiple colors + 'enable_sorting' => true, + 'is_filter' => true, + ]); + + // Create color values + $colors = ['Red', 'Blue', 'Green', 'Black', 'White', 'Yellow', 'Purple', 'Orange']; + foreach ($colors as $index => $color) { + PropertyValue::create([ + 'property_id' => $colorProperty->id, + 'value' => ['en' => $color], + 'sort' => $index * 10, + ]); + } + + // Create Size property (for clothing type only) + $sizeProperty = Property::create([ + 'code' => 'size', + 'name' => ['en' => 'Size'], + 'description' => ['en' => 'Clothing size'], + 'is_active' => true, + 'is_global' => false, + 'max_values' => 1, + 'enable_sorting' => true, + 'is_filter' => true, + ]); + + // Create size values + $sizes = ['XS', 'S', 'M', 'L', 'XL', 'XXL']; + foreach ($sizes as $index => $size) { + PropertyValue::create([ + 'property_id' => $sizeProperty->id, + 'value' => ['en' => $size], + 'sort' => $index * 10, + ]); + } + + // Create Material property + $materialProperty = Property::create([ + 'code' => 'material', + 'name' => ['en' => 'Material'], + 'description' => ['en' => 'Product material composition'], + 'is_active' => true, + 'is_global' => false, + 'max_values' => 2, // Allow multiple materials + 'enable_sorting' => false, + 'is_filter' => true, + ]); + + // Create material values + $materials = ['Cotton', 'Polyester', 'Wool', 'Silk', 'Leather', 'Plastic', 'Metal', 'Wood']; + foreach ($materials as $index => $material) { + PropertyValue::create([ + 'property_id' => $materialProperty->id, + 'value' => ['en' => $material], + 'sort' => $index * 10, + ]); + } + + // If there are product types, assign non-global properties to some of them + $productTypes = ProductType::all(); + if ($productTypes->isNotEmpty()) { + // Assign size to first product type (assuming it's clothing) + if ($productTypes->count() > 0) { + $productTypes->first()->properties()->attach($sizeProperty->id, ['sort' => 10]); + } + + // Assign material to first two product types + foreach ($productTypes->take(2) as $index => $productType) { + $productType->properties()->attach($materialProperty->id, ['sort' => 20]); + } + } + } +} diff --git a/src/Factories/PropertyFactory.php b/src/Factories/PropertyFactory.php new file mode 100644 index 0000000..b4dcd98 --- /dev/null +++ b/src/Factories/PropertyFactory.php @@ -0,0 +1,54 @@ + $this->faker->unique()->slug(2), + 'name' => ['en' => $this->faker->words(2, true)], + 'description' => ['en' => $this->faker->sentence()], + 'internal_name' => $this->faker->words(3, true), + 'is_active' => true, + 'is_global' => $this->faker->boolean(20), // 20% chance of being global + 'max_values' => $this->faker->randomElement([1, 2, 5]), + 'enable_sorting' => $this->faker->boolean(30), + 'is_filter' => $this->faker->boolean(40), + ]; + } + + public function global(): static + { + return $this->state(fn (array $attributes) => [ + 'is_global' => true, + ]); + } + + public function singleValue(): static + { + return $this->state(fn (array $attributes) => [ + 'max_values' => 1, + ]); + } + + public function multipleValues(): static + { + return $this->state(fn (array $attributes) => [ + 'max_values' => $this->faker->numberBetween(2, 10), + ]); + } + + public function filter(): static + { + return $this->state(fn (array $attributes) => [ + 'is_filter' => true, + ]); + } +} diff --git a/src/Factories/PropertyValueFactory.php b/src/Factories/PropertyValueFactory.php new file mode 100644 index 0000000..7030221 --- /dev/null +++ b/src/Factories/PropertyValueFactory.php @@ -0,0 +1,30 @@ + Property::factory(), + 'value' => ['en' => $this->faker->word()], + 'sort' => $this->faker->numberBetween(0, 100), + 'info_url' => $this->faker->optional(0.3)->url(), + 'image' => $this->faker->optional(0.2)->imageUrl(200, 200), + ]; + } + + public function forProperty(Property $property): static + { + return $this->state(fn (array $attributes) => [ + 'property_id' => $property->id, + ]); + } +} diff --git a/src/Filament/Resources/ProductResource.php b/src/Filament/Resources/ProductResource.php index 753850d..5fe0458 100644 --- a/src/Filament/Resources/ProductResource.php +++ b/src/Filament/Resources/ProductResource.php @@ -7,13 +7,17 @@ use Eclipse\Catalogue\Filament\Resources\ProductResource\Pages; use Eclipse\Catalogue\Models\Category; use Eclipse\Catalogue\Models\Product; +use Eclipse\Catalogue\Models\Property; +use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\Radio; use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; use Filament\Forms\Components\Tabs; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; +use Filament\Forms\Get; use Filament\Resources\Concerns\Translatable; use Filament\Resources\Resource; use Filament\Tables\Actions\ActionGroup; @@ -144,6 +148,93 @@ function ($query) { ->hidden(fn (?Product $record) => $record === null), ]), + Tabs\Tab::make('Properties') + ->schema([ + Section::make('Product Properties') + ->description('Select values for properties applicable to this product type') + ->schema(function (Get $get, ?Product $record) { + $productTypeId = $get('product_type_id') ?? $record?->product_type_id; + + if (! $productTypeId) { + return [ + Placeholder::make('no_type') + ->label('') + ->content('Please select a product type first to see available properties.'), + ]; + } + + $properties = Property::where('is_active', true) + ->where(function ($query) use ($productTypeId) { + $query->where('is_global', true) + ->orWhereHas('productTypes', function ($q) use ($productTypeId) { + $q->where('pim_product_types.id', $productTypeId); + }); + }) + ->with(['values' => function ($query) { + $query->orderBy('sort'); + }]) + ->get(); + + $schema = []; + + foreach ($properties as $property) { + $valueOptions = $property->values->pluck('value', 'id')->toArray(); + + if (empty($valueOptions)) { + continue; + } + + $fieldType = $property->getFormFieldType(); + $fieldName = "property_values_{$property->id}"; + + switch ($fieldType) { + case 'radio': + $schema[] = Radio::make($fieldName) + ->label($property->name) + ->options($valueOptions) + ->descriptions($property->values->pluck('info_url', 'id')->filter()->toArray()) + ->helperText($property->description); + break; + + case 'select': + $schema[] = Select::make($fieldName) + ->label($property->name) + ->options($valueOptions) + ->searchable() + ->helperText($property->description); + break; + + case 'checkbox': + $schema[] = CheckboxList::make($fieldName) + ->label($property->name) + ->options($valueOptions) + ->descriptions($property->values->pluck('info_url', 'id')->filter()->toArray()) + ->helperText($property->description) + ->rules($property->max_values > 1 ? ["max:{$property->max_values}"] : []); + break; + + case 'multiselect': + $schema[] = Select::make($fieldName) + ->label($property->name) + ->options($valueOptions) + ->multiple() + ->searchable() + ->helperText($property->description) + ->rules($property->max_values > 1 ? ["max:{$property->max_values}"] : []); + break; + } + } + + return $schema ?: [ + Placeholder::make('no_properties') + ->label('') + ->content('No properties are configured for this product type.'), + ]; + }) + ->reactive() + ->columns(2), + ]), + Tabs\Tab::make('Images') ->schema([ ImageManager::make('images') diff --git a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php index cbda00d..7fbc21f 100644 --- a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php @@ -4,6 +4,7 @@ use Eclipse\Catalogue\Filament\Resources\Concerns\HandlesImageUploads; use Eclipse\Catalogue\Filament\Resources\ProductResource; +use Eclipse\Catalogue\Models\Property; use Filament\Actions; use Filament\Resources\Pages\CreateRecord; @@ -20,4 +21,39 @@ protected function getHeaderActions(): array Actions\LocaleSwitcher::make(), ]; } + + protected function mutateFormDataBeforeCreate(array $data): array + { + // Extract property values from form data + $propertyData = []; + foreach ($data as $key => $value) { + if (str_starts_with($key, 'property_values_')) { + $propertyId = str_replace('property_values_', '', $key); + $propertyData[$propertyId] = $value; + unset($data[$key]); + } + } + + // Store property data for later use in afterCreate + $this->propertyData = $propertyData; + + return $data; + } + + protected function afterCreate(): void + { + // Save property values + if (isset($this->propertyData) && $this->record) { + foreach ($this->propertyData as $propertyId => $values) { + if ($values) { + $valuesToAttach = is_array($values) ? $values : [$values]; + $valuesToAttach = array_filter($valuesToAttach); // Remove null values + + if (! empty($valuesToAttach)) { + $this->record->propertyValues()->attach($valuesToAttach); + } + } + } + } + } } diff --git a/src/Filament/Resources/ProductResource/Pages/EditProduct.php b/src/Filament/Resources/ProductResource/Pages/EditProduct.php index 7c69d40..73276e5 100644 --- a/src/Filament/Resources/ProductResource/Pages/EditProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/EditProduct.php @@ -3,6 +3,7 @@ namespace Eclipse\Catalogue\Filament\Resources\ProductResource\Pages; use Eclipse\Catalogue\Filament\Resources\ProductResource; +use Eclipse\Catalogue\Models\Property; use Filament\Actions; use Filament\Resources\Pages\EditRecord; use Illuminate\Database\Eloquent\Model; @@ -31,6 +32,82 @@ protected function getHeaderActions(): array ]; } + protected function mutateFormDataBeforeFill(array $data): array + { + // Load property values for the product + if ($this->record && $this->record->product_type_id) { + $properties = Property::where('is_active', true) + ->where(function ($query) { + $query->where('is_global', true) + ->orWhereHas('productTypes', function ($q) { + $q->where('pim_product_types.id', $this->record->product_type_id); + }); + }) + ->get(); + + foreach ($properties as $property) { + $fieldName = "property_values_{$property->id}"; + $selectedValues = $this->record->propertyValues() + ->where('property_id', $property->id) + ->pluck('pim_property_value.id') + ->toArray(); + + if ($property->max_values === 1) { + $data[$fieldName] = $selectedValues[0] ?? null; + } else { + $data[$fieldName] = $selectedValues; + } + } + } + + return $data; + } + + protected function mutateFormDataBeforeSave(array $data): array + { + // Extract property values from form data + $propertyData = []; + foreach ($data as $key => $value) { + if (str_starts_with($key, 'property_values_')) { + $propertyId = str_replace('property_values_', '', $key); + $propertyData[$propertyId] = $value; + unset($data[$key]); + } + } + + // Store property data for later use in afterSave + $this->propertyData = $propertyData; + + return $data; + } + + protected function afterSave(): void + { + // Save property values + if (isset($this->propertyData) && $this->record) { + foreach ($this->propertyData as $propertyId => $values) { + // Remove existing values for this property + $this->record->propertyValues() + ->wherePivot('property_value_id', 'IN', function ($query) use ($propertyId) { + $query->select('id') + ->from('pim_property_value') + ->where('property_id', $propertyId); + }) + ->detach(); + + // Add new values + if ($values) { + $valuesToAttach = is_array($values) ? $values : [$values]; + $valuesToAttach = array_filter($valuesToAttach); // Remove null values + + if (! empty($valuesToAttach)) { + $this->record->propertyValues()->attach($valuesToAttach); + } + } + } + } + } + /** * Override the getRecordUrl method to navigate to edit pages instead of view pages */ diff --git a/src/Filament/Resources/ProductTypeResource.php b/src/Filament/Resources/ProductTypeResource.php index c53ec52..b072689 100644 --- a/src/Filament/Resources/ProductTypeResource.php +++ b/src/Filament/Resources/ProductTypeResource.php @@ -4,6 +4,7 @@ use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions; use Eclipse\Catalogue\Filament\Resources\ProductTypeResource\Pages; +use Eclipse\Catalogue\Filament\Resources\ProductTypeResource\RelationManagers; use Eclipse\Catalogue\Models\ProductType; use Filament\Resources\Concerns\Translatable; use Filament\Resources\Resource; @@ -93,6 +94,13 @@ public static function table(Table $table): Table ]); } + public static function getRelations(): array + { + return [ + RelationManagers\PropertiesRelationManager::class, + ]; + } + public static function getPages(): array { return [ diff --git a/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php b/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php new file mode 100644 index 0000000..381f985 --- /dev/null +++ b/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php @@ -0,0 +1,112 @@ +schema([ + Forms\Components\Select::make('property_id') + ->label('Property') + ->options(Property::where('is_active', true)->pluck('name', 'id')) + ->required() + ->searchable(), + + Forms\Components\TextInput::make('sort') + ->label('Sort Order') + ->numeric() + ->default(0) + ->helperText('Lower numbers appear first'), + ]); + } + + public function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->label('Property Name') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('code') + ->label('Code') + ->searchable(), + + Tables\Columns\IconColumn::make('is_global') + ->label('Global') + ->boolean(), + + Tables\Columns\TextColumn::make('max_values') + ->label('Max Values') + ->formatStateUsing(fn ($state) => $state === 1 ? 'Single' : 'Multiple'), + + Tables\Columns\IconColumn::make('is_filter') + ->label('Filter') + ->boolean(), + + Tables\Columns\TextColumn::make('pivot_sort') + ->label('Sort Order') + ->state(fn ($record) => $record->pivot->sort ?? null), + + Tables\Columns\TextColumn::make('values_count') + ->label('Values') + ->counts('values'), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_global') + ->label('Global Properties'), + ]) + ->headerActions([ + Tables\Actions\AttachAction::make() + ->form(fn (Tables\Actions\AttachAction $action): array => [ + $action->getRecordSelect() + ->options(Property::where('is_active', true)->pluck('name', 'id')) + ->searchable(), + Forms\Components\TextInput::make('sort') + ->label('Sort Order') + ->numeric() + ->default(0), + ]), + ]) + ->actions([ + Tables\Actions\DetachAction::make(), + Tables\Actions\Action::make('edit_pivot') + ->label('Edit Sort') + ->icon('heroicon-o-pencil') + ->form([ + Forms\Components\TextInput::make('sort') + ->label('Sort Order') + ->numeric() + ->required(), + ]) + ->fillForm(fn ($record): array => [ + 'sort' => $record->pivot->sort, + ]) + ->action(function (array $data, $record): void { + $record->pivot->update(['sort' => $data['sort']]); + }), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DetachBulkAction::make(), + ]), + ]) + ->persistSortInSession(false) + ->defaultSort('pim_product_type_has_property.sort') + ->reorderable('pim_product_type_has_property.sort'); + } +} diff --git a/src/Filament/Resources/PropertyResource.php b/src/Filament/Resources/PropertyResource.php new file mode 100644 index 0000000..c699e7b --- /dev/null +++ b/src/Filament/Resources/PropertyResource.php @@ -0,0 +1,209 @@ +schema([ + Forms\Components\Section::make('Basic Information') + ->schema([ + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('code') + ->label('Code') + ->helperText('Optional alphanumeric code with underscores, automatically converted to lowercase') + ->regex('/^[a-zA-Z0-9_]*$/') + ->unique(ignoreRecord: true), + + Forms\Components\Textarea::make('description') + ->label('Description') + ->rows(3), + + Forms\Components\TextInput::make('internal_name') + ->label('Internal Name') + ->helperText('Internal name for distinction, not translatable') + ->maxLength(255), + ])->columns(2), + + Forms\Components\Section::make('Configuration') + ->schema([ + Forms\Components\Toggle::make('is_active') + ->label('Active') + ->default(true), + + Forms\Components\Toggle::make('is_global') + ->label('Global Property') + ->helperText('Auto-assigned to all product types') + ->reactive(), + + Forms\Components\Select::make('max_values') + ->label('Maximum Values') + ->options([ + 1 => 'Single value (1)', + 2 => 'Multiple values (2+)', + ]) + ->helperText('Controls form field type: single = radio/select, multiple = checkbox/multiselect'), + + Forms\Components\Toggle::make('enable_sorting') + ->label('Enable Manual Sorting') + ->helperText('Allow drag-and-drop sorting of property values'), + + Forms\Components\Toggle::make('is_filter') + ->label('Show as Filter') + ->helperText('Display property as filter in product table'), + ])->columns(2), + + Forms\Components\Section::make('Product Types') + ->schema([ + Forms\Components\CheckboxList::make('product_types') + ->label('Assign to Product Types') + ->relationship('productTypes', 'name') + ->options(ProductType::pluck('name', 'id')) + ->helperText('Select product types for this property (ignored if Global is enabled)') + ->hidden(fn (Forms\Get $get) => $get('is_global')), + ]) + ->hidden(fn (Forms\Get $get) => $get('is_global')), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('code') + ->label('Code') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('name') + ->label('Name') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('internal_name') + ->label('Internal Name') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\IconColumn::make('is_global') + ->label('Global') + ->boolean(), + + Tables\Columns\TextColumn::make('max_values') + ->label('Max Values') + ->formatStateUsing(fn ($state) => $state === 1 ? 'Single' : 'Multiple'), + + Tables\Columns\IconColumn::make('enable_sorting') + ->label('Sortable') + ->boolean(), + + Tables\Columns\IconColumn::make('is_filter') + ->label('Filter') + ->boolean(), + + Tables\Columns\IconColumn::make('is_active') + ->label('Active') + ->boolean(), + + Tables\Columns\TextColumn::make('values_count') + ->label('Values') + ->counts('values'), + + Tables\Columns\TextColumn::make('created_at') + ->label('Created') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('product_type') + ->label('Product Type') + ->relationship('productTypes', 'name') + ->multiple(), + + Tables\Filters\TernaryFilter::make('is_global') + ->label('Global Properties'), + + Tables\Filters\TernaryFilter::make('is_active') + ->label('Active Properties'), + + Tables\Filters\TernaryFilter::make('is_filter') + ->label('Filter Properties'), + ]) + ->actions([ + Tables\Actions\ActionGroup::make([ + Tables\Actions\Action::make('values') + ->label('Values') + ->icon('heroicon-o-list-bullet') + ->url(fn (Property $record): string => PropertyValueResource::getUrl('index', ['property' => $record->id])), + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ])->label('Actions'), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->recordUrl(fn (Property $record): string => PropertyValueResource::getUrl('index', ['property' => $record->id])) + ->defaultSort('name'); + } + + public static function getRelations(): array + { + return [ + RelationManagers\ValuesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListProperties::route('/'), + 'create' => Pages\CreateProperty::route('/create'), + 'edit' => Pages\EditProperty::route('/{record}/edit'), + ]; + } + + public static function getPermissionPrefixes(): array + { + return [ + 'view_any', + 'view', + 'create', + 'update', + 'delete', + 'delete_any', + 'force_delete', + 'force_delete_any', + 'restore', + 'restore_any', + ]; + } +} diff --git a/src/Filament/Resources/PropertyResource/Pages/CreateProperty.php b/src/Filament/Resources/PropertyResource/Pages/CreateProperty.php new file mode 100644 index 0000000..3bae26e --- /dev/null +++ b/src/Filament/Resources/PropertyResource/Pages/CreateProperty.php @@ -0,0 +1,22 @@ +schema([ + Forms\Components\TextInput::make('value') + ->label('Value') + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('info_url') + ->label('Info URL') + ->helperText('Optional "read more" link') + ->url() + ->maxLength(255), + + Forms\Components\FileUpload::make('image') + ->label('Image') + ->helperText('Optional image for this value') + ->image() + ->disk('public') + ->directory('property-values'), + + Forms\Components\TextInput::make('sort') + ->label('Sort Order') + ->numeric() + ->default(0) + ->helperText('Lower numbers appear first'), + ]); + } + + public function table(Table $table): Table + { + /** @var Property $property */ + $property = $this->getOwnerRecord(); + + $table = $table + ->columns([ + Tables\Columns\TextColumn::make('value') + ->label('Value') + ->searchable() + ->sortable(), + + Tables\Columns\ImageColumn::make('image') + ->label('Image') + ->disk('public') + ->size(40), + + Tables\Columns\TextColumn::make('info_url') + ->label('Info URL') + ->limit(50) + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('sort') + ->label('Sort') + ->sortable(), + + Tables\Columns\TextColumn::make('products_count') + ->label('Products') + ->counts('products'), + ]) + ->filters([ + // + ]) + ->headerActions([ + Tables\Actions\CreateAction::make(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]); + + if ($property->enable_sorting) { + $table = $table + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->reorderable('sort') + ->defaultSort('sort'); + } else { + $table = $table + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('value'); + } + + return $table; + } +} diff --git a/src/Filament/Resources/PropertyValueResource.php b/src/Filament/Resources/PropertyValueResource.php new file mode 100644 index 0000000..4ce7f2b --- /dev/null +++ b/src/Filament/Resources/PropertyValueResource.php @@ -0,0 +1,152 @@ +schema([ + Forms\Components\Section::make('Value Information') + ->schema([ + Forms\Components\Select::make('property_id') + ->label('Property') + ->relationship('property', 'name') + ->required() + ->disabled(fn ($livewire) => $livewire instanceof Pages\CreatePropertyValue && request()->has('property')), + + Forms\Components\TextInput::make('value') + ->label('Value') + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('info_url') + ->label('Info URL') + ->helperText('Optional "read more" link') + ->url() + ->maxLength(255), + + Forms\Components\FileUpload::make('image') + ->label('Image') + ->helperText('Optional image for this value (e.g., brand logo)') + ->image() + ->disk('public') + ->directory('property-values'), + + Forms\Components\TextInput::make('sort') + ->label('Sort Order') + ->numeric() + ->default(0) + ->helperText('Lower numbers appear first'), + ])->columns(2), + ]); + } + + public static function table(Table $table): Table + { + $propertyId = request()->has('property') ? (int) request('property') : null; + $property = $propertyId ? Property::find($propertyId) : null; + + $table = $table + ->columns([ + Tables\Columns\TextColumn::make('property.name') + ->label('Property') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('value') + ->label('Value') + ->searchable() + ->sortable(), + + Tables\Columns\ImageColumn::make('image') + ->label('Image') + ->disk('public') + ->size(40), + + Tables\Columns\TextColumn::make('info_url') + ->label('Info URL') + ->limit(50) + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('sort') + ->label('Sort') + ->sortable(), + + Tables\Columns\TextColumn::make('products_count') + ->label('Products') + ->counts('products'), + + Tables\Columns\TextColumn::make('created_at') + ->label('Created') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('property') + ->relationship('property', 'name'), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + + if ($property && $property->enable_sorting) { + $table = $table->reorderable('sort')->defaultSort('sort'); + } else { + $table = $table->defaultSort('value'); + } + + return $table + ->modifyQueryUsing(function (Builder $query) { + if (request()->has('property')) { + $query->where('property_id', request('property')); + } + }); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPropertyValues::route('/'), + 'create' => Pages\CreatePropertyValue::route('/create'), + 'edit' => Pages\EditPropertyValue::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + if (request()->has('property')) { + $query->where('property_id', request('property')); + } + + return $query; + } +} diff --git a/src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php b/src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php new file mode 100644 index 0000000..80c0adc --- /dev/null +++ b/src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php @@ -0,0 +1,33 @@ +has('property')) { + $property = Property::find(request('property')); + if ($property) { + $this->form->fill(['property_id' => $property->id]); + } + } + } + + protected function getRedirectUrl(): string + { + if (request()->has('property')) { + return PropertyValueResource::getUrl('index', ['property' => request('property')]); + } + + return parent::getRedirectUrl(); + } +} diff --git a/src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php b/src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php new file mode 100644 index 0000000..25f72ea --- /dev/null +++ b/src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php @@ -0,0 +1,28 @@ +has('property')) { + return PropertyValueResource::getUrl('index', ['property' => request('property')]); + } + + return parent::getRedirectUrl(); + } +} diff --git a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php new file mode 100644 index 0000000..a9f8ef4 --- /dev/null +++ b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php @@ -0,0 +1,41 @@ +has('property')) { + $this->property = Property::find(request('property')); + } + } + + protected function getHeaderActions(): array + { + return [ + Actions\CreateAction::make() + ->url(fn (): string => PropertyValueResource::getUrl('create', ['property' => $this->property?->id])), + ]; + } + + public function getTitle(): string + { + if ($this->property) { + return "Values for: {$this->property->name}"; + } + + return 'Property Values'; + } +} diff --git a/src/Models/Product.php b/src/Models/Product.php index e760feb..8d71b4e 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; @@ -58,6 +59,12 @@ public function type(): BelongsTo return $this->belongsTo(ProductType::class, 'product_type_id'); } + public function propertyValues(): BelongsToMany + { + return $this->belongsToMany(PropertyValue::class, 'catalogue_product_has_property_value', 'product_id', 'property_value_id') + ->withTimestamps(); + } + protected static function newFactory(): ProductFactory { return ProductFactory::new(); diff --git a/src/Models/ProductType.php b/src/Models/ProductType.php index 18b3451..4092b89 100644 --- a/src/Models/ProductType.php +++ b/src/Models/ProductType.php @@ -6,6 +6,7 @@ use Eclipse\Catalogue\Traits\HasTenantScopedData; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Translatable\HasTranslations; @@ -48,6 +49,17 @@ public function productTypeData(): HasMany return $this->hasMany(ProductTypeData::class, 'product_type_id'); } + /** + * Get all properties assigned to this product type. + */ + public function properties(): BelongsToMany + { + return $this->belongsToMany(Property::class, 'pim_product_type_has_property') + ->withPivot('sort') + ->withTimestamps() + ->orderByPivot('sort'); + } + /** * Find the default product type for a tenant. * If tenantId is omitted, the current Filament tenant is used. @@ -94,6 +106,17 @@ protected function casts(): array ]; } + protected static function booted(): void + { + static::created(function (ProductType $productType) { + // Auto-assign global properties to new product types + $globalProperties = Property::where('is_global', true)->get(); + foreach ($globalProperties as $property) { + $productType->properties()->attach($property->id, ['sort' => 0]); + } + }); + } + protected static function newFactory(): ProductTypeFactory { return ProductTypeFactory::new(); diff --git a/src/Models/Property.php b/src/Models/Property.php new file mode 100644 index 0000000..1dc92e9 --- /dev/null +++ b/src/Models/Property.php @@ -0,0 +1,125 @@ + 'array', + 'description' => 'array', + 'is_active' => 'boolean', + 'is_global' => 'boolean', + 'enable_sorting' => 'boolean', + 'is_filter' => 'boolean', + 'max_values' => 'integer', + ]; + + protected static function booted(): void + { + static::creating(function (Property $property) { + if ($property->code) { + $property->code = strtolower($property->code); + } + }); + + static::updating(function (Property $property) { + if ($property->isDirty('code') && $property->code) { + $property->code = strtolower($property->code); + } + }); + + static::created(function (Property $property) { + if ($property->is_global) { + $property->assignToAllProductTypes(); + } + }); + + static::updated(function (Property $property) { + if ($property->wasChanged('is_global') && $property->is_global) { + $property->assignToAllProductTypes(); + } + }); + + static::deleting(function (Property $property) { + if ($property->isForceDeleting()) { + // Force delete related values + $property->values()->forceDelete(); + // Delete pivot rows + $property->productTypes()->detach(); + } + }); + } + + public function values(): HasMany + { + return $this->hasMany(PropertyValue::class); + } + + public function productTypes(): BelongsToMany + { + return $this->belongsToMany(ProductType::class, 'pim_product_type_has_property') + ->withPivot('sort') + ->withTimestamps() + ->orderByPivot('sort'); + } + + public function assignToAllProductTypes(): void + { + $existingTypeIds = $this->productTypes()->pluck('pim_product_types.id')->toArray(); + $allTypeIds = ProductType::pluck('id')->toArray(); + $newTypeIds = array_diff($allTypeIds, $existingTypeIds); + + if (! empty($newTypeIds)) { + $attachData = []; + foreach ($newTypeIds as $typeId) { + $attachData[$typeId] = ['sort' => 0]; + } + $this->productTypes()->attach($attachData); + } + } + + public function getFormFieldType(): string + { + $valueCount = $this->values()->count(); + + if ($this->max_values === 1) { + return $valueCount < 4 ? 'radio' : 'select'; + } else { + return $valueCount < 4 ? 'checkbox' : 'multiselect'; + } + } + + protected static function newFactory(): PropertyFactory + { + return PropertyFactory::new(); + } +} diff --git a/src/Models/PropertyValue.php b/src/Models/PropertyValue.php new file mode 100644 index 0000000..3634a9a --- /dev/null +++ b/src/Models/PropertyValue.php @@ -0,0 +1,75 @@ + 'array', + 'info_url' => 'array', + 'image' => 'array', + 'sort' => 'integer', + 'property_id' => 'integer', + ]; + + public function property(): BelongsTo + { + return $this->belongsTo(Property::class); + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'catalogue_product_has_property_value', 'property_value_id', 'product_id') + ->withTimestamps(); + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('images') + ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']) + ->useDisk('public'); + } + + protected static function booted(): void + { + static::deleting(function (PropertyValue $value) { + if ($value->isForceDeleting()) { + // Delete product assignments + $value->products()->detach(); + } + }); + } + + protected static function newFactory(): PropertyValueFactory + { + return PropertyValueFactory::new(); + } +} diff --git a/src/Policies/PropertyPolicy.php b/src/Policies/PropertyPolicy.php new file mode 100644 index 0000000..ae588d0 --- /dev/null +++ b/src/Policies/PropertyPolicy.php @@ -0,0 +1,92 @@ +can('view_any_property'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(Authorizable $user, Property $property): bool + { + return $user->can('view_property'); + } + + /** + * Determine whether the user can create models. + */ + public function create(Authorizable $user): bool + { + return $user->can('create_property'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(Authorizable $user, Property $property): bool + { + return $user->can('update_property'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Authorizable $user, Property $property): bool + { + return $user->can('delete_property'); + } + + /** + * Determine whether the user can bulk delete. + */ + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_property'); + } + + /** + * Determine whether the user can permanently delete. + */ + public function forceDelete(Authorizable $user, Property $property): bool + { + return $user->can('force_delete_property'); + } + + /** + * Determine whether the user can permanently bulk delete. + */ + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_property'); + } + + /** + * Determine whether the user can restore. + */ + public function restore(Authorizable $user, Property $property): bool + { + return $user->can('restore_property'); + } + + /** + * Determine whether the user can bulk restore. + */ + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_property'); + } +} diff --git a/tests/Feature/PropertyCrudTest.php b/tests/Feature/PropertyCrudTest.php new file mode 100644 index 0000000..63f034a --- /dev/null +++ b/tests/Feature/PropertyCrudTest.php @@ -0,0 +1,189 @@ +migrate(); +}); + +it('can create a property', function () { + $property = Property::create([ + 'name' => ['en' => 'Brand'], + 'code' => 'brand', + 'description' => ['en' => 'Product brand'], + 'internal_name' => 'Brand/Manufacturer', + 'is_active' => true, + 'is_global' => false, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => true, + ]); + + expect($property)->toBeInstanceOf(Property::class); + expect($property->getTranslation('name', 'en'))->toBe('Brand'); + expect($property->code)->toBe('brand'); + + $this->assertDatabaseHas('pim_property', [ + 'id' => $property->id, + 'code' => 'brand', + 'is_active' => true, + 'is_global' => false, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => true, + ]); +}); + +it('can update a property', function () { + $property = Property::factory()->create([ + 'name' => ['en' => 'Original Name'], + 'code' => 'original', + 'is_global' => false, + ]); + + $property->update([ + 'name' => ['en' => 'Updated Name'], + 'code' => 'updated', + 'is_global' => true, + ]); + + expect($property->getTranslation('name', 'en'))->toBe('Updated Name'); + expect($property->code)->toBe('updated'); + expect($property->is_global)->toBeTrue(); + + $this->assertDatabaseHas('pim_property', [ + 'id' => $property->id, + 'code' => 'updated', + 'is_global' => true, + ]); +}); + +it('can soft delete a property', function () { + $property = Property::factory()->create(); + + $property->delete(); + + $this->assertSoftDeleted('pim_property', [ + 'id' => $property->id, + ]); +}); + +it('can restore a soft deleted property', function () { + $property = Property::factory()->create(); + + $property->delete(); + $property->restore(); + + $this->assertDatabaseHas('pim_property', [ + 'id' => $property->id, + 'deleted_at' => null, + ]); +}); + +it('can create property with values', function () { + $property = Property::factory()->create(); + + $value1 = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Nike'], + 'sort' => 10, + ]); + + $value2 = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Adidas'], + 'sort' => 20, + ]); + + expect($property->values)->toHaveCount(2); + + $this->assertDatabaseHas('pim_property_value', [ + 'property_id' => $property->id, + 'sort' => 10, + ]); + + $this->assertDatabaseHas('pim_property_value', [ + 'property_id' => $property->id, + 'sort' => 20, + ]); +}); + +it('can assign property to product types', function () { + $property = Property::factory()->create(['is_global' => false]); + $productType1 = ProductType::factory()->create(); + $productType2 = ProductType::factory()->create(); + + $property->productTypes()->attach([ + $productType1->id => ['sort' => 10], + $productType2->id => ['sort' => 20], + ]); + + expect($property->productTypes)->toHaveCount(2); + + $this->assertDatabaseHas('pim_product_type_has_property', [ + 'property_id' => $property->id, + 'product_type_id' => $productType1->id, + 'sort' => 10, + ]); + + $this->assertDatabaseHas('pim_product_type_has_property', [ + 'property_id' => $property->id, + 'product_type_id' => $productType2->id, + 'sort' => 20, + ]); +}); + +it('can detach property from product types', function () { + $property = Property::factory()->create(['is_global' => false]); + $productType = ProductType::factory()->create(); + + $property->productTypes()->attach($productType->id, ['sort' => 10]); + expect($property->productTypes)->toHaveCount(1); + + $property->productTypes()->detach($productType->id); + $property->refresh(); + expect($property->productTypes)->toHaveCount(0); + + $this->assertDatabaseMissing('pim_product_type_has_property', [ + 'property_id' => $property->id, + 'product_type_id' => $productType->id, + ]); +}); + +it('cascades delete to property values', function () { + $property = Property::factory()->create(); + $value = PropertyValue::factory()->create(['property_id' => $property->id]); + + // First soft delete, then force delete to test cascade + $property->delete(); + $property->forceDelete(); + + $this->assertDatabaseMissing('pim_property', [ + 'id' => $property->id, + ]); + + // Property value should also be force deleted due to cascade + expect(PropertyValue::withTrashed()->find($value->id))->toBeNull(); +}); + +it('cascades delete to product type assignments', function () { + $property = Property::factory()->create(['is_global' => false]); + $productType = ProductType::factory()->create(); + + $property->productTypes()->attach($productType->id, ['sort' => 10]); + + // First soft delete, then force delete to test cascade + $property->delete(); + $property->forceDelete(); + + $this->assertDatabaseMissing('pim_property', [ + 'id' => $property->id, + ]); + + $this->assertDatabaseMissing('pim_product_type_has_property', [ + 'property_id' => $property->id, + 'product_type_id' => $productType->id, + ]); +}); diff --git a/tests/Feature/PropertyIntegrationTest.php b/tests/Feature/PropertyIntegrationTest.php new file mode 100644 index 0000000..a2310e3 --- /dev/null +++ b/tests/Feature/PropertyIntegrationTest.php @@ -0,0 +1,239 @@ +migrate(); +}); + +it('global property is auto-assigned to existing product types on creation', function () { + // Create product types first + $productType1 = ProductType::factory()->create(); + $productType2 = ProductType::factory()->create(); + + // Create global property + $property = Property::create([ + 'name' => ['en' => 'Global Brand'], + 'is_active' => true, + 'is_global' => true, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + // Check that property was auto-assigned to both product types + expect($productType1->properties()->where('property_id', $property->id)->exists())->toBeTrue(); + expect($productType2->properties()->where('property_id', $property->id)->exists())->toBeTrue(); + + $this->assertDatabaseHas('pim_product_type_has_property', [ + 'product_type_id' => $productType1->id, + 'property_id' => $property->id, + ]); + + $this->assertDatabaseHas('pim_product_type_has_property', [ + 'product_type_id' => $productType2->id, + 'property_id' => $property->id, + ]); +}); + +it('global property is auto-assigned to new product types', function () { + // Create global property first + $property = Property::create([ + 'name' => ['en' => 'Global Brand'], + 'is_active' => true, + 'is_global' => true, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + // Create product type after global property exists + $productType = ProductType::factory()->create(); + + // Check that property was auto-assigned to new product type + expect($productType->properties()->where('property_id', $property->id)->exists())->toBeTrue(); + + $this->assertDatabaseHas('pim_product_type_has_property', [ + 'product_type_id' => $productType->id, + 'property_id' => $property->id, + ]); +}); + +it('updating property to global assigns it to all product types', function () { + // Create product types and non-global property + $productType1 = ProductType::factory()->create(); + $productType2 = ProductType::factory()->create(); + + $property = Property::factory()->create(['is_global' => false]); + + // Initially not assigned to any product types + expect($productType1->properties()->where('property_id', $property->id)->exists())->toBeFalse(); + expect($productType2->properties()->where('property_id', $property->id)->exists())->toBeFalse(); + + // Update to global + $property->update(['is_global' => true]); + + // Should now be assigned to all product types + expect($productType1->properties()->where('property_id', $property->id)->exists())->toBeTrue(); + expect($productType2->properties()->where('property_id', $property->id)->exists())->toBeTrue(); +}); + +it('can assign property values to products', function () { + $productType = ProductType::factory()->create(); + $product = Product::factory()->create(['product_type_id' => $productType->id]); + + $property = Property::factory()->create(); + $value1 = PropertyValue::factory()->create(['property_id' => $property->id]); + $value2 = PropertyValue::factory()->create(['property_id' => $property->id]); + + // Assign property values to product + $product->propertyValues()->attach([$value1->id, $value2->id]); + + expect($product->propertyValues)->toHaveCount(2); + expect($product->propertyValues->pluck('id')->toArray())->toContain($value1->id); + expect($product->propertyValues->pluck('id')->toArray())->toContain($value2->id); + + $this->assertDatabaseHas('catalogue_product_has_property_value', [ + 'product_id' => $product->id, + 'property_value_id' => $value1->id, + ]); + + $this->assertDatabaseHas('catalogue_product_has_property_value', [ + 'product_id' => $product->id, + 'property_value_id' => $value2->id, + ]); +}); + +it('can get products by property value', function () { + $property = Property::factory()->create(); + $value = PropertyValue::factory()->create(['property_id' => $property->id]); + + $product1 = Product::factory()->create(); + $product2 = Product::factory()->create(); + $product3 = Product::factory()->create(); + + // Assign value to first two products + $product1->propertyValues()->attach($value->id); + $product2->propertyValues()->attach($value->id); + + $productsWithValue = $value->products; + + expect($productsWithValue)->toHaveCount(2); + expect($productsWithValue->pluck('id')->toArray())->toContain($product1->id); + expect($productsWithValue->pluck('id')->toArray())->toContain($product2->id); + expect($productsWithValue->pluck('id')->toArray())->not->toContain($product3->id); +}); + +it('property form field type changes based on value count', function () { + $singleProperty = Property::factory()->create(['max_values' => 1]); + $multiProperty = Property::factory()->create(['max_values' => 3]); + + // Initially no values - should still work + expect($singleProperty->getFormFieldType())->toBe('radio'); + expect($multiProperty->getFormFieldType())->toBe('checkbox'); + + // Add 2 values - should be radio/checkbox + PropertyValue::factory()->count(2)->create(['property_id' => $singleProperty->id]); + PropertyValue::factory()->count(2)->create(['property_id' => $multiProperty->id]); + + $singleProperty->refresh(); + $multiProperty->refresh(); + + expect($singleProperty->getFormFieldType())->toBe('radio'); + expect($multiProperty->getFormFieldType())->toBe('checkbox'); + + // Add more values to reach 4+ - should be select/multiselect + PropertyValue::factory()->count(3)->create(['property_id' => $singleProperty->id]); + PropertyValue::factory()->count(3)->create(['property_id' => $multiProperty->id]); + + $singleProperty->refresh(); + $multiProperty->refresh(); + + expect($singleProperty->getFormFieldType())->toBe('select'); + expect($multiProperty->getFormFieldType())->toBe('multiselect'); +}); + +it('can sort properties within product type', function () { + $productType = ProductType::factory()->create(); + $property1 = Property::factory()->create(['is_global' => false]); + $property2 = Property::factory()->create(['is_global' => false]); + $property3 = Property::factory()->create(['is_global' => false]); + + // Attach with specific sort orders + $productType->properties()->attach([ + $property1->id => ['sort' => 30], + $property2->id => ['sort' => 10], + $property3->id => ['sort' => 20], + ]); + + $sortedProperties = $productType->properties()->orderBy('pim_product_type_has_property.sort')->get(); + + expect($sortedProperties->pluck('id')->toArray())->toBe([ + $property2->id, + $property3->id, + $property1->id, + ]); +}); + +it('can update property sort order within product type', function () { + $productType = ProductType::factory()->create(); + $property = Property::factory()->create(['is_global' => false]); + + $productType->properties()->attach($property->id, ['sort' => 10]); + + // Update sort order + $productType->properties()->updateExistingPivot($property->id, ['sort' => 50]); + + $pivot = $productType->properties()->where('property_id', $property->id)->first()->pivot; + expect($pivot->sort)->toBe(50); +}); + +it('deleting property removes product type assignments', function () { + $productType = ProductType::factory()->create(); + $property = Property::factory()->create(['is_global' => false]); + + $productType->properties()->attach($property->id, ['sort' => 10]); + + // Verify assignment exists + $this->assertDatabaseHas('pim_product_type_has_property', [ + 'product_type_id' => $productType->id, + 'property_id' => $property->id, + ]); + + // First soft delete, then force delete to test cascade + $property->delete(); + $property->forceDelete(); + + // Verify assignment is removed + $this->assertDatabaseMissing('pim_product_type_has_property', [ + 'product_type_id' => $productType->id, + 'property_id' => $property->id, + ]); +}); + +it('deleting property value removes product assignments', function () { + $product = Product::factory()->create(); + $property = Property::factory()->create(); + $value = PropertyValue::factory()->create(['property_id' => $property->id]); + + $product->propertyValues()->attach($value->id); + + // Verify assignment exists + $this->assertDatabaseHas('catalogue_product_has_property_value', [ + 'product_id' => $product->id, + 'property_value_id' => $value->id, + ]); + + // First soft delete, then force delete to test cascade + $value->delete(); + $value->forceDelete(); + + // Verify assignment is removed + $this->assertDatabaseMissing('catalogue_product_has_property_value', [ + 'product_id' => $product->id, + 'property_value_id' => $value->id, + ]); +}); diff --git a/tests/Feature/PropertyPermissionTest.php b/tests/Feature/PropertyPermissionTest.php new file mode 100644 index 0000000..b1f7e31 --- /dev/null +++ b/tests/Feature/PropertyPermissionTest.php @@ -0,0 +1,48 @@ +migrate(); +}); + +test('unauthorized access can be prevented', function () { + // Create regular user with no permissions + $this->setUpCommonUser(); + + // Create test property + $property = Property::factory()->create([ + 'name' => ['en' => 'Test Property'], + 'is_active' => true, + 'is_global' => false, + ]); + + // View table + $this->get(PropertyResource::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_property'); + + // Create property + livewire(ListProperties::class) + ->assertActionDisabled('create'); + + // Edit property + livewire(ListProperties::class) + ->assertCanSeeTableRecords([$property]) + ->assertTableActionDisabled('edit', $property); + + // Delete property + livewire(ListProperties::class) + ->assertTableActionDisabled('delete', $property); + + // Test delete action + livewire(ListProperties::class) + ->assertTableActionDisabled('delete', $property); +}); diff --git a/tests/Feature/PropertyValidationTest.php b/tests/Feature/PropertyValidationTest.php new file mode 100644 index 0000000..eb176b5 --- /dev/null +++ b/tests/Feature/PropertyValidationTest.php @@ -0,0 +1,168 @@ +migrate(); +}); + +it('validates property code uniqueness', function () { + Property::factory()->create(['code' => 'brand']); + + expect(function () { + Property::create([ + 'name' => ['en' => 'Another Brand'], + 'code' => 'brand', // Duplicate code + 'is_active' => true, + 'is_global' => false, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + })->toThrow(\Illuminate\Database\QueryException::class); +}); + +it('allows null property codes', function () { + $property1 = Property::create([ + 'name' => ['en' => 'First Property'], + 'code' => null, + 'is_active' => true, + 'is_global' => false, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + $property2 = Property::create([ + 'name' => ['en' => 'Second Property'], + 'code' => null, + 'is_active' => true, + 'is_global' => false, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + expect($property1->code)->toBeNull(); + expect($property2->code)->toBeNull(); +}); + +it('validates property code format', function () { + // Valid codes should work + $validCodes = ['brand', 'brand_name', 'brand123', 'BRAND_NAME_123']; + + foreach ($validCodes as $code) { + $property = Property::factory()->create(['code' => $code]); + expect($property->code)->toBe(strtolower($code)); + } +}); + +it('requires property name', function () { + expect(function () { + Property::create([ + 'code' => 'test', + 'is_active' => true, + 'is_global' => false, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + })->toThrow(\Illuminate\Database\QueryException::class); +}); + +it('validates property value belongs to property', function () { + $property1 = Property::factory()->create(); + $property2 = Property::factory()->create(); + + $value = PropertyValue::factory()->create(['property_id' => $property1->id]); + + // Should not be able to assign value to different property + expect($value->property_id)->toBe($property1->id); + expect($value->property_id)->not->toBe($property2->id); +}); + +it('validates unique product property value assignment', function () { + $product = \Eclipse\Catalogue\Models\Product::factory()->create(); + $property = Property::factory()->create(); + $value = PropertyValue::factory()->create(['property_id' => $property->id]); + + // First assignment should work + $product->propertyValues()->attach($value->id); + expect($product->propertyValues)->toHaveCount(1); + + // Duplicate assignment should be prevented by unique constraint + expect(function () use ($product, $value) { + $product->propertyValues()->attach($value->id); + })->toThrow(\Illuminate\Database\QueryException::class); +}); + +it('validates property value sort order is numeric', function () { + $property = Property::factory()->create(); + + $value = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Test Value'], + 'sort' => 10, + ]); + + expect($value->sort)->toBeInt(); + expect($value->sort)->toBe(10); +}); + +it('allows property values with same sort order', function () { + $property = Property::factory()->create(); + + $value1 = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'First Value'], + 'sort' => 10, + ]); + + $value2 = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Second Value'], + 'sort' => 10, // Same sort order + ]); + + expect($value1->sort)->toBe(10); + expect($value2->sort)->toBe(10); +}); + +it('validates max_values is positive integer', function () { + $property = Property::factory()->create(['max_values' => 5]); + expect($property->max_values)->toBe(5); + expect($property->max_values)->toBeGreaterThan(0); +}); + +it('allows property without max_values', function () { + $property = Property::create([ + 'name' => ['en' => 'Test Property'], + 'is_active' => true, + 'is_global' => false, + 'max_values' => null, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + expect($property->max_values)->toBeNull(); +}); + +it('validates boolean fields have correct types', function () { + $property = Property::factory()->create([ + 'is_active' => true, + 'is_global' => false, + 'enable_sorting' => true, + 'is_filter' => false, + ]); + + expect($property->is_active)->toBeBool(); + expect($property->is_global)->toBeBool(); + expect($property->enable_sorting)->toBeBool(); + expect($property->is_filter)->toBeBool(); + + expect($property->is_active)->toBeTrue(); + expect($property->is_global)->toBeFalse(); + expect($property->enable_sorting)->toBeTrue(); + expect($property->is_filter)->toBeFalse(); +}); diff --git a/tests/Feature/PropertyValueCrudTest.php b/tests/Feature/PropertyValueCrudTest.php new file mode 100644 index 0000000..2f80137 --- /dev/null +++ b/tests/Feature/PropertyValueCrudTest.php @@ -0,0 +1,151 @@ +migrate(); +}); + +it('can create a property value', function () { + $property = Property::factory()->create(); + + $value = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Nike'], + 'sort' => 10, + 'info_url' => ['en' => 'https://nike.com'], + 'image' => ['en' => 'nike-logo.png'], + ]); + + expect($value)->toBeInstanceOf(PropertyValue::class); + expect($value->getTranslation('value', 'en'))->toBe('Nike'); + + $this->assertDatabaseHas('pim_property_value', [ + 'id' => $value->id, + 'property_id' => $property->id, + 'sort' => 10, + ]); +}); + +it('can update a property value', function () { + $value = PropertyValue::factory()->create([ + 'value' => ['en' => 'Original Value'], + 'sort' => 10, + ]); + + $value->update([ + 'value' => ['en' => 'Updated Value'], + 'sort' => 20, + 'info_url' => ['en' => 'https://updated.com'], + ]); + + expect($value->getTranslation('value', 'en'))->toBe('Updated Value'); + expect($value->sort)->toBe(20); + expect($value->getTranslation('info_url', 'en'))->toBe('https://updated.com'); + + $this->assertDatabaseHas('pim_property_value', [ + 'id' => $value->id, + 'sort' => 20, + ]); +}); + +it('can soft delete a property value', function () { + $value = PropertyValue::factory()->create(); + + $value->delete(); + + $this->assertSoftDeleted('pim_property_value', [ + 'id' => $value->id, + ]); +}); + +it('can restore a soft deleted property value', function () { + $value = PropertyValue::factory()->create(); + + $value->delete(); + $value->restore(); + + $this->assertDatabaseHas('pim_property_value', [ + 'id' => $value->id, + 'deleted_at' => null, + ]); +}); + +it('maintains sort order when creating multiple values', function () { + $property = Property::factory()->create(); + + $value1 = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Third'], + 'sort' => 30, + ]); + + $value2 = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'First'], + 'sort' => 10, + ]); + + $value3 = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Second'], + 'sort' => 20, + ]); + + $sortedValues = PropertyValue::where('property_id', $property->id)->get(); + + expect($sortedValues->pluck('sort')->toArray())->toBe([10, 20, 30]); + expect($sortedValues->pluck('id')->toArray())->toBe([$value2->id, $value3->id, $value1->id]); +}); + +it('can update sort order', function () { + $property = Property::factory()->create(); + + $value1 = PropertyValue::factory()->create([ + 'property_id' => $property->id, + 'sort' => 10, + ]); + + $value2 = PropertyValue::factory()->create([ + 'property_id' => $property->id, + 'sort' => 20, + ]); + + // Swap sort orders + $value1->update(['sort' => 25]); + $value2->update(['sort' => 5]); + + $sortedValues = PropertyValue::where('property_id', $property->id)->get(); + + expect($sortedValues->first()->id)->toBe($value2->id); + expect($sortedValues->last()->id)->toBe($value1->id); +}); + +it('can create value with all translatable fields', function () { + $property = Property::factory()->create(); + + $value = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => [ + 'en' => 'English Value', + 'sl' => 'Slovenska vrednost', + ], + 'info_url' => [ + 'en' => 'https://example.com/en', + 'sl' => 'https://example.com/sl', + ], + 'image' => [ + 'en' => 'image-en.png', + 'sl' => 'image-sl.png', + ], + 'sort' => 10, + ]); + + expect($value->getTranslation('value', 'en'))->toBe('English Value'); + expect($value->getTranslation('value', 'sl'))->toBe('Slovenska vrednost'); + expect($value->getTranslation('info_url', 'en'))->toBe('https://example.com/en'); + expect($value->getTranslation('info_url', 'sl'))->toBe('https://example.com/sl'); + expect($value->getTranslation('image', 'en'))->toBe('image-en.png'); + expect($value->getTranslation('image', 'sl'))->toBe('image-sl.png'); +}); diff --git a/tests/Unit/PropertyTest.php b/tests/Unit/PropertyTest.php new file mode 100644 index 0000000..843f0e5 --- /dev/null +++ b/tests/Unit/PropertyTest.php @@ -0,0 +1,251 @@ +migrate(); +}); + +it('can create a property', function () { + $property = Property::create([ + 'name' => ['en' => 'Brand'], + 'code' => 'brand', + 'is_active' => true, + 'is_global' => false, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + expect($property)->toBeInstanceOf(Property::class); + expect($property->getTranslation('name', 'en'))->toBe('Brand'); + expect($property->code)->toBe('brand'); + expect($property->is_active)->toBeTrue(); + expect($property->is_global)->toBeFalse(); +}); + +it('converts property code to lowercase', function () { + $property = Property::create([ + 'name' => ['en' => 'Brand Name'], + 'code' => 'BRAND_NAME', + 'is_active' => true, + 'is_global' => false, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + expect($property->code)->toBe('brand_name'); +}); + +it('can create property without code', function () { + $property = Property::create([ + 'name' => ['en' => 'Brand'], + 'is_active' => true, + 'is_global' => false, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + expect($property->code)->toBeNull(); +}); + +it('auto-assigns global property to all existing product types', function () { + $productType1 = ProductType::factory()->create(); + $productType2 = ProductType::factory()->create(); + + $property = Property::create([ + 'name' => ['en' => 'Global Brand'], + 'code' => 'global_brand', + 'is_active' => true, + 'is_global' => true, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + expect($productType1->properties()->where('property_id', $property->id)->exists())->toBeTrue(); + expect($productType2->properties()->where('property_id', $property->id)->exists())->toBeTrue(); +}); + +it('auto-assigns global property to new product types', function () { + $globalProperty = Property::create([ + 'name' => ['en' => 'Global Brand'], + 'code' => 'global_brand', + 'is_active' => true, + 'is_global' => true, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + // Create product type after global property exists + $productType = ProductType::factory()->create(); + + expect($productType->properties()->where('property_id', $globalProperty->id)->exists())->toBeTrue(); +}); + +it('can have property values', function () { + $property = Property::factory()->create(); + $value = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Nike'], + 'sort' => 10, + ]); + + expect($property->values)->toHaveCount(1); + expect($property->values->first()->getTranslation('value', 'en'))->toBe('Nike'); +}); + +it('determines correct form field type for single value properties', function () { + $property = Property::create([ + 'name' => ['en' => 'Brand'], + 'is_active' => true, + 'is_global' => false, + 'max_values' => 1, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + // With less than 4 values, should be radio + PropertyValue::factory()->count(2)->create(['property_id' => $property->id]); + expect($property->getFormFieldType())->toBe('radio'); + + // With 4+ values, should be select + PropertyValue::factory()->count(3)->create(['property_id' => $property->id]); + $property->refresh(); + expect($property->getFormFieldType())->toBe('select'); +}); + +it('determines correct form field type for multiple value properties', function () { + $property = Property::create([ + 'name' => ['en' => 'Colors'], + 'is_active' => true, + 'is_global' => false, + 'max_values' => 3, + 'enable_sorting' => false, + 'is_filter' => false, + ]); + + // With less than 4 values, should be checkbox + PropertyValue::factory()->count(2)->create(['property_id' => $property->id]); + expect($property->getFormFieldType())->toBe('checkbox'); + + // With 4+ values, should be multiselect + PropertyValue::factory()->count(3)->create(['property_id' => $property->id]); + $property->refresh(); + expect($property->getFormFieldType())->toBe('multiselect'); +}); + +it('can be assigned to specific product types', function () { + $property = Property::factory()->create(['is_global' => false]); + $productType1 = ProductType::factory()->create(); + $productType2 = ProductType::factory()->create(); + + $property->productTypes()->attach($productType1->id, ['sort' => 10]); + + expect($property->productTypes)->toHaveCount(1); + expect($property->productTypes->first()->id)->toBe($productType1->id); + expect($productType2->properties()->where('property_id', $property->id)->exists())->toBeFalse(); +}); + +it('can update global status and assign to all product types', function () { + $property = Property::factory()->create(['is_global' => false]); + $productType1 = ProductType::factory()->create(); + $productType2 = ProductType::factory()->create(); + + // Initially not assigned to any product types + expect($property->productTypes)->toHaveCount(0); + + // Update to global + $property->update(['is_global' => true]); + + // Should now be assigned to all product types + expect($productType1->properties()->where('property_id', $property->id)->exists())->toBeTrue(); + expect($productType2->properties()->where('property_id', $property->id)->exists())->toBeTrue(); +}); + +it('can soft delete property', function () { + $property = Property::factory()->create(); + $id = $property->id; + + $property->delete(); + + expect(Property::find($id))->toBeNull(); + expect(Property::withTrashed()->find($id))->not->toBeNull(); + expect(Property::withTrashed()->find($id)->trashed())->toBeTrue(); +}); + +it('can restore soft deleted property', function () { + $property = Property::factory()->create(); + $property->delete(); + + $property->restore(); + + expect($property->trashed())->toBeFalse(); + expect(Property::find($property->id))->not->toBeNull(); +}); + +// Translation tests +it('name attribute is translatable', function () { + $property = Property::factory()->create([ + 'name' => [ + 'en' => 'English Brand', + 'sl' => 'Slovenska znamka', + ], + ]); + + expect($property->getTranslation('name', 'en'))->toBe('English Brand'); + expect($property->getTranslation('name', 'sl'))->toBe('Slovenska znamka'); +}); + +it('description attribute is translatable', function () { + $property = Property::factory()->create([ + 'description' => [ + 'en' => 'English description', + 'sl' => 'Slovenski opis', + ], + ]); + + expect($property->getTranslation('description', 'en'))->toBe('English description'); + expect($property->getTranslation('description', 'sl'))->toBe('Slovenski opis'); +}); + +// Factory tests +it('factory creates valid properties', function () { + $property = Property::factory()->create(); + + expect($property->getTranslation('name', 'en'))->toBeString(); + expect($property->is_active)->toBeBool(); + expect($property->is_global)->toBeBool(); + expect($property->max_values)->toBeInt(); + expect($property->enable_sorting)->toBeBool(); + expect($property->is_filter)->toBeBool(); +}); + +it('factory can create global properties', function () { + $property = Property::factory()->global()->create(); + + expect($property->is_global)->toBeTrue(); +}); + +it('factory can create single value properties', function () { + $property = Property::factory()->singleValue()->create(); + + expect($property->max_values)->toBe(1); +}); + +it('factory can create multiple value properties', function () { + $property = Property::factory()->multipleValues()->create(); + + expect($property->max_values)->toBeGreaterThan(1); +}); + +it('factory can create filter properties', function () { + $property = Property::factory()->filter()->create(); + + expect($property->is_filter)->toBeTrue(); +}); diff --git a/tests/Unit/PropertyValueTest.php b/tests/Unit/PropertyValueTest.php new file mode 100644 index 0000000..c54493e --- /dev/null +++ b/tests/Unit/PropertyValueTest.php @@ -0,0 +1,161 @@ +migrate(); +}); + +it('can create a property value', function () { + $property = Property::factory()->create(); + + $value = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Nike'], + 'sort' => 10, + ]); + + expect($value)->toBeInstanceOf(PropertyValue::class); + expect($value->getTranslation('value', 'en'))->toBe('Nike'); + expect($value->sort)->toBe(10); + expect($value->property_id)->toBe($property->id); +}); + +it('belongs to a property', function () { + $property = Property::factory()->create(); + $value = PropertyValue::factory()->create(['property_id' => $property->id]); + + expect($value->property)->toBeInstanceOf(Property::class); + expect($value->property->id)->toBe($property->id); +}); + +it('can have info url', function () { + $property = Property::factory()->create(); + + $value = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Nike'], + 'info_url' => ['en' => 'https://nike.com'], + 'sort' => 10, + ]); + + expect($value->getTranslation('info_url', 'en'))->toBe('https://nike.com'); +}); + +it('can have image', function () { + $property = Property::factory()->create(); + + $value = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Nike'], + 'image' => ['en' => 'nike-logo.png'], + 'sort' => 10, + ]); + + expect($value->getTranslation('image', 'en'))->toBe('nike-logo.png'); +}); + +it('is sorted by sort field by default', function () { + $property = Property::factory()->create(); + + $value1 = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Third'], + 'sort' => 30, + ]); + + $value2 = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'First'], + 'sort' => 10, + ]); + + $value3 = PropertyValue::create([ + 'property_id' => $property->id, + 'value' => ['en' => 'Second'], + 'sort' => 20, + ]); + + $sortedValues = PropertyValue::where('property_id', $property->id)->get(); + + expect($sortedValues->first()->id)->toBe($value2->id); + expect($sortedValues->get(1)->id)->toBe($value3->id); + expect($sortedValues->last()->id)->toBe($value1->id); +}); + +it('can soft delete property value', function () { + $value = PropertyValue::factory()->create(); + $id = $value->id; + + $value->delete(); + + expect(PropertyValue::find($id))->toBeNull(); + expect(PropertyValue::withTrashed()->find($id))->not->toBeNull(); + expect(PropertyValue::withTrashed()->find($id)->trashed())->toBeTrue(); +}); + +it('can restore soft deleted property value', function () { + $value = PropertyValue::factory()->create(); + $value->delete(); + + $value->restore(); + + expect($value->trashed())->toBeFalse(); + expect(PropertyValue::find($value->id))->not->toBeNull(); +}); + +// Translation tests +it('value attribute is translatable', function () { + $value = PropertyValue::factory()->create([ + 'value' => [ + 'en' => 'English Value', + 'sl' => 'Slovenska vrednost', + ], + ]); + + expect($value->getTranslation('value', 'en'))->toBe('English Value'); + expect($value->getTranslation('value', 'sl'))->toBe('Slovenska vrednost'); +}); + +it('info_url attribute is translatable', function () { + $value = PropertyValue::factory()->create([ + 'info_url' => [ + 'en' => 'https://example.com/en', + 'sl' => 'https://example.com/sl', + ], + ]); + + expect($value->getTranslation('info_url', 'en'))->toBe('https://example.com/en'); + expect($value->getTranslation('info_url', 'sl'))->toBe('https://example.com/sl'); +}); + +it('image attribute is translatable', function () { + $value = PropertyValue::factory()->create([ + 'image' => [ + 'en' => 'image-en.png', + 'sl' => 'image-sl.png', + ], + ]); + + expect($value->getTranslation('image', 'en'))->toBe('image-en.png'); + expect($value->getTranslation('image', 'sl'))->toBe('image-sl.png'); +}); + +// Factory tests +it('factory creates valid property values', function () { + $value = PropertyValue::factory()->create(); + + expect($value->getTranslation('value', 'en'))->toBeString(); + expect($value->sort)->toBeInt(); + expect($value->property_id)->toBeInt(); + expect($value->property)->toBeInstanceOf(Property::class); +}); + +it('factory can create value for specific property', function () { + $property = Property::factory()->create(); + $value = PropertyValue::factory()->forProperty($property)->create(); + + expect($value->property_id)->toBe($property->id); + expect($value->property->id)->toBe($property->id); +}); From f29262d924a3ad9fba716ffc01e95a20fbf159d6 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Thu, 21 Aug 2025 10:14:04 +0200 Subject: [PATCH 04/20] fix: product value fixes on product resource --- .../ProductResource/Pages/CreateProduct.php | 26 ++++++------ .../ProductResource/Pages/EditProduct.php | 41 ++++++++++--------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php index 7fbc21f..ffae6a9 100644 --- a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php @@ -4,7 +4,6 @@ use Eclipse\Catalogue\Filament\Resources\Concerns\HandlesImageUploads; use Eclipse\Catalogue\Filament\Resources\ProductResource; -use Eclipse\Catalogue\Models\Property; use Filament\Actions; use Filament\Resources\Pages\CreateRecord; @@ -24,30 +23,31 @@ protected function getHeaderActions(): array protected function mutateFormDataBeforeCreate(array $data): array { - // Extract property values from form data - $propertyData = []; - foreach ($data as $key => $value) { + foreach (array_keys($data) as $key) { if (str_starts_with($key, 'property_values_')) { - $propertyId = str_replace('property_values_', '', $key); - $propertyData[$propertyId] = $value; unset($data[$key]); } } - // Store property data for later use in afterCreate - $this->propertyData = $propertyData; - return $data; } protected function afterCreate(): void { - // Save property values - if (isset($this->propertyData) && $this->record) { - foreach ($this->propertyData as $propertyId => $values) { + 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); // Remove null values + $valuesToAttach = array_filter($valuesToAttach); if (! empty($valuesToAttach)) { $this->record->propertyValues()->attach($valuesToAttach); diff --git a/src/Filament/Resources/ProductResource/Pages/EditProduct.php b/src/Filament/Resources/ProductResource/Pages/EditProduct.php index 73276e5..3a7b11f 100644 --- a/src/Filament/Resources/ProductResource/Pages/EditProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/EditProduct.php @@ -48,7 +48,7 @@ protected function mutateFormDataBeforeFill(array $data): array foreach ($properties as $property) { $fieldName = "property_values_{$property->id}"; $selectedValues = $this->record->propertyValues() - ->where('property_id', $property->id) + ->where('pim_property_value.property_id', $property->id) ->pluck('pim_property_value.id') ->toArray(); @@ -65,35 +65,36 @@ protected function mutateFormDataBeforeFill(array $data): array protected function mutateFormDataBeforeSave(array $data): array { - // Extract property values from form data - $propertyData = []; - foreach ($data as $key => $value) { + foreach (array_keys($data) as $key) { if (str_starts_with($key, 'property_values_')) { - $propertyId = str_replace('property_values_', '', $key); - $propertyData[$propertyId] = $value; unset($data[$key]); } } - // Store property data for later use in afterSave - $this->propertyData = $propertyData; - return $data; } protected function afterSave(): void { - // Save property values - if (isset($this->propertyData) && $this->record) { - foreach ($this->propertyData as $propertyId => $values) { - // Remove existing values for this property - $this->record->propertyValues() - ->wherePivot('property_value_id', 'IN', function ($query) use ($propertyId) { - $query->select('id') - ->from('pim_property_value') - ->where('property_id', $propertyId); - }) - ->detach(); + 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) { + $idsToDetach = \Eclipse\Catalogue\Models\PropertyValue::query() + ->where('property_id', $propertyId) + ->pluck('id') + ->all(); + + if (! empty($idsToDetach)) { + $this->record->propertyValues()->detach($idsToDetach); + } // Add new values if ($values) { From 6b59264bcc122705e4abd229845bfedfcbf443c1 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Thu, 21 Aug 2025 11:20:43 +0200 Subject: [PATCH 05/20] fix: more testing fixes --- src/Filament/Resources/PropertyResource.php | 44 +++++++++++++++++++ .../Resources/PropertyValueResource.php | 39 ++++++++++++++++ .../Pages/CreatePropertyValue.php | 18 ++++++-- .../Pages/EditPropertyValue.php | 16 ++++--- .../Pages/ListPropertyValues.php | 2 + src/Models/PropertyValue.php | 21 +++++++-- 6 files changed, 129 insertions(+), 11 deletions(-) diff --git a/src/Filament/Resources/PropertyResource.php b/src/Filament/Resources/PropertyResource.php index c699e7b..0db368a 100644 --- a/src/Filament/Resources/PropertyResource.php +++ b/src/Filament/Resources/PropertyResource.php @@ -95,6 +95,50 @@ public static function form(Form $form): Form public static function table(Table $table): Table { return $table + ->relationship(function () { + $state = $this->getLivewire()->getTableFilterState('product_type') ?? []; + + $selected = []; + if (is_array($state)) { + if (array_key_exists('values', $state) && is_array($state['values'])) { + $selected = $state['values']; + } elseif (array_key_exists('value', $state)) { + $selected = is_array($state['value']) ? $state['value'] : [$state['value']]; + } else { + $selected = $state; + } + } + + $selected = array_values(array_filter($selected, fn ($v) => is_numeric($v))); + + if (count($selected) === 1) { + $type = ProductType::find((int) $selected[0]); + + return $type?->properties(); + } + + return null; + }) + ->query(fn () => Property::query()) + ->reorderable( + column: 'pivot.sort', + condition: function (): bool { + $state = $this->getLivewire()->getTableFilterState('product_type') ?? []; + $selected = []; + if (is_array($state)) { + if (array_key_exists('values', $state) && is_array($state['values'])) { + $selected = $state['values']; + } elseif (array_key_exists('value', $state)) { + $selected = is_array($state['value']) ? $state['value'] : [$state['value']]; + } else { + $selected = $state; + } + } + $selected = array_values(array_filter($selected, fn ($v) => is_numeric($v))); + + return count($selected) === 1; + } + ) ->columns([ Tables\Columns\TextColumn::make('code') ->label('Code') diff --git a/src/Filament/Resources/PropertyValueResource.php b/src/Filament/Resources/PropertyValueResource.php index 4ce7f2b..f0fc448 100644 --- a/src/Filament/Resources/PropertyValueResource.php +++ b/src/Filament/Resources/PropertyValueResource.php @@ -7,6 +7,7 @@ use Eclipse\Catalogue\Models\PropertyValue; use Filament\Forms; use Filament\Forms\Form; +use Filament\Resources\Concerns\Translatable; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; @@ -14,6 +15,8 @@ class PropertyValueResource extends Resource { + use Translatable; + protected static ?string $model = PropertyValue::class; protected static ?string $navigationIcon = 'heroicon-o-squares-2x2'; @@ -49,6 +52,30 @@ public static function form(Form $form): Form ->label('Image') ->helperText('Optional image for this value (e.g., brand logo)') ->image() + ->formatStateUsing(function ($state) { + if (is_string($state) || $state === null) { + return $state; + } + + if (is_array($state)) { + $locale = app()->getLocale(); + $byLocale = $state[$locale] ?? null; + if (is_string($byLocale) && $byLocale !== '') { + return $byLocale; + } + + foreach ($state as $value) { + if (is_string($value) && $value !== '') { + return $value; + } + } + + return null; + } + + return null; + }) + ->nullable() ->disk('public') ->directory('property-values'), @@ -139,6 +166,18 @@ public static function getPages(): array ]; } + /** + * Attributes stored as JSON translations on the model. + */ + public static function getTranslatableAttributes(): array + { + return [ + 'value', + 'info_url', + 'image', + ]; + } + public static function getEloquentQuery(): Builder { $query = parent::getEloquentQuery(); diff --git a/src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php b/src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php index 80c0adc..9d59b7c 100644 --- a/src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php +++ b/src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php @@ -4,10 +4,14 @@ use Eclipse\Catalogue\Filament\Resources\PropertyValueResource; use Eclipse\Catalogue\Models\Property; +use Filament\Actions\LocaleSwitcher; use Filament\Resources\Pages\CreateRecord; +use Filament\Resources\Pages\CreateRecord\Concerns\Translatable; class CreatePropertyValue extends CreateRecord { + use Translatable; + protected static string $resource = PropertyValueResource::class; public function mount(): void @@ -22,12 +26,20 @@ public function mount(): void } } + protected function getHeaderActions(): array + { + return [ + LocaleSwitcher::make(), + ]; + } + protected function getRedirectUrl(): string { - if (request()->has('property')) { - return PropertyValueResource::getUrl('index', ['property' => request('property')]); + $propertyId = request('property'); + if ($propertyId) { + return PropertyValueResource::getUrl('index', ['property' => $propertyId]); } - return parent::getRedirectUrl(); + return PropertyValueResource::getUrl('index'); } } diff --git a/src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php b/src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php index 25f72ea..78f6a46 100644 --- a/src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php +++ b/src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php @@ -3,26 +3,32 @@ namespace Eclipse\Catalogue\Filament\Resources\PropertyValueResource\Pages; use Eclipse\Catalogue\Filament\Resources\PropertyValueResource; -use Filament\Actions; +use Filament\Actions\DeleteAction; +use Filament\Actions\LocaleSwitcher; use Filament\Resources\Pages\EditRecord; +use Filament\Resources\Pages\EditRecord\Concerns\Translatable; class EditPropertyValue extends EditRecord { + use Translatable; + protected static string $resource = PropertyValueResource::class; protected function getHeaderActions(): array { return [ - Actions\DeleteAction::make(), + LocaleSwitcher::make(), + DeleteAction::make(), ]; } protected function getRedirectUrl(): string { - if (request()->has('property')) { - return PropertyValueResource::getUrl('index', ['property' => request('property')]); + $propertyId = request('property'); + if ($propertyId) { + return PropertyValueResource::getUrl('index', ['property' => $propertyId]); } - return parent::getRedirectUrl(); + return PropertyValueResource::getUrl('index'); } } diff --git a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php index a9f8ef4..5e538a2 100644 --- a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php +++ b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php @@ -5,6 +5,7 @@ use Eclipse\Catalogue\Filament\Resources\PropertyValueResource; use Eclipse\Catalogue\Models\Property; use Filament\Actions; +use Filament\Actions\LocaleSwitcher; use Filament\Resources\Pages\ListRecords; class ListPropertyValues extends ListRecords @@ -25,6 +26,7 @@ public function mount(): void protected function getHeaderActions(): array { return [ + LocaleSwitcher::make(), Actions\CreateAction::make() ->url(fn (): string => PropertyValueResource::getUrl('create', ['property' => $this->property?->id])), ]; diff --git a/src/Models/PropertyValue.php b/src/Models/PropertyValue.php index 3634a9a..e45eaeb 100644 --- a/src/Models/PropertyValue.php +++ b/src/Models/PropertyValue.php @@ -33,9 +33,6 @@ class PropertyValue extends Model implements HasMedia ]; protected $casts = [ - 'value' => 'array', - 'info_url' => 'array', - 'image' => 'array', 'sort' => 'integer', 'property_id' => 'integer', ]; @@ -72,4 +69,22 @@ protected static function newFactory(): PropertyValueFactory { return PropertyValueFactory::new(); } + + /** + * Ensure Filament receives scalar values for form hydration. + * + * In particular, return the current-locale string (or null) for the + * translatable `image` attribute instead of the full translations array. + */ + public function attributesToArray(): array + { + $attributes = parent::attributesToArray(); + + if (array_key_exists('image', $attributes) && is_array($attributes['image'])) { + $translation = $this->getTranslation('image', app()->getLocale()); + $attributes['image'] = $translation !== '' ? $translation : null; + } + + return $attributes; + } } From 85742f7c02da6e1f869f1b31c6b02f393b49ac1f Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Thu, 21 Aug 2025 11:23:20 +0200 Subject: [PATCH 06/20] fix: remove unnecessary code --- src/Filament/Resources/PropertyResource.php | 44 --------------------- 1 file changed, 44 deletions(-) diff --git a/src/Filament/Resources/PropertyResource.php b/src/Filament/Resources/PropertyResource.php index 0db368a..c699e7b 100644 --- a/src/Filament/Resources/PropertyResource.php +++ b/src/Filament/Resources/PropertyResource.php @@ -95,50 +95,6 @@ public static function form(Form $form): Form public static function table(Table $table): Table { return $table - ->relationship(function () { - $state = $this->getLivewire()->getTableFilterState('product_type') ?? []; - - $selected = []; - if (is_array($state)) { - if (array_key_exists('values', $state) && is_array($state['values'])) { - $selected = $state['values']; - } elseif (array_key_exists('value', $state)) { - $selected = is_array($state['value']) ? $state['value'] : [$state['value']]; - } else { - $selected = $state; - } - } - - $selected = array_values(array_filter($selected, fn ($v) => is_numeric($v))); - - if (count($selected) === 1) { - $type = ProductType::find((int) $selected[0]); - - return $type?->properties(); - } - - return null; - }) - ->query(fn () => Property::query()) - ->reorderable( - column: 'pivot.sort', - condition: function (): bool { - $state = $this->getLivewire()->getTableFilterState('product_type') ?? []; - $selected = []; - if (is_array($state)) { - if (array_key_exists('values', $state) && is_array($state['values'])) { - $selected = $state['values']; - } elseif (array_key_exists('value', $state)) { - $selected = is_array($state['value']) ? $state['value'] : [$state['value']]; - } else { - $selected = $state; - } - } - $selected = array_values(array_filter($selected, fn ($v) => is_numeric($v))); - - return count($selected) === 1; - } - ) ->columns([ Tables\Columns\TextColumn::make('code') ->label('Code') From 295634094d6a5f7756e2648f6bba2035ee812385 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Sat, 23 Aug 2025 17:32:55 +0200 Subject: [PATCH 07/20] chore: add seeder to CatalogueSeeder & fix namespace --- database/seeders/CatalogueSeeder.php | 1 + database/seeders/PropertySeeder.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/database/seeders/CatalogueSeeder.php b/database/seeders/CatalogueSeeder.php index 0c261fa..f7a0128 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(PropertySeeder::class); $this->call(ProductSeeder::class); } } diff --git a/database/seeders/PropertySeeder.php b/database/seeders/PropertySeeder.php index 4820cfb..f3ac957 100644 --- a/database/seeders/PropertySeeder.php +++ b/database/seeders/PropertySeeder.php @@ -1,6 +1,6 @@ Date: Sat, 23 Aug 2025 17:33:59 +0200 Subject: [PATCH 08/20] fix: fix translations --- resources/lang/en/property-value.php | 57 ++++++++++++ resources/lang/en/property.php | 88 +++++++++++++++++++ resources/lang/sl/property-value.php | 57 ++++++++++++ resources/lang/sl/property.php | 88 +++++++++++++++++++ src/Filament/Resources/PropertyResource.php | 76 ++++++++-------- .../PropertyResource/Pages/ListProperties.php | 3 + .../Resources/PropertyValueResource.php | 49 ++++------- .../Pages/ListPropertyValues.php | 3 + 8 files changed, 351 insertions(+), 70 deletions(-) create mode 100644 resources/lang/en/property-value.php create mode 100644 resources/lang/en/property.php create mode 100644 resources/lang/sl/property-value.php create mode 100644 resources/lang/sl/property.php diff --git a/resources/lang/en/property-value.php b/resources/lang/en/property-value.php new file mode 100644 index 0000000..6969ca1 --- /dev/null +++ b/resources/lang/en/property-value.php @@ -0,0 +1,57 @@ + 'Property Value', + 'plural' => 'Property Values', + + 'fields' => [ + 'value' => 'Value', + 'info_url' => 'Info URL', + 'image' => 'Image', + 'sort' => 'Sort Order', + ], + + 'sections' => [ + 'value_information' => 'Value Information', + ], + + 'placeholders' => [ + 'value' => 'Enter property value', + 'info_url' => 'Enter optional "read more" link', + 'sort' => 'Enter sort order (lower numbers appear first)', + ], + + 'help_text' => [ + 'info_url' => 'Optional "read more" link', + 'image' => 'Optional image for this value (e.g., brand logo)', + 'sort' => 'Lower numbers appear first', + ], + + 'table' => [ + 'columns' => [ + 'value' => 'Value', + 'image' => 'Image', + 'info_url' => 'Info URL', + 'sort' => 'Sort', + 'products_count' => 'Products', + 'created_at' => 'Created', + ], + 'filters' => [ + 'property' => 'Property', + ], + 'actions' => [ + 'edit' => 'Edit', + 'delete' => 'Delete', + ], + ], + + 'modal' => [ + 'edit_heading' => 'Edit Property Value', + ], + + 'messages' => [ + 'created' => 'Property value created successfully.', + 'updated' => 'Property value updated successfully.', + 'deleted' => 'Property value deleted successfully.', + ], +]; diff --git a/resources/lang/en/property.php b/resources/lang/en/property.php new file mode 100644 index 0000000..87ba99d --- /dev/null +++ b/resources/lang/en/property.php @@ -0,0 +1,88 @@ + 'Property', + 'plural' => 'Properties', + + 'fields' => [ + 'name' => 'Name', + 'code' => 'Code', + 'description' => 'Description', + 'internal_name' => 'Internal Name', + 'is_active' => 'Active', + 'is_global' => 'Global Property', + 'max_values' => 'Maximum Values', + 'enable_sorting' => 'Enable Manual Sorting', + 'is_filter' => 'Show as Filter', + 'product_types' => 'Assign to Product Types', + ], + + 'sections' => [ + 'basic_information' => 'Basic Information', + 'configuration' => 'Configuration', + 'product_types' => 'Product Types', + ], + + 'placeholders' => [ + 'name' => 'Enter property name', + 'code' => 'Optional alphanumeric code with underscores', + 'description' => 'Enter property description', + 'internal_name' => 'Enter internal name for distinction', + ], + + 'help_text' => [ + 'code' => 'Optional alphanumeric code with underscores, automatically converted to lowercase', + 'internal_name' => 'Internal name for distinction, not translatable', + 'is_global' => 'Auto-assigned to all product types', + 'max_values' => 'Controls form field type: single = radio/select, multiple = checkbox/multiselect', + 'enable_sorting' => 'Allow drag-and-drop sorting of property values', + 'is_filter' => 'Display property as filter in product table', + 'product_types' => 'Select product types for this property (ignored if Global is enabled)', + ], + + 'table' => [ + 'columns' => [ + 'code' => 'Code', + 'name' => 'Name', + 'internal_name' => 'Internal Name', + 'is_global' => 'Global', + 'max_values' => 'Max Values', + 'enable_sorting' => 'Sortable', + 'is_filter' => 'Filter', + 'is_active' => 'Active', + 'values_count' => 'Values', + 'created_at' => 'Created', + ], + 'filters' => [ + 'product_type' => 'Product Type', + 'is_global' => 'Global Properties', + 'is_active' => 'Active Properties', + 'is_filter' => 'Filter Properties', + ], + 'actions' => [ + 'values' => 'Values', + 'edit' => 'Edit', + 'delete' => 'Delete', + ], + ], + + 'options' => [ + 'max_values' => [ + 1 => 'Single value (1)', + 2 => 'Multiple values (2+)', + ], + ], + + 'format' => [ + 'max_values' => [ + 'single' => 'Single', + 'multiple' => 'Multiple', + ], + ], + + 'messages' => [ + 'created' => 'Property created successfully.', + 'updated' => 'Property updated successfully.', + 'deleted' => 'Property deleted successfully.', + ], +]; diff --git a/resources/lang/sl/property-value.php b/resources/lang/sl/property-value.php new file mode 100644 index 0000000..0cf610c --- /dev/null +++ b/resources/lang/sl/property-value.php @@ -0,0 +1,57 @@ + 'Vrednost lastnosti', + 'plural' => 'Vrednosti lastnosti', + + 'fields' => [ + 'value' => 'Vrednost', + 'info_url' => 'URL informacij', + 'image' => 'Slika', + 'sort' => 'Vrstni red', + ], + + 'sections' => [ + 'value_information' => 'Informacije o vrednosti', + ], + + 'placeholders' => [ + 'value' => 'Vnesite vrednost lastnosti', + 'info_url' => 'Vnesite neobvezno povezavo "več informacij"', + 'sort' => 'Vnesite vrstni red (nižje številke se prikažejo prve)', + ], + + 'help_text' => [ + 'info_url' => 'Neobvezna povezava "več informacij"', + 'image' => 'Neobvezna slika za to vrednost (npr. logotip blagovne znamke)', + 'sort' => 'Nižje številke se prikažejo prve', + ], + + 'table' => [ + 'columns' => [ + 'value' => 'Vrednost', + 'image' => 'Slika', + 'info_url' => 'URL informacij', + 'sort' => 'Vrstni red', + 'products_count' => 'Proizvodi', + 'created_at' => 'Ustvarjeno', + ], + 'filters' => [ + 'property' => 'Lastnost', + ], + 'actions' => [ + 'edit' => 'Uredi', + 'delete' => 'Izbriši', + ], + ], + + 'modal' => [ + 'edit_heading' => 'Uredi vrednost lastnosti', + ], + + 'messages' => [ + 'created' => 'Vrednost lastnosti je bila uspešno ustvarjena.', + 'updated' => 'Vrednost lastnosti je bila uspešno posodobljena.', + 'deleted' => 'Vrednost lastnosti je bila uspešno izbrisana.', + ], +]; diff --git a/resources/lang/sl/property.php b/resources/lang/sl/property.php new file mode 100644 index 0000000..4909266 --- /dev/null +++ b/resources/lang/sl/property.php @@ -0,0 +1,88 @@ + 'Lastnost', + 'plural' => 'Lastnosti', + + 'fields' => [ + 'name' => 'Ime', + 'code' => 'Koda', + 'description' => 'Opis', + 'internal_name' => 'Interno ime', + 'is_active' => 'Aktiven', + 'is_global' => 'Globalna lastnost', + 'max_values' => 'Največje število vrednosti', + 'enable_sorting' => 'Omogoči ročno razvrščanje', + 'is_filter' => 'Prikaži kot filter', + 'product_types' => 'Dodeli tipom proizvodov', + ], + + 'sections' => [ + 'basic_information' => 'Osnovne informacije', + 'configuration' => 'Konfiguracija', + 'product_types' => 'Tipi proizvodov', + ], + + 'placeholders' => [ + 'name' => 'Vnesite ime lastnosti', + 'code' => 'Neobvezna alfanumerična koda s podčrtaji', + 'description' => 'Vnesite opis lastnosti', + 'internal_name' => 'Vnesite interno ime za razlikovanje', + ], + + 'help_text' => [ + 'code' => 'Neobvezna alfanumerična koda s podčrtaji, avtomatsko pretvorjena v male črke', + 'internal_name' => 'Interno ime za razlikovanje, ni prevedeno', + 'is_global' => 'Avtomatsko dodeljeno vsem tipom proizvodov', + 'max_values' => 'Nadzoruje tip polja obrazca: ena = radio/select, več = checkbox/multiselect', + 'enable_sorting' => 'Dovoli razvrščanje vrednosti lastnosti z vlečenjem', + 'is_filter' => 'Prikaži lastnost kot filter v tabeli proizvodov', + 'product_types' => 'Izberi tipe proizvodov za to lastnost (ignorirano, če je Global omogočeno)', + ], + + 'table' => [ + 'columns' => [ + 'code' => 'Koda', + 'name' => 'Ime', + 'internal_name' => 'Interno ime', + 'is_global' => 'Globalna', + 'max_values' => 'Največ vrednosti', + 'enable_sorting' => 'Razvrščanje', + 'is_filter' => 'Filter', + 'is_active' => 'Aktiven', + 'values_count' => 'Vrednosti', + 'created_at' => 'Ustvarjeno', + ], + 'filters' => [ + 'product_type' => 'Tip proizvoda', + 'is_global' => 'Globalne lastnosti', + 'is_active' => 'Aktivne lastnosti', + 'is_filter' => 'Lastnosti filtra', + ], + 'actions' => [ + 'values' => 'Vrednosti', + 'edit' => 'Uredi', + 'delete' => 'Izbriši', + ], + ], + + 'options' => [ + 'max_values' => [ + 1 => 'Ena vrednost (1)', + 2 => 'Več vrednosti (2+)', + ], + ], + + 'format' => [ + 'max_values' => [ + 'single' => 'Ena', + 'multiple' => 'Več', + ], + ], + + 'messages' => [ + 'created' => 'Lastnost je bila uspešno ustvarjena.', + 'updated' => 'Lastnost je bila uspešno posodobljena.', + 'deleted' => 'Lastnost je bila uspešno izbrisana.', + ], +]; diff --git a/src/Filament/Resources/PropertyResource.php b/src/Filament/Resources/PropertyResource.php index c699e7b..2490855 100644 --- a/src/Filament/Resources/PropertyResource.php +++ b/src/Filament/Resources/PropertyResource.php @@ -28,64 +28,64 @@ public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\Section::make('Basic Information') + Forms\Components\Section::make(__('eclipse-catalogue::property.sections.basic_information')) ->schema([ Forms\Components\TextInput::make('name') - ->label('Name') + ->label(__('eclipse-catalogue::property.fields.name')) ->required() ->maxLength(255), Forms\Components\TextInput::make('code') - ->label('Code') - ->helperText('Optional alphanumeric code with underscores, automatically converted to lowercase') + ->label(__('eclipse-catalogue::property.fields.code')) + ->helperText(__('eclipse-catalogue::property.help_text.code')) ->regex('/^[a-zA-Z0-9_]*$/') ->unique(ignoreRecord: true), Forms\Components\Textarea::make('description') - ->label('Description') + ->label(__('eclipse-catalogue::property.fields.description')) ->rows(3), Forms\Components\TextInput::make('internal_name') - ->label('Internal Name') - ->helperText('Internal name for distinction, not translatable') + ->label(__('eclipse-catalogue::property.fields.internal_name')) + ->helperText(__('eclipse-catalogue::property.help_text.internal_name')) ->maxLength(255), ])->columns(2), - Forms\Components\Section::make('Configuration') + Forms\Components\Section::make(__('eclipse-catalogue::property.sections.configuration')) ->schema([ Forms\Components\Toggle::make('is_active') - ->label('Active') + ->label(__('eclipse-catalogue::property.fields.is_active')) ->default(true), Forms\Components\Toggle::make('is_global') - ->label('Global Property') - ->helperText('Auto-assigned to all product types') + ->label(__('eclipse-catalogue::property.fields.is_global')) + ->helperText(__('eclipse-catalogue::property.help_text.is_global')) ->reactive(), Forms\Components\Select::make('max_values') - ->label('Maximum Values') + ->label(__('eclipse-catalogue::property.fields.max_values')) ->options([ - 1 => 'Single value (1)', - 2 => 'Multiple values (2+)', + 1 => __('eclipse-catalogue::property.options.max_values.1'), + 2 => __('eclipse-catalogue::property.options.max_values.2'), ]) - ->helperText('Controls form field type: single = radio/select, multiple = checkbox/multiselect'), + ->helperText(__('eclipse-catalogue::property.help_text.max_values')), Forms\Components\Toggle::make('enable_sorting') - ->label('Enable Manual Sorting') - ->helperText('Allow drag-and-drop sorting of property values'), + ->label(__('eclipse-catalogue::property.fields.enable_sorting')) + ->helperText(__('eclipse-catalogue::property.help_text.enable_sorting')), Forms\Components\Toggle::make('is_filter') - ->label('Show as Filter') - ->helperText('Display property as filter in product table'), + ->label(__('eclipse-catalogue::property.fields.is_filter')) + ->helperText(__('eclipse-catalogue::property.help_text.is_filter')), ])->columns(2), - Forms\Components\Section::make('Product Types') + Forms\Components\Section::make(__('eclipse-catalogue::property.sections.product_types')) ->schema([ Forms\Components\CheckboxList::make('product_types') - ->label('Assign to Product Types') + ->label(__('eclipse-catalogue::property.fields.product_types')) ->relationship('productTypes', 'name') ->options(ProductType::pluck('name', 'id')) - ->helperText('Select product types for this property (ignored if Global is enabled)') + ->helperText(__('eclipse-catalogue::property.help_text.product_types')) ->hidden(fn (Forms\Get $get) => $get('is_global')), ]) ->hidden(fn (Forms\Get $get) => $get('is_global')), @@ -97,69 +97,69 @@ public static function table(Table $table): Table return $table ->columns([ Tables\Columns\TextColumn::make('code') - ->label('Code') + ->label(__('eclipse-catalogue::property.table.columns.code')) ->searchable() ->sortable(), Tables\Columns\TextColumn::make('name') - ->label('Name') + ->label(__('eclipse-catalogue::property.table.columns.name')) ->searchable() ->sortable(), Tables\Columns\TextColumn::make('internal_name') - ->label('Internal Name') + ->label(__('eclipse-catalogue::property.table.columns.internal_name')) ->searchable() ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\IconColumn::make('is_global') - ->label('Global') + ->label(__('eclipse-catalogue::property.table.columns.is_global')) ->boolean(), Tables\Columns\TextColumn::make('max_values') - ->label('Max Values') - ->formatStateUsing(fn ($state) => $state === 1 ? 'Single' : 'Multiple'), + ->label(__('eclipse-catalogue::property.table.columns.max_values')) + ->formatStateUsing(fn ($state) => $state === 1 ? __('eclipse-catalogue::property.format.max_values.single') : __('eclipse-catalogue::property.format.max_values.multiple')), Tables\Columns\IconColumn::make('enable_sorting') - ->label('Sortable') + ->label(__('eclipse-catalogue::property.table.columns.enable_sorting')) ->boolean(), Tables\Columns\IconColumn::make('is_filter') - ->label('Filter') + ->label(__('eclipse-catalogue::property.table.columns.is_filter')) ->boolean(), Tables\Columns\IconColumn::make('is_active') - ->label('Active') + ->label(__('eclipse-catalogue::property.table.columns.is_active')) ->boolean(), Tables\Columns\TextColumn::make('values_count') - ->label('Values') + ->label(__('eclipse-catalogue::property.table.columns.values_count')) ->counts('values'), Tables\Columns\TextColumn::make('created_at') - ->label('Created') + ->label(__('eclipse-catalogue::property.table.columns.created_at')) ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ Tables\Filters\SelectFilter::make('product_type') - ->label('Product Type') + ->label(__('eclipse-catalogue::property.table.filters.product_type')) ->relationship('productTypes', 'name') ->multiple(), Tables\Filters\TernaryFilter::make('is_global') - ->label('Global Properties'), + ->label(__('eclipse-catalogue::property.table.filters.is_global')), Tables\Filters\TernaryFilter::make('is_active') - ->label('Active Properties'), + ->label(__('eclipse-catalogue::property.table.filters.is_active')), Tables\Filters\TernaryFilter::make('is_filter') - ->label('Filter Properties'), + ->label(__('eclipse-catalogue::property.table.filters.is_filter')), ]) ->actions([ Tables\Actions\ActionGroup::make([ Tables\Actions\Action::make('values') - ->label('Values') + ->label(__('eclipse-catalogue::property.table.actions.values')) ->icon('heroicon-o-list-bullet') ->url(fn (Property $record): string => PropertyValueResource::getUrl('index', ['property' => $record->id])), Tables\Actions\EditAction::make(), diff --git a/src/Filament/Resources/PropertyResource/Pages/ListProperties.php b/src/Filament/Resources/PropertyResource/Pages/ListProperties.php index 8c90c2f..16d8919 100644 --- a/src/Filament/Resources/PropertyResource/Pages/ListProperties.php +++ b/src/Filament/Resources/PropertyResource/Pages/ListProperties.php @@ -5,9 +5,12 @@ use Eclipse\Catalogue\Filament\Resources\PropertyResource; use Filament\Actions; use Filament\Resources\Pages\ListRecords; +use Filament\Resources\Pages\ListRecords\Concerns\Translatable; class ListProperties extends ListRecords { + use Translatable; + protected static string $resource = PropertyResource::class; protected function getHeaderActions(): array diff --git a/src/Filament/Resources/PropertyValueResource.php b/src/Filament/Resources/PropertyValueResource.php index f0fc448..9fca085 100644 --- a/src/Filament/Resources/PropertyValueResource.php +++ b/src/Filament/Resources/PropertyValueResource.php @@ -29,28 +29,22 @@ public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\Section::make('Value Information') + Forms\Components\Section::make(__('eclipse-catalogue::property-value.sections.value_information')) ->schema([ - Forms\Components\Select::make('property_id') - ->label('Property') - ->relationship('property', 'name') - ->required() - ->disabled(fn ($livewire) => $livewire instanceof Pages\CreatePropertyValue && request()->has('property')), - Forms\Components\TextInput::make('value') - ->label('Value') + ->label(__('eclipse-catalogue::property-value.fields.value')) ->required() ->maxLength(255), Forms\Components\TextInput::make('info_url') - ->label('Info URL') - ->helperText('Optional "read more" link') + ->label(__('eclipse-catalogue::property-value.fields.info_url')) + ->helperText(__('eclipse-catalogue::property-value.help_text.info_url')) ->url() ->maxLength(255), Forms\Components\FileUpload::make('image') - ->label('Image') - ->helperText('Optional image for this value (e.g., brand logo)') + ->label(__('eclipse-catalogue::property-value.fields.image')) + ->helperText(__('eclipse-catalogue::property-value.help_text.image')) ->image() ->formatStateUsing(function ($state) { if (is_string($state) || $state === null) { @@ -78,13 +72,7 @@ public static function form(Form $form): Form ->nullable() ->disk('public') ->directory('property-values'), - - Forms\Components\TextInput::make('sort') - ->label('Sort Order') - ->numeric() - ->default(0) - ->helperText('Lower numbers appear first'), - ])->columns(2), + ]), ]); } @@ -95,46 +83,44 @@ public static function table(Table $table): Table $table = $table ->columns([ - Tables\Columns\TextColumn::make('property.name') - ->label('Property') - ->searchable() - ->sortable(), - Tables\Columns\TextColumn::make('value') - ->label('Value') + ->label(__('eclipse-catalogue::property-value.table.columns.value')) ->searchable() ->sortable(), Tables\Columns\ImageColumn::make('image') - ->label('Image') + ->label(__('eclipse-catalogue::property-value.table.columns.image')) ->disk('public') ->size(40), Tables\Columns\TextColumn::make('info_url') - ->label('Info URL') + ->label(__('eclipse-catalogue::property-value.table.columns.info_url')) ->limit(50) ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('sort') - ->label('Sort') + ->label(__('eclipse-catalogue::property-value.table.columns.sort')) ->sortable(), Tables\Columns\TextColumn::make('products_count') - ->label('Products') + ->label(__('eclipse-catalogue::property-value.table.columns.products_count')) ->counts('products'), Tables\Columns\TextColumn::make('created_at') - ->label('Created') + ->label(__('eclipse-catalogue::property-value.table.columns.created_at')) ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ Tables\Filters\SelectFilter::make('property') + ->label(__('eclipse-catalogue::property-value.table.filters.property')) ->relationship('property', 'name'), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->modalWidth('lg') + ->modalHeading(__('eclipse-catalogue::property-value.modal.edit_heading')), Tables\Actions\DeleteAction::make(), ]) ->bulkActions([ @@ -162,7 +148,6 @@ public static function getPages(): array return [ 'index' => Pages\ListPropertyValues::route('/'), 'create' => Pages\CreatePropertyValue::route('/create'), - 'edit' => Pages\EditPropertyValue::route('/{record}/edit'), ]; } diff --git a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php index 5e538a2..fc244af 100644 --- a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php +++ b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php @@ -7,9 +7,12 @@ use Filament\Actions; use Filament\Actions\LocaleSwitcher; use Filament\Resources\Pages\ListRecords; +use Filament\Resources\Pages\ListRecords\Concerns\Translatable; class ListPropertyValues extends ListRecords { + use Translatable; + protected static string $resource = PropertyValueResource::class; public ?Property $property = null; From 9e91b6815957b39ef4faf7d161279b830532b0dc Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Sat, 23 Aug 2025 18:00:10 +0200 Subject: [PATCH 09/20] fix: fix breadcrumbs & add more translations --- resources/lang/en/property-value.php | 11 +++++++++++ resources/lang/sl/property-value.php | 11 +++++++++++ .../Pages/ListPropertyValues.php | 19 +++++++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/resources/lang/en/property-value.php b/resources/lang/en/property-value.php index 6969ca1..e36619d 100644 --- a/resources/lang/en/property-value.php +++ b/resources/lang/en/property-value.php @@ -54,4 +54,15 @@ 'updated' => 'Property value updated successfully.', 'deleted' => 'Property value deleted successfully.', ], + + 'pages' => [ + 'title' => [ + 'with_property' => 'Values for: :property', + 'default' => 'Property Values', + ], + 'breadcrumbs' => [ + 'properties' => 'Properties', + 'list' => 'List', + ], + ], ]; diff --git a/resources/lang/sl/property-value.php b/resources/lang/sl/property-value.php index 0cf610c..560e189 100644 --- a/resources/lang/sl/property-value.php +++ b/resources/lang/sl/property-value.php @@ -54,4 +54,15 @@ 'updated' => 'Vrednost lastnosti je bila uspešno posodobljena.', 'deleted' => 'Vrednost lastnosti je bila uspešno izbrisana.', ], + + 'pages' => [ + 'title' => [ + 'with_property' => 'Vrednosti za: :property', + 'default' => 'Vrednosti lastnosti', + ], + 'breadcrumbs' => [ + 'properties' => 'Lastnosti', + 'list' => 'Seznam', + ], + ], ]; diff --git a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php index fc244af..073d6ae 100644 --- a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php +++ b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php @@ -2,6 +2,7 @@ namespace Eclipse\Catalogue\Filament\Resources\PropertyValueResource\Pages; +use Eclipse\Catalogue\Filament\Resources\PropertyResource; use Eclipse\Catalogue\Filament\Resources\PropertyValueResource; use Eclipse\Catalogue\Models\Property; use Filament\Actions; @@ -36,11 +37,25 @@ protected function getHeaderActions(): array } public function getTitle(): string + { + return $this->property + ? __('eclipse-catalogue::property-value.pages.title.with_property', ['property' => $this->property->name]) + : __('eclipse-catalogue::property-value.pages.title.default'); + } + + public function getBreadcrumbs(): array { if ($this->property) { - return "Values for: {$this->property->name}"; + return [ + PropertyResource::getUrl('index') => __('eclipse-catalogue::property-value.pages.breadcrumbs.properties'), + null => $this->property->name, + request()->url() => __('eclipse-catalogue::property-value.pages.breadcrumbs.list'), + ]; } - return 'Property Values'; + return [ + PropertyValueResource::getUrl('index') => __('eclipse-catalogue::property-value.pages.title.default'), + request()->url() => __('eclipse-catalogue::property-value.pages.breadcrumbs.list'), + ]; } } From 80a8fb3acc7bbf80c9ae72865ba3fd35bfb35e39 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Sat, 23 Aug 2025 18:14:32 +0200 Subject: [PATCH 10/20] fix: db structure fixes --- ...172834_create_pim_property_value_table.php | 3 +- ...te_pim_product_type_has_property_table.php | 4 +- ...logue_product_has_property_value_table.php | 4 +- ..._175841_add_indexes_to_property_tables.php | 42 ------------------- src/Models/PropertyValue.php | 13 +----- tests/Feature/PropertyCrudTest.php | 16 ------- tests/Feature/PropertyIntegrationTest.php | 24 ----------- tests/Feature/PropertyValueCrudTest.php | 22 ---------- tests/Unit/PropertyValueTest.php | 21 ---------- 9 files changed, 6 insertions(+), 143 deletions(-) delete mode 100644 database/migrations/2025_08_19_175841_add_indexes_to_property_tables.php diff --git a/database/migrations/2025_08_19_172834_create_pim_property_value_table.php b/database/migrations/2025_08_19_172834_create_pim_property_value_table.php index 9c75ce4..6cbec09 100644 --- a/database/migrations/2025_08_19_172834_create_pim_property_value_table.php +++ b/database/migrations/2025_08_19_172834_create_pim_property_value_table.php @@ -13,13 +13,12 @@ public function up(): void { Schema::create('pim_property_value', function (Blueprint $table) { $table->id(); - $table->foreignId('property_id')->constrained('pim_property')->onDelete('cascade'); + $table->foreignId('property_id')->constrained('pim_property')->onDelete('cascade')->onUpdate('cascade'); $table->string('value'); $table->smallInteger('sort')->default(0); $table->string('info_url')->nullable(); $table->string('image')->nullable(); $table->timestamps(); - $table->softDeletes(); }); } diff --git a/database/migrations/2025_08_19_174519_create_pim_product_type_has_property_table.php b/database/migrations/2025_08_19_174519_create_pim_product_type_has_property_table.php index 396f924..5fea7b4 100644 --- a/database/migrations/2025_08_19_174519_create_pim_product_type_has_property_table.php +++ b/database/migrations/2025_08_19_174519_create_pim_product_type_has_property_table.php @@ -12,8 +12,8 @@ public function up(): void { Schema::create('pim_product_type_has_property', function (Blueprint $table) { - $table->foreignId('product_type_id')->constrained('pim_product_types')->onDelete('cascade'); - $table->foreignId('property_id')->constrained('pim_property')->onDelete('cascade'); + $table->foreignId('product_type_id')->constrained('pim_product_types')->onDelete('cascade')->onUpdate('cascade'); + $table->foreignId('property_id')->constrained('pim_property')->onDelete('cascade')->onUpdate('cascade'); $table->smallInteger('sort')->nullable(); $table->timestamps(); $table->primary(['product_type_id', 'property_id']); diff --git a/database/migrations/2025_08_19_175623_create_catalogue_product_has_property_value_table.php b/database/migrations/2025_08_19_175623_create_catalogue_product_has_property_value_table.php index 05017a9..c5675f9 100644 --- a/database/migrations/2025_08_19_175623_create_catalogue_product_has_property_value_table.php +++ b/database/migrations/2025_08_19_175623_create_catalogue_product_has_property_value_table.php @@ -12,8 +12,8 @@ public function up(): void { Schema::create('catalogue_product_has_property_value', function (Blueprint $table) { - $table->foreignId('product_id')->constrained('catalogue_products')->onDelete('cascade'); - $table->foreignId('property_value_id')->constrained('pim_property_value')->onDelete('cascade'); + $table->foreignId('product_id')->constrained('catalogue_products')->onDelete('cascade')->onUpdate('cascade'); + $table->foreignId('property_value_id')->constrained('pim_property_value')->onDelete('cascade')->onUpdate('cascade'); $table->timestamps(); $table->unique(['product_id', 'property_value_id'], 'product_property_value_unique'); }); diff --git a/database/migrations/2025_08_19_175841_add_indexes_to_property_tables.php b/database/migrations/2025_08_19_175841_add_indexes_to_property_tables.php deleted file mode 100644 index 15a08fa..0000000 --- a/database/migrations/2025_08_19_175841_add_indexes_to_property_tables.php +++ /dev/null @@ -1,42 +0,0 @@ -index(['is_active', 'is_global']); - $table->index('is_filter'); - }); - Schema::table('pim_property_value', function (Blueprint $table) { - $table->index(['property_id', 'sort']); - }); - Schema::table('pim_product_type_has_property', function (Blueprint $table) { - $table->index(['product_type_id', 'sort']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('pim_property', function (Blueprint $table) { - $table->dropIndex('pim_property_is_active_is_global_index'); - $table->dropIndex('pim_property_is_filter_index'); - }); - Schema::table('pim_property_value', function (Blueprint $table) { - $table->dropIndex('pim_property_value_property_id_sort_index'); - }); - Schema::table('pim_product_type_has_property', function (Blueprint $table) { - $table->dropIndex('pim_product_type_has_property_product_type_id_sort_index'); - }); - } -}; diff --git a/src/Models/PropertyValue.php b/src/Models/PropertyValue.php index e45eaeb..3184faf 100644 --- a/src/Models/PropertyValue.php +++ b/src/Models/PropertyValue.php @@ -7,14 +7,13 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Translatable\HasTranslations; class PropertyValue extends Model implements HasMedia { - use HasFactory, HasTranslations, InteractsWithMedia, SoftDeletes; + use HasFactory, HasTranslations, InteractsWithMedia; protected $table = 'pim_property_value'; @@ -55,16 +54,6 @@ public function registerMediaCollections(): void ->useDisk('public'); } - protected static function booted(): void - { - static::deleting(function (PropertyValue $value) { - if ($value->isForceDeleting()) { - // Delete product assignments - $value->products()->detach(); - } - }); - } - protected static function newFactory(): PropertyValueFactory { return PropertyValueFactory::new(); diff --git a/tests/Feature/PropertyCrudTest.php b/tests/Feature/PropertyCrudTest.php index 63f034a..54d0390 100644 --- a/tests/Feature/PropertyCrudTest.php +++ b/tests/Feature/PropertyCrudTest.php @@ -152,22 +152,6 @@ ]); }); -it('cascades delete to property values', function () { - $property = Property::factory()->create(); - $value = PropertyValue::factory()->create(['property_id' => $property->id]); - - // First soft delete, then force delete to test cascade - $property->delete(); - $property->forceDelete(); - - $this->assertDatabaseMissing('pim_property', [ - 'id' => $property->id, - ]); - - // Property value should also be force deleted due to cascade - expect(PropertyValue::withTrashed()->find($value->id))->toBeNull(); -}); - it('cascades delete to product type assignments', function () { $property = Property::factory()->create(['is_global' => false]); $productType = ProductType::factory()->create(); diff --git a/tests/Feature/PropertyIntegrationTest.php b/tests/Feature/PropertyIntegrationTest.php index a2310e3..e61238b 100644 --- a/tests/Feature/PropertyIntegrationTest.php +++ b/tests/Feature/PropertyIntegrationTest.php @@ -213,27 +213,3 @@ 'property_id' => $property->id, ]); }); - -it('deleting property value removes product assignments', function () { - $product = Product::factory()->create(); - $property = Property::factory()->create(); - $value = PropertyValue::factory()->create(['property_id' => $property->id]); - - $product->propertyValues()->attach($value->id); - - // Verify assignment exists - $this->assertDatabaseHas('catalogue_product_has_property_value', [ - 'product_id' => $product->id, - 'property_value_id' => $value->id, - ]); - - // First soft delete, then force delete to test cascade - $value->delete(); - $value->forceDelete(); - - // Verify assignment is removed - $this->assertDatabaseMissing('catalogue_product_has_property_value', [ - 'product_id' => $product->id, - 'property_value_id' => $value->id, - ]); -}); diff --git a/tests/Feature/PropertyValueCrudTest.php b/tests/Feature/PropertyValueCrudTest.php index 2f80137..be34f7f 100644 --- a/tests/Feature/PropertyValueCrudTest.php +++ b/tests/Feature/PropertyValueCrudTest.php @@ -50,28 +50,6 @@ ]); }); -it('can soft delete a property value', function () { - $value = PropertyValue::factory()->create(); - - $value->delete(); - - $this->assertSoftDeleted('pim_property_value', [ - 'id' => $value->id, - ]); -}); - -it('can restore a soft deleted property value', function () { - $value = PropertyValue::factory()->create(); - - $value->delete(); - $value->restore(); - - $this->assertDatabaseHas('pim_property_value', [ - 'id' => $value->id, - 'deleted_at' => null, - ]); -}); - it('maintains sort order when creating multiple values', function () { $property = Property::factory()->create(); diff --git a/tests/Unit/PropertyValueTest.php b/tests/Unit/PropertyValueTest.php index c54493e..4c0810b 100644 --- a/tests/Unit/PropertyValueTest.php +++ b/tests/Unit/PropertyValueTest.php @@ -84,27 +84,6 @@ expect($sortedValues->last()->id)->toBe($value1->id); }); -it('can soft delete property value', function () { - $value = PropertyValue::factory()->create(); - $id = $value->id; - - $value->delete(); - - expect(PropertyValue::find($id))->toBeNull(); - expect(PropertyValue::withTrashed()->find($id))->not->toBeNull(); - expect(PropertyValue::withTrashed()->find($id)->trashed())->toBeTrue(); -}); - -it('can restore soft deleted property value', function () { - $value = PropertyValue::factory()->create(); - $value->delete(); - - $value->restore(); - - expect($value->trashed())->toBeFalse(); - expect(PropertyValue::find($value->id))->not->toBeNull(); -}); - // Translation tests it('value attribute is translatable', function () { $value = PropertyValue::factory()->create([ From 7a0b436d4376df50ee862c0c1507784a97d610b0 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Sat, 23 Aug 2025 18:20:08 +0200 Subject: [PATCH 11/20] fix: max values fix --- resources/lang/en/property.php | 14 +------------- resources/lang/sl/property.php | 14 +------------- src/Filament/Resources/PropertyResource.php | 11 +++++------ src/Models/PropertyValue.php | 7 +++++++ 4 files changed, 14 insertions(+), 32 deletions(-) diff --git a/resources/lang/en/property.php b/resources/lang/en/property.php index 87ba99d..5c6386b 100644 --- a/resources/lang/en/property.php +++ b/resources/lang/en/property.php @@ -34,7 +34,7 @@ 'code' => 'Optional alphanumeric code with underscores, automatically converted to lowercase', 'internal_name' => 'Internal name for distinction, not translatable', 'is_global' => 'Auto-assigned to all product types', - 'max_values' => 'Controls form field type: single = radio/select, multiple = checkbox/multiselect', + 'max_values' => 'Maximum number of values allowed for this property (1 = single value, 2+ = multiple values)', 'enable_sorting' => 'Allow drag-and-drop sorting of property values', 'is_filter' => 'Display property as filter in product table', 'product_types' => 'Select product types for this property (ignored if Global is enabled)', @@ -66,19 +66,7 @@ ], ], - 'options' => [ - 'max_values' => [ - 1 => 'Single value (1)', - 2 => 'Multiple values (2+)', - ], - ], - 'format' => [ - 'max_values' => [ - 'single' => 'Single', - 'multiple' => 'Multiple', - ], - ], 'messages' => [ 'created' => 'Property created successfully.', diff --git a/resources/lang/sl/property.php b/resources/lang/sl/property.php index 4909266..cfe36c6 100644 --- a/resources/lang/sl/property.php +++ b/resources/lang/sl/property.php @@ -34,7 +34,7 @@ 'code' => 'Neobvezna alfanumerična koda s podčrtaji, avtomatsko pretvorjena v male črke', 'internal_name' => 'Interno ime za razlikovanje, ni prevedeno', 'is_global' => 'Avtomatsko dodeljeno vsem tipom proizvodov', - 'max_values' => 'Nadzoruje tip polja obrazca: ena = radio/select, več = checkbox/multiselect', + 'max_values' => 'Največje število vrednosti, ki so dovoljene za to lastnost (1 = ena vrednost, 2+ = več vrednosti)', 'enable_sorting' => 'Dovoli razvrščanje vrednosti lastnosti z vlečenjem', 'is_filter' => 'Prikaži lastnost kot filter v tabeli proizvodov', 'product_types' => 'Izberi tipe proizvodov za to lastnost (ignorirano, če je Global omogočeno)', @@ -66,19 +66,7 @@ ], ], - 'options' => [ - 'max_values' => [ - 1 => 'Ena vrednost (1)', - 2 => 'Več vrednosti (2+)', - ], - ], - 'format' => [ - 'max_values' => [ - 'single' => 'Ena', - 'multiple' => 'Več', - ], - ], 'messages' => [ 'created' => 'Lastnost je bila uspešno ustvarjena.', diff --git a/src/Filament/Resources/PropertyResource.php b/src/Filament/Resources/PropertyResource.php index 2490855..0806666 100644 --- a/src/Filament/Resources/PropertyResource.php +++ b/src/Filament/Resources/PropertyResource.php @@ -62,12 +62,11 @@ public static function form(Form $form): Form ->helperText(__('eclipse-catalogue::property.help_text.is_global')) ->reactive(), - Forms\Components\Select::make('max_values') + Forms\Components\TextInput::make('max_values') ->label(__('eclipse-catalogue::property.fields.max_values')) - ->options([ - 1 => __('eclipse-catalogue::property.options.max_values.1'), - 2 => __('eclipse-catalogue::property.options.max_values.2'), - ]) + ->numeric() + ->minValue(1) + ->maxValue(10) ->helperText(__('eclipse-catalogue::property.help_text.max_values')), Forms\Components\Toggle::make('enable_sorting') @@ -117,7 +116,7 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('max_values') ->label(__('eclipse-catalogue::property.table.columns.max_values')) - ->formatStateUsing(fn ($state) => $state === 1 ? __('eclipse-catalogue::property.format.max_values.single') : __('eclipse-catalogue::property.format.max_values.multiple')), + ->numeric(), Tables\Columns\IconColumn::make('enable_sorting') ->label(__('eclipse-catalogue::property.table.columns.enable_sorting')) diff --git a/src/Models/PropertyValue.php b/src/Models/PropertyValue.php index 3184faf..bab243e 100644 --- a/src/Models/PropertyValue.php +++ b/src/Models/PropertyValue.php @@ -59,6 +59,13 @@ protected static function newFactory(): PropertyValueFactory return PropertyValueFactory::new(); } + protected static function booted(): void + { + static::addGlobalScope('orderBySort', function ($query) { + $query->orderBy('sort'); + }); + } + /** * Ensure Filament receives scalar values for form hydration. * From 5d25af8b5c68d9c3dbbcbc995d1ad1b9f85ef080 Mon Sep 17 00:00:00 2001 From: KilianTrunk <75316208+KilianTrunk@users.noreply.github.com> Date: Sat, 23 Aug 2025 16:20:31 +0000 Subject: [PATCH 12/20] style: fix code style --- resources/lang/en/property.php | 2 -- resources/lang/sl/property.php | 2 -- 2 files changed, 4 deletions(-) diff --git a/resources/lang/en/property.php b/resources/lang/en/property.php index 5c6386b..e86f394 100644 --- a/resources/lang/en/property.php +++ b/resources/lang/en/property.php @@ -66,8 +66,6 @@ ], ], - - 'messages' => [ 'created' => 'Property created successfully.', 'updated' => 'Property updated successfully.', diff --git a/resources/lang/sl/property.php b/resources/lang/sl/property.php index cfe36c6..ffdc89a 100644 --- a/resources/lang/sl/property.php +++ b/resources/lang/sl/property.php @@ -66,8 +66,6 @@ ], ], - - 'messages' => [ 'created' => 'Lastnost je bila uspešno ustvarjena.', 'updated' => 'Lastnost je bila uspešno posodobljena.', From 07531f8eef4063e7a40c8f7335cc70c926b807d6 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Sat, 23 Aug 2025 18:45:46 +0200 Subject: [PATCH 13/20] chore: more improvements --- resources/lang/en/property-value.php | 1 + resources/lang/en/property.php | 2 - resources/lang/sl/property-value.php | 1 + resources/lang/sl/property.php | 2 - src/Filament/Resources/ProductResource.php | 126 ++++++++++++++---- .../PropertiesRelationManager.php | 9 +- .../Resources/PropertyValueResource.php | 1 - .../Pages/CreatePropertyValue.php | 45 ------- .../Pages/EditPropertyValue.php | 34 ----- .../Pages/ListPropertyValues.php | 31 ++++- 10 files changed, 142 insertions(+), 110 deletions(-) delete mode 100644 src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php delete mode 100644 src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php diff --git a/resources/lang/en/property-value.php b/resources/lang/en/property-value.php index e36619d..52af7c5 100644 --- a/resources/lang/en/property-value.php +++ b/resources/lang/en/property-value.php @@ -46,6 +46,7 @@ ], 'modal' => [ + 'create_heading' => 'Create Property Value', 'edit_heading' => 'Edit Property Value', ], diff --git a/resources/lang/en/property.php b/resources/lang/en/property.php index 5c6386b..e86f394 100644 --- a/resources/lang/en/property.php +++ b/resources/lang/en/property.php @@ -66,8 +66,6 @@ ], ], - - 'messages' => [ 'created' => 'Property created successfully.', 'updated' => 'Property updated successfully.', diff --git a/resources/lang/sl/property-value.php b/resources/lang/sl/property-value.php index 560e189..471a113 100644 --- a/resources/lang/sl/property-value.php +++ b/resources/lang/sl/property-value.php @@ -46,6 +46,7 @@ ], 'modal' => [ + 'create_heading' => 'Ustvari vrednost lastnosti', 'edit_heading' => 'Uredi vrednost lastnosti', ], diff --git a/resources/lang/sl/property.php b/resources/lang/sl/property.php index cfe36c6..ffdc89a 100644 --- a/resources/lang/sl/property.php +++ b/resources/lang/sl/property.php @@ -66,8 +66,6 @@ ], ], - - 'messages' => [ 'created' => 'Lastnost je bila uspešno ustvarjena.', 'updated' => 'Lastnost je bila uspešno posodobljena.', diff --git a/src/Filament/Resources/ProductResource.php b/src/Filament/Resources/ProductResource.php index 5fe0458..c6cb927 100644 --- a/src/Filament/Resources/ProductResource.php +++ b/src/Filament/Resources/ProductResource.php @@ -103,6 +103,31 @@ public static function form(Form $form): Form ->searchable() ->placeholder('Category (optional)'), + TextInput::make('short_description'), + + RichEditor::make('description') + ->columnSpanFull(), + ]), + + Section::make('Timestamps') + ->schema([ + Placeholder::make('created_at') + ->label('Created Date') + ->content(fn (?Product $record): string => $record?->created_at?->diffForHumans() ?? '-'), + + Placeholder::make('updated_at') + ->label('Last Modified Date') + ->content(fn (?Product $record): string => $record?->updated_at?->diffForHumans() ?? '-'), + ]) + ->columns(2) + ->hidden(fn (?Product $record) => $record === null), + ]), + + Tabs\Tab::make('Properties') + ->schema([ + Section::make('Product Type Selection') + ->description('Select the product type to see available properties') + ->schema([ Select::make('product_type_id') ->label(__('eclipse-catalogue::product.fields.product_type')) ->relationship( @@ -126,30 +151,11 @@ function ($query) { ) ->searchable() ->preload() - ->placeholder(__('eclipse-catalogue::product.placeholders.product_type')), - - TextInput::make('short_description'), - - RichEditor::make('description') - ->columnSpanFull(), - ]), - - Section::make('Timestamps') - ->schema([ - Placeholder::make('created_at') - ->label('Created Date') - ->content(fn (?Product $record): string => $record?->created_at?->diffForHumans() ?? '-'), - - Placeholder::make('updated_at') - ->label('Last Modified Date') - ->content(fn (?Product $record): string => $record?->updated_at?->diffForHumans() ?? '-'), + ->placeholder(__('eclipse-catalogue::product.placeholders.product_type')) + ->reactive(), ]) - ->columns(2) - ->hidden(fn (?Product $record) => $record === null), - ]), + ->columns(1), - Tabs\Tab::make('Properties') - ->schema([ Section::make('Product Properties') ->description('Select values for properties applicable to this product type') ->schema(function (Get $get, ?Product $record) { @@ -193,7 +199,25 @@ function ($query) { ->label($property->name) ->options($valueOptions) ->descriptions($property->values->pluck('info_url', 'id')->filter()->toArray()) - ->helperText($property->description); + ->helperText($property->description) + ->createOptionForm([ + TextInput::make('value') + ->label('Value') + ->required() + ->maxLength(255), + TextInput::make('info_url') + ->label('Info URL') + ->url() + ->maxLength(255), + TextInput::make('image') + ->label('Image') + ->maxLength(255), + ]) + ->createOptionAction(function ($action) { + return $action + ->modalHeading('Create New Property Value') + ->modalSubmitActionLabel('Create Value'); + }); break; case 'select': @@ -201,6 +225,24 @@ function ($query) { ->label($property->name) ->options($valueOptions) ->searchable() + ->createOptionForm([ + TextInput::make('value') + ->label('Value') + ->required() + ->maxLength(255), + TextInput::make('info_url') + ->label('Info URL') + ->url() + ->maxLength(255), + TextInput::make('image') + ->label('Image') + ->maxLength(255), + ]) + ->createOptionAction(function ($action) { + return $action + ->modalHeading('Create New Property Value') + ->modalSubmitActionLabel('Create Value'); + }) ->helperText($property->description); break; @@ -210,7 +252,25 @@ function ($query) { ->options($valueOptions) ->descriptions($property->values->pluck('info_url', 'id')->filter()->toArray()) ->helperText($property->description) - ->rules($property->max_values > 1 ? ["max:{$property->max_values}"] : []); + ->rules($property->max_values > 1 ? ["max:{$property->max_values}"] : []) + ->createOptionForm([ + TextInput::make('value') + ->label('Value') + ->required() + ->maxLength(255), + TextInput::make('info_url') + ->label('Info URL') + ->url() + ->maxLength(255), + TextInput::make('image') + ->label('Image') + ->maxLength(255), + ]) + ->createOptionAction(function ($action) { + return $action + ->modalHeading('Create New Property Value') + ->modalSubmitActionLabel('Create Value'); + }); break; case 'multiselect': @@ -219,6 +279,24 @@ function ($query) { ->options($valueOptions) ->multiple() ->searchable() + ->createOptionForm([ + TextInput::make('value') + ->label('Value') + ->required() + ->maxLength(255), + TextInput::make('info_url') + ->label('Info URL') + ->url() + ->maxLength(255), + TextInput::make('image') + ->label('Image') + ->maxLength(255), + ]) + ->createOptionAction(function ($action) { + return $action + ->modalHeading('Create New Property Value') + ->modalSubmitActionLabel('Create Value'); + }) ->helperText($property->description) ->rules($property->max_values > 1 ? ["max:{$property->max_values}"] : []); break; diff --git a/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php b/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php index 381f985..6dc114b 100644 --- a/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php +++ b/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php @@ -83,7 +83,12 @@ public function table(Table $table): Table ]), ]) ->actions([ - Tables\Actions\DetachAction::make(), + Tables\Actions\Action::make('edit_property') + ->label('Edit Property') + ->icon('heroicon-o-pencil') + ->url(fn ($record): string => \Eclipse\Catalogue\Filament\Resources\PropertyResource::getUrl('edit', ['record' => $record->id])) + ->openUrlInNewTab(), + Tables\Actions\Action::make('edit_pivot') ->label('Edit Sort') ->icon('heroicon-o-pencil') @@ -99,6 +104,8 @@ public function table(Table $table): Table ->action(function (array $data, $record): void { $record->pivot->update(['sort' => $data['sort']]); }), + + Tables\Actions\DetachAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ diff --git a/src/Filament/Resources/PropertyValueResource.php b/src/Filament/Resources/PropertyValueResource.php index 9fca085..464c841 100644 --- a/src/Filament/Resources/PropertyValueResource.php +++ b/src/Filament/Resources/PropertyValueResource.php @@ -147,7 +147,6 @@ public static function getPages(): array { return [ 'index' => Pages\ListPropertyValues::route('/'), - 'create' => Pages\CreatePropertyValue::route('/create'), ]; } diff --git a/src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php b/src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php deleted file mode 100644 index 9d59b7c..0000000 --- a/src/Filament/Resources/PropertyValueResource/Pages/CreatePropertyValue.php +++ /dev/null @@ -1,45 +0,0 @@ -has('property')) { - $property = Property::find(request('property')); - if ($property) { - $this->form->fill(['property_id' => $property->id]); - } - } - } - - protected function getHeaderActions(): array - { - return [ - LocaleSwitcher::make(), - ]; - } - - protected function getRedirectUrl(): string - { - $propertyId = request('property'); - if ($propertyId) { - return PropertyValueResource::getUrl('index', ['property' => $propertyId]); - } - - return PropertyValueResource::getUrl('index'); - } -} diff --git a/src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php b/src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php deleted file mode 100644 index 78f6a46..0000000 --- a/src/Filament/Resources/PropertyValueResource/Pages/EditPropertyValue.php +++ /dev/null @@ -1,34 +0,0 @@ - $propertyId]); - } - - return PropertyValueResource::getUrl('index'); - } -} diff --git a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php index 073d6ae..33db7b3 100644 --- a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php +++ b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php @@ -32,7 +32,36 @@ protected function getHeaderActions(): array return [ LocaleSwitcher::make(), Actions\CreateAction::make() - ->url(fn (): string => PropertyValueResource::getUrl('create', ['property' => $this->property?->id])), + ->modalWidth('lg') + ->modalHeading(__('eclipse-catalogue::property-value.modal.create_heading')) + ->form([ + \Filament\Forms\Components\TextInput::make('value') + ->label(__('eclipse-catalogue::property-value.fields.value')) + ->required() + ->maxLength(255), + + \Filament\Forms\Components\TextInput::make('info_url') + ->label(__('eclipse-catalogue::property-value.fields.info_url')) + ->helperText(__('eclipse-catalogue::property-value.help_text.info_url')) + ->url() + ->maxLength(255), + + \Filament\Forms\Components\FileUpload::make('image') + ->label(__('eclipse-catalogue::property-value.fields.image')) + ->helperText(__('eclipse-catalogue::property-value.help_text.image')) + ->image() + ->nullable() + ->disk('public') + ->directory('property-values'), + ]) + ->mutateFormDataUsing(function (array $data): array { + // Set the property_id from the request if available + if (request()->has('property')) { + $data['property_id'] = (int) request('property'); + } + + return $data; + }), ]; } From 18ce832acb9ee901f8ed3062ca0663246ee97fae Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Sat, 23 Aug 2025 19:05:31 +0200 Subject: [PATCH 14/20] chore: product type fixes & removage of sort column --- .../PropertiesRelationManager.php | 42 +++++++------------ .../ValuesRelationManager.php | 4 -- .../Resources/PropertyValueResource.php | 4 -- 3 files changed, 15 insertions(+), 35 deletions(-) diff --git a/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php b/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php index 6dc114b..993b949 100644 --- a/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php +++ b/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php @@ -58,10 +58,6 @@ public function table(Table $table): Table ->label('Filter') ->boolean(), - Tables\Columns\TextColumn::make('pivot_sort') - ->label('Sort Order') - ->state(fn ($record) => $record->pivot->sort ?? null), - Tables\Columns\TextColumn::make('values_count') ->label('Values') ->counts('values'), @@ -72,14 +68,20 @@ public function table(Table $table): Table ]) ->headerActions([ Tables\Actions\AttachAction::make() + ->label('Add property') + ->modalHeading('Add Property') + ->modalSubmitActionLabel('Add Property') + ->modalCancelActionLabel('Cancel') + ->extraModalFooterActions( + fn (Tables\Actions\AttachAction $action): array => [ + $action->makeModalSubmitAction('submitAnother', ['another' => true]) + ->label('Add Property & Add Another'), + ] + ) ->form(fn (Tables\Actions\AttachAction $action): array => [ $action->getRecordSelect() - ->options(Property::where('is_active', true)->pluck('name', 'id')) + ->options(Property::where('is_active', true)->pluck('name', 'id')->all()) ->searchable(), - Forms\Components\TextInput::make('sort') - ->label('Sort Order') - ->numeric() - ->default(0), ]), ]) ->actions([ @@ -89,27 +91,13 @@ public function table(Table $table): Table ->url(fn ($record): string => \Eclipse\Catalogue\Filament\Resources\PropertyResource::getUrl('edit', ['record' => $record->id])) ->openUrlInNewTab(), - Tables\Actions\Action::make('edit_pivot') - ->label('Edit Sort') - ->icon('heroicon-o-pencil') - ->form([ - Forms\Components\TextInput::make('sort') - ->label('Sort Order') - ->numeric() - ->required(), - ]) - ->fillForm(fn ($record): array => [ - 'sort' => $record->pivot->sort, - ]) - ->action(function (array $data, $record): void { - $record->pivot->update(['sort' => $data['sort']]); - }), - - Tables\Actions\DetachAction::make(), + Tables\Actions\DetachAction::make() + ->label('Remove'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DetachBulkAction::make(), + Tables\Actions\DetachBulkAction::make() + ->label('Remove'), ]), ]) ->persistSortInSession(false) diff --git a/src/Filament/Resources/PropertyResource/RelationManagers/ValuesRelationManager.php b/src/Filament/Resources/PropertyResource/RelationManagers/ValuesRelationManager.php index c768e48..a4291af 100644 --- a/src/Filament/Resources/PropertyResource/RelationManagers/ValuesRelationManager.php +++ b/src/Filament/Resources/PropertyResource/RelationManagers/ValuesRelationManager.php @@ -67,10 +67,6 @@ public function table(Table $table): Table ->limit(50) ->toggleable(isToggledHiddenByDefault: true), - Tables\Columns\TextColumn::make('sort') - ->label('Sort') - ->sortable(), - Tables\Columns\TextColumn::make('products_count') ->label('Products') ->counts('products'), diff --git a/src/Filament/Resources/PropertyValueResource.php b/src/Filament/Resources/PropertyValueResource.php index 464c841..4befeed 100644 --- a/src/Filament/Resources/PropertyValueResource.php +++ b/src/Filament/Resources/PropertyValueResource.php @@ -98,10 +98,6 @@ public static function table(Table $table): Table ->limit(50) ->toggleable(isToggledHiddenByDefault: true), - Tables\Columns\TextColumn::make('sort') - ->label(__('eclipse-catalogue::property-value.table.columns.sort')) - ->sortable(), - Tables\Columns\TextColumn::make('products_count') ->label(__('eclipse-catalogue::property-value.table.columns.products_count')) ->counts('products'), From 799c9b9d329f0c08cf7fc8d0c2de7c3dee89d79a Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Sat, 23 Aug 2025 19:25:40 +0200 Subject: [PATCH 15/20] chore: update sorting of product type --- .../RelationManagers/PropertiesRelationManager.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php b/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php index 993b949..35e1a33 100644 --- a/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php +++ b/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php @@ -100,8 +100,14 @@ public function table(Table $table): Table ->label('Remove'), ]), ]) - ->persistSortInSession(false) ->defaultSort('pim_product_type_has_property.sort') - ->reorderable('pim_product_type_has_property.sort'); + ->reorderable('pim_product_type_has_property.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') + ); } } From 7fae8ec2e7b598417bc3b91e2c7a611290634dd4 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Sat, 23 Aug 2025 19:39:11 +0200 Subject: [PATCH 16/20] chore: improve sorting reordering for property values --- .../Resources/PropertyValueResource.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Filament/Resources/PropertyValueResource.php b/src/Filament/Resources/PropertyValueResource.php index 4befeed..bbefcf3 100644 --- a/src/Filament/Resources/PropertyValueResource.php +++ b/src/Filament/Resources/PropertyValueResource.php @@ -126,17 +126,21 @@ public static function table(Table $table): Table ]); if ($property && $property->enable_sorting) { - $table = $table->reorderable('sort')->defaultSort('sort'); + $table = $table + ->reorderable('sort') + ->defaultSort('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') + ); } else { $table = $table->defaultSort('value'); } - return $table - ->modifyQueryUsing(function (Builder $query) { - if (request()->has('property')) { - $query->where('property_id', request('property')); - } - }); + return $table; } public static function getPages(): array From d6ecc8eee8e760479113db0022bb59a0c5e292b2 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Sat, 23 Aug 2025 20:36:32 +0200 Subject: [PATCH 17/20] chore: set default property --- src/Filament/Resources/PropertyValueResource.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Filament/Resources/PropertyValueResource.php b/src/Filament/Resources/PropertyValueResource.php index bbefcf3..b59ad58 100644 --- a/src/Filament/Resources/PropertyValueResource.php +++ b/src/Filament/Resources/PropertyValueResource.php @@ -111,7 +111,8 @@ public static function table(Table $table): Table ->filters([ Tables\Filters\SelectFilter::make('property') ->label(__('eclipse-catalogue::property-value.table.filters.property')) - ->relationship('property', 'name'), + ->relationship('property', 'name') + ->default(fn () => request('property')), ]) ->actions([ Tables\Actions\EditAction::make() From 6e42b4f6dc4230a44b5372255f1b500cb3cd611e Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Sat, 23 Aug 2025 21:27:33 +0200 Subject: [PATCH 18/20] fix: fix reordering for property value --- .../Resources/PropertyValueResource.php | 19 ------------------- .../Pages/ListPropertyValues.php | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/Filament/Resources/PropertyValueResource.php b/src/Filament/Resources/PropertyValueResource.php index b59ad58..77aec5a 100644 --- a/src/Filament/Resources/PropertyValueResource.php +++ b/src/Filament/Resources/PropertyValueResource.php @@ -3,7 +3,6 @@ namespace Eclipse\Catalogue\Filament\Resources; use Eclipse\Catalogue\Filament\Resources\PropertyValueResource\Pages; -use Eclipse\Catalogue\Models\Property; use Eclipse\Catalogue\Models\PropertyValue; use Filament\Forms; use Filament\Forms\Form; @@ -78,9 +77,6 @@ public static function form(Form $form): Form public static function table(Table $table): Table { - $propertyId = request()->has('property') ? (int) request('property') : null; - $property = $propertyId ? Property::find($propertyId) : null; - $table = $table ->columns([ Tables\Columns\TextColumn::make('value') @@ -126,21 +122,6 @@ public static function table(Table $table): Table ]), ]); - if ($property && $property->enable_sorting) { - $table = $table - ->reorderable('sort') - ->defaultSort('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') - ); - } else { - $table = $table->defaultSort('value'); - } - return $table; } diff --git a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php index 33db7b3..f046494 100644 --- a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php +++ b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php @@ -9,6 +9,8 @@ use Filament\Actions\LocaleSwitcher; use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords\Concerns\Translatable; +use Filament\Tables; +use Filament\Tables\Table; class ListPropertyValues extends ListRecords { @@ -87,4 +89,19 @@ public function getBreadcrumbs(): array request()->url() => __('eclipse-catalogue::property-value.pages.breadcrumbs.list'), ]; } + + public function getTable(): Table + { + return parent::getTable() + ->reorderable('sort', $this->property?->enable_sorting) + ->defaultSort($this->property?->enable_sorting ? 'sort' : 'value') + ->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') + ->extraAttributes(['class' => 'reorder-trigger']) + ); + } } From cd784e6ab74c32f14d0370f61b077c09e1e529fa Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Mon, 25 Aug 2025 12:56:27 +0200 Subject: [PATCH 19/20] fix: fix --- src/Filament/Resources/ProductResource.php | 141 ++++++++---------- .../ProductResource/Pages/EditProduct.php | 82 +++++----- 2 files changed, 101 insertions(+), 122 deletions(-) diff --git a/src/Filament/Resources/ProductResource.php b/src/Filament/Resources/ProductResource.php index c5c4f88..56b1d9a 100644 --- a/src/Filament/Resources/ProductResource.php +++ b/src/Filament/Resources/ProductResource.php @@ -9,12 +9,12 @@ use Eclipse\Catalogue\Models\Category; use Eclipse\Catalogue\Models\Product; use Eclipse\Catalogue\Models\Property; -use Filament\Forms\Components\CheckboxList; -use Filament\Forms\Components\Placeholder; -use Filament\Forms\Components\Radio; use Eclipse\Catalogue\Traits\HandlesTenantData; use Eclipse\Catalogue\Traits\HasTenantFields; use Eclipse\World\Models\Country; +use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\Radio; use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; @@ -104,13 +104,7 @@ public static function form(Form $form): Form TextInput::make('short_description') ->maxLength(500), - Select::make('category_id') - ->label('Category') - ->options(Category::getHierarchicalOptions()) - ->searchable() - ->placeholder('Category (optional)'), - - TextInput::make('short_description'), + // Category is tenant-scoped; configured in Tenant Settings section. RichEditor::make('description') ->columnSpanFull(), @@ -128,6 +122,65 @@ public static function form(Form $form): Form ]) ->columns(2) ->hidden(fn (?Product $record) => $record === null), + + Section::make(__('eclipse-catalogue::product.sections.additional')) + ->schema([ + Select::make('origin_country_id') + ->label(__('eclipse-catalogue::product.fields.origin_country_id')) + ->relationship('originCountry', 'name') + ->getOptionLabelFromRecordUsing(fn ($record) => "{$record->id} - {$record->name}") + ->searchable(['id', 'name']) + ->preload() + ->placeholder(__('eclipse-catalogue::product.placeholders.origin_country_id')), + ]) + ->collapsible() + ->persistCollapsed(), + + Section::make(__('eclipse-catalogue::product.sections.seo')) + ->description(__('eclipse-catalogue::product.sections.seo_description')) + ->schema([ + TextInput::make('meta_title') + ->label(__('eclipse-catalogue::product.fields.meta_title')) + ->maxLength(255) + ->placeholder(__('eclipse-catalogue::product.placeholders.meta_title')), + + Textarea::make('meta_description') + ->label(__('eclipse-catalogue::product.fields.meta_description')) + ->rows(3) + ->placeholder(__('eclipse-catalogue::product.placeholders.meta_description')), + ]) + ->collapsible() + ->persistCollapsed(), + + GenericTenantFieldsComponent::make( + tenantFlags: ['is_active', 'has_free_delivery'], + mutuallyExclusiveFlagSets: [], + translationPrefix: 'eclipse-catalogue::product', + extraFieldsBuilder: function (int $tenantId, string $tenantName) { + return [ + Select::make("tenant_data.{$tenantId}.category_id") + ->label(__('eclipse-catalogue::product.fields.category_id')) + ->options(function () use ($tenantId) { + return Category::query() + ->withoutGlobalScopes() + ->where(config('eclipse-catalogue.tenancy.foreign_key', 'site_id'), $tenantId) + ->orderBy('name') + ->pluck('name', 'id') + ->toArray(); + }) + ->searchable() + ->preload() + ->placeholder(__('eclipse-catalogue::product.placeholders.category_id')), + TextInput::make("tenant_data.{$tenantId}.sorting_label") + ->label(__('eclipse-catalogue::product.fields.sorting_label')) + ->maxLength(255), + \Filament\Forms\Components\DateTimePicker::make("tenant_data.{$tenantId}.available_from_date") + ->label(__('eclipse-catalogue::product.fields.available_from_date')), + ]; + }, + sectionTitle: __('eclipse-catalogue::product.sections.tenant_settings'), + sectionDescription: __('eclipse-catalogue::product.sections.tenant_settings_description'), + ), ]), Tabs\Tab::make('Properties') @@ -318,74 +371,6 @@ function ($query) { }) ->reactive() ->columns(2), - ->placeholder(__('eclipse-catalogue::product.placeholders.product_type')), - - RichEditor::make('short_description') - ->columnSpanFull(), - - RichEditor::make('description') - ->columnSpanFull(), - ]), - - Section::make(__('eclipse-catalogue::product.sections.additional')) - ->schema([ - Select::make('origin_country_id') - ->label(__('eclipse-catalogue::product.fields.origin_country_id')) - ->relationship('originCountry', 'name') - ->getOptionLabelFromRecordUsing(fn ($record) => "{$record->id} - {$record->name}") - ->searchable(['id', 'name']) - ->preload() - ->placeholder(__('eclipse-catalogue::product.placeholders.origin_country_id')), - ]) - ->collapsible() - ->persistCollapsed(), - - Section::make(__('eclipse-catalogue::product.sections.seo')) - ->description(__('eclipse-catalogue::product.sections.seo_description')) - ->schema([ - TextInput::make('meta_title') - ->label(__('eclipse-catalogue::product.fields.meta_title')) - ->maxLength(255) - ->placeholder(__('eclipse-catalogue::product.placeholders.meta_title')), - - Textarea::make('meta_description') - ->label(__('eclipse-catalogue::product.fields.meta_description')) - ->rows(3) - ->placeholder(__('eclipse-catalogue::product.placeholders.meta_description')), - ]) - ->collapsible() - ->persistCollapsed(), - - // Tenant settings (embedded in General tab) - GenericTenantFieldsComponent::make( - tenantFlags: ['is_active', 'has_free_delivery'], - mutuallyExclusiveFlagSets: [], - translationPrefix: 'eclipse-catalogue::product', - extraFieldsBuilder: function (int $tenantId, string $tenantName) { - return [ - Select::make("tenant_data.{$tenantId}.category_id") - ->label(__('eclipse-catalogue::product.fields.category_id')) - ->options(function () use ($tenantId) { - return Category::query() - ->withoutGlobalScopes() - ->where(config('eclipse-catalogue.tenancy.foreign_key', 'site_id'), $tenantId) - ->orderBy('name') - ->pluck('name', 'id') - ->toArray(); - }) - ->searchable() - ->preload() - ->placeholder(__('eclipse-catalogue::product.placeholders.category_id')), - TextInput::make("tenant_data.{$tenantId}.sorting_label") - ->label(__('eclipse-catalogue::product.fields.sorting_label')) - ->maxLength(255), - \Filament\Forms\Components\DateTimePicker::make("tenant_data.{$tenantId}.available_from_date") - ->label(__('eclipse-catalogue::product.fields.available_from_date')), - ]; - }, - sectionTitle: __('eclipse-catalogue::product.sections.tenant_settings'), - sectionDescription: __('eclipse-catalogue::product.sections.tenant_settings_description'), - ), ]), Tabs\Tab::make('Images') diff --git a/src/Filament/Resources/ProductResource/Pages/EditProduct.php b/src/Filament/Resources/ProductResource/Pages/EditProduct.php index 1034d4a..d38890c 100644 --- a/src/Filament/Resources/ProductResource/Pages/EditProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/EditProduct.php @@ -38,7 +38,7 @@ protected function getHeaderActions(): array protected function mutateFormDataBeforeFill(array $data): array { - // Load property values for the product + // Hydrate property values for the product if ($this->record && $this->record->product_type_id) { $properties = Property::where('is_active', true) ->where(function ($query) { @@ -56,14 +56,46 @@ protected function mutateFormDataBeforeFill(array $data): array ->pluck('pim_property_value.id') ->toArray(); - if ($property->max_values === 1) { - $data[$fieldName] = $selectedValues[0] ?? null; - } else { - $data[$fieldName] = $selectedValues; - } + $data[$fieldName] = ($property->max_values === 1) + ? ($selectedValues[0] ?? null) + : $selectedValues; + } + } + + // Hydrate tenant-scoped fields + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + + if (! $tenantFK) { + $recordData = $this->record->productData()->first(); + if ($recordData) { + $data['is_active'] = $recordData->is_active; + $data['has_free_delivery'] = $recordData->has_free_delivery; + $data['available_from_date'] = $recordData->available_from_date; + $data['sorting_label'] = $recordData->sorting_label; + $data['category_id'] = $recordData->category_id ?? null; } + + return $data; + } + + $tenantData = []; + $dataRecords = $this->record->productData; + + foreach ($dataRecords as $tenantRecord) { + $tenantId = $tenantRecord->getAttribute($tenantFK); + $tenantData[$tenantId] = [ + 'is_active' => $tenantRecord->is_active, + 'has_free_delivery' => $tenantRecord->has_free_delivery, + 'available_from_date' => $tenantRecord->available_from_date, + 'sorting_label' => $tenantRecord->sorting_label, + 'category_id' => $tenantRecord->category_id ?? null, + ]; } + $data['tenant_data'] = $tenantData; + $currentTenant = \Filament\Facades\Filament::getTenant(); + $data['selected_tenant'] = $currentTenant?->id; + return $data; } @@ -141,44 +173,6 @@ protected function getFormActions(): array ]; } - protected function mutateFormDataBeforeFill(array $data): array - { - $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); - - if (! $tenantFK) { - $recordData = $this->record->productData()->first(); - if ($recordData) { - $data['is_active'] = $recordData->is_active; - $data['has_free_delivery'] = $recordData->has_free_delivery; - $data['available_from_date'] = $recordData->available_from_date; - $data['sorting_label'] = $recordData->sorting_label; - $data['category_id'] = $recordData->category_id ?? null; - } - - return $data; - } - - $tenantData = []; - $dataRecords = $this->record->productData; - - foreach ($dataRecords as $tenantRecord) { - $tenantId = $tenantRecord->getAttribute($tenantFK); - $tenantData[$tenantId] = [ - 'is_active' => $tenantRecord->is_active, - 'has_free_delivery' => $tenantRecord->has_free_delivery, - 'available_from_date' => $tenantRecord->available_from_date, - 'sorting_label' => $tenantRecord->sorting_label, - 'category_id' => $tenantRecord->category_id ?? null, - ]; - } - - $data['tenant_data'] = $tenantData; - $currentTenant = \Filament\Facades\Filament::getTenant(); - $data['selected_tenant'] = $currentTenant?->id; - - return $data; - } - protected function handleRecordUpdate(Model $record, array $data): Model { $tenantData = $this->extractTenantDataFromFormData($data); From f2723e50f9250ff234b38da67f9a8246ec898550 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Thu, 28 Aug 2025 09:44:36 +0200 Subject: [PATCH 20/20] chore: property values improvements --- .../PropertiesRelationManager.php | 26 +++-- .../PropertyResource/Pages/EditProperty.php | 5 + .../ValuesRelationManager.php | 97 +++++++++++++------ .../Resources/PropertyValueResource.php | 82 ++++++++-------- .../Pages/ListPropertyValues.php | 41 ++++---- 5 files changed, 152 insertions(+), 99 deletions(-) diff --git a/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php b/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php index 35e1a33..60c9cc5 100644 --- a/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php +++ b/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php @@ -78,11 +78,17 @@ public function table(Table $table): Table ->label('Add Property & Add Another'), ] ) - ->form(fn (Tables\Actions\AttachAction $action): array => [ - $action->getRecordSelect() - ->options(Property::where('is_active', true)->pluck('name', 'id')->all()) - ->searchable(), - ]), + ->recordSelectOptionsQuery(function ($query) { + $attachedIds = $this->getOwnerRecord() + ->properties() + ->pluck('pim_property.id'); + + return $query + ->where('is_active', true) + ->whereNotIn('id', $attachedIds); + }) + ->preloadRecordSelect() + ->recordSelectSearchColumns(['name', 'code']), ]) ->actions([ Tables\Actions\Action::make('edit_property') @@ -92,12 +98,18 @@ public function table(Table $table): Table ->openUrlInNewTab(), Tables\Actions\DetachAction::make() - ->label('Remove'), + ->label('Remove') + ->modalHeading(fn ($record) => 'Remove '.($record->name ?? 'property')) + ->modalSubmitActionLabel('Remove') + ->modalCancelActionLabel('Cancel'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DetachBulkAction::make() - ->label('Remove'), + ->label('Remove') + ->modalHeading('Remove selected') + ->modalSubmitActionLabel('Remove') + ->modalCancelActionLabel('Cancel'), ]), ]) ->defaultSort('pim_product_type_has_property.sort') diff --git a/src/Filament/Resources/PropertyResource/Pages/EditProperty.php b/src/Filament/Resources/PropertyResource/Pages/EditProperty.php index 09259da..beb1ccc 100644 --- a/src/Filament/Resources/PropertyResource/Pages/EditProperty.php +++ b/src/Filament/Resources/PropertyResource/Pages/EditProperty.php @@ -14,6 +14,11 @@ class EditProperty extends EditRecord protected static string $resource = PropertyResource::class; + public function hasCombinedRelationManagerTabsWithContent(): bool + { + return true; + } + protected function getHeaderActions(): array { return [ diff --git a/src/Filament/Resources/PropertyResource/RelationManagers/ValuesRelationManager.php b/src/Filament/Resources/PropertyResource/RelationManagers/ValuesRelationManager.php index a4291af..3abeae2 100644 --- a/src/Filament/Resources/PropertyResource/RelationManagers/ValuesRelationManager.php +++ b/src/Filament/Resources/PropertyResource/RelationManagers/ValuesRelationManager.php @@ -5,12 +5,15 @@ use Eclipse\Catalogue\Models\Property; use Filament\Forms; use Filament\Forms\Form; +use Filament\Resources\RelationManagers\Concerns\Translatable; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; class ValuesRelationManager extends RelationManager { + use Translatable; + protected static string $relationship = 'values'; protected static ?string $recordTitleAttribute = 'value'; @@ -20,29 +23,48 @@ public function form(Form $form): Form return $form ->schema([ Forms\Components\TextInput::make('value') - ->label('Value') + ->label(__('eclipse-catalogue::property-value.fields.value')) ->required() ->maxLength(255), Forms\Components\TextInput::make('info_url') - ->label('Info URL') - ->helperText('Optional "read more" link') + ->label(__('eclipse-catalogue::property-value.fields.info_url')) + ->helperText(__('eclipse-catalogue::property-value.help_text.info_url')) ->url() ->maxLength(255), Forms\Components\FileUpload::make('image') - ->label('Image') - ->helperText('Optional image for this value') + ->label(__('eclipse-catalogue::property-value.fields.image')) + ->helperText(__('eclipse-catalogue::property-value.help_text.image')) ->image() + ->formatStateUsing(function ($state) { + if (is_string($state) || $state === null) { + return $state; + } + + if (is_array($state)) { + $locale = app()->getLocale(); + $byLocale = $state[$locale] ?? null; + if (is_string($byLocale) && $byLocale !== '') { + return $byLocale; + } + + foreach ($state as $value) { + if (is_string($value) && $value !== '') { + return $value; + } + } + + return null; + } + + return null; + }) + ->nullable() ->disk('public') ->directory('property-values'), - - Forms\Components\TextInput::make('sort') - ->label('Sort Order') - ->numeric() - ->default(0) - ->helperText('Lower numbers appear first'), - ]); + ]) + ->columns(1); } public function table(Table $table): Table @@ -53,52 +75,65 @@ public function table(Table $table): Table $table = $table ->columns([ Tables\Columns\TextColumn::make('value') - ->label('Value') + ->label(__('eclipse-catalogue::property-value.table.columns.value')) ->searchable() ->sortable(), Tables\Columns\ImageColumn::make('image') - ->label('Image') + ->label(__('eclipse-catalogue::property-value.table.columns.image')) ->disk('public') ->size(40), Tables\Columns\TextColumn::make('info_url') - ->label('Info URL') + ->label(__('eclipse-catalogue::property-value.table.columns.info_url')) ->limit(50) ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('products_count') - ->label('Products') + ->label(__('eclipse-catalogue::property-value.table.columns.products_count')) ->counts('products'), + + Tables\Columns\TextColumn::make('created_at') + ->label(__('eclipse-catalogue::property-value.table.columns.created_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ // ]) + ->deferLoading() ->headerActions([ - Tables\Actions\CreateAction::make(), + Tables\Actions\CreateAction::make() + ->modalWidth('lg') + ->modalHeading(__('eclipse-catalogue::property-value.modal.create_heading')), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->modalWidth('lg') + ->modalHeading(__('eclipse-catalogue::property-value.modal.edit_heading')), Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), ]); if ($property->enable_sorting) { $table = $table - ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), - ]), - ]) ->reorderable('sort') - ->defaultSort('sort'); + ->defaultSort('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') + ->extraAttributes(['class' => 'reorder-trigger']) + ); } else { - $table = $table - ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), - ]), - ]) - ->defaultSort('value'); + $table = $table->defaultSort('value'); } return $table; diff --git a/src/Filament/Resources/PropertyValueResource.php b/src/Filament/Resources/PropertyValueResource.php index 77aec5a..5f15ffd 100644 --- a/src/Filament/Resources/PropertyValueResource.php +++ b/src/Filament/Resources/PropertyValueResource.php @@ -28,51 +28,49 @@ public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\Section::make(__('eclipse-catalogue::property-value.sections.value_information')) - ->schema([ - Forms\Components\TextInput::make('value') - ->label(__('eclipse-catalogue::property-value.fields.value')) - ->required() - ->maxLength(255), - - Forms\Components\TextInput::make('info_url') - ->label(__('eclipse-catalogue::property-value.fields.info_url')) - ->helperText(__('eclipse-catalogue::property-value.help_text.info_url')) - ->url() - ->maxLength(255), - - Forms\Components\FileUpload::make('image') - ->label(__('eclipse-catalogue::property-value.fields.image')) - ->helperText(__('eclipse-catalogue::property-value.help_text.image')) - ->image() - ->formatStateUsing(function ($state) { - if (is_string($state) || $state === null) { - return $state; + Forms\Components\TextInput::make('value') + ->label(__('eclipse-catalogue::property-value.fields.value')) + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('info_url') + ->label(__('eclipse-catalogue::property-value.fields.info_url')) + ->helperText(__('eclipse-catalogue::property-value.help_text.info_url')) + ->url() + ->maxLength(255), + + Forms\Components\FileUpload::make('image') + ->label(__('eclipse-catalogue::property-value.fields.image')) + ->helperText(__('eclipse-catalogue::property-value.help_text.image')) + ->image() + ->formatStateUsing(function ($state) { + if (is_string($state) || $state === null) { + return $state; + } + + if (is_array($state)) { + $locale = app()->getLocale(); + $byLocale = $state[$locale] ?? null; + if (is_string($byLocale) && $byLocale !== '') { + return $byLocale; + } + + foreach ($state as $value) { + if (is_string($value) && $value !== '') { + return $value; } + } - if (is_array($state)) { - $locale = app()->getLocale(); - $byLocale = $state[$locale] ?? null; - if (is_string($byLocale) && $byLocale !== '') { - return $byLocale; - } + return null; + } - foreach ($state as $value) { - if (is_string($value) && $value !== '') { - return $value; - } - } - - return null; - } - - return null; - }) - ->nullable() - ->disk('public') - ->directory('property-values'), - ]), - ]); + return null; + }) + ->nullable() + ->disk('public') + ->directory('property-values'), + ]) + ->columns(1); } public static function table(Table $table): Table diff --git a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php index f046494..d3b5e98 100644 --- a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php +++ b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php @@ -36,26 +36,29 @@ protected function getHeaderActions(): array Actions\CreateAction::make() ->modalWidth('lg') ->modalHeading(__('eclipse-catalogue::property-value.modal.create_heading')) - ->form([ - \Filament\Forms\Components\TextInput::make('value') - ->label(__('eclipse-catalogue::property-value.fields.value')) - ->required() - ->maxLength(255), + ->form(fn (\Filament\Forms\Form $form) => $form + ->schema([ + \Filament\Forms\Components\TextInput::make('value') + ->label(__('eclipse-catalogue::property-value.fields.value')) + ->required() + ->maxLength(255), - \Filament\Forms\Components\TextInput::make('info_url') - ->label(__('eclipse-catalogue::property-value.fields.info_url')) - ->helperText(__('eclipse-catalogue::property-value.help_text.info_url')) - ->url() - ->maxLength(255), + \Filament\Forms\Components\TextInput::make('info_url') + ->label(__('eclipse-catalogue::property-value.fields.info_url')) + ->helperText(__('eclipse-catalogue::property-value.help_text.info_url')) + ->url() + ->maxLength(255), - \Filament\Forms\Components\FileUpload::make('image') - ->label(__('eclipse-catalogue::property-value.fields.image')) - ->helperText(__('eclipse-catalogue::property-value.help_text.image')) - ->image() - ->nullable() - ->disk('public') - ->directory('property-values'), - ]) + \Filament\Forms\Components\FileUpload::make('image') + ->label(__('eclipse-catalogue::property-value.fields.image')) + ->helperText(__('eclipse-catalogue::property-value.help_text.image')) + ->image() + ->nullable() + ->disk('public') + ->directory('property-values'), + ]) + ->columns(1) + ) ->mutateFormDataUsing(function (array $data): array { // Set the property_id from the request if available if (request()->has('property')) { @@ -79,7 +82,7 @@ public function getBreadcrumbs(): array if ($this->property) { return [ PropertyResource::getUrl('index') => __('eclipse-catalogue::property-value.pages.breadcrumbs.properties'), - null => $this->property->name, + PropertyResource::getUrl('edit', ['record' => $this->property]) => $this->property->name, request()->url() => __('eclipse-catalogue::property-value.pages.breadcrumbs.list'), ]; }