diff --git a/config/Migrations/20250405081049_CreateProductAttributes.php b/config/Migrations/20250405081049_CreateProductAttributes.php new file mode 100644 index 0000000..9e1fc53 --- /dev/null +++ b/config/Migrations/20250405081049_CreateProductAttributes.php @@ -0,0 +1,41 @@ +table('product_attributes', ['id' => false, 'primary_key' => ['id']]); + $table->addColumn('id', 'uuid', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('product_id', 'uuid', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('product_category_attribute_id', 'uuid', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('attribute_value', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]); + $table->addColumn('product_category_attribute_option_id', 'uuid', [ + 'default' => null, + 'null' => true, + ]); + $table->create(); + } +} diff --git a/src/Controller/ProductsController.php b/src/Controller/ProductsController.php index f54c288..48e169c 100644 --- a/src/Controller/ProductsController.php +++ b/src/Controller/ProductsController.php @@ -67,8 +67,16 @@ class ProductsController extends AppController $productsTable = $this->getTable(); $product = $productsTable->newEmptyEntity(); if ($this->request->is('post')) { - $product = $productsTable->patchEntity($product, $this->request->getData()); - if ($productsTable->save($product)) { + $postData = $this->request->getData(); + $saveOptions = [ + 'associated' => ['ProductAttributes'], + ]; +// Log::debug(print_r('$postData', true)); +// Log::debug(print_r($postData, true)); +// Log::debug(print_r('$saveOptions', true)); +// Log::debug(print_r($saveOptions, true)); + $product = $productsTable->patchEntity($product, $postData, $saveOptions); + if ($productsTable->save($product, $saveOptions)) { $this->Flash->success(__('The product has been saved.')); return $this->redirect(['action' => 'index']); @@ -104,8 +112,9 @@ class ProductsController extends AppController Log::debug(print_r($product->getErrors(), true)); $this->Flash->error(__('The product could not be saved. Please, try again.')); } - $productCategories = $productsTable->ProductCategories->find('list', limit: 200)->all(); - $this->set(compact('product', 'productCategories')); + $productCategory = $product->product_category_id ? $productsTable->ProductCategories->find()->where(['internal_id' => $product->product_category_id])->first() : null; + $productCatalogs = $productsTable->ProductCategories->ProductCatalogs->find('list')->toArray(); + $this->set(compact('product', 'productCatalogs', 'productCategory')); } /** diff --git a/src/Model/Entity/Product.php b/src/Model/Entity/Product.php index 6cac6aa..05e935e 100644 --- a/src/Model/Entity/Product.php +++ b/src/Model/Entity/Product.php @@ -14,6 +14,7 @@ use Cake\ORM\Entity; * @property \CakeProducts\Model\Enum\ProductProductTypeId $product_type_id * * @property \CakeProducts\Model\Entity\ProductCategory $product_category + * @property \CakeProducts\Model\Entity\ProductAttribute[] $product_attributes */ class Product extends Entity { @@ -31,5 +32,6 @@ class Product extends Entity 'product_category_id' => true, 'product_type_id' => true, 'product_category' => true, + 'product_attributes' => true, ]; } diff --git a/src/Model/Entity/ProductAttribute.php b/src/Model/Entity/ProductAttribute.php new file mode 100644 index 0000000..39133e6 --- /dev/null +++ b/src/Model/Entity/ProductAttribute.php @@ -0,0 +1,41 @@ + + */ + protected array $_accessible = [ + 'product_id' => true, + 'product_category_attribute_id' => true, + 'attribute_value' => true, + 'product_category_attribute_option_id' => true, + 'product' => false, + 'product_category_attribute' => false, + 'product_category_attribute_option' => false, + ]; +} diff --git a/src/Model/Table/ProductAttributesTable.php b/src/Model/Table/ProductAttributesTable.php new file mode 100644 index 0000000..8d40720 --- /dev/null +++ b/src/Model/Table/ProductAttributesTable.php @@ -0,0 +1,111 @@ + newEntities(array $data, array $options = []) + * @method \App\Model\Entity\ProductAttribute get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args) + * @method \App\Model\Entity\ProductAttribute findOrCreate($search, ?callable $callback = null, array $options = []) + * @method \App\Model\Entity\ProductAttribute patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = []) + * @method array<\App\Model\Entity\ProductAttribute> patchEntities(iterable $entities, array $data, array $options = []) + * @method \App\Model\Entity\ProductAttribute|false save(\Cake\Datasource\EntityInterface $entity, array $options = []) + * @method \App\Model\Entity\ProductAttribute saveOrFail(\Cake\Datasource\EntityInterface $entity, array $options = []) + * @method iterable<\App\Model\Entity\ProductAttribute>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\ProductAttribute>|false saveMany(iterable $entities, array $options = []) + * @method iterable<\App\Model\Entity\ProductAttribute>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\ProductAttribute> saveManyOrFail(iterable $entities, array $options = []) + * @method iterable<\App\Model\Entity\ProductAttribute>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\ProductAttribute>|false deleteMany(iterable $entities, array $options = []) + * @method iterable<\App\Model\Entity\ProductAttribute>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\ProductAttribute> deleteManyOrFail(iterable $entities, array $options = []) + */ +class ProductAttributesTable extends Table +{ + /** + * Initialize method + * + * @param array $config The configuration for the Table. + * @return void + */ + public function initialize(array $config): void + { + parent::initialize($config); + + $this->setTable('product_attributes'); + $this->setDisplayField('id'); + $this->setPrimaryKey('id'); + + $this->setEntityClass( + Configure::read('CakeProducts.ProductAttributes.entity', 'CakeProducts\Model\Entity\ProductAttribute') + ); + + $this->belongsTo('Products', [ + 'foreignKey' => 'product_id', + 'className' => 'CakeProducts.Products', + 'joinType' => 'INNER', + ]); + $this->belongsTo('ProductCategoryAttributes', [ + 'foreignKey' => 'product_category_attribute_id', + 'className' => 'CakeProducts.ProductCategoryAttributes', + 'joinType' => 'INNER', + ]); + $this->belongsTo('ProductCategoryAttributeOptions', [ + 'foreignKey' => 'product_category_attribute_option_id', + 'className' => 'CakeProducts.ProductCategoryAttributeOptions', + ]); + } + + /** + * Default validation rules. + * + * @param \Cake\Validation\Validator $validator Validator instance. + * @return \Cake\Validation\Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->uuid('product_id') + ->notEmptyString('product_id'); + + $validator + ->uuid('product_category_attribute_id') + ->notEmptyString('product_category_attribute_id'); + + $validator + ->scalar('attribute_value') + ->maxLength('attribute_value', 255) + ->allowEmptyString('attribute_value'); + + $validator + ->uuid('product_category_attribute_option_id') + ->allowEmptyString('product_category_attribute_option_id'); + + return $validator; + } + + /** + * Returns a rules checker object that will be used for validating + * application integrity. + * + * @param \Cake\ORM\RulesChecker $rules The rules object to be modified. + * @return \Cake\ORM\RulesChecker + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add($rules->existsIn(['product_id'], 'Products'), ['errorField' => 'product_id']); + $rules->add($rules->existsIn(['product_category_attribute_id'], 'ProductCategoryAttributes'), ['errorField' => 'product_category_attribute_id']); + $rules->add($rules->existsIn(['product_category_attribute_option_id'], 'ProductCategoryAttributeOptions'), ['errorField' => 'product_category_attribute_option_id']); + + return $rules; + } +} diff --git a/src/Model/Table/ProductCategoryAttributesTable.php b/src/Model/Table/ProductCategoryAttributesTable.php index ae2a70c..0250e94 100644 --- a/src/Model/Table/ProductCategoryAttributesTable.php +++ b/src/Model/Table/ProductCategoryAttributesTable.php @@ -111,9 +111,15 @@ class ProductCategoryAttributesTable extends Table return $rules; } - public function getAllCategoryAttributesForCategoryId(string $internalCategoryId) + /** + * @param SelectQuery $query + * @param string $internalCategoryId + * + * @return array|\Cake\ORM\Query|SelectQuery + */ + public function findAllCategoryAttributesForCategoryId(SelectQuery $query, string $internalCategoryId) { - $category = $this->ProductCategories->find()->where(['internal_id' => $internalCategoryId])->first(); + $category = $this->ProductCategories->find()->where(['internal_id' => $internalCategoryId])->firstOrFail(); $this->ProductCategories->behaviors()->get('Tree')->setConfig([ 'scope' => [ @@ -121,9 +127,12 @@ class ProductCategoryAttributesTable extends Table ], ]); - return $category ? $this->ProductCategories + return $this->ProductCategories ->find('path', for: $category->id) - ->contain(['ProductCategoryAttributes', 'ProductCategoryAttributes.ProductCategoryAttributeOptions']) - ->toArray() : []; + ->contain(['ProductCategoryAttributes', 'ProductCategoryAttributes.ProductCategoryAttributeOptions']); + } + public function getAllCategoryAttributesForCategoryId(string $internalCategoryId) + { + return $this->find('allCategoryAttributesForCategoryId', $internalCategoryId)->toArray(); } } diff --git a/src/Model/Table/ProductsTable.php b/src/Model/Table/ProductsTable.php index c16c65e..b54a38a 100644 --- a/src/Model/Table/ProductsTable.php +++ b/src/Model/Table/ProductsTable.php @@ -58,6 +58,9 @@ class ProductsTable extends Table 'className' => 'CakeProducts.ProductCategories', ]); + $this->hasMany('ProductAttributes', [ + 'className' => 'CakeProducts.ProductAttributes', + ]); $this->getSchema()->setColumnType('product_type_id', EnumType::from(ProductProductTypeId::class)); } @@ -99,6 +102,7 @@ class ProductsTable extends Table { $rules->add($rules->isUnique(['product_category_id', 'name']), ['errorField' => 'product_category_id']); $rules->add($rules->existsIn(['product_category_id'], 'ProductCategories'), ['errorField' => 'product_category_id']); +// $rules->add($rules->validCount('product_attributes', 0, '<=', 'You must not have any tags')); return $rules; } diff --git a/templates/ProductCategoryAttributes/form.php b/templates/ProductCategoryAttributes/form.php index ce352ed..f20fbda 100644 --- a/templates/ProductCategoryAttributes/form.php +++ b/templates/ProductCategoryAttributes/form.php @@ -28,7 +28,7 @@ foreach ($productCategoryAttributes as $productCategoryAttribute) { if ($productCategoryAttribute->attribute_type_id === ProductCategoryAttributeTypeId::Integer) { $inputType = 'number'; } - echo $this->Form->control('product_attributes.' . $cnt . '.product_category_attribute_id', [ + echo $this->Form->control('product_attributes.' . $cnt . '.attribute_value', [ 'type' => $inputType, 'label' => $productCategoryAttribute->name, 'options' => $options, diff --git a/tests/Fixture/ProductAttributesFixture.php b/tests/Fixture/ProductAttributesFixture.php new file mode 100644 index 0000000..953e7da --- /dev/null +++ b/tests/Fixture/ProductAttributesFixture.php @@ -0,0 +1,25 @@ +records = [ + + ]; + parent::init(); + } +} diff --git a/tests/Fixture/ProductCategoryAttributeOptionsFixture.php b/tests/Fixture/ProductCategoryAttributeOptionsFixture.php index 0a53303..5260c7c 100644 --- a/tests/Fixture/ProductCategoryAttributeOptionsFixture.php +++ b/tests/Fixture/ProductCategoryAttributeOptionsFixture.php @@ -21,10 +21,24 @@ class ProductCategoryAttributeOptionsFixture extends TestFixture [ 'id' => 'e06f1723-2456-483a-b3c4-004603e032a8', 'product_category_attribute_id' => '37078cf0-0130-4b93-bb7e-abe7d665ed2c', - 'attribute_value' => 'Lorem ipsum dolor sit amet', - 'attribute_label' => 'Lorem ipsum dolor sit amet', + 'attribute_value' => 'Red', + 'attribute_label' => 'Red', 'enabled' => 1, ], + [ + 'id' => 'e06f1723-2456-483a-b3c4-004603e032a1', + 'product_category_attribute_id' => '37078cf0-0130-4b93-bb7e-abe7d665ed2c', + 'attribute_value' => 'Blue', + 'attribute_label' => 'Blue', + 'enabled' => 1, + ], + [ + 'id' => 'e06f1723-2456-483a-b3c4-004603e032a2', + 'product_category_attribute_id' => '37078cf0-0130-4b93-bb7e-abe7d665ed2c', + 'attribute_value' => 'Green', + 'attribute_label' => 'Green', + 'enabled' => 1, + ] ]; parent::init(); } diff --git a/tests/TestCase/Controller/ProductsControllerTest.php b/tests/TestCase/Controller/ProductsControllerTest.php index 74a35db..5756fa3 100644 --- a/tests/TestCase/Controller/ProductsControllerTest.php +++ b/tests/TestCase/Controller/ProductsControllerTest.php @@ -32,7 +32,10 @@ class ProductsControllerTest extends BaseControllerTest */ protected array $fixtures = [ 'plugin.CakeProducts.Products', + 'plugin.CakeProducts.ProductAttributes', 'plugin.CakeProducts.ProductCategories', + 'plugin.CakeProducts.ProductCategoryAttributes', + 'plugin.CakeProducts.ProductCategoryAttributeOptions', // 'plugin.CakeProducts.ProductCatalogs', ]; @@ -135,6 +138,52 @@ class ProductsControllerTest extends BaseControllerTest $this->assertEquals($cntBefore, $cntAfter); } + /** + * Test add method + * + * Tests the add action with a logged in user + * + * @return void + * @throws Exception + * + * @uses ProductsController::add + */ + public function testAddPostSuccess(): void + { + $cntBefore = $this->Products->find()->count(); + $productAttributesCntBefore = $this->Products->ProductAttributes->find()->count(); + + $this->loginUserByRole('admin'); + $url = [ + 'plugin' => 'CakeProducts', + 'controller' => 'Products', + 'action' => 'add', + ]; + $data = [ + // test new data here + 'product_catalog_id' => '115153f3-2f59-4234-8ff8-e1b205761428', + 'product_category_id' => '6d223283-361b-4f9f-a7f1-c97aa0ca4c23', + 'name' => '14AWG Red Wire', + 'product_type_id' => 1, + 'product_attributes' => [ + [ + 'product_category_attribute_id' => '37078cf0-0130-4b93-bb7e-abe7d665ed2c', + 'product_category_attribute_option_id' => 'e06f1723-2456-483a-b3c4-004603e032a2', // green + 'attribute_value' => '', + ], + ], + ]; + $this->post($url, $data); + $this->assertResponseCode(302); + $this->assertRedirectContains('products'); + + $cntAfter = $this->Products->find()->count(); + $productAttributesCntAfter = $this->Products->ProductAttributes->find()->count(); + $this->assertEquals($cntBefore + 1, $cntAfter); + $this->assertEquals($productAttributesCntBefore + 1, $productAttributesCntAfter); + } + + /** * Test add method * @@ -160,6 +209,7 @@ class ProductsControllerTest extends BaseControllerTest 'product_category_id' => '', 'name' => '', 'product_type_id' => 1, + 'product_attributes' => [], ]; $this->post($url, $data); $this->assertResponseCode(200); diff --git a/tests/TestCase/Model/Table/ProductsTableTest.php b/tests/TestCase/Model/Table/ProductsTableTest.php index 57bb0bd..8a92bf0 100644 --- a/tests/TestCase/Model/Table/ProductsTableTest.php +++ b/tests/TestCase/Model/Table/ProductsTableTest.php @@ -63,6 +63,7 @@ class ProductsTableTest extends TestCase // verify all associations loaded $expectedAssociations = [ 'ProductCategories', + 'ProductAttributes', ]; $associations = $this->Products->associations();