# Joins: Connecting Piece Types to One Another
Joins are a powerful way to connect content in Apostrophe. With pieces, you can use joins to avoid duplicating work and display complex content on your site.
# Pieces and joins: relating people to their jobs
Let's say you have 100 employees, working at 10 different jobs. Many employees might have the same job description, and you don't want to duplicate that information every time you add an employee.
So we create a second pieces module, jobs
. Here's how to set that up in app.js
. We'll set up a pieces-pages
module too, to let the public browse the jobs on the site:
// app.js
modules: {
'jobs': {
extend: 'apostrophe-pieces'
},
'jobs-pages': {
extend: 'apostrophe-pieces-pages'
},
'apostrophe-pages': {
// Don't forget to add the page type!
types: [
... other page types here ...
{
name: 'people-pages',
label: 'People'
},
{
name: 'jobs-pages',
label: 'Jobs'
},
]
}
}
To keep app.js
tidy, put the rest of the configuration for jobs
in lib/modules/jobs/index.js
:
module.exports = {
name: 'job',
addFields: [
{
name: 'description',
type: 'singleton',
widgetType: 'apostrophe-rich-text',
options: {
toolbar: [ 'Bold', 'Italic', 'Link' ]
}
}
]
};
TIP
You can output this lovely description
rich text with an apos.singleton
call in lib/modules/jobs/views/show.html
. See the example of show.html
earlier in this tutorial.
# Relating people to their jobs
Great, now you have jobs. But there is no relationship between pieces and jobs yet. How do you create one?
Let's add a joinByOne
schema field to the people
module, relating it to the new job
pieces:
// lib/modules/people/index.js
module.exports = {
extend: 'apostrophe-pieces',
name: 'person',
label: 'Person',
pluralLabel: 'People',
addFields: [
... other fields as shown earlier go here ...
{
// Join field names MUST start with _
name: '_job',
label: 'Job',
type: 'joinByOne',
// SINGULAR, to match the `name` option, not the module name
withType: 'job'
}
]
};
Now, when you edit a person, you see a new Job
field in the dialog box. In that field, you can start typing the name of a job already in the system and select it. Or, you can click the "Browse" button to select a job or even create a brand new one on the fly.
# Displaying joined pieces
Now you have a join between each person and their job. But how do you display the job?
Here's what that looks like in lib/modules/people/views/show.html
:
{# As in the earlier example, then... #}
{% if data.piece._job %}
<h4>
Position: <a href="{{ data.piece._job._url }}">{{ data.piece._job.title }}</a>
</h4>
{% endif %}
NOTE
"What's going on in this code?" Once you add a join to the schema, you can access the joined piece like you would any other property. Apostrophe automatically loads the joined jobs after loading the people.
Notice that we use an if
statement to make sure the person has a job. Even if you set a joinByOne
field required: true
, it is always possible that someone has moved the job to the trash, changed its permissions, or made it inaccessible in some other way. Never assume a join still has a value.
# Joins in widgets: watch out for projections
Earlier, for performance, we showed how to restrict the projection used to fetch people from the database for widgets. This is good, but if you try to access piece._job
in that template now, you'll be disappointed.
You can fix this by adding _job
to the projection:
// lib/modules/people-widgets/index.js
module.exports = {
extend: 'apostrophe-pieces-widgets',
filters: {
projection: {
title: 1,
phone: 1,
thumbnail: 1,
_url: 1,
_job: 1
}
}
}
// etc.
TIP
Just like _url
, adding _job: 1
will fetch everything needed to populate _job
, even though it is not a real database property. Apostrophe takes care of this "under the hood," adding the jobId
property that contains the actual _id of the job... and you don't have to worry about it.
# joinByArray
: when people have multiple jobs
Turns out your employees can have more than one job! Oops. How do we express that?
// lib/modules/people/index.js
module.exports = {
extend: 'apostrophe-pieces',
name: 'person',
label: 'Person',
pluralLabel: 'People',
addFields: [
... other fields as shown earlier go here ...
{
// Join field names MUST start with _
name: '_jobs',
label: 'Jobs',
type: 'joinByArray',
// SINGULAR, to match the `name` option, not the module name
withType: 'job'
}
]
};
Now when editing a person, you can select more than one job.
And in our templates, we can access the array of jobs like this:
{# lib/modules/people/views/show.html #}
{% for job in data.piece._jobs %}
<h4>
Position: <a href="{{ job._url }}">{{ job.title }}</a>
</h4>
{% endfor %}
# Filtering the list of people
Before long you'll start wanting to filter this list of people, taking advantage of joins, tags and other field types. Here's how to do that on the public-facing site. Later in this tutorial we'll also talk about how to do it in the "Manage" view.
To make it easier to browse a listing of pieces, the apostrophe-pieces-pages module will automatically permit you to filter by the value of most schema fields when submitted as query string parameters, provided they are marked for this purpose as you'll see below.
TIP
You can also use q
or search
as a query parameter to do a full-text search. Tip: often this is all users want.
Next we'll explore how to add a filter by tag. Later, we'll look at filtering by a join as well.
Add this code to lib/modules/people-pages/index.js
. Note that earlier you added this module to app.js
, extending apostrophe-pieces-pages
. Now you need to add some custom configuration:
module.exports = {
// We already set the "extend" option in app.js, or we'd need it here
// Specify the schema fields we want to be able to filter by
piecesFilters: [
{
name: 'tags'
}
]
}
Here you're asking apostrophe-pieces-pages
to automatically populate req.data.piecesFilters.tags
with an array of choices. And, you're also asking that tags
be accepted via the query string in the URL (for example, /people?tags=doctors
).
Now you can take advantage of that:
{# lib/modules/people/views/index.html #}
{# Link to all the tags, adding a parameter to the query string #}
<ul class="tag-filters">
{% for tag in data.piecesFilters.tags %}
<li><a href="{{ data.url | build({ tags: tag.value }) }}">{{ tag.label }}</a></li>
{% endfor %}
</ul>
NOTE
"What's going on in this code?" On a pieces index page, data.url
always contains the current URL. We want to add a tags
parameter to the query string. Apostrophe's build
filter merges new query parameters with the URL. We can also remove a query parameter by passing the empty string as its value.
Notice that there are separate value
and label
properties for each tag, even though they are the same. This pattern is used consistently for all fields we define filters for, including fields like joins or select fields where the value and the label can be quite different. This lets you write a single macro to handle many filters.
# Displaying counts for tags
You can display counts for the choices, so users know how many items are available with a given tag.
- Add the
counts: true;
property to thepiecesFilters
inindex.js
- Add
({{ tag.count }})
inside of thehref
tag inindex.html
.
// lib/modules/people-pages/index.js
module.exports = {
piecesFilters: [
{
name: 'tags',
counts: true
}
]
}
{# lib/modules/people-pages/index.html #}
<ul class="tag-filters">
{% for tag in data.piecesFilters.tags %}
<li><a href="{{ data.url | build({ tags: tag.value }) }}">{{ tag.label }} ({{ tag.count }})</a></li>
{% endfor %}
</ul>
# Showing the current state of the filter
Usually you want to indicate the tag the user has already chosen. And, you want a way to remove the filter and to see the full results.
How can we do that? Again, in index.html
:
{# lib/modules/people-pages/index.html #}
{# Link to all the tags, adding a parameter to the query string #}
<ul class="tag-filters">
{% for tag in data.piecesFilters.tags %}
<li>
{% if data.query.tags == tag.value %}
<a href="{{ data.url | build({ tags: '' }) }}" class="current">
{% else %}
<a href="{{ data.url | build({ tags: tag.value }) }}">
{% endif %}
{{ tag.label }}
</a>
</li>
{% endfor %}
</ul>
NOTE
"What's going on in this code?" The current query string is automatically unpacked to data.query
for you as an object. So just compare data.query.tags
to the value of each of the choices.
We add a current
CSS class to the link to remove the current filter. It's up to you to style that; for instance, you might use an ::after
pseudo-element to add an "x."
# Filtering on joins and other schema field types
Tags are the simplest example, but you can filter on most schema field types, notably including select
fields and joinByOne
or joinByArray
fields.
Add a filter on the _jobs
schema field we saw earlier:
// lib/modules/people-pages/index.js
module.exports = {
piecesFilters: [
{
name: 'jobs'
}
]
}
NOTE
"Why is the filter named jobs
, even though the field is named _jobs
?" It works like this: if we specify _jobs
for the filter, then the value in the query string will be the _id
property of the job. This works, and it is stable no matter what gets edited later. But it isn't pretty. If we remove the _
from the filter name, the value in the query string will be the slug of the job, which is more user-friendly and good for SEO.
However, keep in mind that if you change the slug someone's bookmarked links might break. So it's up to you whether to use _jobs
(for the _id
) or jobs
(for the slug
).
Now you can filter people by job:
{# lib/modules/people-pages/index.html #}
{# Link to all the tags, adding a parameter to the query string #}
<ul class="job-filters">
{% for job in data.piecesFilters.jobs %}
<li><a href="{{ data.url | build({ jobs: job.value }) }}">{{ job.label }}</a></li>
{% endfor %}
</ul>
TIP
Notice that this template looks exactly like the one for tags. That's intentional. You could use a single Nunjucks macro for both.
# Filtering on multiple values
You're not restricted to filtering on a single value for a join, or for a tags
field. If the query string looks like this:
?jobs[]=anointer&jobs[]=flosser
You'll see people with either job.
TIP
If you want to be more restrictive and only display results that have all of the specified values, add And
to the filter name—in both the piecesFilters
array (in the module's index.js
) and the template references. As before, you can do this with _
for _id
, or without for slug
.For instance, _jobsAnd
expects ids, while jobsAnd
expects slugs.
However, keep in mind this usually is very frustrating for users because they will rarely get any matches. We recommend the default "or" behavior.
Here's how to build query strings that contain arrays in your template:
{# lib/modules/people-pages/index.html #}
<ul class="job-filters">
{% for job in data.piecesFilters.jobs %}
{% if apos.utils.contains(data.query.jobs, job.value) %}
<a href="{{ data.url | build({ jobs: { $pull: job.value } }) }}" class="current">
{% else %}
<a href="{{ data.url | build({ jobs: { $addToSet: job.value } }) }}">
{% endif %}
{{ job.label }}
</a>
{% endfor %}
</ul>
NOTE
"What's going on in this code?" Like before, we are using the build
filter to add and remove query parameters. However, this time, we are using the special $pull
operator to remove a job from the array without removing the others, and using the special $addToSet
operator to add a job to the array. In this way, we can manage filter URLs like /people?jobs[]=doctor&jobs[]=technician
with very little effort.
Pieces are very powerful and have a lot of depth, for more pieces topics and code samples, see the Advanced Pieces section.