# How do I Create a Custom schema field type?
Apostrophe's schemas provide a simple and powerful way to add new properties to any doc type in Apostrophe, such as a piece or a page. It's a powerful feature. But sometimes you might want to add a field type that doesn't already exist.
# A simple color picker: the server side
Let's add a simple color picker field to our project. Our color picker will display an inline preview of the color chosen.
We'll make a new Apostrophe module to power our field type. Here's the server-side code:
// in app.js
var apos = require('apostrophe')({
// ... other configuration ...
modules: {
// ... other modules ...
'color-picker': {}
}
});
// in lib/modules/color-picker/index.js
var _ = require('lodash');
module.exports = {
afterConstruct: function(self) {
self.addFieldType();
self.pushAssets();
self.pushCreateSingleton();
},
construct: function(self, options) {
self.addFieldType = function() {
self.apos.schemas.addFieldType({
name: 'color-picker',
converters: {
csv: function(req, data, name, object, field, callback) {
var def = field.def || 'rgb(0, 0, 0)';
var s = self.apos.launder.string(data[name], def);
var matches = s.match(/^rgb\((.*?)\)$/);
if (!matches) {
return safety();
}
var channels = matches[1].split(/,/);
if (channels.length !== 3) {
return safety();
}
channels = _.map(channels, function(s) {
return self.apos.launder.integer(s, 0);
});
object[name] = 'rgb(' + channels.join(',') + ')';
return setImmediate(callback);
function safety() {
object[name] = def;
return setImmediate(callback);
}
},
form: 'csv'
},
partial: self.fieldTypePartial
});
};
self.fieldTypePartial = function(data) {
return self.partial('field', data);
};
self.pushAssets = function() {
self.pushAsset('script', 'user', { when: 'user' });
self.pushAsset('stylesheet', 'user', { when: 'user' });
};
}
};
# What's happening in this code?
In construct
, we add two methods to our module: addFieldType
, fieldTypePartial
and pushAssets
.
In afterConstruct
we invoke addFieldType
, pushAssets
and pushCreateSingleton
, which is provided for us by apostrophe-module
.
We don't have to delay the "real work" until
afterConstruct
like this, but doing so allows anyone extending our module a chance to override first.
addFieldType
calls the addFieldType method of the apostrophe-schemas
module to add a new schema field type to Apostrophe.
The converters
property covers two cases: CSV import and ordinary form submissions.
For the color picker, the format is the same for both: a simple RGB color string as used in CSS.
So we set form
to the string "csv"
to indicate we don't want to supply a separate converter just for forms, and we implement only the csv
converter.
Inside our csv
converter function, the data the user submitted will be in data[name]
.
We first use self.apos.launder.string
to ensure it is a string. Then we use a regular expression to make sure it is formatted as a nice RGB color, like rgb(255, 127, 127)
.
If anything looks suspicious, we just set it to the default, or to black.
Remember, you can never, ever trust a web browser! Browser side "validation" is ONLY a convenience to help the user and must not be trusted under any circumstances. That's why our server-side code must check the input thoroughly. That "web browser" might just be a malicious program that doesn't even run your nice JavaScript.
You can invoke callback
with an error if the response is unacceptable, but this is not a good user
experience. Whenever you can, just supply a reasonable default. You can use browser-side code to
encourage better user responses. The server's job is just to make sure what is saved is safe and reasonable.
When we're done, we copy the cleaned-up value into object[name]
and invoke the callback.
"Why don't you just invoke
callback(null)
?" Converters are allowed to be asynchronous, but this one doesn't make any asynchronous calls. If our schema is large and too many fields like this one just invokecallback(null)
, the stack will eventually crash. CallingsetImmediate(callback)
and then returning guarantees that doesn't happen. If your converter actually does something that is asynchronous, then you can just invokecallback(null)
.
# The template
We also supply a fieldTypePartial
method and configure the partial
property of the new field type to use it. This method is responsible for rendering the markup for the field.
The self.partial method renders a Nunjucks template in the
views/
folder of this module with the data we pass to it, as part of a larger response that is already being generated, such as a complete modal for editing a piece. Since a response is already in progress for a specific request, we don't passreq
to this method. This is different from self.render, which is used when you want to generate and send an HTML fragment directly in response to an AJAX request.
Here's the template file we need:
{# In lib/modules/color-picker/views/field.html #}
{%- import "apostrophe-schemas:macros.html" as schemas -%}
{# A macro for our color picker control's content #}
{% macro colorPicker(field) %}
<canvas width="256" height="256" class="color-picker-canvas" data-color-picker></canvas>
<div class="color-picker-preview" data-color-picker-preview></div>
{% endmacro %}
{# Wrap our content in a standard fieldset #}
{{ schemas.fieldset(data, colorPicker) }}
# What's going on in this template?
This template takes advantage of macros provided by the apostrophe-schemas
module. It uses a cross-module path to import them.
First we define our own macro to output a color picker. It's short because JavaScript will do the really interesting work to render the canvas. Then we invoke schemas.fieldset
, a macro that wraps our own macro's output in a fieldset in the correct way.
"Hey, I don't see you output the current value of the field anywhere!" That's right — browser side JavaScript handles that, as you'll see in a moment. Schema field templates only know about the field definition, not the current value.
# The stylesheet
Don't forget the stylesheet! You'll have a tough time seeing the clickable colors and the preview without it. We pushed it in our pushAssets
method:
// in lib/modules/color-picker/public/css/user.less
.color-picker-canvas {
display: inline-block;
width: 256px;
height: 256px;
margin: 12px 12px 12px 0px;
cursor: pointer;
}
.apos-ui .color-picker-preview {
vertical-align: top;
display: inline-block;
width: 64px;
height: 64px;
}
# Handling user input: the browser side
Earlier in afterConstruct
we saw a call to pushAssets
. That method pushes a stylesheet and a javascript file to the browser when a user is logged in and might need to pick colors.
In addition, we saw a call to pushCreateSingleton
. This method creates an object to represent our module on the browser side. It'll look for one with a moog type name that matches the module's name... and our user.js
file will provide that:
// in lib/modules/color-picker/public/js/user.js
apos.define('color-picker', {
afterConstruct: function(self) {
self.addFieldType();
},
construct: function(self, options) {
self.addFieldType = function() {
apos.schemas.addFieldType({
name: 'color-picker',
populate: self.populate,
convert: self.convert
});
};
self.populate = function(object, name, $field, $el, field, callback) {
var $fieldset = apos.schemas.findFieldset($el, name);
var $colorPicker = $fieldset.find('[data-color-picker]');
var $preview = $fieldset.find('[data-color-picker-preview]');
if (object[name]) {
$preview.css('background-color', object[name]);
}
var canvas = $colorPicker[0];
var red, green, blue, x, y, ctx;
ctx = canvas.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
y = 0;
for (blue = 0; (blue < 256); blue += 32) {
x = 0;
for (green = 0; (green < 256); green += 32) {
for (red = 0; (red < 256); red += 32) {
ctx.fillStyle = 'rgb(' + red + ',' + green + ',' + blue + ')';
ctx.fillRect(x, y, 4, 32);
x += 4;
}
}
y += 32;
}
$fieldset.data('color', object[name]);
$colorPicker.on('click', function(e) {
var x = e.pageX - $(this).offset().left;
var y = e.pageY - $(this).offset().top;
var cellX = Math.floor(x / 4);
var cellY = Math.floor(y / 32);
var red = Math.min((cellX % 8) * 32, 255);
var green = Math.min(Math.floor(cellX / 8) * 32, 255);
var blue = Math.min(cellY * 32, 255);
var color = 'rgb(' + red + ',' + green + ',' + blue + ')';
$fieldset.data('color', color);
$preview.css('background-color', color);
return false;
});
return setImmediate(callback);
};
self.convert = function(object, name, $field, $el, field, callback) {
var $fieldset = apos.schemas.findFieldset($el, name);
object[name] = $fieldset.data('color');
if (field.required && (!object[name])) {
return setImmediate(_.partial(callback, 'required'));
}
return setImmediate(callback);
};
}
});
# What's going on in this code?
We start by calling apos.define
to create a moog type on the browser side with the same name as the module. The pushCreateSingleton
call earlier will take care of calling apos.create
for us.
Next we define an addFieldType
method in construct
and call it from afterConstruct
, just like on the server side.
On the browser side we need to provide a populate
function and a convert
function for each field type. This time we set these up as methods, making the code easier to maintain and extend.
# The populate
method
In populate
, we get the field ready to pick colors and display the current color.
We use apos.schemas.findFieldset
to locate the fieldset
element that contains the entire field, then we use jQuery's find
method to locate things within that. Never, ever use $('...')
directly here. There can be more than one color picker in your world! Always use find() within the fieldset to keep things in scope.
"Can't we just use
$field
?" That's just a convenience for cases where the field type is a simple one likestring
where there's a traditional form field, like an input element. For interesting controls like this one, we need the fieldset, which is guaranteed to contain all of our markup.
Displaying the current color is easy: we know it's already a CSS-friendly color string, so we just set the background-color
CSS attribute of our preview element.
Choosing colors is a little tricker. We create a simple grid of 4x4 boxes, offering a choice of 512 colors. (No, they aren't great colors. Hey, it's just an example.) And we use an HTML5 canvas (opens new window) element to render those colors without creating hundreds of spans or divs.
When a click takes place on the canvas, we turn the process around. We grab the location of the click in the document, subtract the offset of the canvas, and scale the numbers to get back to a range between 0 and 255 for each channel: red, green and blue.
We then associate the resulting CSS color string with the fieldset using jQuery's data
function, so that we can get it back later.
"I see a lot of click handlers being attached to things. Is this safe? What if the field gets populated twice? Will I get two click handlers?"
Apostrophe fieldsets are never populated twice. Schema-driven modals call
populate
only once. Butconvert
may be run many times, if the user's first inputs are rejected.
# The convert
method
The convert
method's job is to clean up the data, raise an objection if it is unacceptable, and otherwise store it back to object[name]
.
Here the click handler in populate
has done most of the work already. So we just pick up the color string from the fieldset again using jQuery's data()
function and store it in object[name]
. Then we invoke setImmediate(callback)
. Just like on the server side, we don't want to crash the stack by invoking callback(null)
directly too many times.
"Is
setImmediate(callback)
cross-browser safe?" It is in Apostrophe. Apostrophe always pushes a "polyfill" to makesetImmediate
available in all browsers.
However, if no choice has been made and the field is marked required
, we need to let the modal know an error has occurred so it can call attention to the field and stop the save operation.
To do that, we invoke the callback with the string required
as our error.
Again though, we don't want to just invoke the callback directly, because we haven't done anything asynchronous. So we use setImmediate
, and we use _.partial
to create a function that will invoke the callback with 'required'
as its sole argument when setImmediate
invokes it.
The
_.partial
function of lodash is a very useful tool for creating callbacks that are already "primed" to pass a particular argument when they are called.
# Using our field type
That's it! We've built a custom field type, and it's not a trivial one. With this knowledge you can go on to add sophisticated field types to your Apostrophe projects.
Just one last question: how to use it? Just like any other field type. Add it to the piece type of your choice via the addFields
option in the usual way. For instance, we might use it like this:
module.exports = {
extend: 'apostrophe-pieces',
name: 'story',
addFields: [
{
type: 'color-picker',
name: 'color',
label: 'Background Color'
}
]
};
Now we might use the color
property of each piece as a CSS background color when overriding, for example, the show.html
page of our subclass of apostrophe-pieces-pages
.
"Why are all these methods asynchronous?" In many simple cases it seems unnecessary. But for field types like our
joinByOne
andjoinByArray
, the ability to do asynchronous work is essential. You'll appreciate it the first time you create a field type that needs to query an API to validate something.
# Publishing field types in npm
It's easy to publish a field type in npm for everyone to use. You can just pack up your module as an npm module, and you'll find you can still configure it like any other Apostrophe module.
Naturally you will also need to add it to package.json
so that npm install
knows what to do.
If you are publishing a field type in npm, or just want to avoid conflicts in the future, please prefix your field type name with something unique to you or your organization.
# More examples of custom field types
There are two excellent examples of custom field types already built as separate modules in Apostrophe: apostrophe-attachments and apostrophe-video-fields. You can learn from the code on github (opens new window). The video field type is the simpler of the two. There's no magic here: if you build a field type using a module in your project in exactly the same way, or publish it to npm, it will work just as well.