Extend Admin UI
===============
The final section of the book has a deeper look at how to integrate your own entities and corresponding views into the
Sulu administration interface. This includes adding new items to the navigation and configuring list views and form
views for your entity.
.. note::
The `Sulu workshop`_ is a great supplementary resource to this document.
It consists of 12 assignments that guide you through creating a small website that integrates two simple custom
entities.
Additionally, the `Sulu Demo repository`_ contains multiple pull requests that demonstrate how to extend different
parts of the administration interface using simple examples.
Sulu is built with extensibility as a core value and allows the integration of a custom entity without writing any
JavaScript code in most cases. In order to provide this extensibility in a simple way, Sulu requires the APIs used for
managing the entities follow some rules. If you provide such an API, Sulu comes with a variety of existing frontend
views and components that cover a lot of different use cases. Furthermore, once you have reached the limits of the
existing components, Sulu provides various extension points of different granularity allowing you to hook into most
areas of the system using custom JavaScript code. A description of the JavaScript views, services and components of
the administration interface frontend and its extension points is available in the `Sulu Javascript Docs`_.
As stated above, the frontend components coming with Sulu expect that your APIs deliver the data for the administration
interface to match a certain standard. These standards affect the data for the list and form views that will be used to
manage your entity. Sulu uses the `FOSRestBundle`_ internally to build the REST APIs for the preexisting entities, but
the data format expected by the frontend components is completely architecture agnostic and library independent.
Therefore Sulu does not enforce how to actually implement the API for your entity - you can try to keep your code as
simple as possible by following the `Symfony Best Practices`_, go full `DDD`_ or anything between.
The following sections will list the requirements for your API to be compatible with the Sulu frontend components.
To keep things practical, the sections will use a custom Event entity as an example.
First of all, Sulu expects your API to expose the standard REST actions. In the case of our ``EventController`` this
means there has to be a ``POST`` action for creating events, a ``PUT`` action for modifying events, a ``DELETE`` action
for deleting events and finally a ``GET`` action for retrieving information about an event. The ``POST``, ``PUT`` and
``GET`` actions accept and return a JSON serialization of the event entity. The serialization could look something
like this:
.. code-block:: json
{
"id": 1,
"name": "Sulu Con 2020",
"startDate": "2020-10-24T08:00:00",
"endDate": "2020-10-25T18:00:00"
}
A JSON object like this can be sent to the ``POST`` action (without the ID) to create a new event or to the ``PUT``
action to update an existing event. Both of the previous actions must return a response in the same format as the
``GET`` action.
Furthermore, Sulu expects the URLs of your API to follows certain rules. All these actions are encapsulated behind the
same URL, in the event case e.g. ``/admin/api/events``. This endpoint returns a paginated list of available entities
when it receives a ``GET`` request and creates a new event when it receives a ``POST`` request with a JSON object like
shown above.
There has to be a sub URL including the ID for single events as well. E.g. the URL ``/admin/api/events/5`` represents
the event with the ID 5. This endpoint will accept a ``GET`` request to return a JSON object like above, a ``PUT``
request to update the event using a JSON object and a ``DELETE`` request to delete the event (this one does not need
any data).
For your own entities you only have to implement the actions you really need. E.g. if you have an entity that cannot be
deleted afterwards, then you don't have to implement the ``DELETE`` action. However, then you have also to make sure
that you don't activate any deletion functionality in the administration interface.
List configuration and controller
---------------------------------
This section assumes that you have a ``RestController`` with some actions already located at
``src/Controller/Admin/EventController`` and will add a ``cgetAction``. After finishing the action will be tied to the
``/admin/api/events`` endpoint, and should return a paginated list.
The list integrated in Sulu comes already with quite some features, including pagination, search, sorting and so on.
However, these functionalities also have to be implemented by the REST API the list talks to. Since it would be quite
some work to implement this for every REST API you are offering, we have build an abstraction that is doing that for
you in a very efficient manner. This abstraction is called ``ListBuilder``, and uses some metadata to generate queries.
It will only join tables that are absolutely necessary for the result of the query and is also capable of filtering,
sorting, searching and so on. Apart from that responses created with the ``ListBuilder`` will already match the
conventions required by the list in the administration interface.
Let's assume that we have this very simplified entity enriched with `doctrine annotations`_ already available at
``src/Entity/Event.php``:
.. code-block:: php
The root tag is called ``list`` and has two sub tags: The ``key`` tag contains a key that must be unique among all
defined lists. Usually it is a safe bet to just reuse the above ``RESOURCE_KEY`` constant of the ``Event`` entity,
unless you want to have different lists for the same entity.
Afterwards the ``properties`` tag lists all properties available in this list. Each property is described by a
``property`` tag. These tags consist of a few attributes:
- The ``name`` attribute defines the name of the property in the representation returned by the ``ListBuilder``.
- The ``visibility`` attribute allows to define if the property can be excluded from the list and if it is shown by
default. A value of ``yes`` or ``no`` only describes if it is shown by default, but the setting can be changed by the
user. ``never`` and ``always`` do the same, but the don't allow the user of the system to change this settings.
- The ``translation`` attribute takes a translation key, which is resolved by the `Symfony Translations component`_ and
uses this value as the header for the given column in the list. All translations are taken from the ``admin``
translation domain, so make sure that the file is called something like ``admin.en.json``.
- The ``searchability`` attribute describes if the value of this property is used by the search field in the list.
- Finally the ``type`` attribute allows to define how to display the content of this property. In the above example it
is used to display the datetime value in the localization of the user. There is a ``listFieldTransformerRegistry``
extension point for these types, which allows to add more of them via JS.
In addition to these attributes the ``property`` tag has some sub tags as well. This includes the ``field-name``
telling the ``ListBuilder`` how the column holding the value in the database is called, and the ``entity-name``
describing which entity holds the property. Based on this information the ``ListBuilder`` can build a very efficient
query.
The ``Controller`` returning the data from the ``ListBuilder`` uses the `FOSRestBundle`_ as well. The ``cgetAction``
calls the ``FieldDescriptorFactory`` to load the information written in the above XML file. It then uses the
``DoctrineListBuilderFactory`` to get an instance of a ``DoctrineListBuilder``, which implements the logic to load data
in an efficient way from the database. The ``RestHelper`` helps to set certain parameters of the ``ListBuilder`` from
the HTTP request, so that this code has not been copied over multiple times. Finally the ``PaginatedRepresentation``
takes care of building an object representing the loaded data and enhance it with information like how many results
exist in total. This object will be serialized by the ``handleView`` method of the `FOSRestBundle`_. The following code
shows a controller doing what has just been described.
.. code-block:: php
viewHandler = $viewHandler;
$this->fieldDescriptorFactory = $fieldDescriptorFactory;
$this->listBuilderFactory = $listBuilderFactory;
$this->restHelper = $restHelper;
}
public function cgetAction(): Response
{
$fieldDescriptors = $this->fieldDescriptorFactory->getFieldDescriptors(Event::RESOURCE_KEY);
$listBuilder = $this->listBuilderFactory->create(Event::class);
$this->restHelper->initializeListBuilder($listBuilder, $fieldDescriptors);
$listRepresentation = new PaginatedRepresentation(
$listBuilder->execute(),
Event::RESOURCE_KEY,
$listBuilder->getCurrentPage(),
$listBuilder->getLimit(),
$listBuilder->count()
);
return $this->viewHandler->handle(View::create($listRepresentation));
}
}
Register your new Controller in the ``config/routes_admin.yaml`` file the following way:
.. code-block:: yaml
app_events_api:
type: rest
prefix: /admin/api
resource: App\Controller\Admin\EventController
name_prefix: app.
Configure resources
-------------------
At this point the controller should register its actions already as routes. If you have already created other actions
as well, then you should be able to see these actions when using the ``debug:router`` command from Symfony:
.. code-block:: bash
$ bin/adminconsole debug:router | grep event
app.get_events GET ANY ANY /admin/api/events.{_format}
app.post_event POST ANY ANY /admin/api/events.{_format}
app.get_event GET ANY ANY /admin/api/events/{id}.{_format}
app.put_event PUT ANY ANY /admin/api/events/{id}.{_format}
app.delete_event DELETE ANY ANY /admin/api/events/{id}.{_format}
These routes are spread over two different URLs, one without the ID (``/admin/api/events``) and one with the ID
(``/admin/api/events/{id}``). The first one is used to get a list of available events and to create new events, while
the latter is about already existing events.
The question is how to pass this information now to our administration JS application. One way would have been to
separately pass a ``getAction``, a ``postAction``, a ``deleteAction`` and so on to every part of the application that
needs something like this. This would be a bit tedious, therefore we decided to introduce a concept called resources.
Every resource is identified by a unique key, which we added as a constant to the ``Event`` entity above. So our
example uses ``events`` as the resource key. A list URL (``/admin/api/events``) and/or a detail URL
(``/admin/api/events/{id}``) will be assigned to every resource key. Afterwards the resource key can be used in
multiple places, without worrying about which exact actions have to be used.
This is done by using the ``sulu_admin.resources`` configuration. The following configuration can be placed e.g. in the
`/config/packages/sulu_admin.yaml` file of your project:
.. code-block:: yaml
sulu_admin:
resources:
events:
routes:
list: app.get_events
detail: app.get_event
The configuration makes use of the route names you have seen listed above by the `debug:router` command. For both
variants of the URL (``/admin/api/events`` and ``/admin/api/events{id}``) one representative is used as a proxy for the
list and detail URL - whereby the detail URL has to be the one including the ID.
Admin class
-----------
After having registered the ``events`` resource, we can continue to include the events in the administration interface.
This is not done via a configuration, but in a separate ``Admin`` class. These ``Admin`` classes are registered as
services and collected by the system using `tags`_, which in turn calls their methods. This approach has the advantage
that you can use other services when adding stuff to the administration interface.
The two most important hooks are for views and navigation items.
Views are `React`_ components, whereby Sulu comes with
a few of them predefined. These predefined views can be configured via certain options, so that they are reusable in
different contexts. Such a view takes most of the space of the screen, the only things being excluded from it being the
toolbar on the very top of the screen and the navigation on the left.
.. figure:: ../img/extend-admin-screen-adjustment.jpg
Navigation items allow to add an item to the navigation on the left. Therefore they have to describe the title of this
item and where to navigate when the user clicks on the item.
The ``EventAdmin`` class can be located e.g. at the `/src/Admin` folder of your project. The two important methods are
called ``configureNavigationItems`` and ``configureViews``. The following example omits the implementation for these
methods, but it will be already correctly registered in the service container of Symfony without any configuration
because of the autoconfigure feature of Symfony:
.. code-block:: php
viewBuilderFactory = $viewBuilderFactory;
}
public function configureNavigationItems(NavigationItemCollection $navigationItemCollection): void
{
// add navigation items
}
public function configureViews(ViewCollection $viewCollection): void
{
// add views
}
}
Configure list view
-------------------
Views are the most important administration concept in Sulu. In JS a so called ``ViewRegistry`` exists, where a mapping
from a `React`_ component to a string is established. This string can be used as a key when defining views in the
previously mentioned ``Admin`` classes. Therefore a ``View`` class in PHP exists, which requires at least a ``name``, a
``path`` and a ``type``. The ``name`` must be unique and is e.g. used to reference this specific view in different
places, e.g. for the routing in the JS application. The ``path`` defines under which URL this view is displayed, and
the ``type`` is the reference to the React component in the ``ViewRegistry``.
Additionally the ``View`` class also has a ``setOption`` method, which allows to configure the ``View``. This allows us
to build the predefined views mentioned above. So the behavior of views can be influenced by these options, so we can
e.g. tell a view representing a list to load a different type of resource and reuse a lot of logic, instead of
implementing these things twice. And it allows you to build nice lists with a lot of features being consistent in the
entire system without touching a single line of JS.
However, directly using the ``View`` class does not really offer a nice developer experience, because this class cannot
really validate anything. It has to accept everything, because Sulu does not not what views will be registered in the
future. For this reason the concept of ``ViewBuilders`` has been introduced. As the name suggests it is an
implementation of the `Builder pattern`_, and provides a better interface to build specific views. For this purpose a
builder for each type of view has been implemented, which can consider the options required for each view. All of them
have in common that they share a ``getView`` method, which return a ``View`` object with the correctly set options. This
function can also validate the input and throw proper ``Exceptions`` in case some option does not make any sense.
All of these ``ViewBuilders`` are created by the ``ViewBuilderFactory``, which is a service that has already been
injected in the code example of the ``Admin`` class above. The minimum code to only show a list with already existing
items looks like this:
.. code-block:: php
viewBuilderFactory = $viewBuilderFactory;
}
// ...
public function configureViews(ViewCollection $viewCollection): void
{
$listView = $this->viewBuilderFactory->createListViewBuilder(static::EVENT_LIST_VIEW, '/events')
->setResourceKey(Event::RESOURCE_KEY)
->setListKey('events')
->addListAdapters(['table']);
$viewCollection->add($listView);
}
}
The ``createListViewBuilder`` method returns a ``ListViewBuilder``, which already knows which type of view it needs.
Therefore we only need to name the view (``app.events_list`` in this example), and tell Sulu on which URL it should be
rendered (``/events``). Then the previously defined resource key from the `Configure resources`_ section and the list
key from the XML in the `List configuration and controller`_ section are defined. The list adapters define how the list
shows the content it has loaded. There is a ``listAdapterRegistry`` JS extension point to register adapter, but for
now we use the ``table`` adapter, which makes use of an HTML table element.
Finally the ``View`` object has to be added to the ``ViewCollection``, which is passed as the first parameter to the
``configureViews`` method. This has been implemented like this to allow other bundles to further manipulate views that
have already been added by bundles registered previously.
After that an empty list should appear on ``/admin/#/events``. But if you add some data to the ``event`` table it
should be listed:
.. figure:: ../img/extend-admin-list.jpg
Configure navigation
--------------------
The ``configureNavigationItems`` method is quite similar to the ``configureViews`` method. It passes an object of type
``NavigationItemCollection`` as first parameter, which can be used to add new ``NavigationItems`` resp. to manipulate
the ones that have already been added before.
The ``NavigationItem`` accepts a name as constructor parameter, which will also be used as translation key and
translated by the `Symfony Translations component`_. The other mandatory thing to set is the view, which is referenced
by the name used in the ``createListViewBuilder`` call in `Configure list view`_. With ``setIcon`` the icon shown right
next to the translation is defined, whereby every icon is referenced by a string. If the string starts with `su-`, then
our own icon font is used. However, if the Sulu icon font does not have a matching icon, then the prefix `fa-` can be
used to choose an icon from the `Font Awesome icon font`_. Finally ``setPosition`` allows to decide where to place that
``NavigationItem``. The items will be ordered by their position value.
.. code-block:: php
setView(static::EVENT_LIST_VIEW);
$eventNavigationItem->setIcon('su-calendar');
$eventNavigationItem->setPosition(30);
$navigationItemCollection->add($eventNavigationItem);
}
// ...
}
Form configuration
------------------
The ``Form`` component in Sulu has the same problem as the ``List``: The metadata we have delivered so far (including
the list and doctrine annotations) are not enough to render an actual form. The most important information missing is
how to render the information. Doctrine already gives us some information about the type, e.g. that a certain property
is a string, but Sulu still does not know how to render this information. A string could represented e.g. in a simple
``input`` field, in a ``textarea`` or in a rich text editor. That is why we need more information in separate XML file.
The following XML snippet shows how this metadata could be written:
.. code-block:: xml