Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFR] Better explain custom types #643

Merged
merged 1 commit into from
Sep 1, 2015
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 184 additions & 52 deletions doc/Custom-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,92 +2,224 @@

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.

## Configuration Types
## Understanding Types

Each time you define a field, you give it a type. For instance:
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 an specialized instance of [the `Field` class](https://github.com/marmelab/admin-config/blob/master/lib/Field/Field.js). For instance:

```js
myEntity.listView().fields([
nga.field('name'), // 'string' type
nga.field('birth_date', 'date').format('small'), // 'date' type
product.listView().fields([
nga.field('price', 'number')
.format('$0.00')
]);
```

`nga.field()` is a factory method returning an instance of [the `Field` class](https://github.com/marmelab/admin-config/blob/master/lib/Field/Field.js). Such instances are containers for your application configuration. For some types, the instance returned by `nga.field()` is a specialized subclass of `Field`, with methods specific to this type (like `format()` for [the `DateField` class](https://github.com/marmelab/admin-config/blob/master/lib/Field/DateField.js)). These methods are later used in the presentation layer to get the specific configuration for that type.
The call to `nga.field('price', 'number')` translates to `new NumberField('price')`. The [`NumberField` class source](https://github.com/marmelab/admin-config/blob/master/lib/Field/NumberField.js) is:

```js
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;
```

You can change the field class returned by `nga.field()` for a given type at configuration time. For instance, to change the `Field` class for the 'date' type:
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](https://github.com/marmelab/ng-admin/blob/master/src/javascripts/ng-admin/Crud/fieldView/NumberFieldView.js) is:

```js
myApp.config(['NgAdminConfigurationProvider', function(nga) {
nga.registerFieldType('date', require('path/to/MyCustomDateField'))
}]);
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 ng-click="gotoDetail()">' + 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](https://github.com/marmelab/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`](https://github.com/marmelab/admin-config/blob/master/lib/Factory.js#L116), and [the registered FieldView subclass is `NumberFieldView`](https://github.com/marmelab/ng-admin/blob/master/src/javascripts/ng-admin/Crud/config/factories.js#L11). You'll see shortly how to register your own classes.

## Writing a Custom Field Class

You custom type should extend the `Field` type at least, or another existing type.
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:

```js
// in path/to/MyCustomDateField.js
// ES6 version
import DateField from 'admin-config/lib/Field/DateField';
export default class MyCustomDateField extends DateField {
formatSmall() {
return this.format('small');
import NumberField from 'admin-config/lib/Field/NumberField';
class AmountField extends NumberField {
constructor(name) {
super(name);
this._currency = '$';
}
currency(currency) {
if (!arguments.length) return this._currency;
this._currency = currency;
return this;
}
}
export default AmountField;
```

// ES5 version
var DateField = require('admin-config/lib/Field/DateField');
function MyCustomDateField(name) {
DateField.call(this, name);
}
MyCustomDateField.prototype = new DateField();
MyCustomDateField.prototype.formatSmall = function() {
return this.format('small');
}
module.exports = MyCustomDateField;
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](http://webpack.github.io/) and [babel](https://babeljs.io/).

```js
// 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.

```sh
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`:

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

Use the same technique to add a new type.
You need to *register* this new field type in your application:

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

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

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

A given type renders in a different way in the views of the administration. For instance, a 'date' field renders as a formatted date in the `listView`, and as a datepicker widget in the `editionView`. The mapping between a type and the widget to use for a given view is done in `FieldView` objects.
## Defining The Rendering Logic For a Type

For instance, here is the `DateFieldView` object:
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:

```js
var DateFieldView = {
// displayed in listView and showView
getReadWidget: function getReadWidget() {
return '<ma-date-column field="::field" value="::entry.values[field.name()]"></ma-date-column>';
},
// displayed in listView and showView when isDetailLink is true
getLinkWidget: function getLinkWidget() {
return '<a ng-click="gotoDetail()">' + getReadWidget() + '</a>';
},
// displayed in the filter form in the listView
getFilterWidget: function getFilterWidget() {
return '<ma-date-field field="::field" value="values[field.name()]"></ma-date-field>';
},
// displayed in editionView and creationView
getWriteWidget: function getWriteWidget() {
return '<div class="row"><ma-date-field field="::field" value="entry.values[field.name()]" class="col-sm-4"></ma-date-field></div>';
}
export default {
getReadWidget: () => '{{ field.currency() }}<ma-number-column field="::field" value="::entry.values[field.name()]"></ma-number-column>',
getLinkWidget: () => '<a ng-click="gotoDetail()">' + 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:

```js
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.

```js
function amountStringDirective() {
return {
restrict: 'E',
scope: {
value: '&',
field: '&'
},
template: '<span>{{ field().currency() }}{{ value() | numeraljs:field().format() }}</span>'
};
}
export default amountStringDirective;
```

As you can see, the mapping uses ng-admin directives (like `<ma-date-column>`). It can also use any custom directive defined by you.
```js
myApp.directive('amountString', require('path/to/amountStringDirective.js'));
```

`FieldView` objects, just like `Field` classes, are registered at configuration time, and can be easily overriden.
```js
// in AmountFieldView.js
export default {
getReadWidget: () => '<amount-string field="::field" 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:

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

Then, register this field view for the `number` type:

```js
myApp.config(['FieldViewConfigurationProvider', function(fvp) {
fvp.registerFieldView('date', require('path/to/MyCustomDateFieldView'))
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!