Johannes Wachter
Johannes Wachter
Core Developer – Sulu GmbH
Sulu Core Developer and Open-Source Enthusiast.

How to develop a bundle in the Sulu-Admin – #5: News-List and Finish

This part of the tutorial is based on the results of the previous blog-post.

Part 5 is about building the list of “News” items and finishing the bundle with build process for JavaScript files. Additionally we will add the translations for the necessary translation-keys.

After that the bundle is finished and can be used to manage simple entities in Sulu-Admin.

“News” items list view

For the list view of the “News” items we have to define the structure of the data. This will be used to render the table. For that we use the "FieldDescriptors" to define which property is accessible and how the query can access the database to load the value from the table. Currently the documentation for this is not very comprehensive so feel free to contact us if you have problems with extending the list.

The “FieldDescriptors” will be used in the JavaScript "Datagrid" component to generate a request with the select columns, the sorting and the limiting options. The same descriptors will also be used in the backend to generate SQL statements that only return the requested data. To populate the “FieldDescriptors” and the list response add the following lines to the "NewsController":

// file: Controller/NewController.php

const ENTITY_NAME = 'ExampleNewsBundle:NewsItem';

/**
 * Returns array of existing field-descriptors.
 *
 * @return array
 */
private function getFieldDescriptors()
{
    return [
        'id' => new DoctrineFieldDescriptor(
            'id',
            'id',
            self::ENTITY_NAME,
            'public.id',
            [],
            true
        ),
        'title' => new DoctrineFieldDescriptor(
            'title',
            'title',
            self::ENTITY_NAME,
            'public.title'
        ),
        'content' => new DoctrineFieldDescriptor(
            'content',
            'content',
            self::ENTITY_NAME,
            'news.content'
        )
    ];
}

/**
 * Returns all fields that can be used by list.
 *
 * @FOS\RestBundle\Controller\Annotations\Get("news/fields")
 *
 * @return Response
 */
public function getNewsFieldsAction()
{
    return $this->handleView($this->view(array_values($this->getFieldDescriptors())));
}

/**
 * Shows all news-items
 *
 * @param Request $request
 *
 * @Get("news")
 *
 * @return \Symfony\Component\HttpFoundation\Response
 */
public function getNewsListAction(Request $request)
{
    $restHelper = $this->get('sulu_core.doctrine_rest_helper');
    $factory = $this->get('sulu_core.doctrine_list_builder_factory');

    $listBuilder = $factory->create(self::$entityName);
    $restHelper->initializeListBuilder($listBuilder, $this->getFieldDescriptors());
    $results = $listBuilder->execute();

    $list = new ListRepresentation(
        $results,
        'news-items',
        'get_news_list',
        $request->query->all(),
        $listBuilder->getCurrentPage(),
        $listBuilder->getLimit(),
        $listBuilder->count()
    );

    $view = $this->view($list, 200);

    return $this->handleView($view);
}

With this little extension the API is able to accept requests like "/admin/api/news?fields=id,title" which only returns the ID and the title of each “News” item or "/admin/api/news?fields=id,title&searchFields=title&search=sulu" which returns only entries which matches "sulu" in the title.

Additionally the Sulu-Admin application is able to load the field descriptors to display them in a table.

With this endpoint we are able to bootstrap the component "news/list@examplenews" which is placed in the folder "Resources/public/js/components/news/list". There is already a simple component which we will extend now. You can access it with the fragment "#example/news" since we have introduced this route in the second part of this tutorial.

In the component we call a helper function to start all the important components (datagrid, list-toolbar and search). This ensures that all lists in Sulu looks and feel exactly the same. Additionally we add and initialize the Sulu-Admin template. Like we have done it for the edit component last time. The full code of the component can be seen in the pull-request.

// file: Resources/public/js/components/news/list/main.js

render: function() {
    this.$el.html(this.templates.list());

    this.sandbox.sulu.initListToolbarAndList.call(this,
        'news',
        '/admin/api/news/fields',
        {
            el: this.$find('#list-toolbar-container'),
            instanceName: 'news',
            template: this.sandbox.sulu.buttons.get({
                settings: {
                    options: {
                        dropdownItems: [
                            {
                                type: 'columnOptions'
                            }
                        ]
                    }
                }
            })
        },
        {
            el: this.sandbox.dom.find('#news-list'),
            url: '/admin/api/news',
            searchInstanceName: 'news',
            searchFields: ['title', 'content'],
            resultKey: 'news-items',
            instanceName: 'news',
            actionCallback: this.toEdit.bind(this),
            viewOptions: {
                table: {
                    actionIconColumn: 'title'
                }
            }
        }
    );
},

toEdit: function(id) {
    this.sandbox.emit('sulu.router.navigate', 'example/news/edit:' + id);
}

This function-call seems to be very complicated but there are only a few things to consider. 

  • The first parameter is the key to store the settings of the user - like selected columns or the sort-column. 
  • The second one is the url where the fields can be loaded. 
  • The third and fourth are the options for the list and the toolbar. 

In the toolbar you define the function buttons. For our example we only want to have the "column-options" button where we can choose which columns should be displayed. In the datagrid options we define the url to load the data from, the searchable fields, the result-key which is also defined in the "NewsController::getNewsListAction" and the “action” button which will be displayed next to the title in this example. When it’s clicked the "actionCallback" will be called and the user will be redirected to the edit form.

After this step we have nearly completed our “News” example. But there are still a few little things missing.

Finishing the UI

To finish the UI we need to add functionality to three buttons: 

  • Delete selected (in the list)
  • Add (in the list)
  • Back (in the form)

First we will add the routing for "add" and "back" button.

"Add" button:

// file: Resources/public/js/components/news/list/main.js

bindCustomEvents: function() {
    this.sandbox.on('sulu.toolbar.add', this.toAdd.bind(this));
},

toAdd: function() {
    this.sandbox.emit('sulu.router.navigate', 'example/news/add');
}

"Back" button:

// file: Resources/public/js/components/news/form/main.js

bindCustomEvents: function() {
    this.sandbox.on('sulu.toolbar.save', this.save.bind(this));
    this.sandbox.on('sulu.header.back', function() {
        this.sandbox.emit('sulu.router.navigate', 'example/news');
    }.bind(this));
}

And to handle the "Delete" and "Add" buttons this to "list/main.js":

// file: Resources/public/js/components/news/list/main.js

bindCustomEvents: function() {
    this.sandbox.on('husky.datagrid.news.number.selections', function(number) {
        var postfix = number > 0 ? 'enable' : 'disable';
        this.sandbox.emit('sulu.header.toolbar.item.' + postfix, 'deleteSelected', false);
    }.bind(this));

    this.sandbox.on('sulu.toolbar.delete', function() {
        this.sandbox.emit('husky.datagrid.news.items.get-selected', this.deleteItems.bind(this));
    }.bind(this));
},

deleteItems: function(ids) {
    for (var i = 0, length = ids.length; i < length; i++) {
        this.deleteItem(ids[i]);
    }
},

deleteItem: function(id) {
    this.sandbox.util.save('/admin/api/news/' + id, 'DELETE').then(function() {
        this.sandbox.emit('husky.datagrid.news.record.remove', id);
    }.bind(this));
}

The "Delete" works in four steps:

  • Listen to the datagrid if there are selected items - if yes enable the delete button in the toolbar.
  • When the "Delete" button is clicked get an array of selected IDs from datagrid.
  • Loop through the array of IDs and delete them.
  • When deleting has been finished remove the item from UI.

Build dist files

After this step we have a working simple UI to manage our entities.

For now on we can only use the UI in dev mode of Symfony because we have no dist-files of our JavaScript. Sulu uses Grunt to build all files for production. For more information how to install and configure Grunt see the oficial documentation http://gruntjs.com/getting-started.

To see the full changes which enables you to use Grunt take a look at this commit.

After you call the command "npm install && grunt build" you can also test your UI in prod mode of Symfony. Where the smaller dist-files will be loaded from the dist directory to improve load performance.

Translations

Now the last step we have to do is adding the translations for our translation keys. If you take a deeper look into the UI you will see that we have three terms there that are not translated:

  • navigation.news - in the navigation
  • news.headline - in the form and list
  • news.content - in the form and list

Sulu uses the XLF format to manage the backend translations. Each bundle can add it’s own file per locale which will be placed in the folder "Resources/translations/sulu".

We’ll put this two files to translate missing keys in English, German:

<!-- File: Resources/translations/sulu/backend.en.xlf -->

<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
    <file source-language="en" datatype="plaintext" original="file.ext">
        <body>
            <trans-unit id="1" resname="navigation.news">
                <source>navigation.news</source>
                <target>News</target>
            </trans-unit>
            <trans-unit id="2" resname="news.headline">
                <source>news.headline</source>
                <target>News</target>
            </trans-unit>
            <trans-unit id="3" resname="news.content">
                <source>news.content</source>
                <target>Content</target>
            </trans-unit>
        </body>
    </file>
</xliff>

<!-- File: Resources/translations/sulu/backend.de.xlf -->

<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
    <file source-language="de" datatype="plaintext" original="file.ext">
        <body>
            <trans-unit id="1" resname="navigation.news">
                <source>navigation.news</source>
                <target>Neuigkeiten</target>
            </trans-unit>
            <trans-unit id="2" resname="news.headline">
                <source>news.headline</source>
                <target>Neuigkeiten</target>
            </trans-unit>
            <trans-unit id="3" resname="news.content">
                <source>news.content</source>
                <target>Inhalt</target>
            </trans-unit>
        </body>
    </file>
</xliff>

To build the translations you have to add the bundle "SuluTranslateBundle" to the abstract kernel and run the command "app/console sulu:build translations". After that remove the "SuluTranslateBundle" from "AbstractKernel" and reload the UI. You should see the translated keys now.

(The addition of translation keys is will be improved in a future version of Sulu.)

public function registerBundles()
{
    ...
    $bundles[] = new \Sulu\Bundle\TranslateBundle\SuluTranslateBundle();
    ...
}

We’re done!

That's it for this series. You can now extend the bundle with your own features - feel free to fork the “News” bundle. We are looking forward what you make out of it.

Don’t forget to subscribe to our Slack-Channel where you can ask for help.

The code for this part of the tutorial can be found here.