From 88602b9bf6dea8d666b35478671094aa9c7d40bf Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 24 May 2026 17:09:26 +0600 Subject: [PATCH 1/4] fix(product): apply msProductData fields in Create/Update processors MODX Resource processors call fromArray() without ignoreInvalid, so extra fields and flat msProductData keys were dropped. Add Data payload support and apply allowed fields in beforeSave() after loadMap(). Closes #297 --- .../src/Processors/Product/Create.php | 6 + .../Product/ProductDataPayloadTrait.php | 123 ++++++++++++++++++ .../src/Processors/Product/Update.php | 6 + 3 files changed, 135 insertions(+) create mode 100644 core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php diff --git a/core/components/minishop3/src/Processors/Product/Create.php b/core/components/minishop3/src/Processors/Product/Create.php index ab1c3ba5..de422867 100644 --- a/core/components/minishop3/src/Processors/Product/Create.php +++ b/core/components/minishop3/src/Processors/Product/Create.php @@ -9,6 +9,8 @@ class Create extends CreateProcessor { + use ProductDataPayloadTrait; + public $classKey = msProduct::class; public $languageTopics = ['resource', 'minishop3:default']; public $permission = 'msproduct_save'; @@ -71,6 +73,8 @@ public function beforeSet() ), ]); + $this->captureProductDataPayload(); + $properties = $this->getProperties(); $options = []; $hadOptionFieldsInRequest = false; @@ -105,6 +109,8 @@ public function beforeSet() public function beforeSave() { $this->object->set('isfolder', false); + $this->applyProductDataPayload(); + return parent::beforeSave(); } diff --git a/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php b/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php new file mode 100644 index 00000000..45989ea3 --- /dev/null +++ b/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php @@ -0,0 +1,123 @@ +fromArray($properties)` without ignoreInvalid, + * so msProductData columns must be applied in beforeSave(). + * + * @property \MODX\Revolution\modX $modx + * @property msProduct $object + */ +trait ProductDataPayloadTrait +{ + private const PRODUCT_DATA_PROPERTY = 'Data'; + + /** @var array|null */ + protected ?array $ms3ProductDataPayload = null; + + protected function captureProductDataPayload(): void + { + $this->ms3ProductDataPayload = null; + + $payload = $this->getProperty(self::PRODUCT_DATA_PROPERTY); + if ($payload === null || $payload === '') { + return; + } + + $this->unsetProperty(self::PRODUCT_DATA_PROPERTY); + + if (is_array($payload)) { + $this->ms3ProductDataPayload = $payload; + } + } + + protected function applyProductDataPayload(): void + { + if (!$this->object instanceof msProduct) { + return; + } + + $this->ensureProductDataFieldMapLoaded(); + + $allowedFields = $this->getAllowedProductDataFieldNames(); + $flatFields = $this->collectFlatProductDataFields($allowedFields); + $nestedFields = $this->collectNestedProductDataFields($allowedFields); + + if ($flatFields === [] && $nestedFields === []) { + return; + } + + $productData = $this->object->loadData(); + $this->assignProductDataFields($productData, $flatFields); + $this->assignProductDataFields($productData, $nestedFields); + } + + private function ensureProductDataFieldMapLoaded(): void + { + if (!$this->modx->services->has('ms3')) { + return; + } + + $this->modx->services->get('ms3')->loadMap(); + } + + /** + * @return array + */ + private function getAllowedProductDataFieldNames(): array + { + $fields = array_flip($this->object->getDataFieldsNames()); + unset($fields['id']); + + return $fields; + } + + /** + * @param array $allowedFields + * @return array + */ + private function collectFlatProductDataFields(array $allowedFields): array + { + $fields = []; + + foreach ($this->getProperties() as $key => $value) { + if (!isset($allowedFields[$key])) { + continue; + } + $fields[$key] = $value; + } + + return $fields; + } + + /** + * @param array $allowedFields + * @return array + */ + private function collectNestedProductDataFields(array $allowedFields): array + { + if ($this->ms3ProductDataPayload === null) { + return []; + } + + return array_intersect_key($this->ms3ProductDataPayload, $allowedFields); + } + + /** + * @param array $fields + */ + private function assignProductDataFields(msProductData $productData, array $fields): void + { + if ($fields === []) { + return; + } + + $productData->fromArray($fields, '', true, true); + } +} diff --git a/core/components/minishop3/src/Processors/Product/Update.php b/core/components/minishop3/src/Processors/Product/Update.php index fdefec18..4bc07eb3 100644 --- a/core/components/minishop3/src/Processors/Product/Update.php +++ b/core/components/minishop3/src/Processors/Product/Update.php @@ -11,6 +11,8 @@ class Update extends UpdateProcessor { + use ProductDataPayloadTrait; + public $classKey = msProduct::class; public $languageTopics = ['resource', 'minishop3:default']; public $permission = 'msproduct_save'; @@ -48,6 +50,9 @@ public static function getInstance(modX $modx, $className, $properties = []) public function beforeSet() { $this->ms3ProductFormOptions = null; + + $this->captureProductDataPayload(); + $properties = $this->getProperties(); $options = []; $hadOptionFieldsInRequest = false; @@ -109,6 +114,7 @@ public function checkFriendlyAlias() public function beforeSave() { $this->object->set('isfolder', false); + $this->applyProductDataPayload(); return parent::beforeSave(); } From 6565d3a6ead714e8e34ffb6acc759c6629c06461 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 24 May 2026 19:39:08 +0600 Subject: [PATCH 2/4] fix(product): persist msProductData on Create afterSave (#297) Move product data payload application to afterSave on Create so price and extra fields save once the resource id exists; add MODX resolver aliases for Resource Create/Update and JSON-decoded Data payloads. --- .../src/Model/msProductCreateProcessor.php | 12 ++++++ .../src/Model/msProductUpdateProcessor.php | 12 ++++++ .../src/Processors/Product/Create.php | 4 +- .../Product/ProductDataPayloadTrait.php | 43 +++++++++++++++++-- 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 core/components/minishop3/src/Model/msProductCreateProcessor.php create mode 100644 core/components/minishop3/src/Model/msProductUpdateProcessor.php diff --git a/core/components/minishop3/src/Model/msProductCreateProcessor.php b/core/components/minishop3/src/Model/msProductCreateProcessor.php new file mode 100644 index 00000000..734a2c43 --- /dev/null +++ b/core/components/minishop3/src/Model/msProductCreateProcessor.php @@ -0,0 +1,12 @@ +object->set('isfolder', false); - $this->applyProductDataPayload(); return parent::beforeSave(); } @@ -134,6 +133,9 @@ public function afterSave() $result = parent::afterSave(); + // msProductData needs resource id; composite may not persist payload fields on insert (#297). + $this->persistProductDataPayload(); + // Same contract as Update::afterSave (#199): only sync when the request contained options-* keys (#257). if ($this->ms3ProductFormOptions !== null) { /** @var \MiniShop3\Model\msProductData $productData */ diff --git a/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php b/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php index 45989ea3..9edcbba3 100644 --- a/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php +++ b/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php @@ -9,7 +9,8 @@ * Applies msProductData fields from the `Data` block and flat request keys (#297). * * Resource Create/Update call `$object->fromArray($properties)` without ignoreInvalid, - * so msProductData columns must be applied in beforeSave(). + * so msProductData columns must be applied explicitly. On Create the msProductData row + * needs a resource id — persist in afterSave(); on Update beforeSave() is enough. * * @property \MODX\Revolution\modX $modx * @property msProduct $object @@ -32,15 +33,44 @@ protected function captureProductDataPayload(): void $this->unsetProperty(self::PRODUCT_DATA_PROPERTY); + if (is_string($payload)) { + $decoded = json_decode($payload, true); + if (is_array($decoded)) { + $payload = $decoded; + } else { + return; + } + } + if (is_array($payload)) { $this->ms3ProductDataPayload = $payload; } } + /** + * Assign msProductData fields on the in-memory composite (Update / pre-save). + */ protected function applyProductDataPayload(): void + { + $this->assignProductDataPayload(false); + } + + /** + * Assign and save msProductData after the resource id exists (Create). + */ + protected function persistProductDataPayload(): bool + { + return $this->assignProductDataPayload(true); + } + + private function assignProductDataPayload(bool $persist): bool { if (!$this->object instanceof msProduct) { - return; + return false; + } + + if ($persist && (int) $this->object->get('id') <= 0) { + return false; } $this->ensureProductDataFieldMapLoaded(); @@ -50,12 +80,19 @@ protected function applyProductDataPayload(): void $nestedFields = $this->collectNestedProductDataFields($allowedFields); if ($flatFields === [] && $nestedFields === []) { - return; + return false; } $productData = $this->object->loadData(); + + if ($persist) { + $productData->set('id', (int) $this->object->get('id')); + } + $this->assignProductDataFields($productData, $flatFields); $this->assignProductDataFields($productData, $nestedFields); + + return $persist ? (bool) $productData->save() : true; } private function ensureProductDataFieldMapLoaded(): void From 9a4a8a1e05806e3f2c7f1d8edeabde6e2856e3ea Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 14 Jun 2026 16:15:19 +0600 Subject: [PATCH 3/4] fix(product): address PR #298 review (PHPStan, persist errors, JSON log) - Annotate msProduct::getDataFieldsNames() as list for PHPStan - Treat empty msProductData payload as noop success in trait - Log ERROR when Create fails to persist msProductData after resource insert - Log WARN when Data JSON payload is malformed --- core/components/minishop3/src/Model/msProduct.php | 5 ++++- .../minishop3/src/Processors/Product/Create.php | 9 ++++++++- .../src/Processors/Product/ProductDataPayloadTrait.php | 8 +++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/core/components/minishop3/src/Model/msProduct.php b/core/components/minishop3/src/Model/msProduct.php index 349150e9..ec18ea46 100644 --- a/core/components/minishop3/src/Model/msProduct.php +++ b/core/components/minishop3/src/Model/msProduct.php @@ -352,7 +352,10 @@ public function addMany(&$obj, $alias = '') * * @return array */ - public function getDataFieldsNames() + /** + * @return list + */ + public function getDataFieldsNames(): array { return array_keys($this->loadData()->_fieldMeta); } diff --git a/core/components/minishop3/src/Processors/Product/Create.php b/core/components/minishop3/src/Processors/Product/Create.php index 84581953..fee40f11 100644 --- a/core/components/minishop3/src/Processors/Product/Create.php +++ b/core/components/minishop3/src/Processors/Product/Create.php @@ -5,6 +5,7 @@ use MiniShop3\Model\msProduct; use MiniShop3\Utils\Utils; use MODX\Revolution\modDocument; +use MODX\Revolution\modX; use MODX\Revolution\Processors\Resource\Create as CreateProcessor; class Create extends CreateProcessor @@ -134,7 +135,13 @@ public function afterSave() $result = parent::afterSave(); // msProductData needs resource id; composite may not persist payload fields on insert (#297). - $this->persistProductDataPayload(); + if (!$this->persistProductDataPayload()) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + '[msProduct/Create] failed to persist msProductData for resource id ' + . $this->object->get('id') + ); + } // Same contract as Update::afterSave (#199): only sync when the request contained options-* keys (#257). if ($this->ms3ProductFormOptions !== null) { diff --git a/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php b/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php index 9edcbba3..70e78859 100644 --- a/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php +++ b/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php @@ -4,6 +4,7 @@ use MiniShop3\Model\msProduct; use MiniShop3\Model\msProductData; +use MODX\Revolution\modX; /** * Applies msProductData fields from the `Data` block and flat request keys (#297). @@ -38,6 +39,11 @@ protected function captureProductDataPayload(): void if (is_array($decoded)) { $payload = $decoded; } else { + $this->modx->log( + modX::LOG_LEVEL_WARN, + '[msProduct] malformed Data JSON payload: ' . json_last_error_msg() + ); + return; } } @@ -80,7 +86,7 @@ private function assignProductDataPayload(bool $persist): bool $nestedFields = $this->collectNestedProductDataFields($allowedFields); if ($flatFields === [] && $nestedFields === []) { - return false; + return true; } $productData = $this->object->loadData(); From 796f121de79e25a8521f713a59ef4e6f9569faa9 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Mon, 15 Jun 2026 10:37:37 +0600 Subject: [PATCH 4/4] fix(product): use array_fill_keys for msProductData whitelist (#298) --- .../src/Processors/Product/ProductDataPayloadTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php b/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php index 70e78859..9a6fd6f4 100644 --- a/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php +++ b/core/components/minishop3/src/Processors/Product/ProductDataPayloadTrait.php @@ -115,7 +115,7 @@ private function ensureProductDataFieldMapLoaded(): void */ private function getAllowedProductDataFieldNames(): array { - $fields = array_flip($this->object->getDataFieldsNames()); + $fields = array_fill_keys($this->object->getDataFieldsNames(), true); unset($fields['id']); return $fields;