Skip to content

Commit 57bad41

Browse files
authored
Merge pull request #10546 from magento-gl/aegis_spartans_pr_16032026
[Aegis Spartans] BugFixes Delivery
2 parents 28348c3 + d93a632 commit 57bad41

12 files changed

Lines changed: 618 additions & 25 deletions

File tree

app/code/Magento/Catalog/Controller/Adminhtml/Product/Attribute/Save.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ public function execute()
152152
$data,
153153
$optionData
154154
);
155+
$this->markAttributeOptionsAsDeleted($data);
155156

156157
if ($data) {
157158
$setId = $this->getRequest()->getParam('set');
@@ -375,6 +376,43 @@ private function returnResult($path = '', array $params = [], array $response =
375376
return $this->resultFactory->create(ResultFactory::TYPE_REDIRECT)->setPath($path, $params);
376377
}
377378

379+
/**
380+
* Ensures extra option_N entries (not present in attribute_options_*) are marked as deleted.
381+
*
382+
* @param array $data
383+
* @return void
384+
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
385+
*/
386+
private function markAttributeOptionsAsDeleted(array &$data): void
387+
{
388+
$rowsKey = null;
389+
390+
if (!empty($data['attribute_options_select']) && is_array($data['attribute_options_select'])) {
391+
$rowsKey = 'attribute_options_select';
392+
} elseif (!empty($data['attribute_options_multiselect']) && is_array($data['attribute_options_multiselect'])) {
393+
$rowsKey = 'attribute_options_multiselect';
394+
}
395+
396+
if (!$rowsKey ||
397+
empty($data['option']) || !is_array($data['option']) ||
398+
empty($data['option']['value']) || !is_array($data['option']['value'])
399+
) {
400+
return;
401+
}
402+
403+
$expectedCount = count($data[$rowsKey]);
404+
405+
foreach (array_keys($data['option']['value']) as $optKey) {
406+
if (preg_match('/^option_(\d+)$/', (string)$optKey, $m)) {
407+
$n = (int)$m[1];
408+
409+
if ($n >= $expectedCount) {
410+
$data['option']['delete'][$optKey] = 1;
411+
}
412+
}
413+
}
414+
}
415+
378416
/**
379417
* Define whether request is Ajax
380418
*

app/code/Magento/Catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ protected function setUp(): void
157157
$this->redirectMock = $this->createMock(ResultRedirect::class);
158158
$this->jsonResultMock = $this->createMock(ResultJson::class);
159159
$this->productAttributeMock = $this->createMock(Attribute::class);
160-
160+
161161
$this->buildFactoryMock->expects($this->any())
162162
->method('create')
163163
->willReturn($this->builderMock);
@@ -1035,4 +1035,73 @@ private function createControllerWithAttributeFactory($localAttributeFactory)
10351035
'_session' => $this->sessionMock
10361036
]);
10371037
}
1038+
1039+
public function testMarkAttributeOptionsAsDeletedMarksGhostOptionsForSelect(): void
1040+
{
1041+
$controller = $this->getModel();
1042+
1043+
// 23 real records => valid option_0 .. option_22
1044+
$data = [
1045+
'attribute_options_select' => array_fill(0, 23, ['record_id' => 0]),
1046+
'option' => [
1047+
'value' => [],
1048+
'delete' => [
1049+
// simulate real user deletions too
1050+
'option_12' => 1,
1051+
'option_22' => 1,
1052+
],
1053+
],
1054+
];
1055+
1056+
// UI sometimes sends extra ghost keys (option_23/option_24) repeating the last value
1057+
for ($i = 0; $i <= 24; $i++) {
1058+
$data['option']['value']['option_' . $i] = [0 => (string)($i + 1), 1 => (string)($i + 1)];
1059+
}
1060+
1061+
$this->invokeMarkAttributeOptionsAsDeleted($controller, $data);
1062+
1063+
// Ghost keys must be forced deleted:
1064+
$this->assertSame(1, $data['option']['delete']['option_23']);
1065+
$this->assertSame(1, $data['option']['delete']['option_24']);
1066+
1067+
// Real keys should not be auto-deleted by this method:
1068+
$this->assertArrayNotHasKey('option_0', $data['option']['delete']);
1069+
1070+
// Existing delete flags should remain:
1071+
$this->assertSame(1, $data['option']['delete']['option_12']);
1072+
$this->assertSame(1, $data['option']['delete']['option_22']);
1073+
}
1074+
1075+
public function testMarkAttributeOptionsAsDeletedDoesNothingWhenNoRowsKey(): void
1076+
{
1077+
$controller = $this->getModel();
1078+
1079+
$data = [
1080+
// no attribute_options_select or attribute_options_multiselect
1081+
'option' => [
1082+
'value' => [
1083+
'option_0' => [0 => '1', 1 => '1'],
1084+
'option_1' => [0 => '2', 1 => '2'],
1085+
],
1086+
'delete' => [],
1087+
],
1088+
];
1089+
1090+
$this->invokeMarkAttributeOptionsAsDeleted($controller, $data);
1091+
1092+
$this->assertSame([], $data['option']['delete']);
1093+
}
1094+
1095+
/**
1096+
* Helper to call the private method via reflection.
1097+
*/
1098+
private function invokeMarkAttributeOptionsAsDeleted(
1099+
\Magento\Catalog\Controller\Adminhtml\Product\Attribute\Save $controller,
1100+
array &$data
1101+
): void {
1102+
$method = new \ReflectionMethod($controller, 'markAttributeOptionsAsDeleted');
1103+
1104+
$args = [&$data]; // pass by reference
1105+
$method->invokeArgs($controller, $args);
1106+
}
10381107
}

app/code/Magento/Catalog/view/adminhtml/web/js/form/element/action-delete.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,13 @@ define([
9292
/**
9393
* Delete record instance
9494
* update data provider dataScope
95-
*
96-
* @param {Object} parents
95+
* @param index
96+
* @param recordId
97+
* @returns {*}
9798
*/
98-
deleteRecord: function (parents) {
99-
this.value(1);
100-
parents[1].deleteRecord(parents[0].index, parents[0].recordId);
101-
99+
deleteRecord: function (index, recordId) {
100+
this.value(1); // sets option.delete[option_X] = 1
101+
this.bubble('deleteRecord', index, recordId); // triggers dynamicRows deletion
102102
return this;
103103
}
104104
});

app/code/Magento/Catalog/view/adminhtml/web/template/form/element/action-delete.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<button class="action-delete"
88
attr="{'data-action': 'remove_row'}"
99
data-bind="
10-
click: function(){ $parent.processingDeleteRecord($record().index, $record.recordId); },
10+
click: $data.deleteRecord.bind($data, $record().index, $record().recordId),
1111
attr: {
1212
title: $parent.deleteButtonLabel
1313
}

app/code/Magento/Checkout/Block/Cart/AbstractCart.php

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
*/
66
namespace Magento\Checkout\Block\Cart;
77

8+
use Magento\Framework\App\ObjectManager;
89
use Magento\Quote\Model\Quote;
10+
use Magento\Checkout\Observer\CatalogRuleSaveAfterObserver;
911

1012
/**
1113
* Shopping cart abstract block
@@ -15,7 +17,12 @@ class AbstractCart extends \Magento\Framework\View\Element\Template
1517
/**
1618
* Block alias fallback
1719
*/
18-
const DEFAULT_TYPE = 'default';
20+
public const DEFAULT_TYPE = 'default';
21+
22+
/**
23+
* Session key for last time cart totals were recollected (used with catalog rules cache).
24+
*/
25+
private const SESSION_KEY_LAST_RECOLLECT_AT = 'last_cart_totals_recollect_at';
1926

2027
/**
2128
* @var Quote|null
@@ -44,21 +51,28 @@ class AbstractCart extends \Magento\Framework\View\Element\Template
4451
*/
4552
protected $_checkoutSession;
4653

54+
/**
55+
* @var \Magento\Framework\App\CacheInterface
56+
*/
57+
private $cache;
58+
4759
/**
4860
* @param \Magento\Framework\View\Element\Template\Context $context
4961
* @param \Magento\Customer\Model\Session $customerSession
5062
* @param \Magento\Checkout\Model\Session $checkoutSession
5163
* @param array $data
52-
* @codeCoverageIgnore
64+
* @param \Magento\Framework\App\CacheInterface|null $cache
5365
*/
5466
public function __construct(
5567
\Magento\Framework\View\Element\Template\Context $context,
5668
\Magento\Customer\Model\Session $customerSession,
5769
\Magento\Checkout\Model\Session $checkoutSession,
58-
array $data = []
70+
array $data = [],
71+
?\Magento\Framework\App\CacheInterface $cache = null
5972
) {
6073
$this->_customerSession = $customerSession;
6174
$this->_checkoutSession = $checkoutSession;
75+
$this->cache = $cache ?? ObjectManager::getInstance()->get(\Magento\Framework\App\CacheInterface::class);
6276
parent::__construct($context, $data);
6377
$this->_isScopePrivate = true;
6478
}
@@ -107,15 +121,46 @@ public function getQuote()
107121
{
108122
if (null === $this->_quote) {
109123
$this->_quote = $this->_checkoutSession->getQuote();
124+
125+
if ($this->_quote->getId() && $this->shouldRecollectTotals()) {
126+
$existingItemsCount = $this->_quote->getItemsCount();
127+
$existingItemsQty = $this->_quote->getItemsQty();
128+
$existingVirtualItemsQty = $this->_quote->getData('virtual_items_qty');
129+
$this->_quote->setData('totals_collected_flag', false);
130+
$this->_quote->collectTotals();
131+
$this->_quote->setItemsCount($existingItemsCount);
132+
$this->_quote->setItemsQty($existingItemsQty);
133+
$this->_quote->setData('virtual_items_qty', $existingVirtualItemsQty);
134+
$this->_checkoutSession->setData(
135+
self::SESSION_KEY_LAST_RECOLLECT_AT,
136+
$this->cache->load(CatalogRuleSaveAfterObserver::CACHE_KEY_CATALOG_RULES_UPDATED_AT)
137+
);
138+
}
110139
}
111140
return $this->_quote;
112141
}
113142

143+
/**
144+
* Whether cart totals should be recollected (only after a catalog price rule was saved).
145+
*
146+
* @return bool
147+
*/
148+
private function shouldRecollectTotals(): bool
149+
{
150+
$rulesUpdatedAt = (int) ($this->cache->load(
151+
CatalogRuleSaveAfterObserver::CACHE_KEY_CATALOG_RULES_UPDATED_AT
152+
) ?: 0);
153+
if ($rulesUpdatedAt <= 0) {
154+
return false;
155+
}
156+
$lastRecollectAt = (int) ($this->_checkoutSession->getData(self::SESSION_KEY_LAST_RECOLLECT_AT) ?: 0);
157+
return $rulesUpdatedAt > $lastRecollectAt;
158+
}
159+
114160
/**
115161
* Get all cart items
116162
*
117163
* @return array
118-
* @codeCoverageIgnore
119164
*/
120165
public function getItems()
121166
{
@@ -135,15 +180,18 @@ public function getItemHtml(\Magento\Quote\Model\Quote\Item $item)
135180
}
136181

137182
/**
183+
* Retrieve totals.
184+
*
138185
* @return array
139-
* @codeCoverageIgnore
140186
*/
141187
public function getTotals()
142188
{
143189
return $this->getTotalsCache();
144190
}
145191

146192
/**
193+
* Retrieve cached totals.
194+
*
147195
* @return array
148196
*/
149197
public function getTotalsCache()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
/**
3+
* Copyright 2026 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Checkout\Observer;
9+
10+
use Magento\Framework\App\CacheInterface;
11+
use Magento\Framework\Event\ObserverInterface;
12+
13+
/**
14+
* Sets a cache flag when a catalog price rule is saved so the cart page can refresh totals only when needed.
15+
*/
16+
class CatalogRuleSaveAfterObserver implements ObserverInterface
17+
{
18+
/**
19+
* Cache key for catalog price rules updated timestamp (used by AbstractCart to decide whether to recollect).
20+
*/
21+
public const CACHE_KEY_CATALOG_RULES_UPDATED_AT = 'checkout_cart_catalog_rules_updated_at';
22+
23+
/**
24+
* Cache lifetime (7 days).
25+
*/
26+
private const CACHE_LIFETIME = 604800;
27+
28+
/**
29+
* @var CacheInterface
30+
*/
31+
private $cache;
32+
33+
/**
34+
* @param CacheInterface $cache
35+
*/
36+
public function __construct(CacheInterface $cache)
37+
{
38+
$this->cache = $cache;
39+
}
40+
41+
/**
42+
* When a catalog rule is saved, set cache timestamp so storefront cart can refresh totals once.
43+
*
44+
* @param \Magento\Framework\Event\Observer $observer
45+
* @return void
46+
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
47+
*/
48+
public function execute(\Magento\Framework\Event\Observer $observer): void
49+
{
50+
$this->cache->save(
51+
(string) time(),
52+
self::CACHE_KEY_CATALOG_RULES_UPDATED_AT,
53+
[],
54+
self::CACHE_LIFETIME
55+
);
56+
}
57+
}

0 commit comments

Comments
 (0)