- Magento 2: Introducing UI Components
- Magento 2: Simplest UI Component
- Magento 2: Simplest UI Knockout Component
- Magento 2: Simplest XSD Valid UI Component
- Magento 2: ES6 Template Literals
- Magento 2: uiClass Data Features
- Magento 2: UI Component Data Sources
- Magento 2: UI Component Retrospective
- Observables, uiElement Objects, and Variable Tracking
- Magento 2: uiElement Features and Checkout Application
- Magento 2: Remaining uiElement Defaults
- Magento 2: Knockout.js Template Primer
- Magento 2 UI Component Code Generation
In our previous two articles we ran through creating a new UI Component from scratch. While we were successful, we needed to add a class <preference/>
(i.e. a rewrite) that disabled Magento 2’s XSD validation. While this was useful as a learning exercise, it’s not that helpful in a real world.
It’s probably OK for a developer managing a single Magento system to use a class <preference/>
, but those sorts of developers probably aren’t creating UI Components. For an extension developer, disabling XSD validation with a class preference seriously compromises the stability of the systems an extension will be deployed to.
This problem with the XSD files sheds light on an untrue thing the software industry likes to tell itself over and over. Namely, that it’s possible, purely through use of advanced design patterns, to crete a system that will be both flexible and stable for developers who didn’t create the system.
It’s clear that Magento 2 set out to create a flexible system for developers — plugins, dependency injection, RequireJS map
features, etc. However, the XSD schema files (a tool meant to reign in the complexity of the XML files) ended up limiting the flexibility of the UI Component system.
Whatever role systems design, and gang-of-four style design patterns play, time and time again we rediscover that the only way to build flexible and stable systems for your end-user-programmers is to listen to them and adjust the system over time toward real world usage patterns. Even if (or especially if) this means abandoning whatever high minded ideals your project’s trying to bring to the table.
All that, however, is a topic for another time (and possibly for all-time). Today, carrying with us the lessons learned so far, we’re going to revisit our simple UI Component. This time though we’re going to do it with XSD validation on, and come up with a pattern we can reuse in real world projects.
The specifics here were tested against Magento 2.1.1, but the concepts should apply across all Magento 2 versions.
Setup
The first thing we’ll want to do is disable our Pulsestorm_SimpleUiComponent
module. We’re doing this to reenable the XSD validation for XML files. Run the following command, and you should be all set. If you never created a Pulsestorm_SimpleUiComponent
module, this step is not necessary.
$ php bin/magento module:disable Pulsestorm_SimpleUiComponent
The following modules have been disabled:
- Pulsestorm_SimpleUiComponent
Cache cleared successfully.
Generated classes cleared successfully. Please run the 'setup:di:compile' command to generate classes.
Info: Some modules might require static view files to be cleared. To do this, run 'module:disable' with the --clear-static-content option to clear them.
Once we’ve done that, we’ll want to create a new admin module for our UI Component. As usual, we’ll use pestle for this.
pestle.phar generate_module Pulsestorm SimpleValidUiComponent 0.0.1
pestle.phar generate_acl Pulsestorm_SimpleValidUiComponent Pulsestorm_SimpleValidUiComponent::top,Pulsestorm_SimpleValidUiComponent::menu_1
pestle.phar generate_menu Pulsestorm_SimpleValidUiComponent Magento_Backend::system_other_settings Pulsestorm_SimpleValidUiComponent::a_menu_item Pulsestorm_SimpleValidUiComponent::menu_1 "Hello Simple Valid Ui Component" pulsestorm_simplevaliduicomponent/index/index 1
pestle.phar generate_route Pulsestorm_SimpleValidUiComponent adminhtml pulsestorm_simplevaliduicomponent
pestle.phar generate_view Pulsestorm_SimpleValidUiComponent adminhtml pulsestorm_simplevaliduicomponent_index_index Main content.phtml 1column
php bin/magento module:enable Pulsestorm_SimpleValidUiComponent
php bin/magento setup:upgrade
With the above in place, clear your cache, and you should be able to navigate to a System -> Other Settings -> Hello Simple Valid Ui Component
menu in your Magento backend.
With our boilerplate generated, lets get started!
A New UI Components
As we did earlier in this series, we’ll add a new <uiComponent/>
to our layout handle XML file.
#File: app/code/Pulsestorm/SimpleValidUiComponent/view/adminhtml/layout/pulsestorm_simplevaliduicomponent_index_index.xml
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<referenceBlock name="content">
<block template="content.phtml" class="Pulsestorm\SimpleValidUiComponent\Block\Adminhtml\Main" name="pulsestorm_simplevaliduicomponent_block_main" />
<uiComponent name="pulsestorm_simple_valid"/>
</referenceBlock>
</page>
and then we’ll create an XML file for this named UI Component.
#File: app/code/Pulsestorm/SimpleValidUiComponent/view/adminhtml/ui_component/pulsestorm_simple_valid.xml
<pulsestorm_simple_valid xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
</pulsestorm_simple_valid>
If we clear our cache and reload the page, we’ll end up with an error something like this.
Exception #0 (Magento\Framework\Exception\LocalizedException): Element
'pulsestorm_simple_valid': No matching global declaration
available for thevalidation root.
As previously mentioned, this error happens because there’s no pulsestorm_simple_valid
in Magento’s vendor/magento/module-ui/view/base/ui_component/etc/definition.xml
file, and we can’t add one because Magento’s XSD schema validation file for UI Component files doesn’t allow root nodes named <pulsestorm_simple_valid/>
.
Unfortunately, there’s no way we can work around this. The schema is the schema. Even if we removed the xsi:noNamespaceSchemaLocation
from our XML files, Magento’s merging our nodes into XML trees that use this XSD file. As of Magento 2.1, there’s no way for third party developers to distribute UI Components without some half-baked module that disables or modifies the XSD validation routines. This is disappointing.
However, we can still take advantage of the <uiCompnent/>
tag if we change our root node to the following.
#File: app/code/Pulsestorm/SimpleValidUiComponent/view/adminhtml/ui_component/pulsestorm_simple_valid.xml
<container xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
</container>
The <container/>
node is a valid root level node. If we reload with the above in place, we’ll end up with the following error instead.
Fatal error: Method Magento\Ui\TemplateEngine\Xhtml\Result::__toString()
must not throw an exception, caught Error: Call to a member function
getConfigData() on null in
/path/to/magento/vendor/magento/module-ui/
Component/Wrapper/UiComponent.php on line 0
In other words, our schema validation errors are gone, and Magento’s just complaining about a missing dataSource
node. We can fix that with a new dataSource
node
#File: app/code/Pulsestorm/SimpleValidUiComponent/view/adminhtml/ui_component/pulsestorm_simple_valid.xml
<container xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
<dataSource name="pulsestorm_simple_valid_data_source">
<argument name="dataProvider" xsi:type="configurableObject">
<!-- the PHP class that implements a data provider -->
<argument name="class" xsi:type="string">Pulsestorm\SimpleValidUiComponent\Model\DataProvider</argument>
<!-- redundant with the `dataSource` name -->
<argument name="name" xsi:type="string">pulsestorm_simple_valid_data_source</argument>
<!-- required: means ui components are meant to work with models -->
<argument name="primaryFieldName" xsi:type="string">entity_id</argument>
<!-- required: means ui components are meant to work with URL passing -->
<argument name="requestFieldName" xsi:type="string">id</argument>
</argument>
</dataSource>
</container>
and a new data provider class
#File: app/code/Pulsestorm/SimpleValidUiComponent/Model/DataProvider.php
<?php
namespace Pulsestorm\SimpleValidUiComponent\Model;
class DataProvider extends \Magento\Ui\DataProvider\AbstractDataProvider
{
}
Reload your page with the above in place, and you should see your page rendered again, sans errors.
What Did We Render?
If we take a look at our raw page source (i.e. not from a DOM Inspector, but the pre-javascript “View Source” source), we’ll see we’ve rendered the following
<div>
<div data-bind="scope: 'pulsestorm_simple_valid.areas'" class="entry-edit form-inline">
<!-- ko template: getTemplate() --><!-- /ko -->
</div>
<script type="text/x-magento-init">
{
"*": {
"Magento_Ui/js/core/app": {
"types": {
"dataSource": [],
"container": {
"extends": "pulsestorm_simple_valid"
},
"html_content": {
"component": "Magento_Ui\/js\/form\/components\/html",
"extends": "pulsestorm_simple_valid"
}
},
"components": {
"pulsestorm_simple_valid": {
"children": {
"pulsestorm_simple_valid": {
"type": "pulsestorm_simple_valid",
"name": "pulsestorm_simple_valid",
"config": {
"component": "uiComponent"
}
},
"pulsestorm_simple_valid_data_source": {
"type": "dataSource",
"name": "pulsestorm_simple_valid_data_source",
"dataScope": "pulsestorm_simple_valid",
"config": {
"params": {
"namespace": "pulsestorm_simple_valid"
}
}
}
}
}
}
}
}
}
</script>
</div>
If we take a look at the <container/>
node’s definition.xml
configuration.
<!-- File: vendor/magento//module-ui/view/base/ui_component/etc/definition.xml -->
<!-- ... -->
<container class="Magento\Ui\Component\Container">
<argument name="data" xsi:type="array">
<item name="config" xsi:type="array">
<item name="component" xsi:type="string">uiComponent</item>
</item>
<item name="template" xsi:type="string">templates/container/default</item>
</argument>
</container>
<!-- ... -->
We see its XHTML template is templates/container/default
, and its component
is the RequireJS map
alias uiComponent
(Magento_Ui/js/lib/core/collection
).
The reason we chose the <container/>
component is two fold. First, it’s one of the few generic components we can use at the root of our ui_compnent
file without tripping a Magento XSD validation error. Second though, the uiComponent
javascript component is exactly what we want. You’ll remember from last time a uiComponent
‘s Knockout.js template (ui/collection
) will run through all its child elements and render their templates – similar to a layout update XML <container/>
node, or a Magento 1 core/text_list
block.
The templates/container/default
XHTML template, however, does not suit our needs.
#File: vendor/magento/module-ui/view/base/ui_component/templates/container/default.xhtml
<div xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../../Ui/etc/ui_template.xsd">
<div data-bind="scope: '{{getName()}}.areas'" class="entry-edit form-inline">
<!-- ko template: getTemplate() --><!-- /ko -->
</div>
</div>
It’s not 100% clear what this template is for. Magento’s core ui_component
files use <container/>
as a sub-node, which means the XHTML template is never rendered. It’s likely this is some legacy cruft left over from earlier days when container
was used by Magento as a root level node. Or maybe it’s something forward looking. Whatever the reason, this is probably why we can use <container/>
as a root level node in the first place. It’s hard to say if this “feature” will stick around, but for now it’s the best we have.
Changing the Template
So if this XHTML template is no good for us, are we stuck? Of course not — we can just configure a new template in our pulsestorm_simple_valid.xml
file
#File: app/code/Pulsestorm/SimpleValidUiComponent/view/adminhtml/ui_component/pulsestorm_simple_valid.xml
<container xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
<argument name="data" xsi:type="array">
<item name="template" xsi:type="string">templates/pulsestorm_simple_valid/default</item>
</argument>
<!-- ... -->
</container>
Remember, the definition.xml
file is where the default sub-nodes for a particular node are set, but each individual file in the ui_component
folder can override these values.
We’ll also want to create the xhtml
file for our templates/pulsestorm_simple_valid/default
template.
#File: app/code/Pulsestorm/SimpleValidUiComponent/view/adminhtml/ui_component/templates/pulsestorm_simple_valid/default.xhtml
<div
data-bind="scope: '{{getName()}}.{{getName()}}'"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../../../../Ui/etc/ui_template.xsd">
<!-- ko template: getTemplate() --><!-- /ko -->
</div>
The code above is based on the default XHTML for a listing grid (vendor/magento/module-ui/view/base/ui_component/templates/listing/default.xhtml
). While it performs the same Knockout.js scope
kickoff as the template from our previous articles, there’s a few more things going on here that are worth mentioning.
First, let’s start with what we know. We have our standard Knockout tag-less template binding.
<!-- ko template: getTemplate() --><!-- /ko -->
We also know that Knockout’s view model for this section is set by the outer scope
binding. However, if we look at that scope binding.
<div data-bind="scope: '{{getName()}}.{{getName()}}'" ...>
We see the first unfamiliar bit. Instead of a hard coded scope, we have {{getName()}}.{{getName()}}
. The text inside the {{...}}
brackets are part of the XHTML template language, and will call through to the underlying UI Component object’s getName()
method. This name will be the name we used in the layout handle XML file — <uiComponent name="pulsestorm_simple_valid"/>
. Meaning the above will render as
<div data-bind="scope: 'pulsestorm_simple_valid.pulsestorm_simple_valid'" ...>
We talked more about these template tags a few articles ago.
The next confusing bit is this
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../../../../Ui/etc/ui_template.xsd"
We have an XML namespace declaration in the root level <div/>
, as well as a schema validation file (xsi:noNamespaceSchemaLocation
). Remember — these are XHTML, not HTML templates. They behave like XML files. This also means you’re only allowed one top level node in an XHTML template.
While these attributes aren’t strictly necessary, they’re used in the Magento core XHTML templates so it’s best to use them here for consistency.
If you’re curious why these attributes aren’t rendered in the final HTML, Magento removes them before rendering here
#File: vendor/magento/module-ui/TemplateEngine/Xhtml/Result.php
public function __toString()
{
//...
foreach ($templateRootElement->attributes as $name => $attribute) {
if ('noNamespaceSchemaLocation' === $name) {
$this->getDocumentElement()->removeAttributeNode($attribute);
break;
}
}
$templateRootElement->removeAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'xsi');
//...
}
Finally, it’s worth taking a peek at the ui_template.xsd
file
#File: vendor/magento/module-ui/etc/ui_template.xsd
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xs:element name="form">
<xs:complexType >
<xs:sequence>
<xs:any minOccurs="0" maxOccurs="unbounded" processContents="lax" />
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="div" >
<xs:complexType >
<xs:sequence>
<xs:any minOccurs="0" maxOccurs="unbounded" processContents="lax" />
</xs:sequence>
<xs:anyAttribute processContents="lax" />
</xs:complexType>
</xs:element>
</xs:schema>
Covering the entire xs:schema
language is a task beyond the scope of this article, but the above says that our xhtml
files must have a root node of div
, or form
. Also, leaving the xsi:noNamespaceSchemaLocation
out of our file won’t skip validation, as these .xhtml
files are merged into an XML tree that includes this schema.
Adding to the Collection
Alright, if we clear our cache and reload the page with the above in place, we won’t see anything changed on the page. However, using the Sources
tab of Chrome’s debugger, we can see Magento has included collection.js
(from Magento_Ui/js/lib/core/collection
, which is the RequireJS library uiCollection
points at).
We can also (via XHR debugging) see that Magento has downloaded the collection.html
Knockout.js template.
We can also pop into the javascript console, and we’ll see a registered view model constructor named pulsestorm_simple_valid.pulsestorm_simple_valid
.
> reg = requirejs('uiRegistry');
> reg.get(function(item){
console.log(item.name);
console.log(item);
});
pulsestorm_simple_valid.pulsestorm_simple_valid
UiClass {_super: undefined, ignoreTmpls: Object, _requesetd: Object, containers: Array[0], exports: Object…}
If you’re unsure of the significance here, you probably want to review our last article. Our next step is adding some child components to render.
We’re going to use an htmlContent
component/node. Add the following to your pulsestorm_simple_valid.xml
file
#File: app/code/Pulsestorm/SimpleValidUiComponent/view/adminhtml/ui_component/pulsestorm_simple_valid.xml
<container>
<!-- ... -->
<htmlContent name="our_first_content">
<argument name="block" xsi:type="object">Pulsestorm\SimpleValidUiComponent\Block\Example</argument>
</htmlContent>
<!-- ... -->
</container>
The htmlContent
node allows you to render the contents of a Magento block object into our x-magento-init
script, and then have those contents rendered onto the page via Knockout.js. The above example will render the block named Pulsestorm\SimpleValidUiComponent\Block\Example
. If we look at the definition.xml
file for <htmlContent/>
<htmlContent class="Magento\Ui\Component\HtmlContent">
<argument name="data" xsi:type="array">
<item name="config" xsi:type="array">
<item name="component" xsi:type="string">Magento_Ui/js/form/components/html</item>
</item>
</argument>
</htmlContent>
We see this rendering happens via the RequireJS Magento_Ui/js/form/components/html
module. Or, said more completely, the Magento_Ui/js/form/components/html
module returns a Knockout.js view model constructor with a Knockout.js “Magento remote” template of ui/content/content
.
//File: vendor/magento/module-ui/view/base/web/js/form/components/html.js
//...
return Component.extend({
defaults: {
content: '',
showSpinner: false,
loading: false,
visible: true,
template: 'ui/content/content',
additionalClasses: {}
},
//...
We’ll leave the specifics of this rendering as an advanced exercise for the user.
If we clear our cache and reload with the above in place, we’ll get the following error.
Exception #0 (ReflectionException): Class Pulsestorm\SimpleValidUiComponent\Block\Example does not exist
Whoops! We forgot to create our Pulsestorm\SimpleValidUiComponent\Block\Example
class. Lets do that now.
#File: app/code/Pulsestorm/SimpleValidUiComponent/Block/Example.php
<?php
namespace Pulsestorm\SimpleValidUiComponent\Block;
use Magento\Framework\View\Element\BlockInterface;
class Example extends \Magento\Framework\View\Element\AbstractBlock
{
public function toHtml()
{
return '<h1>Hello PHP Block Rendered in JS</h1>';
}
}
These are standard block classes rendered via the current area’s layout, and need to extend the Magento\Framework\View\Element\AbstractBlock
class. Normally these are phtml
template blocks, but we’re using a block with a hard coded toHtml
method for simplicity’s sake.
If we reload with the above in place, we should see our block rendered.
Hijacking htmlContent
While the htmlContent
nodes are interesting, if only for their amusing “render some server side code that renders some front-end code that renders some more server side code” pattern, we’re not interested in them today for their core functionality. We chose htmlContent
nodes because
- They’re “XSD allowed” as children of
<container/>
elements - Their base functionality is relatively simple/uncomplicated
- They’re generic, and not likely to imply a specific piece of functionality (vs., say,
<listingToolbar/>
)
This makes them ideal blocks to hijack. By hijack, we mean we’re going to take advantage of the UI Component’s XML merging to make our htmlContent
blocks
- Use a different component class
- Use a different RequireJS view model constructor factory
- Have that RequireJS view model constructor factory point to a new Knockout.js template
Regarding Use a different component class, all we need to do is add a new class
attribute to our htmlContent
XML node
#File: app/code/Pulsestorm/SimpleValidUiComponent/view/adminhtml/ui_component/pulsestorm_simple_valid.xml
<htmlContent class="Pulsestorm\SimpleValidUiComponent\Component\Simple" name="our_first_content">
</htmlContent>
We’ve also removed the block
argument, as this was required by the Magento\Ui\Component\HtmlContent
class our Pulsestorm\SimpleValidUiComponent\Component\Simple
replaces. We’ll also (of course) want to create our Pulsestorm\SimpleValidUiComponent\Component\Simple
class (confused by this? Checkout our previous article for the server side functionality of UI Components).
#File: app/code/Pulsestorm/SimpleValidUiComponent/Component/Simple.php
<?php
namespace Pulsestorm\SimpleValidUiComponent\Component;
class Simple extends \Magento\Ui\Component\AbstractComponent
{
const NAME = 'html_content_pulsestorm_simple';
public function getComponentName()
{
return self::getName();
}
}
Regarding Use a different RequireJS view model constructor factory, all we need to do is add a new @name="data"/@name="config"/@name="component"
argument
<htmlContent class="Pulsestorm\SimpleValidUiComponent\Component\Simple" name="our_first_content">
<argument name="data" xsi:type="array">
<item name="config" xsi:type="array">
<item name="component" xsi:type="string">Pulsestorm_SimpleValidUiComponent/js/pulsestorm_simple_component</item>
</item>
</argument>
</htmlContent>
and then, (conveniently covering our third Have that RequireJS view model constructor factory point to a new Knockout.js template point), have that RequireJS module return a view model constructor with a new Knockout.js remote template.
#File: app/code/Pulsestorm/SimpleValidUiComponent/view/adminhtml/web/js/pulsestorm_simple_component.js
define(['uiElement','ko'], function(Element, ko){
viewModelConstructor = Element.extend({
defaults: {
template: 'Pulsestorm_SimpleValidUiComponent/pulsestorm_simple_template'
}
});
return viewModelConstructor;
});
and then create the Pulsestorm_SimpleValidUiComponent/pulsestorm_simple_template
template.
<!-- File: app/code/Pulsestorm/SimpleValidUiComponent//view/adminhtml/web/template/pulsestorm_simple_template.html -->
<h1>Our Remote Knockout Template!</h1>
With the above in place, clear your cache and reload the page
Congratulations! You’ve just successfully created a UI Component, fully under our programmatic control, without violating Magento’s XSD schema validations.
Wrap Up
Whether or not this is a good idea or not remains to be seen. While we’ve taken every step possible to keep our htmlContent
node under our control (custom PHP component class, custom RequireJS component, custom Knockout.js template), it’s still theoretically possible that a future change by the core Magento engineering team might break what we’ve done here. Right now, a fundamental problem with UI Components is all the programmatic and political evidence points to them being for the core team only, and only time will tell if third party developers are meant to, or will be able to, incorporate them stably into their extensions.