diff --git a/config/Migrations/20250706011935_CreateProductCategoryVariantOptions.php b/config/Migrations/20250706011935_CreateProductCategoryVariantOptions.php new file mode 100644 index 0000000..3491a85 --- /dev/null +++ b/config/Migrations/20250706011935_CreateProductCategoryVariantOptions.php @@ -0,0 +1,57 @@ +table('product_category_variant_options', ['id' => false, 'primary_key' => ['id']]); + $table->addColumn('id', 'uuid', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('product_category_variant_id', 'uuid', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('variant_value', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => false, + ]); + $table->addColumn('variant_label', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => true, + ]); + $table->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('modified', 'datetime', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('deleted', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('enabled', 'boolean', [ + 'default' => true, + 'null' => false, + ]); + + $table->addForeignKey('product_category_variant_id', 'product_category_variants'); + + $table->create(); + } +} diff --git a/src/Model/Entity/ProductCategoryVariant.php b/src/Model/Entity/ProductCategoryVariant.php index e589e06..9270ebf 100644 --- a/src/Model/Entity/ProductCategoryVariant.php +++ b/src/Model/Entity/ProductCategoryVariant.php @@ -17,6 +17,7 @@ use Cake\ORM\Entity; * * @property ProductCategory|EntityInterface $product_category * @property Product|EntityInterface $product + * @property ProductCategoryVariantOption[]|EntityInterface[] $product_category_variant_options */ class ProductCategoryVariant extends Entity { @@ -34,8 +35,10 @@ class ProductCategoryVariant extends Entity 'product_category_id' => true, 'product_id' => true, 'enabled' => true, + // entities 'product_category' => false, 'product' => false, + 'product_category_variant_options' => true, ]; } diff --git a/src/Model/Entity/ProductCategoryVariantOption.php b/src/Model/Entity/ProductCategoryVariantOption.php new file mode 100644 index 0000000..ac99d5f --- /dev/null +++ b/src/Model/Entity/ProductCategoryVariantOption.php @@ -0,0 +1,40 @@ + + */ + protected array $_accessible = [ + 'product_category_variant_id' => true, + 'variant_value' => true, + 'created' => true, + 'modified' => true, + 'deleted' => true, + // entities + 'product_category_variant' => false, + ]; +} diff --git a/src/Model/Table/ProductCategoriesTable.php b/src/Model/Table/ProductCategoriesTable.php index b69c7b1..7c14475 100644 --- a/src/Model/Table/ProductCategoriesTable.php +++ b/src/Model/Table/ProductCategoriesTable.php @@ -91,6 +91,14 @@ class ProductCategoriesTable extends Table 'dependent' => true, 'cascadeCallbacks' => true, ]); + $this->hasMany('ProductCategoryVariants', [ + 'foreignKey' => 'product_category_id', + 'bindingKey' => 'internal_id', + 'className' => 'CakeProducts.ProductCategoryVariants', + 'dependent' => true, + 'cascadeCallbacks' => true, + ]); + $this->behaviors()->Tree->setConfig('scope', ['product_catalog_id' => $this->treeCatalogId]); $this->addBehavior('Muffin/Trash.Trash'); } diff --git a/src/Model/Table/ProductCategoryVariantOptionsTable.php b/src/Model/Table/ProductCategoryVariantOptionsTable.php new file mode 100644 index 0000000..88a82ec --- /dev/null +++ b/src/Model/Table/ProductCategoryVariantOptionsTable.php @@ -0,0 +1,107 @@ + newEntities(array $data, array $options = []) + * @method ProductCategoryVariantOption get(mixed $primaryKey, array|string $finder = 'all', CacheInterface|string|null $cache = null, Closure|string|null $cacheKey = null, mixed ...$args) + * @method ProductCategoryVariantOption findOrCreate($search, ?callable $callback = null, array $options = []) + * @method ProductCategoryVariantOption patchEntity(EntityInterface $entity, array $data, array $options = []) + * @method array patchEntities(iterable $entities, array $data, array $options = []) + * @method ProductCategoryVariantOption|false save(EntityInterface $entity, array $options = []) + * @method ProductCategoryVariantOption saveOrFail(EntityInterface $entity, array $options = []) + * @method iterable|ResultSetInterface|false saveMany(iterable $entities, array $options = []) + * @method iterable|ResultSetInterface saveManyOrFail(iterable $entities, array $options = []) + * @method iterable|ResultSetInterface|false deleteMany(iterable $entities, array $options = []) + * @method iterable|ResultSetInterface deleteManyOrFail(iterable $entities, array $options = []) + * + * @mixin TimestampBehavior + */ +class ProductCategoryVariantOptionsTable 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_category_variant_options'); + $this->setDisplayField('variant_value'); + $this->setPrimaryKey('id'); + + $this->addBehavior('Timestamp'); + + $this->belongsTo('ProductCategoryVariants', [ + 'foreignKey' => 'product_category_variant_id', + 'joinType' => 'INNER', + ]); + } + + /** + * Default validation rules. + * + * @param Validator $validator Validator instance. + * @return Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->uuid('product_category_variant_id') + ->notEmptyString('product_category_variant_id'); + + $validator + ->scalar('variant_value') + ->maxLength('variant_value', 255) + ->requirePresence('variant_value', 'create') + ->notEmptyString('variant_value'); + + $validator + ->scalar('variant_label') + ->maxLength('variant_label', 255) + ->allowEmptyString('variant_label'); + + $validator + ->dateTime('deleted') + ->allowEmptyDateTime('deleted'); + + return $validator; + } + + /** + * Returns a rules checker object that will be used for validating + * application integrity. + * + * @param RulesChecker $rules The rules object to be modified. + * @return RulesChecker + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add($rules->existsIn(['product_category_variant_id'], 'ProductCategoryVariants'), ['errorField' => 'product_category_variant_id']); + + return $rules; + } +} diff --git a/src/Model/Table/ProductCategoryVariantsTable.php b/src/Model/Table/ProductCategoryVariantsTable.php index 261fc5b..6cca32b 100644 --- a/src/Model/Table/ProductCategoryVariantsTable.php +++ b/src/Model/Table/ProductCategoryVariantsTable.php @@ -53,6 +53,11 @@ class ProductCategoryVariantsTable extends Table 'foreignKey' => 'product_id', 'className' => 'CakeProducts.Products', ]); + + $this->hasMany('ProductCategoryVariantOptions', [ + 'foreignKey' => 'product_category_variant_id', + 'className' => 'CakeProducts.ProductCategoryVariantOptions', + ]); } /** @@ -100,4 +105,34 @@ class ProductCategoryVariantsTable extends Table return $rules; } + + /** + * @param SelectQuery $query + * @param string $internalCategoryId + * + * @return array|\Cake\ORM\Query|SelectQuery + */ + public function findAllCategoryVariantsForCategoryId(SelectQuery $query, string $internalCategoryId) + { + $category = $this->ProductCategories->find()->where(['internal_id' => $internalCategoryId])->firstOrFail(); + + $this->ProductCategories->behaviors()->get('Tree')->setConfig([ + 'scope' => [ + 'product_catalog_id' => $category->product_catalog_id ?? 1, + ], + ]); + + return $this->ProductCategories + ->find('path', for: $category->id) + ->contain(['ProductCategoryVariants']); + } + + /** + * @param string $internalCategoryId + * @return array + */ + public function getAllCategoryVariantsForCategoryId(string $internalCategoryId) + { + return $this->find('allCategoryVariantsForCategoryId', $internalCategoryId)->toArray(); + } } diff --git a/src/Model/Table/ProductsTable.php b/src/Model/Table/ProductsTable.php index f29055f..fdf80cb 100644 --- a/src/Model/Table/ProductsTable.php +++ b/src/Model/Table/ProductsTable.php @@ -63,6 +63,14 @@ class ProductsTable extends Table 'dependent' => true, 'cascadeCallbacks' => true, ]); + + $this->hasMany('ProductCategoryVariants', [ + 'foreignKey' => 'product_id', + 'className' => 'CakeProducts.ProductCategoryVariants', + 'dependent' => true, + 'cascadeCallbacks' => true, + ]); + $this->getSchema()->setColumnType('product_type_id', EnumType::from(ProductProductTypeId::class)); $this->addBehavior('Muffin/Trash.Trash'); diff --git a/templates/ProductCategoryVariants/add.php b/templates/ProductCategoryVariants/add.php index 28a187a..2a21bb5 100644 --- a/templates/ProductCategoryVariants/add.php +++ b/templates/ProductCategoryVariants/add.php @@ -25,3 +25,4 @@ +Html->script('CakeProducts.product_category_variant_options.js'); ?> diff --git a/templates/ProductCategoryVariants/edit.php b/templates/ProductCategoryVariants/edit.php index 7ed361b..e90f9fd 100644 --- a/templates/ProductCategoryVariants/edit.php +++ b/templates/ProductCategoryVariants/edit.php @@ -30,3 +30,4 @@ +Html->script('CakeProducts.product_category_variant_options.js'); ?> diff --git a/templates/element/ProductCategoryVariants/form.php b/templates/element/ProductCategoryVariants/form.php index bfa9520..b0f99d1 100644 --- a/templates/element/ProductCategoryVariants/form.php +++ b/templates/element/ProductCategoryVariants/form.php @@ -7,4 +7,7 @@ echo $this->Form->control('name'); echo $this->Form->control('product_category_id', ['options' => $productCategories, 'empty' => true]); echo $this->Form->control('product_id', ['options' => $products, 'empty' => true]); echo $this->Form->control('enabled'); -?> \ No newline at end of file +?> +' . $this->Html->link('Add Option', '#', [ + 'id' => 'add-option-button', + ]) . ''; ?> \ No newline at end of file diff --git a/templates/element/ProductCategoryVariants/product_category_variant_option_form.php b/templates/element/ProductCategoryVariants/product_category_variant_option_form.php new file mode 100644 index 0000000..ec79239 --- /dev/null +++ b/templates/element/ProductCategoryVariants/product_category_variant_option_form.php @@ -0,0 +1,32 @@ + +
+
+
+ Form->control($prefix . 'attribute_value', [ + 'label' => 'Value', + ]); ?> +
+
+ Form->control($prefix . 'attribute_label', [ + 'label' => 'Label', + ]); ?> +
+
+ Form->control($prefix . 'enabled', [ + 'type' => 'checkbox', + 'checked' => true, + 'label' => 'Enabled', + ]); ?> +
+
+ +
diff --git a/tests/Fixture/ProductCategoryVariantOptionsFixture.php b/tests/Fixture/ProductCategoryVariantOptionsFixture.php new file mode 100644 index 0000000..b1d66ee --- /dev/null +++ b/tests/Fixture/ProductCategoryVariantOptionsFixture.php @@ -0,0 +1,42 @@ +records = [ + [ + 'id' => '5a386e9f-6e7a-4ae7-9360-c8e529f78d93', + 'variant_value' => 'Color', + 'variant_label' => null, + 'product_category_variant_id' => '5a386e9f-6e7a-4ae7-9360-c8e529f78d93', + 'created' => '2025-07-04 12:00:00', + 'modified' => '2025-07-04 12:00:00', + 'enabled' => 1, + ], + [ + 'id' => '5a386e9f-6e7a-4ae7-9360-c8e529f78d94', + 'variant_value' => 'Size', + 'variant_label' => null, + 'product_category_variant_id' => '5a386e9f-6e7a-4ae7-9360-c8e529f78d93', + 'created' => '2025-07-04 12:00:00', + 'modified' => '2025-07-04 12:00:00', + 'enabled' => 1, + ], + ]; + parent::init(); + } +} diff --git a/tests/TestCase/Model/Table/ProductCategoriesTableTest.php b/tests/TestCase/Model/Table/ProductCategoriesTableTest.php index a1e2beb..2c4beb2 100644 --- a/tests/TestCase/Model/Table/ProductCategoriesTableTest.php +++ b/tests/TestCase/Model/Table/ProductCategoriesTableTest.php @@ -67,6 +67,7 @@ class ProductCategoriesTableTest extends TestCase 'ChildProductCategories', // 'Products', 'ProductCategoryAttributes', + 'ProductCategoryVariants', ]; $associations = $this->ProductCategories->associations(); diff --git a/tests/TestCase/Model/Table/ProductCategoryVariantOptionsTableTest.php b/tests/TestCase/Model/Table/ProductCategoryVariantOptionsTableTest.php new file mode 100644 index 0000000..9c8994e --- /dev/null +++ b/tests/TestCase/Model/Table/ProductCategoryVariantOptionsTableTest.php @@ -0,0 +1,107 @@ + + */ + protected array $fixtures = [ +// 'ProductCategoryVariantOptions', +// 'ProductCategoryVariants', + ]; + + /** + * setUp method + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $config = $this->getTableLocator()->exists('ProductCategoryVariantOptions') ? [] : ['className' => ProductCategoryVariantOptionsTable::class]; + $this->ProductCategoryVariantOptions = $this->getTableLocator()->get('ProductCategoryVariantOptions', $config); + } + + /** + * tearDown method + * + * @return void + */ + protected function tearDown(): void + { + unset($this->ProductCategoryVariantOptions); + + parent::tearDown(); + } + + /** + * TestInitialize method + * + * @return void + * @uses \App\Model\Table\ProductCategoryVariantOptionsTable::initialize() + */ + public function testInitialize(): void + { + // verify all associations loaded + $expectedAssociations = [ + 'ProductCategoryVariants', + ]; + $associations = $this->ProductCategoryVariantOptions->associations(); + + $this->assertCount(count($expectedAssociations), $associations); + foreach ($expectedAssociations as $expectedAssociation) { + $this->assertTrue($this->ProductCategoryVariantOptions->hasAssociation($expectedAssociation)); + } + + // verify all behaviors loaded + $expectedBehaviors = [ + 'Timestamp', + ]; + $behaviors = $this->ProductCategoryVariantOptions->behaviors(); + + $this->assertCount(count($expectedBehaviors), $behaviors); + foreach ($expectedBehaviors as $expectedBehavior) { + $this->assertTrue($this->ProductCategoryVariantOptions->hasBehavior($expectedBehavior)); + } + } + + /** + * Test validationDefault method + * + * @return void + * @uses \App\Model\Table\ProductCategoryVariantOptionsTable::validationDefault() + */ + public function testValidationDefault(): void + { + $this->markTestIncomplete('Not implemented yet.'); + } + + /** + * Test buildRules method + * + * @return void + * @uses \App\Model\Table\ProductCategoryVariantOptionsTable::buildRules() + */ + public function testBuildRules(): void + { + $this->markTestIncomplete('Not implemented yet.'); + } +} diff --git a/tests/TestCase/Model/Table/ProductsTableTest.php b/tests/TestCase/Model/Table/ProductsTableTest.php index 2737391..94b75a5 100644 --- a/tests/TestCase/Model/Table/ProductsTableTest.php +++ b/tests/TestCase/Model/Table/ProductsTableTest.php @@ -64,6 +64,7 @@ class ProductsTableTest extends TestCase $expectedAssociations = [ 'ProductCategories', 'ProductAttributes', + 'ProductCategoryVariants', ]; $associations = $this->Products->associations(); diff --git a/webroot/js/product_category_variant_options.js b/webroot/js/product_category_variant_options.js new file mode 100644 index 0000000..0c1ed9d --- /dev/null +++ b/webroot/js/product_category_variant_options.js @@ -0,0 +1,15 @@ +const addOptionButton = document.getElementById('add-option-button'); +const variantOptionPrefixInput = document.getElementById('variant_options_prefix'); +if (addOptionButton && variantOptionPrefixInput) { + addOptionButton.addEventListener('click', addOptionButtonClicked); +} +function addOptionButtonClicked(e) +{ + e.preventDefault(); + console.debug('variantOptionPrefixInput.value'); + console.debug(variantOptionPrefixInput.value); + variantOptionPrefixInput.value = parseInt(variantOptionPrefixInput.value) + 1; + variantOptionPrefixInput.dispatchEvent(new Event('change')); + console.debug('variantOptionPrefixInput.value'); + console.debug(variantOptionPrefixInput.value); +}