NgAdminGeneratorBundle: Create a JavaScript Admin Panel and a REST API for Symfony2 Apps in Minutes

Jonathan Petitcolas
Jonathan PetitcolasFebruary 24, 2015
#ng-admin#angular-js#php#oss

We are all, at marmelab, keen on rapid prototyping and iterative enhancement. Thus, we have been working on many developers tools like ng-admin, an Angular.js-powered admin GUI for RESTful APIs, to help us deliver value to our customers faster. Many of our projects are Symfony2 based, with an model layer powered by the Doctrine ORM. We missed a tool to generate ng-admin configuration based on these entities and a few conventions. So we built it: it's called NgAdminGeneratorBundle.

Creating a REST API with Stan Lemon's RestBundle

Let's imagine we want to track our progress at foosball ("baby-foot" in French) through a mobile application. This way, we would be able to scientifically reward the best marmelab player. Our model would be pretty simple:

  • Locations: we got two offices, in Nancy and Paris,
  • Players: with name and location,
  • Games: with date, players and result.

Bootstrapping a Symfony2 application

We decided to use Symfony2 for this API. So, let's create the application:

composer create-project symfony/framework-standard-edition Foosball-tracker

No need to install Acme bundle: we are going to create a bundle from scratch. Give composer all required parameters, and create our brand new bundle via generate:bundle command:

./app/console generate:bundle \
	--namespace=marmelab/FoosballTrackerBundle \
	--dir=src \
	--no-interaction

Of course, you can execute the command without any options to enter into the interactive process.

Generating our domain classes

Let's create our three entities for our domain with the doctrine:generate:entity command. For instance, for the Player entity (output has been partially truncated for readability):

The Entity shortcut name: marmelabFoosballTrackerBundle:Player

Determine the format to use for the mapping information.
Configuration format (yml, xml, php, or annotation) [annotation]:

Instead of starting with a blank entity, you can add some fields now.
Note that the primary key will be added automatically (named id).

New field name (press <return> to stop adding fields): firstName
Field type [string]:
Field length [255]:

New field name (press <return> to stop adding fields): lastName
Field type [string]:
Field length [255]:

New field name (press <return> to stop adding fields): location_id
Field type [integer]:

Do you want to generate an empty repository class [no]?

Generating the entity code: OK

  You can now start using the generated code!

We now have our three entities. Let's add relationships between them. First, link player with its location:

// Entity/Player.php
/**
 * @ORM\Column(name="location_id", type="integer", nullable=false)
 */
protected $location_id;

/**
 * @ORM\ManyToOne(targetEntity="marmelab\FoosballTrackerBundle\Entity\Location")
 * @ORM\JoinColumn(name="location_id", referencedColumnName="id")
 **/
private $location;

Note: we excluded the serialized object and exposed only the location_id field. This is indeed a (temporary) limitation of our configuration generator bundle. Our API should only return ids for references, and not entire object.

In the next days, we are going to fix it using Restangular entity transformers.

/**
 * @ORM\ManyToMany(targetEntity="marmelab\FoosballTrackerBundle\Entity\Player", mappedBy="games")
 **/
private $players;

Setup the model in the database:

./app/console doctrine:schema:update --force

the domain is set, we can now set our API.

Generating a REST API

Let's use Stan Lemon's REST Bundle to quickly generate our API based on the Doctrine entities.

composer require stanlemon/rest-bundle

We register it into the app/AppKernel.php file (don't forget JMSSerializer dependency):

public function registerBundles()
{
    $bundles = array(
    	// ...
        new marmelab\FoosballTrackerBundle\marmelabFoosballTrackerBundle(),
        new JMS\SerializerBundle\JMSSerializerBundle(),
        new \Lemon\RestBundle\LemonRestBundle(),
    );
}

To unleash the power of this bundle, we simply add the entities to the lemon_rest section in config.yml:

lemon_rest:
  envelope: Lemon\RestBundle\Object\Envelope\FlattenedEnvelope
  mappings:
    - { name: location, class: marmelab\FoosballTrackerBundle\Entity\Location }
    - { name: player, class: marmelab\FoosballTrackerBundle\Entity\Player }
    - { name: game, class: marmelab\FoosballTrackerBundle\Entity\Game }

We set a mapping to link all API routes to our entities. We also specified the envelope to use. Envelope is simply a structure to return the final payload to the serializer. As we are going to use some AngularJS based administration, we have to use the FlattenedEnvelope.

Finally, last step, we add the lemon_rest API routes to routing.yml:

lemon_rest:
  resource: "@LemonRestBundle/Resources/config/routing.yml"
  prefix: /api

That's all! The API is now fully configured! We can test it using curl, or better, httpie, a more human-friendly CLI tool that we love:

$ http --body --json GET localhost/api/location
[]
$ http --body --json POST localhost/api/location name="Nancy"
{
	"id": 1,
	"name": "Nancy"
}
$ http --body --json GET localhost/api/location
[
	{
		"id": 1,
		"name": "Nancy"
	}
]

Hurray! In only 10 minutes, we already set a fully functional API. Let's build our administration panel now!

Install admin panel without efforts

Several administration panel bundles exist. You can for instance use the famous SonataAdminBundle. Yet, this bundle, even if powerful, forces you to define a web GUI with Symfony2 objects ; we feel it's more natural to use JavaScript for that. That's why marmelab initiated the ng-admin project, an opiniated and convention oriented administration panel for REST APIs, written in Angular.js. So, let's use it on our brand new API!

Installing ng-admin

First, we need to install it, using bower. But let's configure Bower first by creating a .bowerrc file at the project root:

{
  "directory": "web/components"
}

This way, Bower will place all retrieved dependencies in the document root, allowing us to access it directly from the templates.

bower init
bower install ng-admin --save

If you take a look at the ng-admin documentation, you will quickly see that we should write a JS configuration to map the the REST API resources. But we've just done that already!

Generating ng-admin configuration automatically

Fortunately, we worked the entire last week on a new bundle called NgAdminGeneratorBundle. This tool allows to bootstrap the ng-admin configuration file using a single command. We simply base this generation on all conventions used by Stan's REST bundle. Of course, the generated configuration will need further tweaking after the initial set up, but it will be much easier to start with.

First, install the bundle the usual way:

composer require marmelab/ng-admin-generator-bundle
public function registerBundles()
{
	// ...

    if (in_array($this->getEnvironment(), array('dev', 'test'))) {
            // ...
            $bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle();
            $bundles[] = new marmelab\NgAdminGeneratorBundle\marmelabNgAdminGeneratorBundle(),
        }
    );
}

No need to add it in all environments: you would generate ng-admin configuration only on your development computer. This bundle enables the following command:

Before generating our configuration, we first have to exclude our location object from serializing, as explained above:

/**
 * @ORM\ManyToOne(targetEntity="marmelab\FoosballTrackerBundle\Entity\Location")
 * @ORM\JoinColumn(name="location_id", referencedColumnName="id")
 * @Serializer\Exclude()
 **/
private $location;

Another limitation is the support of ManyToMany relationships. It will be improved over time, but for now, it is likely that you have to configure them manually.

./app/console ng-admin:configuration:generate > web/js/ng-admin.conf.js

The JavaScript configuration for ng-admin is now bootstrapped, but we still need to create a template to load it:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Angular admin</title>
    <meta name="viewport" content="width=device-width" />

    <link rel="stylesheet" href="components/ng-admin/build/ng-admin.min.css" />

    <script src="components/angular/angular.js"></script>
    <script
      src="components/ng-admin/build/ng-admin.min.js"
      type="text/javascript"
    ></script>
    <script src="js/ng-admin.config.js" type="text/javascript"></script>
  </head>
  <body ng-app="myApp">
    <div ui-view></div>
  </body>
</html>

That's enough to get the entire administration up and running:

ng-admin administration based on a Symfony2 REST API

Yes, without any extra configuration, we already have a nice looking UI binded to our model.

Under the hood of NgAdminGeneratorBundle

If you want to dig further down into the ng-admin configuration, let's focus on some parts of the generated configuration.

The following code will be called for every request you do, simply converting URL parameters into the expected REST bundle format. For instance, pagination is done with _page and _perPage parameters in ng-admin, but should be _start and _end in Stan's bundle.

// use custom query parameters function to format the API request correctly
app.config(function(RestangularProvider) {
  RestangularProvider.addFullRequestInterceptor(function(
    element,
    operation,
    what,
    url,
    headers,
    params
  ) {
    if (operation == "getList") {
      // custom pagination params
      params._start = (params._page - 1) * params._perPage;
      params._end = params._page * params._perPage;
      delete params._page;
      delete params._perPage;

      // custom sort params
      if (params._sortField) {
        params._orderBy = params._sortField;
        params._orderDir = params._sortDir;
        delete params._sortField;
        delete params._sortDir;
      }

      // custom filters
      if (params._filters) {
        for (var filter in params._filters) {
          params[filter] = params._filters[filter];
        }
        delete params._filters;
      }
    }

    return { params: params };
  });
});

Then we get several app.config call, one per entity. As the logic is the same, we are just going to look the PlayerAdmin:

player.menuView().icon('<span class="glyphicon glyphicon-user"></span>');

For each entity, the app fetches a particular icon. Here, it displays a user glyphicon for the player entity. NgAdminGeneratorBundle automatically maps many entity names to Bootstrap icons (see the full overview of icons mapping).

Next comes the definition for the dashboard view. NgAdminGeneratorBundle limits the number of displayed fields to three for readability reasons. Indeed, getting too much data here would make the look and feel quite dirty.

player
  .dashboardView()
  .fields([
    nga.field("id", "number"),
    nga.field("first_name"),
    nga.field("last_name"),
  ]);

Then comes the listView. Note the three default actions we display (show, edit, and delete).

player
  .listView()
  .fields([
    nga.field("id", "number"),
    nga.field("first_name"),
    nga.field("last_name"),
    nga
      .field("location_id", "reference")
      .targetEntity(nga.entity("location"))
      .targetField(nga.field("name")),
  ])
  .listActions(["show", "edit", "delete"]);

We also mapped the location field to match its name field. We defined an heuristic to match the best field based on field names. Full list is available in repository.

The creation view doesn't display id:

player.creationView().fields([
  nga.field("first_name"),
  nga.field("last_name"),
  nga
    .field("location_id", "reference")
    .targetEntity(nga.entity("location"))
    .targetField(nga.field("name")),
]);

The edition view is a little bit heavier. We just display all entity fields. All of them are editable, except of course for the id.

player.editionView().fields([
  nga
    .field("id", "number")
    .editable(false)
    .isDetailLink(false),
  nga.field("first_name"),
  nga.field("last_name"),
  nga
    .field("location_id", "reference")
    .targetEntity(nga.entity("location"))
    .targetField(nga.field("name")),
]);

The show view is quite the same as the edition view. We may have used player.editionView().fields() to retrieve the already defined fields, but repeating the code gives more flexibility to tweak it further.

player.showView().fields([
  nga.field("id", "number").isDetailLink(false),
  nga.field("first_name"),
  nga.field("last_name"),
  nga
    .field("location_id", "reference")
    .targetEntity(nga.entity("location"))
    .targetField(nga.field("name")),
]);

We did it! In less than 30 minutes, we've successfully configured an API and an administration panel for recording our Foosball progress!

Don't hesitate to open an issue for every question or issue you may encounter.Your feedback is indeed the most valuable way to orientate the future of this project.

PS: All of this code is under MIT licence. Do whatever you want with it! ;)

Did you like this article? Share it!