Effective Regions

Create editable content regions within your existing, ordinary ActionView::Base views, and update content with an actually-good full-screen WYSIWYG editor.

Define and Insert Snippets (mini model-view components) that intelligently render content based on the user selected attributes.

Specify and Insert pre-defined HTML-only templates for small pieces of common HTML.

Uses the actually-good fullscreen editor effective_ckeditor to achieve near perfect WYSIWYG editting of content regions.

Getting Started

Add to your Gemfile:

gem 'effective_regions'

Run the bundle command to install it:

bundle install

Then run the generator:

rails generate effective_regions:install

The generator will install an initializer which describes all configuration options and creates a database migration.

If you want to tweak the database table name (to use something other than the default 'regions'), manually adjust both the configuration file and the migration now.

Then migrate the database:

rake db:migrate

Add the following helper to your application layout in the <head>..</head> section. This will have the effect of loading the appropriate javascript & stylesheets only when in 'edit mode'.

effective_regions_include_tags

Do not add anything to your asset pipeline javascripts or stylesheets.

Optional Step

(optional)

When you intend to use the full screen editor quite a bit, it does load faster if you add the effective_ckeditor resources to the asset pipeline.

It prevents 2 extra requests on page load, but increases your asset payload size by around 200kb.

To add it to the asset pipeline, put the following to your application.js:

//= require effective_ckeditor

and in your application.css, add:

*= require effective_ckeditor

Usage

To start editting via the full-screen editor, prepend /edit/ to your existing route.

So to begin editing:

http://www.example.com/about => http://www.example.com/edit/about

http://www.example.com/posts/13 => http://www.example.com/edit/posts/13

http://www.example.com/ => http://www.example.com/edit/

of course, there will be nothing to edit until you insert some Regions.

You can also use the link_to helper to automatically prepend /edit/:

= link_to 'Edit Post Content', effective_regions.edit_path(post_path(@post))

Regions

Regions can be global, in which each is referenced by a unique name, or belong to a specific object.

If desired, permissions can be configured such that some users may edit global regions but not object regions or vice versa.

The regions can be created with or without default content. The default content is displayed only when no editable content has been entered.

The names for the regions are to be created on the fly, so you can just make up new names as you go along.

It's super easy to add an effective_region into any regular view, anywhere you want a dynamic content area.

The following is an example of a global region:

%h2 This is a header
%p= effective_region :footer_left

and another example of the same region with some default content:

%h2 This is a header
%p
  = effective_region :footer_left do
    %p Default content
    %p to display when footer_left is empty

Anywhere in your application, in any layout or view, refering to :footer_left will render the same piece of content.

Effective Regions can also belong to a specific object:

%h2= effective_region(@event, :title)

%p
  = effective_region @event, :summary do
    %p= truncate(@event.excerpt)
    %small
      created on
      = @event.created_at

%p= effective_region(@event, :body)

Here each @event will have a unique :title, :summary and :body regions.

Restricting Editable Content

Using a regular effective_region tells the full-screen editor that any kind of HTML content and all available Snippets are allowed.

This is not always desirable - sometimes you want to lock down the content available to a specific region.

To allow text-only entry with no HTML or snippets, use simple_effective_region:

%h2
  = simple_effective_region @event, :title do
    Default Title

The above example ensures that the full-screen editor will only accept a simple title. No HTML is allowed. No Snippets are allowed. No newlines or ENTER keypresses are allowed.

This gives the user full control of the content, and allows the design and presentation to remain entirely in the hands of the developer.

Similarly, you may want to allow only Snippets to be inserted into a specific region:

%div
  = snippet_effective_region :sidebar_mentions

only one type of snippet to be allowed:

%div
  = snippet_effective_region(:sidebar_mentions, :snippets => [:mention])

or allow full content entry, but only a subset of the available Snippets:

%div
  = effective_region(:sidebar_mentions, :snippets => [:mention])

Before Save Callback

Sometimes you may want to programmatically massage the content being assigned from the editor.

One use case for this would be to replace a tweet @someone mention with a full url to the appropriate twitter page.

Found in the config/initializers/effective_regions.rb file, the config.before_save_method hook exists for just such a purpose.

This method is called when a User clicks the 'Save' button in the full screen editor.

It will be called once for each region immediately before the regions are saved to the database.

This is not an ActiveRecord before_save callback and there is no way to cancel the save.

This method is run on the controller.view_context, so you have access to all your regular view helpers as well as the request object.

The second argument, parent, will be the Effective::Region's parent regionable object, or the symbol :global.

If you are gsub'ing the region.content String value or altering the region.snippets Hash values, those changes will not be immediately visible on the front-end.

If you need the User to immediately see these changes, have your Proc or function return the symbol :refresh.

Returning the symbol :refresh will instruct javascript to perform a full page refresh after the Save is complete.

Warning: Don't change the region.title value or the region.regionable parent object, as this will just orphan the region.

Use via Proc:

config.before_save_method = Proc.new do |region, parent|
  region.content = region.content.gsub('force', 'horse') if region.title == 'body'
  :refresh
end

or to use via custom method:

config.before_save_method = :my_region_before_save_method

And then in your application_controller.rb:

def my_region_before_save_method(region, parent)
  if region.title == 'body' && request.fullpath == posts_path
    region.content = region.content.gsub('force', 'horse')
    :refresh
  end
end

or to disable completely:

config.before_save_method = false

Helpers

effectively_editing?

Call effectively_editing? in any controller or view to determine if the current action is in edit mode.

This checks both that request.fullpath.include?('edit=true') and that the current_user has permission to use the editor.

The Exit button

When a user clicks on the 'Exit' button from the full-screen editor toolbar, they are redirected to the last visited page.

You can overide the default behaviour by passing an Exit URL as a parameter:

= link_to 'Edit Post Content', effective_regions.edit_path(post_path(@post), :exit => edit_admin_post_path(@post))

Authorization

All authorization checks are handled through the effective_resources gem and its config.authorization_method found in the config/initializers/effective_resources.rb initializer.

Permissions

There are 3 different levels of permissions to be considered:

1 - Can I use the editor at all?

can :edit, Effective::Region

2 - Can I update the Effective::Region global regions?

can :update, Effective::Region

3 - Can I update the individual objects which define acts_as_regionable

can :update, ActsAsRegionableObject # This would be your Event, Post, or Page, or whatever.

Snippets

Snippets are intelligent pieces of content that can be dropped into an effective_region through the full-screen editor's 'Insert Snippet' dropdown.

They are based on CKEditor Widgets but override some of the Widget internals to instead use the server to render content, allowing us to render Rails objects based on the user selected options.

To implement a Snippet, you must write 3 files: a model, a view, and a javascript options file.

Simple Snippet Example

We are going to create a Snippet called current_user_info.

When the snippet is inserted, the user may choose whether the .email, .first_name or .last_name methods will be called on the current_user object.

These examples use HAML, but ERB or SLIM will work the same way.

The Model

A model that extends from Effective::Snippets::Snippet

Any snippet models defined in app/models/effective/snippets/*.rb will be automatically detected and usable.

The models here are not ActiveRecord objects, and instead rely on virtus for the attribute functionality.

Any number of configurable options can be specified, but in this example we only have one.

This model file is defined in app/models/effective/snippets/current_user_info.rb

module Effective
  module Snippets
    class CurrentUserInfo < Snippet
      attribute :display_method, String
    end
  end
end

The View

The view must be defined as a partial and should be placed in app/views/effective/snippets/_current_user_info.html.haml

- if current_user.blank?
  = 'Not logged in'

- elsif current_user_info.display_method == 'email'
  = current_user.email

- elsif current_user_info.display_method == 'first_name'
  = current_user.first_name

- elsif current_user_info.display_method == 'last_name'
  = current_user.last_name

Or for the meta-programmers, instead of the above, we could:

= (current_user.send(current_user_info.display_method) rescue 'Not logged in')

In the above example, current_user_info is the snippet object, and current_user is the (probably Devise) User object.

The Javascript Options File

This file defines the dialog that CKEditor will present when inserting a new Snippet.

This must follow the CKEditor Widget Dialog Window Definition specification, which you can learn more about at:

http://docs.ckeditor.com/#!/guide/widget_sdk_tutorial_2

http://docs.ckeditor.com/#!/api/CKEDITOR.dialog.definition

The javascript file must be placed in app/assets/javascripts/effective/snippets/current_user_info.js.coffee

CKEDITOR.dialog.add 'current_user_info', (editor) ->  # Must match the class name of the snippet
  title: 'Current User Info',
  minWidth: 200,
  minHeight: 100,
  contents: [
    {
      id: 'current_user_info',    # Just an html id, doesn't really matter what is here
      elements: [
        {                         # elements Array should contain one Hash for each Snippet attribute
          id: 'display_method',
          type: 'select',
          label: 'Current User Info',
          items: [
            ['E-mail', 'email'],
            ['First Name', 'first_name'],
            ['Last Name', 'last_name']
          ],
          setup: (widget) -> this.setValue(widget.data.display_method),
          commit: (widget) -> widget.setData('display_method', this.getValue())
        }
      ]
    }
  ]

Please note, this file should not be included into the asset pipeline. It's a standalone javascript file that is read (just once, and then cached) by CKEditor when the Insert Snippet is triggered.

You may be thinking that this file won't be available due to asset digesting, but there is a custom assets:precompile enhancement task in the effective_ckeditor gem (a dependency of this gem) that ensures these snippet options files are available at the non-digested file path. This just works and is not something you need to worry about.

Summary

We have created a simple Snippet to display the current_user's email, first_name or last_name.

When any logged in user visits this page, their specific instance of current_user will be called, and they will see their own email, first or last name.

Once the Snippet is inserted, the user editting the page can double-click the Snippet and set the display_method to something else.

Advanced Snippet Example

The above, simple, example works great because current_user is something always available to the application.

In this next example we are going to create a Snippet to insert a summary and link to a Post which is created using the standard Rails CRUD workflow.

We must use an AJAX request to query all current Posts, rather than just the ones available at compile/deploy time.

The Model

This snippet model is defined in app/models/effective/snippets/post.rb

module Effective
  module Snippets
    class Post < Snippet
      attribute :post_id, Integer
      attribute :all_posts, Array # We're going to assign this through the effective_region :snippet_locals option

      def post_object
        # We're using ::Post to refer to the app/models/post.rb rather than the Effective::Snippets::Post
        @post ||= begin
          if all_posts.present?
            all_posts.find { |post| post.id == post_id }
          else
            ::Post.find_by_id(post_id)
          end
        end
      end

    end
  end
end

The View

This view partial is defined in app/views/effective/snippets/_post.html.haml

Some advanced snippet partials work best with CKEditor when you can start them with a parent div. This one isn't advanced enough to actually matter.

.post
  %h3= post.post_object.title
  %small
    This is post number
    = post.post_object.id
    created on
    = post.created_at

  %p= post.post_object.summary

The Javascript Options File

The javascript file should be placed in app/assets/javascripts/effective/snippets/post.js.coffee

getPosts = ->
  posts = []

  $.ajax
    url: '/effective/snippets/posts'
    type: 'GET'
    dataType: 'json'
    async: false
    complete: (data) -> posts = data.responseJSON

  posts

CKEDITOR.dialog.add 'post', (editor) ->
  title: 'Post'
  minWidth: 200,
  minHeight: 100,
  contents: [
    {
      id: 'post',
      elements: [
        {
          id: 'post_id',
          type: 'select',
          label: 'Post',
          items: getPosts(),  # This only runs once, when the Dialog is created.
          setup: (widget) -> this.setValue(widget.data.post_id)
          commit: (widget) -> widget.setData('post_id', this.getValue())
        }
      ]
    }
  ]

So when the Snippet dialog for an 'Insert Snippet' -> Post is opened, an AJAX request to the server is made, and the list of Posts is read.

The Controller

This controller is in no way part of the effective_regions/effective_ckeditor magic. It's just a one-off controller action.

For consistency with the other file paths (which do matter), I have namespaced the action under effective/snippets/, but this could be any valid rails route.

This controller is defined in app/controllers/effective/snippets/posts_controller.rb

module Effective
  module Snippets
    class PostsController < ApplicationController
      respond_to :json

      def index
        authorize! :index, Post  # CanCan authorization here

        @posts = Post.order(:title).map { |post| [post.title, post.id] }.to_json

        respond_with @posts
      end
    end
  end
end

and then in your routes.rb:

get '/effective/snippets/posts', :to => 'effective/snippets/posts#index'

Default Content

We can pre-populate an effective_region's default content with some posts. These posts will be displayed until a user edits that region and selects some specific posts.

%h2 Posts

= snippet_effective_region :sidebar_posts, :snippets => [:post], :snippet_locals => {:all_posts => Post.all.to_a} do
  - Post.order(:created_at).first(5).each do |post|
    = render_snippet Effective::Snippets::Post.new(:post_id => post.id, :all_posts => [post])

Using the snippet_locals approach as above prevents an N+1 query when each snippet has to look up its own post.

Summary

This Snippet makes an AJAX request to the server and receives a JSON response containing all the Posts. The Posts are displayed in a select drop-down, and when one is chosen, inserted into the given region.

Templates

Templates are small pieces of reusable HTML that can be inserted into an effective_region with just one or two clicks.

Unlike snippets, there are no configurable options or anything. They're just pieces of raw HTML that can be dropped in and then immediately editted.

They take the form of three files, a model, a view, and an optional image file.

The Model

A model extends from Effective::Templates::Template

Any template models defined in app/models/effective/templates/*.rb will be automatically detected and usable.

The model here is very minimalistic. It's basically just to inform the full-screen editor that a View with the same name exists.

This model is defined at app/models/effective/templates/two_column.rb

module Effective
  module Templates
    class TwoColumn < Template
      def description
        'Two Column Area'
      end
    end
  end
end

The View

The view is defined at app/models/effective/templates/_two_column.html.haml

.row
  .col-sm-6
    %p Left Column
  .col-sm-6
    %p Right column

The Image

The image is used as an icon on the 'Content Templates' dialog screen.

It is optional, but will raise a silent 404 error if it doesn't exist.

The image should be 100x70 pixel .png file stored at /app/assets/images/effective/templates/two_column.png

License

MIT License. Copyright Code and Effect Inc.

Code and Effect is the product arm of AgileStyle, an Edmonton-based shop that specializes in building custom web applications with Ruby on Rails.

Testing

The test suite for this gem is unfortunately not yet complete.

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Bonus points for test coverage
  6. Create new Pull Request