Akeneo PIM Frontend Guide — Part 1

Tamara Robichet
Akeneo Labs
13 min readMay 3, 2019

--

The first part of this series provides some context and explanation for how the frontend of Akeneo PIM works internally. This includes the latest updates for version 3.1.

The frontend of the PIM has many parts, old and new. Here I will explain each one in detail, starting with the legacy:

Twig templates

The roles, groups, category tree, assets, and some user management pages are still using twig templates, twig extensions, and inline Javascript rendered by Symfony controllers.

Example: the product asset edit page which is coupled to the backend, but still requires modules in the inline javascript.

Configuration files

Requirejs.yml

Each bundle that has Javascript modules contains this file. The name can be confusing because we don’t use the RequireJS anymore. This name is a relic from Oro’s RequireJS bundle that used to handle the compilation of the frontend. The job of this bundle was replaced by webpack but we kept the old name for compatibility reasons.

The requirejs.yml file contains a list of modules and templates that will be registered under an alias with some optional config that will be injected into each module. These modules can be any JS file that exports a function. We can also register JSX/TS/TSX files and HTML templates.

The type of module we most commonly have in the PIM is a Backbone View, with some events attached, which renders some HTML. The structure of the requirejs.yml config always looks like this:

So what does this mean? This file tells webpack that we have a module aliased by pim/product/index that can be found in the web/bundles folder at customui/js/product/index (Symfony copies the JS/TS/LESS files from each bundle into web/bundles at the assets installation step). By default, the customui/js/product/index file is assumed to be .js but you can also specify .ts or .html.

The config part at the top tells webpack to inject the title variable into the module. To access the title from inside the module itself, we use a special variable name __moduleConfig which is an object containing the title. This config will be injected no matter where the module is used (ie. in different form extensions).

Once a module is registered like this, it can be overwritten by a bundle with its own requirejs.yml, and it can be required in another module. For example:

It’s not necessary to register a module in requirejs.yml if you want to use it. You could reference the module by file path directly (relative to web/bundles):

Example: A record option component importing a module directly

In the above example, you have a module importing another by file path. I will explain the difference between define, require, and import in the Typescript section.

So, we typically register and provide an alias for all modules that we use as form extensions. Some modules that we don’t want to make extensible are not registered, but we don’t have a strict rule about this.

Form extensions

So now that we have a list of modules and their respective configuration — how do we put them together to build the UI in the PIM?

For every bundle that includes some UI, there will be a form_extensions.yml file or a folder named form_extensions with .yml files inside Resources/config.

These configuration files will define where and how a module should be rendered. The old name 'form extensions' comes from Symfony, but you can just think of them as UI extensions (not necessarily related to a form).

Let’s take an example of a page that we can imagine — a list of products with a header, list, create button and delete all button.

So this page has the following elements:

We could think of this as a tree structure. The root of the tree could be split into different zones — the header and then the main area where the content will go:

So, Index page, Title, Create button, Delete button and Product list can all be considered form extensions, and we can configure them to be displayed in the same order and location.

How can we define form extensions?

A form extension can have the following properties:

An arbitrary name that can be referenced by other form extensions (and can be overridden):

The alias of the module that this extension will render — the module is most commonly a Backbone View that renders HTML from a template:

The pim/product/title module would be an alias that we would have defined in the requirejs.yml of this bundle.

If the form extension is in the tree structure, it should have a reference to its parent form extension. The name of the parent would be a form extension name (not the name of the module).

The target zone where this extension will be rendered:

Since the form extension has a parent and target zone, we would expect that the module for product-index would render some HTML on the page. This HTML content would define the zones where the child extensions should be rendered. For example:

At runtime, the PIM looks for the drop zones defined by a data attribute data-drop-zone and will render all the child form extensions in the defined zone. So, product-title is going to be rendered in the div that has the attribute data-drop-zone="header".

Since we might have multiple form extensions being rendered in a single zone, we should be able to define the rendering order. For this, we can use the position property. By default, the position is 100 (this number is arbitrary).

Let's say that in the header zone of product-index there is another form extension that should be rendered before product-title:

So in this example, product-title will be rendered after product-button in the header drop zone of their parent (product-index) since the position is greater.

Sometimes we don’t want to display a certain UI element depending on permissions. At runtime, we have access to a list of permissions allowed for the current user, and we use them to hide or show a form extension. You can specify aclResourceId on a form extension, with the name of the ACL to control the rendering. For example:

The form extension will only be rendered if the user has the pim_product_create ACL granted. If it is not granted, then this extension will be removed from the tree before the tree is rendered.

Finally, since we can reuse modules for different form extensions, we should be able to configure them. Let’s say we have a generic component that renders a button. We should be able to pass in the label. Form extensions allow us to pass in such configuration. For example:

The config object is passed to the form extension JS module in an initialize function, where it can be passed to the template. The pim/button module can then renders the label property set by the config.

Note: The config provided in form extensions is not the same as the config in requirejs.yml files. You can think of the requirejs.yml config as being ‘hardcoded’ into the module itself, while the form extension config can be overridden and provided to the module at runtime. Both configuration methods are used at the same time.

Now that we know how to configure form extensions, we can go back to our page, divided into two zones, header and main:

So we can use form extensions to configure the elements on this page, assuming that product-index renders two drop zones - header and main:

So, form extensions are pretty flexible, and you can structure the tree in any way that you like. The downside of this is that it can be hard to figure out how to extend the PIM since we don’t have strong conventions nor a strong definition of what is extensible. The way to customize a module is to pass in some config, or override it completely by declaring a new form extension with the same name but with your own module.

Translations

In the code of the PIM you often see something like:

So what’s happening under the hood? __ is how we usually name the translation library that we import in a JS module (but it can have any name).

For example, a function that returns a translated label:

The oro/translator JS library matches a given key with its translation. In the PIM we fetch the translations from web/js/translation/*.js for the given locale. These translation files are dumped by the backend after processing the translation .yml files for each required bundle.

Example: Translation messages for the en_US locale in the Asset bundle

Routing

Configuration

The route configuration for the front and back in the PIM is still tightly coupled. There are two main jobs for the routing in the PIM, first, giving the frontend access to the routes of the back and front, indexed by a key (e.g. pim_enrich_attribute_edit) and second, allowing us and extension developers to define routes for new pages.

The documentation explains how the routing for the frontend works in detail: https://docs.akeneo.com/master/design_pim/guides/add_new_page.html#create-a-new-route

Modules

Like I said before, the frontend of the PIM is a mix of old and new, and you can see that in the variety of libraries and module definitions that we use. Here I’ll explain the different type of modules we have and how we use them.

Backbone

Most of the PIM UI is made up of Backbone Views using AMD module syntax, which is a way to declare the module dependencies and load them in order.

Here is a very basic example of a Backbone View that renders a button in the body of the page:

We can register it in the requirejs.yml of our custom bundle:

And we can use it in any other module:

This example shows the basic usage of a Backbone View, but in the PIM we don’t usually render views directly like this. The form extension system will render each module depending on its parent and drop zone.

I will explain how this works, step by step:

Step 1: Fetching all the form extensions

So we already saw that form extensions are declared in .yml files. At the installation step, the PIM gathers these .yml files for each required bundle (in order) and merges them into a single JSON file. This is where the overrides from custom extensions are applied. For example, if you override a form extension with the same name but with a different module, or provide some extra config.

At runtime, the PIM uses a module called pim/form-config-provider to fetch this JSON file and transform it into a JS object. Also at this point, the config provider filters out the extensions by aclResourceId to return only those that are allowed for the current user.

Example: The form config provider which handles the fetching

Step 2: Build the form extensions

Now that the PIM has all the information it needs, it can render the form extensions starting from the root. The PIM has one module which is the entry point into the application:

Note: This module is not loaded through form extensions, it only serves to start the PIM.

The pim-app form extension is declared in the UIBundle:

The pim/app module does the basic initialization of the PIM, like fetching user details, permissions, and rendering the page template. We pass the name of this module to the pim/form-builder service which will render this form extension and all its children.

So what exactly happens when we run formBuilder.build('pim-app')? How does that start the PIM? The form builder will:

  1. Fetch all the extensions from pim/form-config-provider
  2. Search for an extension with the same name that we passed in (pim-app)
  3. Dynamically require the module for the form extension that was found
  4. Create a new instance of the module and provide it with the config from requirejs.yml
  5. Find all the children of the extension and tell them where they should be rendered

After these steps, the form builder executes the configure method on pim/app and all its children. This method can be anything that returns a Promise. The form extension won't be rendered until all its children have resolved their configure function.

We usually use configure to load data asynchronously or start listening to events. Here's an example where a component that switches locales needs to load the data before being rendered:

Step 3: Render the extensions

After the form builder finishes configuring the form extension and all of its children, the build function will be resolved with an instance of the module, ready to be rendered. So, to return to the entry point of the app:

Here we will call setElement to tell the extension where it should be rendered, and then we can finally call render, which will render this module and all of its children in the correct order, in the specified drop zones.

Note: Here at the entry point of the application we use the form builder to render the app manually. It’s not necessary to do it manually for all the form extensions, since render is called iteratively down the tree of extensions. Each parent extension will render its own children.

Working with data

The ability to configure, setElement, and render children is not built into Backbone Views. We’ve added this functionality by providing a base view that Backbone Views can extend. It’s not strictly necessary to extend this class to use your module in a form extension, but it adds some extra features. Let’s take a typical use case of rendering a form with some text field and value:

First, we declare the modules that we will use, and provide the form with the URL that it should post to.

Note: The string product_update_name would resolve to a route that would have been defined in the bundle’s routing.yml file and passed to the frontend.

Now we can declare the form extensions that will use these modules:

The pim/name-form module should do the following things:

  1. Fetch the existing name with a GET request
  2. Render a form with a field inside its dropzone
  3. Post the new name that was entered into the text field on submit

So we start with the pim/name-form module:

This module fetches the data it needs to render the form and then saves the data the form is submitted. The text field module displays the text field with the value fetched from the parent form, then update the value when the user changes it:

The base view class is useful when parent form extensions need to interact with their children and vice versa. We can see in the example that the updateData method uses setData from the base form extension class. It will create a model for the form extension and set the data inside it for later use.

There is a lot more functionality that can be seen in the code, like getting access to parent extensions, programmatically rendering child extensions, listening to events from the root of the tree etc.

Typescript

Some of the new features we’ve been building in the PIM are using Typescript, which is a superset of Javascript that adds static typing to the language. For Typescript, we use ES6 classes rather than AMD modules (as seen before with the majority of legacy code in the PIM). Similar to AMD, ES6 modules support asynchronous loading but can be statically analyzed and have a more compact and explicit syntax. There is an excellent guide that explains the ES6 standard here: Exploring JS: JavaScript books for programmers.

To simply show the differences in syntax:

AMD

ES6

There is some confusion about mixing import and require in the modules. I will explain each case here using the catalog volume monitoring as an example:

import * as $ from 'jquery'

Standard import to use with es6 modules

import BaseView = require('pimui/js/view/base')

To use when you import a Typescript module that can be used with both .ts and .js files (it uses export = ModuleName at the end).

const BaseController = require('pim/controller/front')

For importing any other .js module that uses the define syntax.

React/Redux

In the Enterprise Edition we have part of the frontend written in React, Redux & Typescript. It’s possible to use React since we saw that the form extension system is flexible enough to just plug in modules that conform to a certain interface (e.g. having a render function).

There are some differences between this part of the app and the legacy:

Folder structure

For the Reference Entities we have in the bundle a folder called front, with the folders organised differently:

It’s not necessary to follow the same folder structure as a typical Symfony bundle (putting everything in Resources/public/js) since our webpack configuration is smart enough to find the files.

Routing

For the routing, we still use controllers that extend the BaseController for Reference Entities, but instead of using the form builder, they use JSX to render the React component using ReactDOM.render. For example in the ReferenceEntityListController:

So finally, it’s not necessary to use Backbone to write the frontend of the PIM. You could use anything, as long as it conforms to what the form extension configuration is expecting (a module with a render function).

LESS/CSS

Just as we collect and compile JS/TS files from each required bundle, we do the same for LESS and CSS in the PIM. Previously, the styles were managed by the deprecated AsseticBundle using .yml files. In 3.1 this has been changed. Now the compilation has been moved from the backend and is handled by the Node using less.js.

Every bundle that has contains a stylesheet should also have an index.less file inside Resources/public/less/index.less. This file will import all the styles used for the bundle. For example in the UIBundle:

We use the final path of the .less file (inside web/bundles where Symfony will dump them). For CSS files we have to kind of ‘cast’ the file into .less by using @import (less) syntax.

Now that each bundle defines their own index.less file, how does this make it into the final PIM CSS file? We have a script that handles this with the following steps:

  1. Get a list of required bundles that were defined in the app kernel
  2. Search each bundle for this index.less file
  3. Get the file contents of each file and import all the required CSS
  4. Concatenate (in order) all the CSS and dump it into web/css/pim.css

There are some extra steps that this script handles, like transforming the URLs inside the stylesheets to make them relative. I will explain this feature in Part 2 of the guide which will go into detail about the frontend build.

Conclusion

Hopefully, this guide gives you a clearer picture of how the PIM frontend works, and how we use the form extension system internally. The upcoming second part of this series will go into detail about how we compile and test the frontend stack with our build tools. Stay tuned for more!

--

--