It surprised me that over a year after Magento 2’s introduction I haven’t had an opportunity to create a new form component using the UI Component system. In the extensions and themes I’ve helped folks port over it made a lot more sense to just convert the old PHP rendered HTML to the new extension. When time is money known technology trumps new, fancy, and undocumented technology.
Having a chance to touch the backend form generation code this week, any doubts I had about my previous approach with clients evaporated. Whether you call it a technology demo or a mess, form components perfectly encapsulate a lot of the the problems Magento developers face with Magento 2’s incomplete rendering layer.
This quickie’s intent is to provide a high level overview of how forms get setup in Magento2 ’s UI Component system. If there are concepts below that confuse you the Magento 2 UI Components and uiElement Internals series are a good place to start reading. Specifics here are Magento 2.1.3.
Server Rendered HTML
First off – although they’re created with UI Component XML
<uiComponent name="customer_form"/>
vendor/magento/module-customer/view/base/ui_component/customer_form.xml
forms are a mix of server rendered HTML from the layout object and Magento’s RequireJS/KnockoutJS view models.
A form’s buttons are layout blocks – specifically a set of blocks added to the page-actions.toolbar
blocks. These child blocks are created by the UI component class,
#File: vendor/magento/module-ui/Component/AbstractComponent.php
if ($this->hasData('buttons')) {
$this->getContext()->addButtons($this->getData('buttons'), $this);
}
which eventually results in Magento calling this code. This code instantiates a MagentoUiComponentControlContainer
object
protected function createContainer($key, UiComponentInterface $view)
{
$container = $this->context->getPageLayout()->createBlock(
'MagentoUiComponentControlContainer',
'container-' . $view->getName() . '-' . $key,
[
'data' => [
'button_item' => $this->items[$key],
'context' => $view,
]
]
);
return $container;
}
These objects get added to the layout tree in the add
method here, which grabs a reference to the block in the layout named page.actions.toolbar
const ACTIONS_PAGE_TOOLBAR = 'page.actions.toolbar';
public function getToolbar()
{
return $this->context->getPageLayout()
? $this->context->getPageLayout()->getBlock(static::ACTIONS_PAGE_TOOLBAR)
: false;
}
public function add($key, array $data, UiComponentInterface $component)
{
$data['id'] = isset($data['id']) ? $data['id'] : $key;
$toolbar = $this->getToolbar();
if ($toolbar !== false) {
$this->items[$key] = $this->itemFactory->create();
$this->items[$key]->setData($data);
$container = $this->createContainer($key, $component);
$toolbar->setChild($key, $container);
}
}
I think I’ve speculated that UI Components could, theoretically, add blocks to the layout for you automatically. This practice appears to back that up.
One thing to watch out for here – these buttons end up wrapped in a <div/>
with a data-mage-init
script.
<div data-mage-init='{"floatingHeader": {}}' class="page-actions" data-ui-id="page-actions-toolbar-content-header" >
</div>
This floatingHeader
RequireJS module (an alias for the mage/backend/floating-header
module) isn’t responsible for business-critical form functionality. Instead, it implements the UX-critical behavior that had the buttons follow you down a scrolling page.
Client Side
A form component still renders out an x-magento-init
/Magento_Ui/js/core/app
script node. Different forms use different sets of view-models/components, but the form handling logic in most (all?) of them revolves around a Magento_Ui/js/form/form
view model. This model has a number of children that handle the work of rendering the form UI.
The Magento_Ui/js/form/form
module pulls in the Magento_Ui/js/form/adapter
module as a dependency, with the local variable name adapter
//File: vendor/magento/module-ui/view/base/web/js/form/form.js
define([
/* ... */,/* ... */,/* ... */,
'./adapter',
/* ... */,/* ... */,/* ... */,
], function (_, loader, resolver, adapter, Collection, utils, $, app) {
/* ... */
});
This adapter is responsible for setting up the handlers that handle a save, save and continue, or a reset.
initAdapter: function () {
adapter.on({
'reset': this.reset.bind(this),
'save': this.save.bind(this, true, {}),
'saveAndContinue': this.save.bind(this, false, {})
}, this.selectorPrefix, this.eventPrefix);
return this;
},
If you take a look at the adapter’s source file, it does this by using three hard coded CSS selector like #save
, #save_and_continue
, or #reset
.
#File: vendor/magento/module-ui/view/base/web/js/form/adapter.js
/* ... */
var buttons = {
'reset': '#reset',
'save': '#save',
'saveAndContinue': '#save_and_continue'
},
selectorPrefix = '',
eventPrefix;
/* ... */
var selector = selectorPrefix ? selectorPrefix + ' ' + buttons[action] : buttons[action],
elem = $(selector)[0];
/* ... */
$(elem).on('click' + eventPrefix, callback);
This is the only (is this true?) connection the RequireJS/KnockoutJS code has to the previously mentioned action buttons.
When it comes time to submit/save a form, Magento uses the Magento_Ui/js/form/form
’s source
object.
#File: vendor/magento/module-ui/view/base/web/js/form/form.js
submit: function (redirect) {
var additional = collectData(this.additionalFields),
source = this.source;
_.each(additional, function (value, name) {
source.set('data.' + name, value);
});
source.save({
redirect: redirect,
ajaxSave: this.ajaxSave,
ajaxSaveType: this.ajaxSaveType,
response: {
data: this.responseData,
status: this.responseStatus
},
attributes: {
id: this.namespace
}
});
},
The source
object is a built-in feature of uiElement
objects. It’s populated via the registry key listed in the provider
property
//File: vendor/magento//module-ui/view/base/web/js/lib/core/element/element.js
initModules: function () {
/* ... */
if (!_.isFunction(this.source)) {
this.source = registry.get(this.provider);
}
/* ... */
},
This provider registry key is part of the x-magento-init
javascript – here’s an example from the CMS Page editing form
"cms_page_form": {
"component": "Magento_Ui/js/form/form",
"provider": "cms_page_form.page_form_data_source",
"deps": "cms_page_form.page_form_data_source"
},
Trace this back to the UI Component XML for the RequireJS module, and it’s usually a Magento_Ui/js/form/provider
. Again, the cms_page_form
as an example.
#File: vendor/magento/module-cms/view/adminhtml/ui_component/cms_page_form.xml
<dataSource name="page_form_data_source">
<item name="component" xsi:type="string">Magento_Ui/js/form/provider</item>
</dataSource>
If you didn’t follow that – in source.save
the save method comes from the Magento_Ui/js/form/provider
module. This source/provider object’s submit_url
property will be the Magento MVC path that a form will post to. If you’re familiar with Magento 2 javascript conventions, you can see that here in the HEY!
comments below. We’ll leave the exploration of the Magento_Ui/js/form/client
module as an exercise for the reader.
#File: vendor/magento/module-ui/view/base/web/js/form/provider.js
define([
'underscore',
'uiElement',
'./client',
'mageUtils'
], function (_, Element, Client, utils) {
'use strict';
return Element.extend({
/* HEY!: sets clientConfig ...*/
defaults: {
clientConfig: {
urls: {
save: '${ $.submit_url }',
beforeSave: '${ $.validate_url }'
}
}
},
/* ... */
/* HEY!: and then Magento uses clientConfig data when
create a new object from the `Magento_Ui/js/form/client`
constructor function */
this.client = new Client(this.clientConfig);
/* ... */
save: function (options) {
var data = this.get('data');
this.client.save(data, options);
return this;
},
/* ... */
So that, in a very confusing (sorry!) nutshell, is how the form generating code is architected. Like a lot of Magento 2’s backend, it looks like a refactoring project that was halted mid-stream. I’ll likely have more to say on this as I wrangle my way through getting a pestle
code generation command up and running for these complicated, but all-important, CRUD forms.