This one’s a long winding journey through all the javascript code involved when Magento updates the Shipping Rates section of the checkout application.
The specifics here are Magento 2.1.1 – and I know for a fact there are some small differences in the 2.0.x branch w/r/t to how the validation listeners get setup. Concepts should be useful throughout Magento 2 though.
Also, this assumes you’ve at least skimmed my Advanced Javascript and UI Components articles. We’re using Commerce Bug 3.2 to match up RequireJS modules with their Knockout.js remote templates.
This one is probably confusing – no way around that. We’ll update each section with an executive summary. If you need further help ask a question on Stack Exchange and then ping me via Twitter.
Shipping Address Fields
We need to start with the Shipping Address form. Each element in the shipping address form is rendered with a RequireJS view model/Knockout.js remote template pair. For example, you can grab the view model for the first name field with
reg = requirejs('uiRegistry');
reg.get('checkout.steps.shipping-step.shippingAddress.shipping-address-fieldset.firstname')
Its RequireJS view module constructor factory is Magento_Ui/js/form/element/abstract
. Its template URI is ui/form/field
. For reasons outside the scope of this post, this view model also has a elementTmpl
property
reg = requirejs('uiRegistry');
reg.get('checkout.steps.shipping-step.shippingAddress.shipping-address-fieldset.firstname').elementTmpl
with a value (for this specific view model) of ui/form/element/input
. This elementTmpl
property contains the actual form field HTML.
<!--
/**
* Copyright © 2016 Magento. All rights reserved.
* See COPYING.txt for license details.
*/
-->
<input class="admin__control-text" type="text" data-bind="
event: {change: userChanges},
value: value,
hasFocus: focused,
valueUpdate: valueUpdate,
attr: {
name: inputName,
placeholder: placeholder,
'aria-describedby': noticeId,
id: uid,
disabled: disabled
}">
The input (as do all the inputs in the shipping form) has several Knockout.js bindings, including a value
binding and an event
binding. Ignore the event
binding. Notice that the value binding binds to the value
property of the view model.
reg = requirejs('uiRegistry');
reg.get('checkout.steps.shipping-step.shippingAddress.shipping-address-fieldset.firstname').value
This value
property is an observable. How it gets setup as an observable is another story for another time, but you can confirm this by updating its value and seeing the form update.
reg = requirejs('uiRegistry');
reg.get('checkout.steps.shipping-step.shippingAddress.shipping-address-fieldset.firstname').value("Test")
We’re going to veer wildly in another direction. The main takeaway for this section is to remember that the value
property of a field view model is an observable.
Shipping Method Event Bindings
The
requirejs('uiRegistry').get('checkout.steps.shipping-step.shippingAddress')
view model is responsible for rendering the shipping address form. It’s view model constructor factory (i.e. its “component”) is the RequireJS module Magento_Checkout/view/shipping
. It has a child view model whose name is shipping-address-fieldset
. This fieldset has child view models that are the individual form elements mentioned in the previous section.
If we look at the source for Magento_Checkout/view/shipping
, we see its initialize
method contains the following
initialize: function () {
var self = this,
hasNewAddress,
fieldsetName = 'checkout.steps.shipping-step.shippingAddress.shipping-address-fieldset';
this._super();
shippingRatesValidator.initFields(fieldsetName);
//...
}
We’re interested in the shippingRatesValidator.initFields(fieldsetName);
call. The shippingRatesValidator
variable is a Magento_Checkout/js/model/shipping-rates-validator
RequireJS module. This module is responsible for setting up the form validation. Its source is worth investigating entirely on its own, but what we’re interested in is the following
#File: vendor/magento/module-checkout/view/frontend/web/js/model/shipping-rates-validator.js
element.on('value', function () {
clearTimeout(self.validateAddressTimeout);
self.validateAddressTimeout = setTimeout(function () {
if (self.postcodeValidation()) {
self.validateFields();
}
}, delay);
});
Via that initial call to initFields
, Magento will eventually call the above code where element
is each form element’s view model. The call to .on('value'
sets up variable tracking for the observable value
field we discussed earlier. In the validateFields
method above (yes, we’re skipping a lot)
#File: vendor/magento/module-checkout/view/frontend/web/js/model/shipping-rates-validator.js
validateFields: function () {
var addressFlat = addressConverter.formDataProviderToFlatData(
this.collectObservedData(),
'shippingAddress'
),
address;
if (this.validateAddressData(addressFlat)) {
address = addressConverter.formAddressDataToQuoteAddress(addressFlat);
selectShippingAddress(address);
}
},
Magento will create a new address object with the call to
address = addressConverter.formAddressDataToQuoteAddress(addressFlat);
and then pass that address object to the selectShippingAddress
function. This function comes from the Magento_Checkout/js/action/select-shipping-address
module.
#File: vendor/magento//module-checkout/view/frontend/web/js/action/select-shipping-address.js
/**
* Copyright © 2016 Magento. All rights reserved.
* See COPYING.txt for license details.
*/
/*global define*/
define(
[
'Magento_Checkout/js/model/quote'
],
function(quote) {
'use strict';
return function(shippingAddress) {
quote.shippingAddress(shippingAddress);
};
}
);
which seems to pass the address on to the shippingAddress
method of the Magento_Checkout/js/model/quote
module. We say seems to, because if we look at the quote model
#File: vendor/magento/module-checkout/view/frontend/web/js/model/quote.js
define(
['ko'],
function (ko) {
'use strict';
//...
var shippingAddress = ko.observable(null);
//...
return {
//...
shippingAddress: shippingAddress,
//...
we see that shippingAddress
is not a method, its an observable. So what’s really happening here
quote.shippingAddress(shippingAddress);
is the shippingAddress
observable on the quote
object is having its value updated.
The main takeaway from all this is the value observables each have a listener/subscriber/tracker setup on them. This listener/subscriber/tracker performs form validation. When validation passes, Magento updates an observable object on the javascript quote model.
Also, so far we’re only using observables to store values, and listen for changes to input
s. No DOM manipulation is happening yet.
Rates Fetching
Next, we need to talk about where rates come from. If we talk a look at the Magento_Checkout/js/view/shipping
module
#File: vendor/magento/module-checkout/view/frontend/web/js/view/shipping.js
define(
[
/* ... */
'Magento_Checkout/js/model/shipping-service',
We see it includes the Magento_Checkout/js/model/shipping-rate-service
module as a dependency. If we look at this module’s source
#File: vendor/magento/module-checkout/view/frontend/web/js/model/shipping-rate-service.js
define(
[
'Magento_Checkout/js/model/quote',
'Magento_Checkout/js/model/shipping-rate-processor/new-address',
'Magento_Checkout/js/model/shipping-rate-processor/customer-address'
],
function (quote, defaultProcessor, customerAddressProcessor) {
'use strict';
var processors = [];
processors.default = defaultProcessor;
processors['customer-address'] = customerAddressProcessor;
quote.shippingAddress.subscribe(function () {
var type = quote.shippingAddress().getType();
if (processors[type]) {
processors[type].getRates(quote.shippingAddress());
} else {
processors.default.getRates(quote.shippingAddress());
}
});
return {
registerProcessor: function (type, processor) {
processors[type] = processor;
}
}
}
);
there’s a few things to take note of. First, this module runs some code before returning an object. The way module loading works in RequireJS, the code prior to return
will only ever run once, no matter how often the module is used throughout a program’s lifecycle.
Second is the following code
quote.shippingAddress.subscribe(function () {
var type = quote.shippingAddress().getType();
if (processors[type]) {
processors[type].getRates(quote.shippingAddress());
} else {
processors.default.getRates(quote.shippingAddress());
}
});
Remember the observable shippingAddress
from the Magento_Checkout/js/model/quote
module? Well, here Magento has setup an observable listener (i.e. a subscriber). Knockout will invoke this callback whenever the value in the quote.shippingAddress()
observable is updated.
The getRates
method
#File: vendor/magento//module-checkout/view/frontend/web/js/model/shipping-rate-processor/new-address.js
getRates: function (address) {
shippingService.isLoading(true);
var cache = rateRegistry.get(address.getCacheKey()),
//...
if (cache) {
shippingService.setShippingRates(cache);
shippingService.isLoading(false);
} else {
storage.post(
serviceUrl, payload, false
).done(
function (result) {
rateRegistry.set(address.getCacheKey(), result);
shippingService.setShippingRates(result);
}
).
/*...*/
}
}
will make an ajax request for the shipping rates, and then cache them in the rateRegistry
(a Magento_Checkout/js/model/shipping-rate-registry
module), and also set them on the shippingService
(a Magento_Checkout/js/model/shipping-service
object). If we take a look at this shipping service, we see
#File: vendor/magento//module-checkout/view/frontend/web/js/model/shipping-service.js
function (ko, checkoutDataResolver) {
"use strict";
var shippingRates = ko.observableArray([]);
return {
isLoading: ko.observable(false),
/**
* Set shipping rates
*
* @param ratesData
*/
setShippingRates: function(ratesData) {
shippingRates(ratesData);
shippingRates.valueHasMutated();
checkoutDataResolver.resolveShippingRates(ratesData);
},
/**
* Get shipping rates
*
* @returns {*}
*/
getShippingRates: function() {
return shippingRates;
}
};
}
that setShippingRates
updates shippingRates
, an internal to the module private data variables. The shippingRates
variable is a Knockout observable array. Anyone can fetch this private variable using the getShippingRates
method.
So, the takeaway from this section: The shippingAddress
observable on a quote object has a subscriber setup. This subscriber fetches rates from a URL using ajax, and updates a second observable in the Magento_Checkout/js/model/shipping-service
module.
Rates Data Binding
We’re almost there. The final piece of this puzzle is the shipping method template code
#File: vendor/magento/module-checkout/view/frontend/web/template/shipping.html
<!--ko foreach: { data: rates(), as: 'method'}-->
<tr></tr>
/*...*/
<!--/ko-->
Above, there’s a tag-less Knockout.js foreach binding that iterates over the array returned by the rates()
function. The checkout/shipping
remote template above has a view model constructor factory (i.e. a “component”) of Magento_Checkout/js/view/shipping
, which means we’ll find the source for the rates()
method in that module’s source code.
#File: vendor/magento/module-checkout/view/frontend/web/js/view/shipping.js
define(
[
/* ... */
'Magento_Checkout/js/model/shipping-service',
/* ... */ ],
function (
/* ... */
shippingService,
/* ... */
) {
'use strict';
var popUp = null;
return Component.extend({
/* ... */
rates: shippingService.getShippingRates(),
/* ... */
});
}
);
The rates
method is just an alias to the shippingService.getShippingRates
method. This is the same getShippingRates
method we discussed in the last section.
In other words, rates()
returns the observable array from Magento_Checkout/js/model/shipping-service
. The same observable array that Magento updates whenever it fetches shipping rates via getRates
. The getRates
that’s called in a subscriber to the shippingAddress
object on the quote module object. The same shipingAddrsess
object that’s updated whenever Magento’s form validates itself successfully. Validation that happens whenever a value
in a form view model is updated. Values that are updated whenever a user enters text in a form.
Since the KnockoutJS template foreach
es over this observable array via a tag-less data-bind
ing, Knockout will update the DOM nodes whenever the rates are updated by a call to the Magento_Checkout/js/model/shipping-service
module’s setShippingRates
method.