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..6cbec09 --- /dev/null +++ b/database/migrations/2025_08_19_172834_create_pim_property_value_table.php @@ -0,0 +1,32 @@ +id(); + $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(); + }); + } + + /** + * 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..5fea7b4 --- /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')->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']); + }); + } + + /** + * 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..c5675f9 --- /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')->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'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('catalogue_product_has_property_value'); + } +}; 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 new file mode 100644 index 0000000..f3ac957 --- /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/resources/lang/en/property-value.php b/resources/lang/en/property-value.php new file mode 100644 index 0000000..52af7c5 --- /dev/null +++ b/resources/lang/en/property-value.php @@ -0,0 +1,69 @@ + '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' => [ + 'create_heading' => 'Create Property Value', + 'edit_heading' => 'Edit Property Value', + ], + + 'messages' => [ + 'created' => 'Property value created successfully.', + '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/en/property.php b/resources/lang/en/property.php new file mode 100644 index 0000000..e86f394 --- /dev/null +++ b/resources/lang/en/property.php @@ -0,0 +1,74 @@ + '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' => '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)', + ], + + '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', + ], + ], + + '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..471a113 --- /dev/null +++ b/resources/lang/sl/property-value.php @@ -0,0 +1,69 @@ + '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' => [ + 'create_heading' => 'Ustvari vrednost lastnosti', + '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.', + ], + + 'pages' => [ + 'title' => [ + 'with_property' => 'Vrednosti za: :property', + 'default' => 'Vrednosti lastnosti', + ], + 'breadcrumbs' => [ + 'properties' => 'Lastnosti', + 'list' => 'Seznam', + ], + ], +]; diff --git a/resources/lang/sl/property.php b/resources/lang/sl/property.php new file mode 100644 index 0000000..ffdc89a --- /dev/null +++ b/resources/lang/sl/property.php @@ -0,0 +1,74 @@ + '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' => '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)', + ], + + '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', + ], + ], + + '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/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 d9751b1..56b1d9a 100644 --- a/src/Filament/Resources/ProductResource.php +++ b/src/Filament/Resources/ProductResource.php @@ -8,9 +8,13 @@ use Eclipse\Catalogue\Forms\Components\GenericTenantFieldsComponent; use Eclipse\Catalogue\Models\Category; use Eclipse\Catalogue\Models\Product; +use Eclipse\Catalogue\Models\Property; 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; @@ -18,6 +22,7 @@ use Filament\Forms\Components\Textarea; 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; @@ -97,38 +102,27 @@ public static function form(Form $form): Form ->required() ->maxLength(255), - Select::make('product_type_id') - ->label(__('eclipse-catalogue::product.fields.product_type')) - ->relationship( - 'type', - 'name', - function ($query) { - $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); - $currentTenant = \Filament\Facades\Filament::getTenant(); - - if ($tenantFK && $currentTenant) { - return $query->whereHas('productTypeData', function ($q) use ($tenantFK, $currentTenant) { - $q->where($tenantFK, $currentTenant->id) - ->where('is_active', true); - }); - } - - return $query->whereHas('productTypeData', function ($q) { - $q->where('is_active', true); - }); - } - ) - ->searchable() - ->preload() - ->placeholder(__('eclipse-catalogue::product.placeholders.product_type')), - - RichEditor::make('short_description') - ->columnSpanFull(), + TextInput::make('short_description') + ->maxLength(500), + // Category is tenant-scoped; configured in Tenant Settings section. 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), + Section::make(__('eclipse-catalogue::product.sections.additional')) ->schema([ Select::make('origin_country_id') @@ -158,7 +152,6 @@ function ($query) { ->collapsible() ->persistCollapsed(), - // Tenant settings (embedded in General tab) GenericTenantFieldsComponent::make( tenantFlags: ['is_active', 'has_free_delivery'], mutuallyExclusiveFlagSets: [], @@ -190,6 +183,196 @@ function ($query) { ), ]), + 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( + 'type', + 'name', + function ($query) { + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $currentTenant = \Filament\Facades\Filament::getTenant(); + + if ($tenantFK && $currentTenant) { + return $query->whereHas('productTypeData', function ($q) use ($tenantFK, $currentTenant) { + $q->where($tenantFK, $currentTenant->id) + ->where('is_active', true); + }); + } + + return $query->whereHas('productTypeData', function ($q) { + $q->where('is_active', true); + }); + } + ) + ->searchable() + ->preload() + ->placeholder(__('eclipse-catalogue::product.placeholders.product_type')) + ->reactive(), + ]) + ->columns(1), + + 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) + ->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': + $schema[] = Select::make($fieldName) + ->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; + + 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}"] : []) + ->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': + $schema[] = Select::make($fieldName) + ->label($property->name) + ->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; + } + } + + 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 dd8201a..0e7ef42 100644 --- a/src/Filament/Resources/ProductResource/Pages/CreateProduct.php +++ b/src/Filament/Resources/ProductResource/Pages/CreateProduct.php @@ -27,6 +27,42 @@ protected function getHeaderActions(): array ]; } + protected function mutateFormDataBeforeCreate(array $data): array + { + foreach (array_keys($data) as $key) { + if (str_starts_with($key, 'property_values_')) { + unset($data[$key]); + } + } + + return $data; + } + + protected function afterCreate(): void + { + 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); + + if (! empty($valuesToAttach)) { + $this->record->propertyValues()->attach($valuesToAttach); + } + } + } + } + } + protected function getFormTenantFlags(): array { return ['is_active', 'has_free_delivery']; diff --git a/src/Filament/Resources/ProductResource/Pages/EditProduct.php b/src/Filament/Resources/ProductResource/Pages/EditProduct.php index 56a6fb4..d38890c 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 Eclipse\Catalogue\Traits\HandlesTenantData; use Eclipse\Catalogue\Traits\HasTenantFields; use Filament\Actions; @@ -35,36 +36,33 @@ protected function getHeaderActions(): array ]; } - protected function getFormTenantFlags(): array - { - return ['is_active', 'has_free_delivery']; - } - - protected function getFormMutuallyExclusiveFlagSets(): array - { - return []; - } - - public function form(Form $form): Form - { - return $form; - } - - protected function getFormActions(): array - { - return [ - $this->getSaveFormAction() - ->action(function () { - $this->storeCurrentTenantData(); - $this->validateDefaultConstraintsBeforeSave(); - $this->save(); - }), - $this->getCancelFormAction(), - ]; - } - protected function mutateFormDataBeforeFill(array $data): array { + // Hydrate 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('pim_property_value.property_id', $property->id) + ->pluck('pim_property_value.id') + ->toArray(); + + $data[$fieldName] = ($property->max_values === 1) + ? ($selectedValues[0] ?? null) + : $selectedValues; + } + } + + // Hydrate tenant-scoped fields $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); if (! $tenantFK) { @@ -101,6 +99,80 @@ protected function mutateFormDataBeforeFill(array $data): array return $data; } + protected function mutateFormDataBeforeSave(array $data): array + { + foreach (array_keys($data) as $key) { + if (str_starts_with($key, 'property_values_')) { + unset($data[$key]); + } + } + + return $data; + } + + protected function afterSave(): void + { + 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) { + $valuesToAttach = is_array($values) ? $values : [$values]; + $valuesToAttach = array_filter($valuesToAttach); // Remove null values + + if (! empty($valuesToAttach)) { + $this->record->propertyValues()->attach($valuesToAttach); + } + } + } + } + } + + protected function getFormTenantFlags(): array + { + return ['is_active', 'has_free_delivery']; + } + + protected function getFormMutuallyExclusiveFlagSets(): array + { + return []; + } + + public function form(Form $form): Form + { + return $form; + } + + protected function getFormActions(): array + { + return [ + $this->getSaveFormAction() + ->action(function () { + $this->storeCurrentTenantData(); + $this->validateDefaultConstraintsBeforeSave(); + $this->save(); + }), + $this->getCancelFormAction(), + ]; + } + protected function handleRecordUpdate(Model $record, array $data): Model { $tenantData = $this->extractTenantDataFromFormData($data); 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..60c9cc5 --- /dev/null +++ b/src/Filament/Resources/ProductTypeResource/RelationManagers/PropertiesRelationManager.php @@ -0,0 +1,125 @@ +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('values_count') + ->label('Values') + ->counts('values'), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_global') + ->label('Global Properties'), + ]) + ->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'), + ] + ) + ->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') + ->label('Edit Property') + ->icon('heroicon-o-pencil') + ->url(fn ($record): string => \Eclipse\Catalogue\Filament\Resources\PropertyResource::getUrl('edit', ['record' => $record->id])) + ->openUrlInNewTab(), + + Tables\Actions\DetachAction::make() + ->label('Remove') + ->modalHeading(fn ($record) => 'Remove '.($record->name ?? 'property')) + ->modalSubmitActionLabel('Remove') + ->modalCancelActionLabel('Cancel'), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DetachBulkAction::make() + ->label('Remove') + ->modalHeading('Remove selected') + ->modalSubmitActionLabel('Remove') + ->modalCancelActionLabel('Cancel'), + ]), + ]) + ->defaultSort('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') + ); + } +} diff --git a/src/Filament/Resources/PropertyResource.php b/src/Filament/Resources/PropertyResource.php new file mode 100644 index 0000000..0806666 --- /dev/null +++ b/src/Filament/Resources/PropertyResource.php @@ -0,0 +1,208 @@ +schema([ + Forms\Components\Section::make(__('eclipse-catalogue::property.sections.basic_information')) + ->schema([ + Forms\Components\TextInput::make('name') + ->label(__('eclipse-catalogue::property.fields.name')) + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('code') + ->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(__('eclipse-catalogue::property.fields.description')) + ->rows(3), + + Forms\Components\TextInput::make('internal_name') + ->label(__('eclipse-catalogue::property.fields.internal_name')) + ->helperText(__('eclipse-catalogue::property.help_text.internal_name')) + ->maxLength(255), + ])->columns(2), + + Forms\Components\Section::make(__('eclipse-catalogue::property.sections.configuration')) + ->schema([ + Forms\Components\Toggle::make('is_active') + ->label(__('eclipse-catalogue::property.fields.is_active')) + ->default(true), + + Forms\Components\Toggle::make('is_global') + ->label(__('eclipse-catalogue::property.fields.is_global')) + ->helperText(__('eclipse-catalogue::property.help_text.is_global')) + ->reactive(), + + Forms\Components\TextInput::make('max_values') + ->label(__('eclipse-catalogue::property.fields.max_values')) + ->numeric() + ->minValue(1) + ->maxValue(10) + ->helperText(__('eclipse-catalogue::property.help_text.max_values')), + + Forms\Components\Toggle::make('enable_sorting') + ->label(__('eclipse-catalogue::property.fields.enable_sorting')) + ->helperText(__('eclipse-catalogue::property.help_text.enable_sorting')), + + Forms\Components\Toggle::make('is_filter') + ->label(__('eclipse-catalogue::property.fields.is_filter')) + ->helperText(__('eclipse-catalogue::property.help_text.is_filter')), + ])->columns(2), + + Forms\Components\Section::make(__('eclipse-catalogue::property.sections.product_types')) + ->schema([ + Forms\Components\CheckboxList::make('product_types') + ->label(__('eclipse-catalogue::property.fields.product_types')) + ->relationship('productTypes', 'name') + ->options(ProductType::pluck('name', 'id')) + ->helperText(__('eclipse-catalogue::property.help_text.product_types')) + ->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(__('eclipse-catalogue::property.table.columns.code')) + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('name') + ->label(__('eclipse-catalogue::property.table.columns.name')) + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('internal_name') + ->label(__('eclipse-catalogue::property.table.columns.internal_name')) + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\IconColumn::make('is_global') + ->label(__('eclipse-catalogue::property.table.columns.is_global')) + ->boolean(), + + Tables\Columns\TextColumn::make('max_values') + ->label(__('eclipse-catalogue::property.table.columns.max_values')) + ->numeric(), + + Tables\Columns\IconColumn::make('enable_sorting') + ->label(__('eclipse-catalogue::property.table.columns.enable_sorting')) + ->boolean(), + + Tables\Columns\IconColumn::make('is_filter') + ->label(__('eclipse-catalogue::property.table.columns.is_filter')) + ->boolean(), + + Tables\Columns\IconColumn::make('is_active') + ->label(__('eclipse-catalogue::property.table.columns.is_active')) + ->boolean(), + + Tables\Columns\TextColumn::make('values_count') + ->label(__('eclipse-catalogue::property.table.columns.values_count')) + ->counts('values'), + + Tables\Columns\TextColumn::make('created_at') + ->label(__('eclipse-catalogue::property.table.columns.created_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('product_type') + ->label(__('eclipse-catalogue::property.table.filters.product_type')) + ->relationship('productTypes', 'name') + ->multiple(), + + Tables\Filters\TernaryFilter::make('is_global') + ->label(__('eclipse-catalogue::property.table.filters.is_global')), + + Tables\Filters\TernaryFilter::make('is_active') + ->label(__('eclipse-catalogue::property.table.filters.is_active')), + + Tables\Filters\TernaryFilter::make('is_filter') + ->label(__('eclipse-catalogue::property.table.filters.is_filter')), + ]) + ->actions([ + Tables\Actions\ActionGroup::make([ + Tables\Actions\Action::make('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(), + 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(__('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; + } + } + + return null; + } + + return null; + }) + ->nullable() + ->disk('public') + ->directory('property-values'), + ]) + ->columns(1); + } + + public function table(Table $table): Table + { + /** @var Property $property */ + $property = $this->getOwnerRecord(); + + $table = $table + ->columns([ + Tables\Columns\TextColumn::make('value') + ->label(__('eclipse-catalogue::property-value.table.columns.value')) + ->searchable() + ->sortable(), + + Tables\Columns\ImageColumn::make('image') + ->label(__('eclipse-catalogue::property-value.table.columns.image')) + ->disk('public') + ->size(40), + + Tables\Columns\TextColumn::make('info_url') + ->label(__('eclipse-catalogue::property-value.table.columns.info_url')) + ->limit(50) + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('products_count') + ->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() + ->modalWidth('lg') + ->modalHeading(__('eclipse-catalogue::property-value.modal.create_heading')), + ]) + ->actions([ + 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 + ->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') + ->extraAttributes(['class' => 'reorder-trigger']) + ); + } else { + $table = $table->defaultSort('value'); + } + + return $table; + } +} diff --git a/src/Filament/Resources/PropertyValueResource.php b/src/Filament/Resources/PropertyValueResource.php new file mode 100644 index 0000000..5f15ffd --- /dev/null +++ b/src/Filament/Resources/PropertyValueResource.php @@ -0,0 +1,155 @@ +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; + } + + 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'), + ]) + ->columns(1); + } + + public static function table(Table $table): Table + { + $table = $table + ->columns([ + Tables\Columns\TextColumn::make('value') + ->label(__('eclipse-catalogue::property-value.table.columns.value')) + ->searchable() + ->sortable(), + + Tables\Columns\ImageColumn::make('image') + ->label(__('eclipse-catalogue::property-value.table.columns.image')) + ->disk('public') + ->size(40), + + Tables\Columns\TextColumn::make('info_url') + ->label(__('eclipse-catalogue::property-value.table.columns.info_url')) + ->limit(50) + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('products_count') + ->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([ + Tables\Filters\SelectFilter::make('property') + ->label(__('eclipse-catalogue::property-value.table.filters.property')) + ->relationship('property', 'name') + ->default(fn () => request('property')), + ]) + ->actions([ + 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(), + ]), + ]); + + return $table; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPropertyValues::route('/'), + ]; + } + + /** + * 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(); + + if (request()->has('property')) { + $query->where('property_id', request('property')); + } + + return $query; + } +} diff --git a/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php new file mode 100644 index 0000000..d3b5e98 --- /dev/null +++ b/src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php @@ -0,0 +1,110 @@ +has('property')) { + $this->property = Property::find(request('property')); + } + } + + protected function getHeaderActions(): array + { + return [ + LocaleSwitcher::make(), + Actions\CreateAction::make() + ->modalWidth('lg') + ->modalHeading(__('eclipse-catalogue::property-value.modal.create_heading')) + ->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\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')) { + $data['property_id'] = (int) request('property'); + } + + return $data; + }), + ]; + } + + 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 [ + PropertyResource::getUrl('index') => __('eclipse-catalogue::property-value.pages.breadcrumbs.properties'), + PropertyResource::getUrl('edit', ['record' => $this->property]) => $this->property->name, + request()->url() => __('eclipse-catalogue::property-value.pages.breadcrumbs.list'), + ]; + } + + return [ + PropertyValueResource::getUrl('index') => __('eclipse-catalogue::property-value.pages.title.default'), + 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']) + ); + } +} diff --git a/src/Models/Product.php b/src/Models/Product.php index c31b83c..45b9fb1 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -9,6 +9,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\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\MediaLibrary\HasMedia; @@ -88,6 +89,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(); + } + public function originCountry(): BelongsTo { return $this->belongsTo(Country::class, 'origin_country_id', 'id'); 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 @@ +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..bab243e --- /dev/null +++ b/src/Models/PropertyValue.php @@ -0,0 +1,86 @@ + '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 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. + * + * 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; + } +} 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..54d0390 --- /dev/null +++ b/tests/Feature/PropertyCrudTest.php @@ -0,0 +1,173 @@ +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 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..e61238b --- /dev/null +++ b/tests/Feature/PropertyIntegrationTest.php @@ -0,0 +1,215 @@ +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, + ]); +}); 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..be34f7f --- /dev/null +++ b/tests/Feature/PropertyValueCrudTest.php @@ -0,0 +1,129 @@ +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('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..4c0810b --- /dev/null +++ b/tests/Unit/PropertyValueTest.php @@ -0,0 +1,140 @@ +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); +}); + +// 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); +});