# Custom widgets
You've seen a lot of the widgets that come "in the box" with Apostrophe. But you can do much more by creating your own.
# Custom navigation
Here's a common case: you want to build your own navigation menu. Apostrophe's page tree is awesome but sometimes you want to manually choose pages from all over the tree, perhaps for the website's footer navigation.
You could use a rich text widget and have editors add links manually, but the links will break each time a page is moved around the site, changing its slug. It's also easier for users to mess up the formatting that way. You want something more consistent.
Let's look at some custom widgets that help provide navigation. you'll start with a simple widget that adds a link in a well-formatted way.
# link
: the simplest widget
TIP
You can use the Apostrophe CLI to set up your new widget quickly. You could skip a few steps below if you have it installed by running apos add widget link
. The following information is still worth reviewing to understand what this does for you.
First you'll need a folder for the module. In the terminal, from the project root, enter:
mkdir -p lib/modules/link-widgets
Just about everything new you create in Apostrophe will be a "module." Project-specific modules live in lib/modules
. Apostrophe will spot them there if you list them in app.js
. You can also publish and install modules with npm
. Apostrophe looks in both places. Your module name should not start with apostrophe
. That's reserved for our official modules. Modules almost always have plural names. The name of a module that provides widgets should end in -widgets
.
Then you'll include the module in our app.js
by adding the following to the modules
object:
// app.js
modules: {
// ...,
'link-widgets': {}
}
Now create an index.js
in lib/modules/link-widgets/
and put some code in there:
// lib/modules/link-widgets/index.js
module.exports = {
extend: 'apostrophe-widgets',
label: 'Link to Anywhere',
addFields: [
{
name: 'url',
type: 'url',
label: 'URL',
required: true
},
{
name: 'label',
type: 'string',
label: 'Label',
required: true
}
]
};
"What does extend
mean here?" Our module is extending the apostrophe-widgets
module, which provides almost all the code you need. Yes, extend
is the correct spelling. Apostrophe uses moog (opens new window) to handle extending or "subclassing" other modules.
"What other field types can I add?" The apostrophe-schemas
module provides a powerful way to build forms and structure data with almost no work. You just pass an array of field definitions as the addFields
option. You'll introduce the details gradually. But if you're in a hurry, check out the schema guide.
"What does the name
property do?" Each field needs to be stored in the widget under its own property name. Play around with aposDocs.find().pretty()
in the mongodb shell to see what it looks like after you save the widget.
Next you'll need a folder to hold our widget's widget.html
template, which renders it on the page.
Create a new folder:
mkdir -p lib/modules/link-widgets/views
Now create a Nunjucks template in
lib/modules/link-widgets/views/widget.html
:
{# lib/modules/link-widgets/views/widget.html #}
<h4><a href="{{ data.widget.url }}">{{ data.widget.label }}</a></h4>
"Hey, don't You need to escape the label before you output it as HTML?" No, Nunjucks does it automatically. If you need to output content that is already valid, safe markup, you must use the | safe
filter to output it without escaping.
Now you'll want to add this widget to an area in one of our page templates, like you learned in widgets, areas, and singletons. Let's add the following to the main
block of our lib/modules/apostrophe-pages/views/pages/home.html
:
{# lib/modules/apostrophe-pages/views/pages/home.html #}
{{
apos.area(data.page, 'navigation', {
widgets: {
link: {}
}
})
}}
You've just created a content area in which only link
widgets are allowed. Each one has a url
field and a label
field, and they are always output in the same style.
# page-link
: using joins in widgets
The link
widget is cool, but if the URL of a page changes, the link breaks. And if the title changes, that is not reflected in the widget.
Let's solve the problem by allowing the user to pick a page on the site instead. Then you can access information about that page programmatically.
Let's make another module and its views folder in one step:
mkdir -p lib/modules/page-link-widgets/views
TIP
The Apostrophe CLI package that you installed earlier can easily create basic module structure for you. You can learn more in the Apostrophe CLI README (opens new window).
- Now add this new widget to the
modules
object in our app.js:
// app.js
modules: {
/// ...,
'link-widgets': {},
'page-link-widgets': {}
}
- And then write
lib/modules/page-link-widgets/index.js
:
// lib/modules/page-link-widgets/index.js
module.exports = {
extend: 'apostrophe-widgets',
label: 'Link to a Page',
addFields: [
{
name: '_page',
type: 'joinByOne',
withType: 'apostrophe-page',
label: 'Page',
required: true,
idField: 'pageId'
}
]
};
"What do type: 'joinByOne'
and idField: 'pageId
do?` you want this widget to remember a connection to another page. To do that, you use the joinByOne
field type and ask Apostrophe to store the MongoDB _id
of the other page in a pageId
property of the widget.
"Why does the name
start with a _
?" Joins get fetched every time this widget is loaded. The relationship is dynamic. Properties that are dynamic and should not be stored back to MongoDB as part of this widget must start with a _
(underscore). Apostrophe automatically ignores them when saving the widget in the database.
Now you're ready for the Nunjucks template, lib/modules/page-link-widgets/views/widget.html
:
{# lib/modules/page-link-widgets/views/widget.html #}
<h4><a href="{{ data.widget._page._url }}">{{ data.widget._page.title }}</a></h4>
TIP
Whoa! So I can access the other page in my template?" Yep. You can access any property of the other page. You can even make apos.area
and apos.singleton
calls with the other page object.
Actually using the widget in an area is just like using the first one. But this time, let's enable both kinds in our area on home.html
:
{# lib/modules/page-link-widgets/views/home.html #}
{{
apos.area(data.page, 'navigation', {
widgets: {
link: {},
'page-link': {}
}
})
}}
Now our users have a choice between do-it-yourself links that can point anywhere and "page" links that can only point to a page. Both can be useful.
TIP
It is also possible to join with more than one type. And once you check out pieces, the benefit of doing so will be clear. To do that, set withType
to an array of type names, which may include apostrophe-pages
. The user is then able to use a tabbed interface to select items of several types for the same join. These "polymorphic joins" are primarily intended for navigation widgets like this one.
# Passing options to widgets
You probably noticed that our widgets don't take any options yet. You can use options to do cool things in our templates. Let's add a simple one to choose between two presentation styles.
All you have to do is access data.options
in your widget.html
template for page-link-widgets
and pass the option in the apos.area
call and home.html
:
- Add
data.options
inwidget.html
:
{# lib/modules/page-link-widgets/views/widget.html #}
<h4 class="{{ 'special' if data.options.special }}"><a href="{{ data.widget._page._url }}">{{ data.widget._page.title }}</a></h4>
- Then pass the option call in
home.html
:
{# lib/modules/page-link-widgets/views/home.html #}
{{
apos.area(data.page, 'navigation', {
widgets: {
link: {},
'page-link': {
special: true
}
}
})
}}
Now all the page links in this particular area will get the special class. You can probably think of much fancier uses for this feature.
# Giving choices to the user
You can also leave the choice up to the user by adding a boolean
field to the schema for page-link-widgets
in its index.js
:
// lib/modules/page-link-widgets/index.js
module.exports = {
extend: 'apostrophe-widgets',
label: 'Link to a Page',
addFields: [
{
name: '_page',
type: 'joinByOne',
withType: 'apostrophe-page',
label: 'Page',
required: true,
idField: 'pageId'
},
{
name: 'special',
label: 'Special',
type: 'boolean'
}
]
};
The new bit here is the special
field.
In your template, access it via data.widget
rather than data.options
:
{# lib/modules/page-link-widgets/views/widget.html #}
<h4 class="{{ 'special' if data.widget.special }}">
<a href="{{ data.widget._page._url }}">{{ data.widget._page.title }}</a>
</h4>
TIP
data.widget
contains the form fields the user can edit. data.options
contains the options passed to apos.area
or apos.singleton
by the frontend developer.
# Performance note: limiting joins
Having access to the entire page object is a neat trick, but it can be very slow. That page might have its own widgets which load a lot of information you don't care about in this situation. Multiply that by 20 links and it starts to impact performance.
Indeed, all you really care about here is the title and the URL. So let's fetch only that information.
You can rewrite index.js
to speed up the code:
// lib/modules/page-link-widgets/index.js
module.exports = {
extend: 'apostrophe-widgets',
label: 'Link to a Page',
addFields: [
{
name: '_page',
type: 'joinByOne',
withType: 'apostrophe-page',
label: 'Page',
required: true,
idField: 'pageId',
filters: {
projection: {
title: 1,
_url: 1
}
}
}
]
};
The new bit is the filters
option. By specifying a projection
filter, you can limit Apostrophe to loading just the title
and _url
properties. Apostrophe needs _url
to figure out the URL of a page. It's almost always a good idea to limit the projection to the fields you care about.
_url
, slug
... what's the difference? For most sites, nothing. But for sites with a prefix
option, the _url
property might have a folder name prepended to it. And there are other ways to transform _url
to suit your needs. So always remember to use it instead of slug
when you output page URLs. And use _url
in your projection to fetch all the properties Apostrophe knows might be involved in calculating the _url
property of the page.
Watch out for reverse joins! If you have reverse joins and your widget doesn't need them, the projection
filter can't help you avoid loading them, because they are loaded from "the other side" (the ids are stored with the documents linking to your documents). Instead, use the joins
filter, and specify an array of join field names your widget actually needs — if any.
What else can I do with filters
? That's an intermediate topic, but you can do anything that cursor filter methods can do.
# Adding a JavaScript widget player on the browser side
So far you've built your widgets entirely with server-side code. But sometimes you'll want to enhance them with JavaScript on the browser side.
Sure, you could just select elements in your widget markup with jQuery, but that's no good for a widget that was just added to an existing page. There's a better way. you'll create a widget player.
Let's say you want to offer some content in a collapsible "drawer." Clicking on the title of the drawer reveals an Apostrophe area with more information.
Your module's index.js
file looks like this:
// lib/modules/drawer-widget/index.js
module.exports = {
extend: 'apostrophe-widgets',
label: 'Drawer',
addFields: [
{
type: 'string',
name: 'title',
label: 'Title'
},
{
type: 'area',
name: 'content',
label: 'Content',
options: {
widgets: {
'apostrophe-rich-text': {
toolbar: [ 'Bold', 'Italic' ]
},
'apostrophe-images': {}
}
}
}
]
};
And your widget.html
file looks like this:
{# lib/modules/drawer-widgets/views/widget.html #}
<h4>
<a data-drawer-title class="drawer-title" href="#">{{ data.widget.title }}</a>
</h4>
<div data-drawer class="drawer-body">
{{ apos.area(data.widget, 'content', { edit: false }) }}
</div>
Here you use data.widget
where you would normally expect data.page
. This allows access to areas nested inside the widget.
Notice that nesting areas and singletons inside the templates of other widgets is allowed. You can use this feature to create widgets that give users flexible control over page layout, for instance a "two column" widget with two
apos.area
calls.
Now, in your default page template, let's create an area that allows a series of drawers to be created:
{# lib/modules/apostrophe-pages/views/default.html #}
{{
apos.area(data.page, 'drawers', {
widgets: {
drawer: {}
}
})
}}
And in app.js
, don't forget to configure the widget:
// app.js
modules: {
// other widgets, then...
'drawer-widgets': {}
}
Even if your widget doesn't require any options, you must configure it in app.js
to instantiate it. This is how Apostrophe knows that you actually want to use this module directly. In many projects, some modules only exist to be extended by other modules.
So far, so good. you can create a whole column of drawer widgets and their titles and their content areas appear. But right now the "drawer" part is visible at all times.
First, you'll need to hide the content of the drawer by default. Let's push an always.less
stylesheet specifically for this module.
In index.js
, you'll extend the pushAssets
method, which is already pushing JavaScript, to push a stylesheet as well:
// lib/modules/drawer-widgets/index.js
module.exports = {
extend: 'apostrophe-widgets',
addFields: [
// ... see above ...
],
construct: function(self, options) {
var superPushAssets = self.pushAssets;
self.pushAssets = function() {
superPushAssets();
self.pushAsset('stylesheet', 'always', { when: 'always' });
};
}
};
In Apostrophe modules, the construct
function is called to add methods to the module. Here you are following the "super pattern," making a note of the original method you inherited from apostrophe-widgets, creating your own replacement method, invoking the original from within it, and then pushing your own asset to the browser.
The pushAsset method can push both stylesheets and scripts. The name always
is a convention meaning "everyone sees this stylesheet, whether logged in or not." And you make sure of that by setting the when
option to always
.
Now you need to supply always.less
in the right place: the public/css
subdirectory of your module's directory.
/* lib/modules/drawer-widgets/public/css/always.less */
.drawer-title {
padding: 2em 0;
text-align: center;
}
.drawer-body {
padding: 2em 0;
display: none;
}
With these changes, your drawers are hidden. But you still need a way to toggle them open when the titles are clicked.
For that, you'll need an always.js
file, in your public/js
folder:
// lib/modules/drawer-widgets/public/js/always.js
// Example of a widget manager with a play method
apos.define('drawer-widgets', {
extend: 'apostrophe-widgets',
construct: function(self, options) {
self.play = function($widget, data, options) {
$widget.find('[data-drawer-title]').click(function() {
$widget.find('[data-drawer]').toggle();
// Stop bubbling and default behavior for jQuery event
return false;
});
};
}
});
# What's happening in this code?
- You called
apos.define
to define a moog type for your "widget manager." A widget manager is an object that is responsible for directing everything related to widgets of that type in the browser. Think of it as the browser's half of your module. - The first argument to
apos.define
is the name of your new type, which is the same as the name of your module. The second argument is an object that defines the type. Just like yourindex.js
file on the server, it contains aconstruct
function. That's because Apostrophe uses moog to manage object-oriented programming in both places. The only difference is that on the server, Apostrophe figures out what type is being defined automatically based on the module's name. Here in browser-land, it's up to you to callapos.define
. - The
extend
property indicates that you want to extend ("subclass" or "inherit from") theapostrophe-widgets
type, which provides most of the plumbing for managing your widget. All you need to do is supply aplay
method. - Inside
construct
, you attach aplay
method to the widget manager. Yourplay
method accepts$widget
(a jQuery object referring to the widget'sdiv
element),data
(an object containing the properties of your widget, liketitle
), andoptions
(an object containing options that were passed to the widget whenapos.area
orapos.singleton
was called). - Apostrophe automatically calls the
play
method at appropriate times. - The play function takes advantage of jQuery's find method (opens new window) to locate the title and the drawer inside the scope of this one widget.
"Can't I just write some jQuery in a $(function() { ... })
block and skip all this?" If you do, you forfeit the ability for your player to work for widgets that were just added to the page by the user, without refreshing the page. Requiring users to refresh the page is very 2005. You might even tease you about it.
Writing widget players that scope all of their jQuery selectors with $widget.find()
helps you avoid the temptation to write code that will install the same handler twice, fail entirely for newly-added widgets, or become a problem later when you want to publish your module in npm.
# What's available in the browser?
Now is a good time to mention highlights of what you can access in the browser by default when working with Apostrophe:
- jQuery (See below)
- lodash (version 3.x)
- async (version 1.x)
- moment
- jquery.fileupload
- jquery.cookie
And as previously mentioned, you can use LESS in your stylesheets.
# jQuery versions
The default version of jQuery that Apostrophe loads is v1.11.3 for the sake of backward compatibility for older sites. This is an older version that does carry some documented vulnerabilities. For this reason, you can set an option in apostrophe-assets
to use jQuery v3 instead. This setting is already in place in the apostrophe-boilerplate
repository for newer projects to use.
If your project isn't already using this, set the jQuery: 3
option in apostrophe-assets
in lib/modules/apostrophe-assets/index.js
or app.js
.
// lib/modules/apostrophe-assets/index.js
module.exports = {
jQuery: 3,
// stylesheets, scripts, and other asset configuration
// ...
};
"What if I want to use browserify, gulp, webpack, etc.?" Sure, go nuts. Just configure your build to output a file that is pushed as an asset by one of your modules.
We chose not to incorporate those frontend build tools into Apostrophe's core because the core set of features needed for good CMS-driven sites doesn't usually rise to that level of complexity. But if you need to build complex in-page JavaScript experiences, go for it.