Skip to content
/ tenon Public

Tenon is a content management system and rapid application development framework. It's designed to allow Rails developers to offer a powerful and flexible content management system to their clients or to use in their personal projects.

License

Notifications You must be signed in to change notification settings

factore/tenon

Repository files navigation

Tenon

Tenon 2.0 runs on Rails 5 and you should probably use at least Ruby 2.3.0. Stay fresh.

License

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.

Installation

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

Note about CKEditor

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.

Scaffolding

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 a reorder action to your controller, adds a default scope to your model to sort by list_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!

Item Revisions/History

TODO: Write this section

Access Control / ACL

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

Internationalization

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:

  1. Add our ‘translates’ gem to your Gemfile and then bundle install

gem 'translates', git: 'https://github.com/factore/translates.git'
  1. 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.
}
  1. Add a language yml file in config/locales/ for each language defined above, or rails will have a fit, eg ‘config/locales/fr.yml’

  2. 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
  1. 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
  1. 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
  1. 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)
...
  1. 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.

  2. 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.

Using and Customizing the ResourceIndex React App

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 simplest thing that could possibly work

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-in breadcrumb_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 the format of 'json' specified.

newPath

The path to the new action for your resource.

Customizing the app

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.

Triggering actions and making changes

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 have id, update_path, and resource_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 include page: 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.

Adding and editing using a modal window

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 Tenon input-block HTML. Other available components include SelectField, CheckBoxField, and DatepickerField. You can use standard HTML and supply the input-block tags yourself if you need something custom.

  • It pulls currentRecord, currentRecordErrors, and onChange out of the supplied props. These will always be available.

  • It passes name, value, onChange, errors, and label along to the TextField component.

  • name, value, and errors are consistent with the field that’s being presented (in this case they all reference title.)

  • 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.

Using the StandaloneList component

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' }

Searching and Filtering Records

Setting up your Rails Controllers and Filterers

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 to value

ilike(field, value)

Used to check if field ILIKE matches value

gt(field, value)

Used to check if field is greater than value

lt(field, value)

Used to check if field is less than value

gte(field, value)

Used to check if field is greater than or equal to value

lte(field, value)

Used to check if field is less than or equal to value

order(field, direction)

Used to order your scope by field in direction, eg. order('books.title', 'asc'). Define a method called allowed_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.

Creating the Filtering UI for your Resource

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.

Creating the Ordering UI for your Resource

(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'} ]

About

Tenon is a content management system and rapid application development framework. It's designed to allow Rails developers to offer a powerful and flexible content management system to their clients or to use in their personal projects.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published