Adding Custom Types

When you map a field between a REST API response and ng-admin, you give it a type. This type determines how the data is displayed and edited. It is very easy to customize existing ng-admin types and add new ones.

Understanding Types

A ng-admin type has two components:

  • a Field class, used to configure the field
  • a FieldView class, used for rendering

Let's see that through an example: the number type.

When you define a field with nga.field(), the second argument is the field type ('string' by default). nga.field() is a factory method returning a specialized instance of the Field class. For instance:

product.listView().fields([
    nga.field('price', 'number')
        .format('$0.00')
]);

The call to nga.field('price', 'number') translates to new NumberField('price'). The NumberField class source is:

import Field from "./Field";
class NumberField extends Field {
    constructor(name) {
        super(name);
        this._type = "number";
        this._format = undefined;
    }

    /**
     * Specify format pattern for number to string conversion.
     */
    format(value) {
        if (!arguments.length) return this._format;
        this._format = value;
        return this;
    }
}
export default NumberField;

Yep, this is ES6 syntax, but you get the idea. The sole purpose of a Field instance is to store configuration - not data (ng-admin stores data in another object, called the DataStore). Here, the NumberField can store a rendering format in addition to normal Field capabilities.

Once it has fetched data from REST endpoints, ng-admin displays it on screen. What will it be: an image, a text input, an elaborate date widget? This depends on the type, of course, but also on the view. The rendering rules for each type are contained in FieldView objects. For instance, the NumberFieldView source is:

module.exports = {
    // displayed in listView and showView
    getReadWidget:   () => '<ma-number-column field="::field" value="::entry.values[field.name()]"></ma-number-column>',
    // displayed in listView and showView when isDetailLink is true
    getLinkWidget:   () => '<a ui-sref="{{detailState}}(detailStateParams)">' + module.exports.getReadWidget() + '</a>',
    // displayed in the filter form in the listView
    getFilterWidget: () => '<ma-input-field type="number" field="::field" value="values[field.name()]"></ma-input-field>',
    // displayed in editionView and creationView
    getWriteWidget:  () => '<ma-input-field type="number" field="::field" value="entry.values[field.name()]"></ma-input-field>'
};

What this means is that, to render a NumberField in a listView, ng-admin will use the related ReadWidget, which is:

<ma-number-column field="::field" value="::entry.values[field.name()]"></ma-number-column>

The <ma-number-column> is a directive defined by ng-admin, but what it essentially does is the following:

<span>{{ value() | numeraljs:field().format() }}</span>

So a field of type 'number' renders in a listView as a formatted number string (e.g. <span>$45.99</span>).

One thing that may sound curious is that the configuration logic (Field subclasses) is defined in the admin-config module, while the rendering logic (FieldView subclasses) is defined in the ng-admin module. Don't let it confuse you. This is just because the configuration logic can be reused by another renderer not based on Angular.js (e.g. react-admin).

One last thing to understand is that ng-admin uses the field type name (e.g. 'number') to relate a Field subclass with a FieldView subclass. For type 'number', the registered Field subclass is NumberField, and the registered FieldView subclass is NumberFieldView. You'll see shortly how to register your own classes.

Writing a Custom Field Class

Let's write an AmountField to manage not only numbers, but amounts. An amount, in addition to a number, has a currency. Create the following AmountType.js class in your project tree:

import NumberField from 'admin-config/lib/Field/NumberField';
class AmountField extends NumberField {
    constructor(name) {
        super(name);
        this._type = 'amount';
        this._currency = '$';
    }
    currency(currency) {
        if (!arguments.length) return this._currency;
        this._currency = currency;
        return this;
    }
}
export default AmountField;

Compared to a NumberField, this adds a .currency() method, which is both a getter and a setter. The AmountField code is ES6, so you'll need to transpile it to JavaScript to make it executable by a web browser. The solution depends on your build tool ; here is the configuration for Webpack and babel.

// in webpack.config.js
module.exports = {
    // ...
    module: {
        loaders: [
            { test: /\.js/, loaders: ['babel'], exclude: /node_modules/ },
        ]
    }
};

The AmountField class depends on the NumberField class from the admin-config module. You'll have to install it.

npm install admin-config --save-dev

This module is also written in ES6, so update the exclude pattern in webpack.config.js to let Webpack transpile all "*.js" file, except the ones under node_modules, but including node_modules/admin-config:

// in webpack.config.js
module.exports = {
    // ...
    module: {
        loaders: [
            { test: /\.js/, loaders: ['babel'], exclude: /node_modules\/(?!admin-config)/ }
        ]
    }
};

You need to register this new field type in your application:

myApp.config(['NgAdminConfigurationProvider', function(nga) {
    nga.registerFieldType('amount', require('path/to/AmountField'))
}]);

Now you can use the new type in your admin configuration:

product.listView().fields([
    nga.field('price', 'amount')
        .format('0.00')
        .currency('$')
]);

Defining The Rendering Logic For a Type

An amount field will render as text with the currency in read context, and as an input with a lateral addon in write context. Create the following AmountFieldView to define the rendering logic:

export default {
    getReadWidget:   () => '{{ field.currency() }}<ma-number-column field="::field" value="::entry.values[field.name()]"></ma-number-column>',
    getLinkWidget:   () => '<a ui-sref="{{detailState}}(detailStateParams)">' + module.exports.getReadWidget() + '</a>',
    getFilterWidget: () => '<ma-input-field type="number" step="any" field="::field" value="values[field.name()]"></ma-input-field>',
    getWriteWidget:  () => '<div class="input-group"><span class="input-group-addon">{{ field.currency() }}</span><ma-input-field type="number" step="any" field="::field" value="entry.values[field.name()]"></ma-input-field></div>'
};

You also need to register this new field view type in your application for ng-admin to find it. Beware that it's a different configuration provider this time:

myApp.config(['FieldViewConfigurationProvider', function(fvp) {
    fvp.registerFieldView('amount', require('path/to/AmountFieldView'))
}]);

Using Custom Directives

Optionally, you can write a custom directive, and use that directive in one of the widget definitions.

function amountStringDirective() {
    return {
        restrict: 'E',
        scope: {
            value: '&',
            field: '&'
        },
        template: '<span>{{ field().currency() }}{{ value() | numeraljs:field().format() }}</span>'
    };
}
export default amountStringDirective;
myApp.directive('amountString', require('path/to/amountStringDirective.js'));
// in AmountFieldView.js
export default {
    getReadWidget:   () => '<amount-string field="::field" value="::entry.values[field.name()]"></amount-string>',
    // ...
};

Fetching data

Custom directives can fetch data, too. For instance, the <amount-string> directive can fetch the conversion rate to another currency, and display the amount in more than one currency. To fetch a remote API, use either the Restangular service, or $http if you prefer.

function amountStringDirective($http) {
    return {
        restrict: 'E',
        scope: {
            value: '&',
            field: '&'
        },
        link: function(scope) {
            scope.fromCurrency = field().currency();
            scope.conversionRate = 1;
            scope.format = field().format();
            $http.get(`http://myconverter.example.com?fromCurrency=${scope.fromCurrency}&toCurrency=USD`)
                .then(response => {
                    scope.conversionRate = response.value;
                });
        },
        template: `<span>{{ fromCurrency }} {{ value() | numeraljs:format }}</span>
                   (<span> USD {{ value() * conversionRate | numeraljs:format }}</span>)`
    };
}
amountStringDirective.$inject = ['$http'];

export default amountStringDirective;

Fetching Data Before Rendering

Fetching data from the link() function of a directive has drawbacks:

  • it triggers redraws of the page when the remote response arrives
  • in a listView, a custom directive will make a lot of queries (one per entry)

The alternative is to prepare the data before the page starts rendering, to store it in the datastore, and to pass the datastore to the custom directive.

Fortunately, every view has a prepare() method, which expects a function as parameter. This function is executed before the view is rendered. It's the ideal place to group fetches to a remote API, and store the results in the datastore. The prepare() function uses the Angular Dependency Injection system, so you can require any service defined previously in the controller logic - like the current DataStore instance (already filled with all the entries required by the view).

product.listView().prepare(['$http', 'datastore', 'view', function($http, datastore, view) {
    const fromCurrency = view.getField('price').currency();
    return $http.get(`http://myconverter.example.com?fromCurrency=${fromCurrency}&toCurrency=USD`)
        .then(response => {
            datastore.addEntry('conversionRate', response.value)
        });
}])

The directives doesn't need $http anymore, but can use the datastore instead:

function amountStringDirective($http) {
    return {
        restrict: 'E',
        scope: {
            value: '&',
            field: '&',
            datastore: '&'
        },
        link: function(scope) {
            scope.fromCurrency = field().currency();
            scope.conversionRate = 1;
            scope.format = field().format();
            scope.convertedValue = datastore.getFirstEntry('conversionRate') * scope.value();
        },
        template: `<span>{{ fromCurrency }} {{ value() | numeraljs:format }}</span>
                   (<span> USD {{ convertedValue | numeraljs:format }}</span>)`
    };
}

export default amountStringDirective;

The datastore just needs to be declared in the fieldView:

// in AmountFieldView.js
export default {
    getReadWidget:   () => '<amount-string field="::field" datastore="::datastore" value="::entry.values[field.name()]"></amount-string>',
    // ...
};

Overriding Existing Types

Just like you can add new types, it's very easy to override existing types. For instance, if you want all number fields to use a directive of yours called <my-number>, create a CustomNumberFieldView script as follows:

module.exports = {
    getReadWidget:   () => '<my-number field="::field" value="::entry.values[field.name()]"></my-number>',
    // ...
};

Then, register this field view for the number type:

myApp.config(['FieldViewConfigurationProvider', function(fvp) {
    fvp.registerFieldView('number', require('path/to/CustomNumberFieldView'))
}]);

Conclusion

Once you get passed the installation step, defining new types is straightforward. It's also a very powerful way to extend ng-admin to your custom needs. Use it as much as possible!

results matching ""

    No results matching ""