You've asked a bit of a mouthful there -- probably too much for a single Stack Exchange question, so I'm going to concentrate on teaching you how to reset the initial state of the form so things no longer appear invalid if there's a blank value. If you're interested in the whys, you'll need to get up to speed on the fundamentals of UI Components and x-magento-init
scripts. My Magento 2 UI Components and uiElement Internals series are a good place to start. The following assumes you have some familiarity with these systems -- if there's something confusing below then the above articles (as well as the other Magento 2 articles) will probably have the information you're looking for.
In the Checkout Application, each form field is represented by a view model, and one-or-many view model templates. You can see this in Commerce Bug's KO Scopes tab.
![enter image description here](https://i.stack.imgur.com/sJd3F.png)
The firstname's view model is an object derived from the constructor function returned by the Magento_Ui/js/form/element/abstract
RequireJS module. You can find that model's source code here
vendor/magento//module-ui/view/base/web/js/form/element/abstract.js
The reset
method is the one we're interested in. This will reset a field's validation state. You'll need to figure out the full uiRegistry
string identifier for the specific model (again, Commerce Bug is useful here), use that string to fetch the instantiated view model, and then call the reset function. For the checkmo
form, the following program (easily adapted into a define
module if you're going to use it in your application) will do that for the firstname
field.
requirejs(['uiRegistry'], function(uiRegistry){
uiRegistry.get('checkout.steps.billing-step.payment.payments-list.checkmo-form.form-fields.firstname').reset();
});
Of course, you'll likely want to fetch each view model that's part of a particular form -- the following is a start on that.
uiRegistry.get(function(viewModel){
var parentName = viewModel['parentName'] ? viewModel['parentName'] : '';
if(parentName.indexOf('checkout.steps.billing-step.payment.payments-list.checkmo-form.form-fields') === -1){
return;
};
console.log(viewModel);
});
This uses the uiRegistry
's callback feature to search for all view models with a parent of checkout.steps.billing-step.payment.payments-list.checkmo-form.form-fields
. Some of these may not be form elements, but we'll leave that problem as an exercise for the questioner.
Hope that helps!
Try to follow all step:
Step 1: SR/ModifiedCheckout/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="SR_ModifiedCheckout" setup_version="2.0.0">
<sequence>
<module name="Magento_Checkout"/>
</sequence>
</module>
</config>
Step 2: SR/ModifiedCheckout/registration.php
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'SR_ModifiedCheckout',
__DIR__
);
Step 3: SR/ModifiedCheckout/etc/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Checkout\Block\Checkout\LayoutProcessor">
<plugin name="sr_modified_checkout_layout_processor" type="SR\ModifiedCheckout\Plugin\Block\LayoutProcessor" sortOrder="1"/>
</type>
</config>
Step 4: SR/ModifiedCheckout/Plugin/Block/LayoutProcessor.php
namespace SR\ModifiedCheckout\Plugin\Block;
use Magento\Customer\Model\AttributeMetadataDataProvider;
use Magento\Ui\Component\Form\AttributeMapper;
use Magento\Checkout\Block\Checkout\AttributeMerger;
use Magento\Checkout\Model\Session as CheckoutSession;
class LayoutProcessor
{
/**
* @var AttributeMetadataDataProvider
*/
public $attributeMetadataDataProvider;
/**
* @var AttributeMapper
*/
public $attributeMapper;
/**
* @var AttributeMerger
*/
public $merger;
/**
* @var CheckoutSession
*/
public $checkoutSession;
/**
* @var null
*/
public $quote = null;
/**
* LayoutProcessor constructor.
*
* @param AttributeMetadataDataProvider $attributeMetadataDataProvider
* @param AttributeMapper $attributeMapper
* @param AttributeMerger $merger
* @param CheckoutSession $checkoutSession
*/
public function __construct(
AttributeMetadataDataProvider $attributeMetadataDataProvider,
AttributeMapper $attributeMapper,
AttributeMerger $merger,
CheckoutSession $checkoutSession
) {
$this->attributeMetadataDataProvider = $attributeMetadataDataProvider;
$this->attributeMapper = $attributeMapper;
$this->merger = $merger;
$this->checkoutSession = $checkoutSession;
}
/**
* Get Quote
*
* @return \Magento\Quote\Model\Quote|null
*/
public function getQuote()
{
if (null === $this->quote) {
$this->quote = $this->checkoutSession->getQuote();
}
return $this->quote;
}
/**
* @param \Magento\Checkout\Block\Checkout\LayoutProcessor $subject
* @param array $jsLayout
* @return array
*/
public function aroundProcess(
\Magento\Checkout\Block\Checkout\LayoutProcessor $subject,
\Closure $proceed,
array $jsLayout
) {
$jsLayoutResult = $proceed($jsLayout);
if($this->getQuote()->isVirtual()) {
return $jsLayoutResult;
}
if(isset($jsLayoutResult['components']['checkout']['children']['steps']['children']['shipping-step']['children']
['shippingAddress']['children']['shipping-address-fieldset'])) {
$jsLayoutResult['components']['checkout']['children']['steps']['children']['shipping-step']
['children']['shippingAddress']['children']['shipping-address-fieldset']['children']['street']['children'][0]['placeholder'] = __('Street Address');
$jsLayoutResult['components']['checkout']['children']['steps']['children']['shipping-step']
['children']['shippingAddress']['children']['shipping-address-fieldset']['children']['street']['children'][1]['placeholder'] = __('Street line 2');
$elements = $this->getAddressAttributes();
$jsLayoutResult['components']['checkout']['children']['steps']['children']['shipping-step']
['children']['shippingAddress']['children']['billing-address'] = $this->getCustomBillingAddressComponent($elements);
$jsLayoutResult['components']['checkout']['children']['steps']['children']['shipping-step']
['children']['shippingAddress']['children']['billing-address']['children']['form-fields']['children']['street']['children'][0]['placeholder'] = __('Street Address');
$jsLayoutResult['components']['checkout']['children']['steps']['children']['shipping-step']
['children']['shippingAddress']['children']['billing-address']['children']['form-fields']['children']['street']['children'][1]['placeholder'] = __('Street line 2');
}
return $jsLayoutResult;
}
/**
* Get all visible address attribute
*
* @return array
*/
private function getAddressAttributes()
{
/** @var \Magento\Eav\Api\Data\AttributeInterface[] $attributes */
$attributes = $this->attributeMetadataDataProvider->loadAttributesCollection(
'customer_address',
'customer_register_address'
);
$elements = [];
foreach ($attributes as $attribute) {
$code = $attribute->getAttributeCode();
if ($attribute->getIsUserDefined()) {
continue;
}
$elements[$code] = $this->attributeMapper->map($attribute);
if (isset($elements[$code]['label'])) {
$label = $elements[$code]['label'];
$elements[$code]['label'] = __($label);
}
}
return $elements;
}
/**
* Prepare billing address field for shipping step for physical product
*
* @param $elements
* @return array
*/
public function getCustomBillingAddressComponent($elements)
{
return [
'component' => 'SR_ModifiedCheckout/js/view/billing-address',
'displayArea' => 'billing-address',
'provider' => 'checkoutProvider',
'deps' => ['checkoutProvider'],
'dataScopePrefix' => 'billingAddress',
'children' => [
'form-fields' => [
'component' => 'uiComponent',
'displayArea' => 'additional-fieldsets',
'children' => $this->merger->merge(
$elements,
'checkoutProvider',
'billingAddress',
[
'country_id' => [
'sortOrder' => 115,
],
'region' => [
'visible' => false,
],
'region_id' => [
'component' => 'Magento_Ui/js/form/element/region',
'config' => [
'template' => 'ui/form/field',
'elementTmpl' => 'ui/form/element/select',
'customEntry' => 'billingAddress.region',
],
'validation' => [
'required-entry' => true,
],
'filterBy' => [
'target' => '${ $.provider }:${ $.parentScope }.country_id',
'field' => 'country_id',
],
],
'postcode' => [
'component' => 'Magento_Ui/js/form/element/post-code',
'validation' => [
'required-entry' => true,
],
],
'company' => [
'validation' => [
'min_text_length' => 0,
],
],
'fax' => [
'validation' => [
'min_text_length' => 0,
],
],
'telephone' => [
'config' => [
'tooltip' => [
'description' => __('For delivery questions.'),
],
],
],
]
),
],
],
];
}
}
Step 5: SR/ModifiedCheckout/view/frontend/layout/checkout_index_index.xml
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" layout="1column" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceBlock name="checkout.root">
<arguments>
<argument name="jsLayout" xsi:type="array">
<item name="components" xsi:type="array">
<item name="checkout" xsi:type="array">
<item name="children" xsi:type="array">
<item name="steps" xsi:type="array">
<item name="children" xsi:type="array">
<item name="shipping-step" xsi:type="array">
<item name="children" xsi:type="array">
<item name="shippingAddress" xsi:type="array">
<item name="config" xsi:type="array">
<item name="template" xsi:type="string">SR_ModifiedCheckout/address</item>
</item>
</item>
<item name="overwriteShippingComponent" xsi:type="array">
<item name="component" xsi:type="string">SR_ModifiedCheckout/js/view/shipping</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</item>
</argument>
</arguments>
</referenceBlock>
</body>
</page>
Step 6:SR/ModifiedCheckout/view/frontend/web/js/view/billing-address.js
/*jshint browser:true*/
/*global define*/
define(
[
'ko',
'underscore',
'jquery',
'Magento_Ui/js/form/form',
'Magento_Customer/js/model/customer',
'Magento_Customer/js/model/address-list',
'Magento_Checkout/js/model/quote',
'Magento_Checkout/js/action/create-billing-address',
'Magento_Checkout/js/action/select-billing-address',
'Magento_Checkout/js/checkout-data',
'Magento_Checkout/js/model/checkout-data-resolver',
'Magento_Customer/js/customer-data',
'Magento_Checkout/js/action/set-billing-address',
'Magento_Ui/js/model/messageList',
'mage/translate'
],
function (
ko,
_,
$,
Component,
customer,
addressList,
quote,
createBillingAddress,
selectBillingAddress,
checkoutData,
checkoutDataResolver,
customerData,
setBillingAddressAction,
globalMessageList,
$t
) {
'use strict';
var newAddressOption = {
/**
* Get new address label
* @returns {String}
*/
getAddressInline: function () {
return $t('New Address');
},
customerAddressId: null
},
countryData = customerData.get('directory-data'),
addressOptions = addressList().filter(function (address) {
return address.getType() == 'customer-address';
});
addressOptions.push(newAddressOption);
return Component.extend({
defaults: {
template: 'SR_ModifiedCheckout/billing-address'
},
currentBillingAddress: quote.billingAddress,
addressOptions: addressOptions,
customerHasAddresses: addressOptions.length > 1,
/**
* Init component
*/
initialize: function () {
this._super();
},
/**
* @return {exports.initObservable}
*/
initObservable: function () {
this._super()
.observe({
selectedAddress: null,
isAddressFormVisible: false,
isAddressSameAsShipping: true,
saveInAddressBook: 1,
isAddressFormListVisible:false
});
return this;
},
canUseShippingAddress: ko.computed(function () {
return !quote.isVirtual() && quote.shippingAddress() && quote.shippingAddress().canUseForBilling();
}),
/**
* @param {Object} address
* @return {*}
*/
addressOptionsText: function (address) {
return address.getAddressInline();
},
/**
* @return {Boolean}
*/
useShippingAddress: function () {
if (this.isAddressSameAsShipping()) {
this.isAddressFormVisible(false);
this.isAddressFormListVisible(false);
} else {
if(addressOptions.length == 1) {
this.isAddressFormVisible(true);
} else {
this.isAddressFormListVisible(true);
}
}
return true;
},
/**
* @param {Object} address
*/
onAddressChange: function (address) {
if(address) {
this.isAddressFormVisible(false);
} else {
this.isAddressFormVisible(true);
}
},
/**
* @param {int} countryId
* @return {*}
*/
getCountryName: function (countryId) {
return countryData()[countryId] != undefined ? countryData()[countryId].name : '';
},
/**
* Get code
* @param {Object} parent
* @returns {String}
*/
getCode: function (parent) {
return _.isFunction(parent.getCode) ? parent.getCode() : 'shared';
}
});
}
);
Step 7:SR/ModifiedCheckout/view/frontend/web/js/view/shipping.js
define(
[
'jquery',
'underscore',
'Magento_Ui/js/form/form',
'ko',
'Magento_Customer/js/model/customer',
'Magento_Customer/js/model/address-list',
'Magento_Checkout/js/model/address-converter',
'Magento_Checkout/js/model/quote',
'Magento_Checkout/js/action/create-shipping-address',
'Magento_Checkout/js/action/select-shipping-address',
'Magento_Checkout/js/view/shipping',
'Magento_Checkout/js/action/create-billing-address',
'Magento_Checkout/js/action/select-billing-address',
'Magento_Checkout/js/action/set-shipping-information',
'Magento_Checkout/js/model/step-navigator',
'Magento_Checkout/js/checkout-data'
],
function(
$,
_,
Component,
ko,
customer,
addressList,
addressConverter,
quote,
createShippingAddress,
selectShippingAddress,
shipping,
createBillingAddress,
selectBillingAddress,
setShippingInformationAction,
stepNavigator,
checkoutData
) {
'use strict';
_.extend(shipping.prototype, {
setShippingInformation: function () {
if (this.validateShippingInformation() && this.validateBillingInformation()) {
setShippingInformationAction().done(
function () {
stepNavigator.next();
}
);
}
},
validateBillingInformation: function() {
if($('[name="billing-address-same-as-shipping"]').is(":checked")) {
if (this.isFormInline) {
var shippingAddress = quote.shippingAddress();
var addressData = addressConverter.formAddressDataToQuoteAddress(
this.source.get('shippingAddress')
);
//Copy form data to quote shipping address object
for (var field in addressData) {
if (addressData.hasOwnProperty(field) &&
shippingAddress.hasOwnProperty(field) &&
typeof addressData[field] != 'function' &&
_.isEqual(shippingAddress[field], addressData[field])
) {
shippingAddress[field] = addressData[field];
} else if (typeof addressData[field] != 'function' &&
!_.isEqual(shippingAddress[field], addressData[field])) {
shippingAddress = addressData;
break;
}
}
if (customer.isLoggedIn()) {
shippingAddress.save_in_address_book = 1;
}
var newBillingAddress = createBillingAddress(shippingAddress);
selectBillingAddress(newBillingAddress);
} else {
selectBillingAddress(quote.shippingAddress());
}
return true;
}
var selectedAddress = $('[name="billing_address_id"]').val();
if(selectedAddress) {
var res = addressList.some(function (addressFromList) {
if (selectedAddress == addressFromList.customerAddressId) {
selectBillingAddress(addressFromList);
return true;
}
return false;
});
return res;
}
this.source.set('params.invalid', false);
this.source.trigger('billingAddress.data.validate');
if (this.source.get('params.invalid')) {
return false;
}
var addressData = this.source.get('billingAddress'),
newBillingAddress;
if ($('#billing-save-in-address-book').is(":checked")) {
addressData.save_in_address_book = 1;
}
newBillingAddress = createBillingAddress(addressData);
selectBillingAddress(newBillingAddress);
return true;
}
});
return Component.extend({});
}
);
Download full module from here
Best Answer
As Aaron pointed out the form is added in
Magento/Checkout/Block/Checkout/LayoutProcessor.php
. With this information I developed a module with an after plugin that hooks onto that processor:app/code/<vendor>/<module>/Model/Checkout/LayoutProcessorPlugin.php
app/code/<vendor>/<module>/etc/module.xml
app/code/<vendor>/<module>/etc/di.xml
app/code/<vendor>/<module>/registration.php
This successfully reorders the billing address form (and hopefully saves some headaches for other people). But there is still work needed on the javascript(?) that handles the
billing address is the same as shipping address
mechanism. As this still works the "standard" way.Additional info:
I saw that in the backend if you create a new order the layout is exactly as wanted. The billing form is "before" the shipping form and the logic is the other way around too. If I can find the time I think it might be beneficial to look at the code there. Maybe it is possible to use it in frontend too.