Tenon 2.0 runs on Rails 5 and you should probably use at least Ruby 2.3.0. Stay fresh.
This project uses the MIT-LICENSE. Do whatever you want with it as long as you don’t violate the licenses of the various open source pieces on which it depends.
In your Gemfile
gem 'tenon'
and then bundle install.
in config/application.rb:
require 'active_record/railtie'
in config/routes.rb:
mount Tenon::Engine => '/tenon'
Run command:
$ rake tenon:install:migrations $ rake db:migrate
You will need to have a database set up at this point. Currently Tenon requires that you use postgres.
Running rake db:migrate probably threw up a devise error. Create config/initializers/devise.rb and paste:
Devise.setup do |config| ## paste the secret key line from the error output ## end
Run that command again:
$ rake db:migrate
Install the Tenon helpers in app/controllers/application_controller.rb:
helper Tenon::Engine.helpers
Install the necessary files to run and customize Tenon (this is now required):
$ rails generate tenon:install
To run seed data (such as creating an admin user) from Tenon, open console and run:
ENV['PASSWORD'] = 'password' # or something at least 8 chars long Tenon::Engine.load_seed
Restart your app and navigate to /tenon
If you want to be able to use rspec, which would be good, you will also need to run:
bundle exec rails generate rspec:install
Currently options for serving CKEditor via the asset pipeline are limited. For now, Tenon works around this by copying CKEditor’s files into your app’s public folder when you run the Tenon install task. As such, you will need a web server that is capable of serving static files out of your public folder. It is on the Tenon roadmap to find a better solution to this problem.
Tenon comes with a powerful scaffold generator that makes it easy for you to prototype new resources. The scaffold generator sits on top of Rails’ built in resource generators and creates everything you need for a working CRUD interface in Tenon.
Let’s create an imaginary Post
resource as an example:
$ rails generate tenon:scaffold Post title:string excerpt:text
This task will create the following files:
app/controllers/tenon/posts_controller.rb app/decorators/post_decorator.rb app/policies/post_policy.rb app/serializers/post_serializer.rb app/models/post.rb app/views/tenon/posts/index.html.haml app/views/tenon/posts/new.html.haml app/views/tenon/posts/edit.html.haml app/views/tenon/posts/_form.html.haml db/migrate/<timestamp>_create_posts.rb
It will also add the necessary routes to config/routes.rb
. If you navigate to /tenon/posts
within your app you will see that you have a fully functioning section with the ability to index, search, add, edit, and delete posts.
On top of the typical Rails generator column types like string
, text
, or integer
, Tenon adds a few new options.
asset
-
Use this to create an asset field that integrates with Tenon’s asset library.
content
-
Use this to create a TenonContent field.
date
-
Automatically links up a date picker widget in your form.
datetime
-
Automatically links up a date and time picker widget in your form.
There are also a handful of column names that you can define to trigger special behaviour in the generator:
title
-
This field is required for your scaffolded resource to work out of the box. You’ll need to override part of the
ResourceIndex
React component tree (more on this below) if you want to use a different field name. publish_at
-
Adds special publishing fields to your form and adds a scope called
published
to your model. list_order
-
Includes
Tenon::Reorderable
in your model, adds areorder
action to your controller, adds a default scope to your model to sort bylist_order
, and makes the items in your index view drag and drop sortable. seo_title
,seo_keywords
,seo_description
-
Adds a special SEO fields panel to your form with explanatory text. Useful for public-facing websites.
With these features in mind, let’s regenerate our Post
resource with all of our special features.
$ rails generate tenon:scaffold Post title:string excerpt:text content:content banner_photo:asset written_on:date publish_at:datetime list_order:integer seo_title:string seo_keywords:string seo_description:text
Out of the box this will give us a working Post
model with functional views. Typically you’ll want to reorganize the fields found in app/views/tenon/posts/_form.html.haml
, but otherwise your work is done!
TODO: Write this section
Tenon uses Pundit for ACL, see github.com/elabs/pundit for documentation.
Have your policies inherit from Tenon::ApplicationPolicy
to get default authorization. Override app/policies/tenon/application_policy.rb
if you want to override the default authorization scheme. Write custom policies as per Pundit standards.
Any controller that inherits from Tenon::ResourcesController
will have ACL applied on all CRUD methods. ACL is enforced on all actions so ensure that you authorize
any time you add a new action or override an existing one. Policy scoping is enforced on the index action, so ensure that if you override the filterer
method that you are using Pundit’s policy_scope
method. For example:
module Tenon class PostsController < ResourcesController private def filterer PostFilterer.new(policy_scope(Post), params) end end end
Although Tenon is currently anglocentric it supports the inclusion of additional languages and provides an interface for managing content in multiple languages.
To add internationalized fields, follow these steps:
-
Add our ‘translates’ gem to your Gemfile and then bundle install
gem 'translates', git: 'https://github.com/factore/translates.git'
-
Tell Tenon which languages you want to support in config/initializers/tenon.rb (You don’t need to add English, Tenon always assumes its in use.)
config.languages = { "French" => :fr, "German" => :de # etc. }
-
Add a language yml file in config/locales/ for each language defined above, or rails will have a fit, eg ‘config/locales/fr.yml’
-
Create or update config/i18n_fields.yml to tell Tenon which fields you would like to have internationalized.
tables: cars: - title - description events: - title - location - description If you want to add internationalization to the default Tenon models you should make your i18n_fields.yml look like this: tables: tenon/events: - title - location tenon/pages: - title - seo_title - seo_keywords - seo_description tenon/posts: - title - excerpt - seo_title - seo_keywords - seo_description
-
Generate and run the internationalization migration. The generator will only try to create columns that don’t already exist, so you can use this generator multiple times throughout the development of your application.
rails generate tenon:i18n_migrations rake db:migrate
-
Update your models to make sure your attributes are translated
class MyModel < ApplicationRecord include Translates # plain old rails attributes translates :title # tenon_content tenon_content :description, i18n: true end
-
Update your tenon views to add the language navigation helper, where needed:
# app/views/tenon/cars/_form.html.haml - content_for :sidebar do .sidebar .content ... = i18n_language_nav(:cars) ...
-
While there, make sure you are using ‘autosaving_form_for’ instead of ‘form_for’ to create your forms. By doing this, Tenon will automatically update the labels when the different languages are selected.
-
Make sure your routes are configured according to your needs and the I18n.locale is being set somehow (see Rails documentation for more info: guides.rubyonrails.org/i18n.html)
Once you’ve done this and restarted your app you will see a language selection nav in the sidebar of each Tenon form that has internationalized fields. On the front end, attributes on your Tenon models will be translated correctly, based on I18n.locale.
One of the biggest changes in Tenon 2.0 is the replacement of the index view for each resource with a common ResourceIndex ReactJS/Redux app. Listing, paginating, filtering, sorting, deleting, editing, and all other tasks typically done on the index route of a resource are managed through this mounted React app.
Rather than scaffolding new code for every resource, code is shared for all resources. If you find yourself needing to customize the index view, individual components of the React app can easily be replaced with custom components. This allows for a high level of customization without creating a lot of repetitive code.
The bare minimum code to get a fully functioning resource index view is as follows. (For an imagined Post
resource this code would be the entirety of app/views/tenon/posts/index.html.haml
)
= react_component 'ResourceIndexRoot', title: 'Posts', breadcrumbs: breadcrumb_links, recordsPath: posts_path(format: 'json'), newPath: new_post_path
This code will instantiate the ResourceIndexRoot
React component, and pass it the following required props:
title
-
The pluralized title of the resource.
breadcrumb_links
-
An array of Ruby hashes in the format of
[{ title: 'A Title', path: '/path/to/somewhere' }]
. Use the built-inbreadcrumb_links
Rails helper to automatically generate this for your current resource, or supply your own. recordsPath
-
The path where the JSON dump of your resource can be found. Typically just the
index
path with theformat
of'json'
specified. newPath
-
The path to the
new
action for your resource.
At some point you will need to make changes to how the ResourceIndex app looks and behaves for a specific resource. Rather than copying the entire app and changing the relevant portions, the app can be instantiated with specific child-components swapped out for your own custom components.
In order to do this first we need to understand the composition of the app. The app is broken up into several smaller child-components, each of which can be swapped out when the app is instantiated in your index
view. The component tree is as follows:
- ResourceIndexRoot - App - QuickSearchToolbar - QuickSearchInput - ActionButtons - FilterToggle - SortOrder - SortOrderItem - QuickSearchOverlay - (Same Children as QuickSearchToolbar) - Filtering - FilterDrawer - FilterOverlay - List - Record - RecordTitle - RecordActions - RecordExpandedContent - LoadMoreButton
A common task when creating index views is changing the way the title of each individual record is displayed. Let’s change our imagined Post
resource to display not only the post’s title, but also its publish date.
The first step to replacing a child-component is changing the instantiation call to the ResourceIndex
component and passing in the name of our new component. In this case, we want to replace RecordTitle
with a custom component, which we’ll call PostsRecordTitle
. Pass it in as a prop like so:
= react_component 'ResourceIndexRoot', title: 'Posts', breadcrumbs: breadcrumb_links, recordsPath: posts_path(format: 'json'), newPath: new_post_path, childComponentNames: { RecordTitle: 'PostsRecordTitle' }
This prop tells the top-level component to render PostsRecordTitle
instead of DefaultRecordTitle
in the component tree.
The next step is to create our PostsRecordTitle
component. Start by copying the code of the DefaultRecordTitle
component, found at app/assets/javascripts/tenon/components/resource-index/components/default/record-title.es6
. The code will look something like this.
Tenon.RI.DefaultRecordTitle = ({ record }) => { return ( <p className="record__title">{record.title}</p> ); };
Copy this code and create the new component at app/assets/javascripts/tenon/components/posts-record-title.es6
. In our case we simply want to change the name of the component, and add a second line with the record’s publish_at
method. Our finished component looks like this:
Tenon.RI.PostsRecordTitle = ({ record }) => { return ( <div> <p className="record__title"> {record.title} </p> <p className="record__title--smallest"> Published on {record.publish_at} </p> </div> ); };
Upon saving this component our imagined Post
resource’s index page will now display a customized title including the publish date/time of the post.
It’s not enough to just display custom information in the index view, often we need to give users the ability to make changes or interact with data as well. You can trigger actions in your custom components that allow you to change the state of the app and update the database. The two most common actions you will want to take are making updates to an individual record, and changing how your records are filtered and sorted. These two actions are known as updateRecord
and updateQuery
and are passed down as methods on the handlers
prop available in any custom component.
updateRecord(event, record, changeObject)
-
Updates the record in question and sends the changes to the server.
-
record
- The record object. At minimum it must haveid
,update_path
, andresource_type
methods. (Any resource generated with a Tenon scaffold will have these.) -
changeObject
- An object describing the changes to the object, eg.{ title: 'My New Title', featured: true }
-
updateQuery(event, changeObject [, appendRecords])
-
Changes the query sent to the server when fetching records, re-fetches records with new query, and updates query string in address bar.
-
changeObject
- An object describing the changes to the query, eg.{ q: 'my search', page: 1 }
. (You should always includepage: 1
in your query unless you are appending records.) -
appendRecords
- Boolean. True: Append new records to the bottom of the list. False: Clear record list before getting new records. Default: false.
-
Let’s create a simple button on our imagined posts index that allows us to toggle whether a given post is featured or not.
The first thing we need to do is update our call to the ResourceIndex
component in index.html.haml
to tell it that we’re going to be passing in our custom set of RecordActions
.
= react_component 'ResourceIndexRoot', title: 'Posts', breadcrumbs: breadcrumb_links, recordsPath: posts_path(format: 'json'), newPath: new_post_path, childComponentNames: { RecordTitle: 'PostsRecordTitle', RecordActions: 'PostsRecordActions' }
Next, we’ll want to make a copy of the DefaultRecordActions
component found at app/assets/javascripts/components/resource-index/components/default/record-actions.es6
. The default component looks like this:
Tenon.RI.DefaultRecordActions = (props) => { const editPath = props.record.edit_path; const onDelete = props.onDelete; return ( <div className="record__actions"> <a className="record__action-icon" href={editPath} title="Edit"> <i className="material-icon">edit</i> </a> <a className="record__action-icon" href="#!" onClick={onDelete} title="Delete"> <i className="material-icon">delete</i> </a> </div> ); };
We’ll make our new component at app/assets/javascripts/tenon/components/posts-record-actions.es6
and add a new icon.
Tenon.RI.PostsRecordActions = (props) => { const editPath = props.record.edit_path; const onDelete = props.onDelete; return ( <div className="record__actions"> <!-- copied edit and delete buttons --> <a className="record__action-icon" href="#!" onClick={<we need something here>} title="Toggle Featured"> <i className="material-icon">star_border</i> </a> </div> ); };
Next we need to tap into the onClick
action of the link to toggle the featured state of the record.
<a className="record__action-icon" href="#!" onClick={(e) => { props.handlers.updateRecord(e, props.record, !props.record.featured) }} title="Toggle Featured"> <i className="material-icon">star_border</i> </a>
This is a little bit lengthy, so let’s extract some constants up above.
Tenon.RI.PostsRecordActions = (props) => { const editPath = props.record.edit_path; const { onDelete, record } = props; const { updateRecord } = props.handlers; return ( <div className="record__actions"> <!-- copied edit and delete buttons --> <a className="record__action-icon" href="#!" onClick={(e) => { updateRecord(e, record, { featured: !record.featured }); }} title="Toggle Featured"> <i className="material-icon">star_border</i> </a> </div> ); };
Finally, let’s add some feedback to show the user that something happened. We’ll have the component display an empty star for regular posts, and a full star for featured ones.
<a className="record__action-icon" href="#!" onClick={(e) => { updateRecord(e, record, { featured: !record.featured }); }} title="Toggle Featured"> <i className="material-icon"> {record.featured ? 'star' : 'star_border'} </i> </a>
Your users can now click on the star to toggle the post’s featured state.
Read on through the next section to understand how updateQuery()
and the query object interacts with the server to filter and return records.
Very basic resources, such as lists of categories, may be easier to manage if their add and edit actions are presented in a modal window rather than on a new page. This can be easily accomplished with the addition of two options and one custom component.
First, change the call to ResourceIndexRoot
to include the modal options, as well as the name of the custom form component you’ll be providing. In this case we’ll use an imagined PostCategory
list as our example:
= react_component 'ResourceIndexRoot', title: 'Categories', breadcrumbs: breadcrumb_links, recordsPath: post_categories_path(format: 'json'), newPath: new_post_category_path, addWithModal: true, editWithModal: true, childComponentNames: { ModalFields: 'PostCategoryFields' }
Note the addition of addWithModal
, editWithModal
, and the name of the ModalFields
child component.
Next we need to create the PostCategoryFields
child component. This file can be created at app/assets/javascripts/tenon/components/post-category-fields.es6
and should look something like this:
Tenon.RI.PostCategoryFields = (props) => { const { currentRecord, currentRecordErrors } = props.data; const { onChange } = props; return ( <div> <TextField name="title" value={currentRecord.title} onChange={onChange} errors={currentRecordErrors.title} label="Title" /> <button type="submit" className="btn">Save</button> </div> ); };
The important things to note about this are as follows:
-
It uses the handy
TextField
component to generate standard Tenoninput-block
HTML. Other available components includeSelectField
,CheckBoxField
, andDatepickerField
. You can use standard HTML and supply theinput-block
tags yourself if you need something custom. -
It pulls
currentRecord
,currentRecordErrors
, andonChange
out of the supplied props. These will always be available. -
It passes
name
,value
,onChange
,errors
, andlabel
along to theTextField
component. -
name
,value
, anderrors
are consistent with the field that’s being presented (in this case they all referencetitle
.) -
The save button is added, but other modal markup is handled automatically further up the chain.
By supplying these options and this custom component, our PostCategory
resource can now be managed completely from the index page without having to visit a secondary form page.
Occasionally you may want to render a list of records inside an existing view, for example if you wanted to embed a list of records inside/alongside a form. You can accomplish this by rendering the StandaloneList
component. It functions identically to the ResourceIndexRoot
component.
This is especially useful for lists that have in-place editing (eg. the feature toggle we just added to posts). You can replace any of the child components in the chain, just as with the ResourceIndexRoot
component.
= react_component 'StandaloneList', recordsPath: posts_path(format: 'json') childComponentNames: { RecordTitle: 'PostsRecordTitle', RecordActions: 'PostsRecordActions' }
Often you will need to provide various different ways to filter records that are returned in your controllers’ index
action. The standard Tenon::ResourcesController#index
action provides a hook to allow the returned records to pass through a Filterer. Filterers receive, at minimum, a scope (eg. an ActiveRecord::Relation
) and a set of params. They can then apply their own internal logic to filter the passed scope. For example, consider the following call to an imagined PostFilterer
:
filterer = PostFilterer.new(Post.all, { q: 'Tenon' }) @posts = filterer.filter
The PostFilterer
could use its internal logic to, for example, return only posts that are called “Tenon”:
class PostFilterer < Tenon::BaseFilterer def filter if params[:q].present? @scope = scope.where(title: params[:q]) end super # Returns the scope end end
or it could use its internal logic to return only posts that are in a Category
called “Tenon”:
class PostFilterer < Tenon::BaseFilterer def filter if params[:q].present? @scope = scope.includes(:category) @scope = scope.where(category: { title: params[:q] }) end super # Returns scope end end
By default, records in the index
action of any controller that inherits from Tenon::ResourcesController
will be filtered by Tenon::GenericFilterer
. While Tenon::BaseFilterer
takes a scope and a params object as its initialization arguments, Tenon::GenericFilterer
also takes as a third argument a list of fields to run a basic text search on. The #quick_search_fields
method on any controller is used to set these fields, like in the following example of a basic controller for posts:
class PostsController < Tenon::ResourcesController private def quick_search_fields ['posts.title', 'posts.excerpt', 'posts.content'] end end
As it’s a convention for all resources in Tenon to respond to a #title
method the default behaviour is to filter on this field.
In order to provide searching and filtering capabilities beyond what the GenericFilterer
provides, simply create a a new filterer in the app/filterers
directory. It is usually best to have this custom filterer inherit from Tenon::GenericFilterer
in order to keep the quick search functionality, but a filterer can also inherit from Tenon::BaseFilterer
.
After creating the new filterer, it can be inserted into the controller by defining the #filterer
method.
class PostsController < Tenon::ResourcesController private def quick_search_fields ['posts.title', 'posts.excerpt', 'posts.content'] end def filterer PostFilterer.new(Post.all, params, quick_search_fields) end end
(Note that in reality you would want to perform an ACL check on the scope you pass into the filterer, replacing Post.all
with policy_scope(Post)
.)
Here is an example of what an imagined PostFilterer
that inherits from Tenon::GenericFilterer
with some date-filtering logic might look like:
class PostFilterer < Tenon::GenericFilterer def filter @scope = filter_start_date @scope = filter_end_date super end private def filter_start_date return scope unless params[:start_date].present? scope.where('publish_at >= ?', params[:start_date]) end def filter_end_date return scope unless params[:end_date].present? scope.where('publish_at <= ?', params[:end_date]) end end
The filter_start_date
and filter_end_date
methods allow custom filtering of the collection that’s passed in, while the call to super
on the #filter
method also allows for the quick_search_fields
to be searched.
Because many filtering tasks are similar, filterers that inherit from Tenon::BaseFilterer
(and thus Tenon::GenericFilterer
) have access to a few convenience methods for easier filtering. These methods are:
eq(field, value)
-
Used to check if
field
is equal tovalue
ilike(field, value)
-
Used to check if
field
ILIKE matchesvalue
gt(field, value)
-
Used to check if
field
is greater thanvalue
lt(field, value)
-
Used to check if
field
is less thanvalue
gte(field, value)
-
Used to check if
field
is greater than or equal tovalue
lte(field, value)
-
Used to check if
field
is less than or equal tovalue
order(field, direction)
-
Used to order your scope by
field
indirection
, eg.order('books.title', 'asc')
. Define a method calledallowed_order_fields
on your Filterer and return an array of allowed fields, eg.['books.title', 'created_at', 'authors.title']
. Direction must be'asc'
or'desc'
. reorder(field, direction)
-
Same as
order
but uses#reorder
instead of#order
on the scope.
These methods will always simply return the current scope if value
is not .present?
, so there’s no need to check for the presence of a param.
Here is an example of the imagined PostFilterer
rewritten using these convenience methods:
class PostFilterer < Tenon::GenericFilterer #:nodoc: def filter @scope = gte('posts.publish_at', params[:start_date]) @scope = lte('posts.publish_at', params[:end_date]) super end end
A custom filterer is just a plain old Ruby object and can use any kind of internal logic to filter a collection. The only requirement is that the #filter
method returns a chainable ActiveRecord::Relation
.
The ResourceIndex component’s toolbar contains a search input that automatically sends its value as params[:q]
when a user types in it. This hook into Tenon::GenericFilterer
on the Rails end and provide basic filtering of a resource. For many resources this all the filtering that’s required, and no customization is necessary.
However, it’s often necessary to build more advanced filtering features, as demonstrated in the above example using the PostFilterer to filter posts on params like :start_date
and :end_date
. In order to expose these options to the end user, we need to create a React component and inject it into our ResourceIndex component. These custom components are called Filter Drawers.
Here is an example of what an imagined PostsFilterDrawer
component, living at /app/assets/javascripts/tenon/components/posts-filter-drawer.es6
, might look like.
Tenon.RI.PostsFilterDrawer = (props) => { const query = props.data.query; const onChange = props.onChange; return ( <div className="panel--block"> <TextField label="Keywords" name="q" value={query.q} onChange={onChange} /> <DatepickerField label="Start Date" name="start_date" value={query.start_date} onChange={onChange} /> <DatepickerField label="End" name="end_date" value={query.end_date} onChange={onChange} /> </div> ); };
This stateless React component (toddmotto.com/stateless-react-components/) is passed the entire state tree from the top-level ResourceIndex
component, but only uses the data.query
object (responsible for which params are passed to the server when fetching records) and an onChange
function passed down from the parent component. Also, notice that the component is set within the Tenon.RI
object. All custom components intended to be passed into the ResourceIndex component tree must be set this way.
The component uses JSX needed to build three simple form controls: a text field for a general query, a datepicker for the start date, and a datepicker for the end date. Each input is passed four props:
- label
-
The visible label for the field
- name
-
The name of the
param
being changed (eg.name="start_date"
->params[:start_date]
) - value
-
The initial value of input, almost always
query.<param_name>
- onChange
-
The
onChange
prop passed in from the parent component.
As long as name
, value
, and onChange
are present you can use any HTML elements and form inputs you like to build your Filter Drawer. There are a handful of simple pre-built components available as conveniences for building form elements, including:
-
TextField
-
DatepickerField
-
SelectField
-
CheckBoxField
To inject this component into the top-level ResourceIndex
component for your particular resource its name needs to be passed in as part of the childComponentNames
prop in your index view. Here is an example of what it might look like in an imagined posts index, located at app/views/tenon/posts/index.html.haml
:
= react_component 'ResourceIndexRoot', title: 'Posts', breadcrumbs: breadcrumb_links, recordsPath: posts_path(format: 'json'), newPath: new_post_path, childComponentNames: { FilterDrawer: 'PostsFilterDrawer' }
When the top-level ResourceIndex
is rendered with a FilterDrawer
, the Filter Drawer will be available to your users, and you can provide as much or as little advanced filtering as you like.
(This section glosses over the process of adding and editing components to the ResourceIndex app. Make sure you read Using and Customizing the ResourceIndex React App before reading this.)
While it’s certainly possible to add the UI for ordering the returned records into the FilterDrawer
component, the recommend approach is to separate it from filtering and instead use a button with a dropdown menu in the toolbar. To add this button, we’re going to supply our own custom ActionButtons
child-component to the ResourceIndex
app.
The first step is initializing the ResourceIndex
app with the name of our custom ActionButtons
component. We’ll continue working on our imagined Post
resource, and so this component will be called PostsActionButtons
.
= react_component 'ResourceIndexRoot', title: 'Posts', breadcrumbs: breadcrumb_links, recordsPath: posts_path(format: 'json'), newPath: new_post_path, childComponentNames: { FilterDrawer: 'PostsFilterDrawer', ActionButtons: 'PostsActionButtons' }
Next, copy the DefaultActionButtons
component, found at app/assets/javascripts/tenon/components/resource-index/components/default/action-buttons.es6
. The default component looks like this:
Tenon.RI.DefaultActionButtons = (props) => { const { FilterDrawerToggle } = props.childComponents; return ( <div className="toolbar__actions toolbar__actions--right"> <FilterDrawerToggle {...props} /> </div> ); };
We’ll create our new component at app/assets/javascripts/tenon/components/posts-action-buttons.es6
. We want to be able to order our posts from oldest to newest, and from newest to oldest, so let’s paste the code from the DefaultActionButtons
component and add some markup that creates a dropdown menu with these options.
Tenon.RI.PostsActionButtons = (props) => { const { FilterDrawerToggle } = props.childComponents; return ( <div className="toolbar__actions toolbar__actions--right"> <FilterDrawerToggle {...props} /> <div className="toolbar__action"> <a className="action-icon dropdown-button" href="#!" title="Sort Order"> <i className="material-icons">tune</i> </a> <ul className="dropdown"> <li className="dropdown__item dropdown__item--label">Order By</li> <li className="dropdown__item"> <a href="#!" className="dropdown__action action-icon"> <span>Oldest to Newest</span> </a> </li> <li className="dropdown__item"> <a href="#!" className="dropdown__action action-icon"> <span>Newest to Oldest</span> </a> </li> </ul> </div> </div> ); };
Next we need to set up the links to update the query. There is an orderBy
handler that takes field
and direction
as arguments available in our props. This handler is just a convenient wrapper around the updateQuery
wrapper. We’ll extract it and then call it in the onClick
prop of our links.
Tenon.RI.PostsActionButtons = (props) => { const { FilterDrawerToggle } = props.childComponents; const { orderBy } = props.handlers; return ( <div className="toolbar__actions toolbar__actions--right"> <FilterDrawerToggle {...props} /> <div className="toolbar__action"> <a className="action-icon dropdown-button" href="#!" title="Sort Order"> <i className="material-icons">sort</i> </a> <ul className="dropdown"> <li className="dropdown__item dropdown__item--label">Order By</li> <li className="dropdown__item"> <a href="#!" className="dropdown__action" onClick={(e) => orderBy(e, 'publish_at', 'asc')}> <span>Oldest to Newest</span> </a> </li> <li className="dropdown__item"> <a href="#!" className="dropdown__action" onClick={(e) => orderBy(e, 'publish_at', 'desc')}> <span>Newest to Oldest</span> </a> </li> </ul> </div> </div> ); };
Finally, let’s give some feedback to the user so they can see which item is currently selected. We’ll pull the order_direction
out from the query
object and then use it to set the active
class on the correct <li>
.
Tenon.RI.PostsActionButtons = (props) => { const { FilterDrawerToggle } = props.childComponents; const { orderBy } = props.handlers; const { order_direction } = props.data.query; return ( <div className="toolbar__actions toolbar__actions--right"> <FilterDrawerToggle {...props} /> <div className="toolbar__action"> <a className="action-icon dropdown-button" href="#!" title="Sort Order"> <i className="material-icons">sort</i> </a> <ul className="dropdown"> <li className="dropdown__item dropdown__item--label">Order By:</li> <li className={order_direction === 'asc' ? 'dropdown__item active' : 'dropdown__item'}> <a href="#!" className="dropdown__action action-icon" onClick={(e) => orderBy(e, 'publish_at', 'asc')}> <span>Oldest to Newest</span> </a> </li> <li className={order_direction === 'desc' ? 'active dropdown__item' : 'dropdown__item'}> <a href="#!" className="dropdown__action action-icon" onClick={(e) => orderBy(e, 'publish_at', 'desc')}> <span>Newest to Oldest</span> </a> </li> </ul> </div> </div> ); };
If you think all this seems a bit effortful for a very common requirement, you’re not wrong. This same behaviour can be replicated by going back to the DefaultActionButtons
component and passing an orderOptions
array as a prop to the ResourceIndex
, like this:
= react_component 'ResourceIndexRoot', title: 'Posts', breadcrumbs: breadcrumb_links, recordsPath: posts_path(format: 'json'), newPath: new_post_path, childComponentNames: { FilterDrawer: 'PostsFilterDrawer' } orderOptions: [ { title: 'Oldest to Newest', order: 'publish_at:asc' }, { title: 'Newest to Oldest', order: 'publish_at:desc'} ]