Categories


Archives


Recent Posts


Categories


Magento 2 UI Form Components: 5,000 ft. View

astorm

Frustrated by Magento? Then you’ll love Commerce Bug, the must have debugging extension for anyone using Magento. Whether you’re just starting out or you’re a seasoned pro, Commerce Bug will save you and your team hours everyday. Grab a copy and start working with Magento instead of against it.

Updated for Magento 2! No Frills Magento Layout is the only Magento front end book you'll ever need. Get your copy today!

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.

Copyright © Alan Storm 1975 – 2019 All Rights Reserved

Originally Posted: 9th February 2017