Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c7a5174
fix(TWO-25554): bound each panel's restored-number read to its own form
dgjlindsay Sep 1, 2026
4402875
fix(TWO-25554): anchor panel chrome beside the popover's wrapper
dgjlindsay Sep 1, 2026
8f3831e
fix(TWO-25554): fill billing forms a third-party component renders
dgjlindsay Sep 1, 2026
7659889
docs(TWO-25554): drop a pointer comment to another test file
dgjlindsay Sep 1, 2026
31190bc
fix(TWO-25554): take a panel's chrome off the page with its mount
dgjlindsay Sep 1, 2026
5b92874
fix(TWO-25554): retire the mode and the adoption with the capture
dgjlindsay Sep 1, 2026
1201d58
fix(TWO-25554): route the quote's billing company on the quote's own …
dgjlindsay Sep 1, 2026
284028b
fix(TWO-25554): key the address write record on the calling panel's i…
dgjlindsay Sep 1, 2026
9b3450b
test(TWO-25554): let the new tables' row descriptions reach the failu…
dgjlindsay Sep 1, 2026
ee594a9
test(TWO-25554): pin the billing predicate against its own false posi…
dgjlindsay Sep 1, 2026
2208fe5
fix(TWO-25554): give the no-destination refusal its own notice
dgjlindsay Sep 1, 2026
ea87d3c
fix(TWO-25554): require a city before a container answers as the addr…
dgjlindsay Sep 1, 2026
9b8b648
docs(TWO-25554): state the rules these comments describe, and name th…
dgjlindsay Sep 1, 2026
70fad60
test(TWO-25554): flush the propagation each new denial denies
dgjlindsay Sep 1, 2026
675a5b6
test(TWO-25554): pin the retirement where it happens, not after an un…
dgjlindsay Sep 1, 2026
72b011d
docs(TWO-25554): collapse five comments to the invariant each carries
dgjlindsay Sep 1, 2026
51632c0
test(TWO-25554): let every quote double answer the cache-key comparison
dgjlindsay Sep 1, 2026
f2aa227
fix(TWO-25554): answer "is billing distinct" from the checkbox and th…
dgjlindsay Sep 1, 2026
b6b6c98
test(TWO-25554): let the direction tables' row descriptions reach the…
dgjlindsay Sep 1, 2026
9989a2f
fix(TWO-25554): strip the abandoned host's chrome and ARIA when the m…
dgjlindsay Sep 1, 2026
ae954a5
fix(TWO-25554): render each panel's address notice at its own field
dgjlindsay Sep 1, 2026
dfeaccc
fix(TWO-25554): give the billing panel its own write destination
dgjlindsay Sep 1, 2026
bc45057
fix(TWO-25554): read the "same as shipping" box of the active method …
dgjlindsay Sep 1, 2026
b6845a5
test(TWO-25554): pin the three panel-separation fixes nothing was hol…
dgjlindsay Sep 1, 2026
448cc00
refactor(TWO-25554): drop the unconsumed announceAddressUnavailable e…
dgjlindsay Sep 1, 2026
72553ae
docs(TWO-25554): collapse the round's comments to the invariant each …
dgjlindsay Sep 1, 2026
5ad0f1f
refactor(TWO-25554): drop four guards shaped around the harness stub
dgjlindsay Sep 1, 2026
26f4768
docs(TWO-25554): correct activeBillingToggle's @returns and tag a dea…
dgjlindsay Sep 1, 2026
d25e541
test(TWO-25554): pin five panel-separation invariants nothing was hol…
dgjlindsay Sep 1, 2026
7007367
refactor(TWO-25554): drop the restored-number multi-match check
dgjlindsay Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 46 additions & 16 deletions Plugin/Model/Checkout/LayoutProcessorPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
class LayoutProcessorPlugin
{
/**
* Core's own billing-address component. Every billing address form is one
* of these, whichever container it was generated into, so matching on it
* is what lets both *Display Billing Address On* settings and every
* payment method code be covered without naming any of them.
* Core's own billing-address component — one of the two things that
* identify a billing form, so neither the *Display Billing Address On*
* setting nor any payment method code has to be named. See
* `isBillingAddressForm()` for the other.
*/
private const BILLING_ADDRESS_COMPONENT = 'Magento_Checkout/js/view/billing-address';

Expand Down Expand Up @@ -161,24 +161,54 @@ private function processBillingFieldsets(array &$jsLayout)
private function processBillingForms(array &$container)
{
foreach ($container as &$node) {
if (!is_array($node)
|| ($node['component'] ?? null) !== self::BILLING_ADDRESS_COMPONENT
|| !isset($node['dataScopePrefix'])
|| !is_string($node['dataScopePrefix'])
|| $node['dataScopePrefix'] === ''
|| !isset($node['children']['form-fields']['children'])
|| !is_array($node['children']['form-fields']['children'])
) {
if (!is_array($node)) {
continue;
}
if ($this->isBillingAddressForm($node)) {
$fieldset = &$node['children']['form-fields']['children'];
$fieldset['company_id'] = $this->companyIdField($node['dataScopePrefix']);
$this->moveCountryBeforeCompany($fieldset);
unset($fieldset);
continue;
}
$fieldset = &$node['children']['form-fields']['children'];
$fieldset['company_id'] = $this->companyIdField($node['dataScopePrefix']);
$this->moveCountryBeforeCompany($fieldset);
unset($fieldset);
// A checkout that wraps the billing form in a container of its own
// puts it below this level, and a form never nests inside a form.
if (isset($node['children']) && is_array($node['children'])) {
$this->processBillingForms($node['children']);
}
}
unset($node);
}

/**
* Whether one layout node is a billing address form this plugin can fill.
*
* Core's component OR core's `billingAddress` scope naming, because a
* checkout that substitutes its own billing-address component still binds
* it to that scope — the field's `dataScope` is what makes the number
* submit with the right address, so a node that does not carry that scope
* is not a billing form whatever else it looks like. The scope test alone
* would also admit the shipping fieldset; it is reached from a different
* path and never appears under these containers.
*
* @param array<string,mixed> $node
* @return bool
*/
private function isBillingAddressForm(array $node): bool
{
if (!isset($node['dataScopePrefix'])
|| !is_string($node['dataScopePrefix'])
|| !isset($node['children']['form-fields']['children'])
|| !is_array($node['children']['form-fields']['children'])
) {
return false;
}

return ($node['component'] ?? null) === self::BILLING_ADDRESS_COMPONENT
? $node['dataScopePrefix'] !== ''
: strpos($node['dataScopePrefix'], 'billingAddress') === 0;
}

/**
* Force `country_id` to render before the native `company` AND `street`
* fields of one address fieldset, only when it does not already.
Expand Down
5 changes: 3 additions & 2 deletions Test/Js/address-step-company-id-text.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ const {
loadCompanySearchPanel,
defaultMocks,
brandConfigMock,
installAsyncSimulation
installAsyncSimulation,
quoteAddress
} = require('./amd-harness');

const SEARCH = 'view/frontend/web/js/model/company-search.js';
Expand Down Expand Up @@ -82,7 +83,7 @@ function load() {
{},
defaultMocks()['Magento_Checkout/js/model/quote'],
{
billingAddress: function () { return { countryId: 'NO' }; },
billingAddress: quoteAddress({ countryId: 'NO' }),
isVirtual: function () { return false; }
}
),
Expand Down
61 changes: 56 additions & 5 deletions Test/Js/amd-harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,11 @@ function defaultMocks() {
'domReady!': null,
'Magento_Checkout/js/view/payment/default': Component,
'Magento_Checkout/js/model/quote': {
shippingAddress: makeObservable({}),
billingAddress: makeObservable({}),
// One cache key for both: the quote is what answers "is billing a
// distinct address", so a double with no key at all cannot model
// either answer. Same key means billing IS shipping.
shippingAddress: quoteAddress(),
billingAddress: quoteAddress(),
getTotals: function () { return makeObservable({}); },
getQuoteId: function () { return null; },
paymentMethod: makeObservable(null),
Expand Down Expand Up @@ -160,8 +163,8 @@ function defaultMocks() {
// above: a spec that cares about what the write or the revert
// actually does loads the real module.
revertAutofilledAddress: function () { return 0; },
announceAddressUnavailable: function (identity) {
identity.addressNotice('address unavailable');
announceAddressUndeliverable: function (identity) {
identity.addressNotice('address undeliverable');
},
hasPrimaryAddressForm: function () { return true; },
isDegradedResponse: function () { return false; },
Expand Down Expand Up @@ -202,7 +205,11 @@ function defaultMocks() {
// No DOM in the inert default: a spec that wants the live
// address-form country read has to supply the real module (or its
// own double) the same way it already does for the search itself.
currentAddressFormCountry: function () { return ''; },
// DELEGATED: it is a pure read of the `$root` it is handed, and
// WHICH root each panel hands it is the invariant specs assert.
currentAddressFormCountry: function ($root) {
return realCompanySearch().currentAddressFormCountry($root);
},
// NOT inert — company-capture.js builds the billing panel's own
// mount selectors from it, so a mock returning undefined would
// exercise selectors production never uses.
Expand Down Expand Up @@ -266,6 +273,17 @@ function defaultMocks() {
let evaluatingComputed = null;

function makeKnockoutMock() {
/**
* A computed over a live read, close enough to model the caching that makes
* a missing notification observable — and no closer. Three divergences from
* real Knockout, all of which a spec must not lean on: the dependency set
* only ever grows, `makeObservable` notifies on every write whether or not
* the value changed, so a re-publish can come from an observable the
* computed no longer reads; and there is no re-entrancy guard.
*
* @param {function} fn
* @returns {function} the computed's value accessor
*/
function computed(fn) {
const out = makeObservable(undefined);
const dependencies = [];
Expand Down Expand Up @@ -295,6 +313,36 @@ function makeKnockoutMock() {
};
}

/** The cache key both default quote addresses answer with: billing IS shipping. */
const ONE_ADDRESS_KEY = 'one-address';

/**
* A quote address observable of the shape `company-capture.js` reads it in: a
* cache key it can be compared with the other address on, and a `subscribe` the
* predicate's invalidation is wired to. A double supplying neither cannot model
* "is billing a distinct address" at all, and throws where production asks.
*
* @param {object} [fields] address fields the spec itself needs
* @param {string} [cacheKey] defaults to the key shippingAddress also answers
* @returns {function} Knockout-shaped observable
*/
function quoteAddress(fields, cacheKey) {
return makeObservable(quoteAddressValue(fields, cacheKey));
}

/**
* The value inside a quoteAddress() observable, for a spec that writes a NEW
* address into one mid-test.
*
* @param {object} [fields] address fields the spec itself needs
* @param {string} [cacheKey] defaults to the key shippingAddress also answers
* @returns {object}
*/
function quoteAddressValue(fields, cacheKey) {
const key = cacheKey || ONE_ADDRESS_KEY;
return Object.assign({ getCacheKey: function () { return key; } }, fields || {});
}

function makeObservable(initial) {
let value = initial;
const subscribers = [];
Expand Down Expand Up @@ -831,6 +879,9 @@ function tagged(description, value) {

module.exports = {
tagged: tagged,
quoteAddress: quoteAddress,
quoteAddressValue: quoteAddressValue,
makeObservable: makeObservable,
dispatchNative: dispatchNative,
isProxyRoute: isProxyRoute,
HARNESS_BASE_URL: HARNESS_BASE_URL,
Expand Down
Loading