Perspectives

Adminable

Just get ActsAsAdminable now!

Admin Interfaces

I was recently working on a site for a client, which was just six simple pages and a CMS in the back-end to administrate content. Our CMS let you add and edit the FAQs on the FAQ page, add/remove downloads on the "Resources" page, etc. I took it to the client and his first question was, "How do I edit the footer?". After discussing, it turned out the client needed the ability to edit any piece of text on the entire site, which was very rich in detail. Every element on the page had a different layout or position, and so on. It's a very graphically complex site!

I could have extended the admin interface to support this. But it wouldn't have fit well with the style of the rest of the admin interface. What would I call that page, "Scattered Bits of Miscellaneous Text"? Something simpler, cleaner and more intuitive was needed.

Enter the Plugin

I searched for a plugin that would solve my problem but didn't find anything that exactly fit my situation, and I didn't look TOO hard because I was already excited at the prospect of writing my own. Long story short, I did, and it's called acts_as_adminable.

Using it couldn't be simpler. After you install it (or run script/runner vendor/plugins/acts_as_adminable/install.rb if you got it via git clone), you just go into your view, find the bit of text you want to replace, and turn it into a content_tag with a :key attribute — and that's it!

An Example

Let's say you have code like this:

<div class="header">
  <h1>At Company Inc., our mission is to meet your needs</h1>
</div>

To administrate this with ActsAsAdminable, first rewrite it like so:

<div class="header">
  <%= content_tag :h1, 'At Company Inc., our mission is to meet your needs', :key => 'mission_statement' %>
</div>

At this point your page should look exactly the same as it did a minute ago; ActsAsAdminable is designed to be easily removed or disabled. Now you just need to tell your controller to make this page adminable:

class PagesController < ApplicationController
  acts_as_adminable :if=>Proc.new{ session[:current_user].is_admin? }
  ...
end

The if parameter is a code snippet that you customize to match whatever you use for authentication. After all, you don't want just anybody to be able to edit your page!

Results

Now when you visit the page, you get a visual clue on mousing over any admin-enabled element. And a single click pops up an Ajax control for editing its contents! Any changes you make will be reflected instantly and for all subsequent visitors. There are only a few caveats:

  1. The :key argument must be a globally unique string.
  2. The content_tag should also have a unique id HTML attribute, although by default ActsAsAdminable will use the :key for that purpose if you didn't specify your own. So also make sure that you either specify a unique "id" HTML attribute, or that the ":key" does not collide with any other element's "id" attribute.
  3. The edit field uses markdown for formatting, so read up if you're not familiar with its syntax.

Now that you know what to do with it, head over to github and simplify your site!



Track

I was working on a large community-driven website recently, which had always had the requirement to synchronize its user database with another (3rd party) site that we didn't control. The most we could get out of the other site was for them to send us regular XML dumps of the changes (additions, removals, deletions). Predictably, we wrote that as a rake task and added it to cron.

@hourly   cd /apps/product/current && export RAILS_ENV=production && rake product:synchronize_database

It worked great, until the client threw in ONE additional little snag; not only should it check for updates every hour, but an admin should be able to log in to the site and request an immediate synchronization. Unfortunately, this task can take anywhere from 10 minutes to 10 hours, depending on the size of the XML file and the number of employees involved. Clearly not a job for a simple backtick or %x{}.

There are a number of different options for running background processes in Rails, but since I had pretty simple requirements (and needed quick results) the best choice for me was clearly Spawn.

You can install the plugin from rubyforge:

script/plugin install http://spawn.rubyforge.org/svn/spawn/

Then implementing the client's request was as simple as adding a button with a remote_function and a controller action with:

spawn(:nice => 7) do
        exec("cd #{RAILS_ROOT} && export RAILS_ENV=#{RAILS_ENV} && rake product:synchronize_database")
end

The :nice option (vital, in my case!) will make sure that your process doesn't monopolize the CPU (just like its shell counterpart). There are only a couple of other options; you can choose to fork or thread your process (fork is the default), and you can wait for it to finish. Neither was necessary in my case. Problem solved and, in typical Rails fashion, it only took a few lines of code!



Globalize

Just give me the globalize_with_google plugin now!

Anybody who's looked into localizing or internationalizing a Rails app has probably come across the "Globalize" plugin. It's a bit of an 800 lb. gorilla in the sense that it supports potentially hundreds of languages, automatic generation of validation messages, and even multiple pluralization cases based on the exact number of objects being counted. (There's a story about a people whose language only had three numbers- 1, 2, and 'many'. Globalize can handle that!) But as long as installation is as easy as "script/plugin install ...", who cares how much the gorilla weighs?

On a related note, Google recently released a series of AJAX APIs that are dead-simple to plug in to any web app, including one that does automatic translation. Can you guess where I'm going with this?

As soon as I saw Google's announcement that they were offering a free translation API, I started thinking about how to write a plugin that used it to initialize a Globalize database.

My solution, as sketched on the back of a napkin, had two pieces: The first would override Globalize's "String.translate" method. The other one would cache the translations so we still had a checklist of phrases for professional translators to go over, if necessary, and so we weren't dependent on the uptime of Google's servers for the functionality of our application. (Not that Google has lousy uptime; but if by chance they ever take down the service or start charging for translations, we can't have our translations just turn off).

The Actual Translation

This part was the easiest. We just modify Globalize's ".t" method to use Google's translation service:

module String
  def self.included(base)
    base.send :alias_method_chain, :translate, :google
    base.send :alias_method, :t, :translate
  end
  def translate_with_google(default = nil, arg = nil)
    local_base_language = defined?(BASE_LANGUAGE) ? BASE_LANGUAGE : 'en'

    #don't translate this if it's already written in the target language
    return self if Locale.language.iso_639_1 == local_base_language

    result = Locale.translate(self, '__translate__', arg)
    return result unless result ==  '__translate__' 

    return %Q{<span id="translation_#{self.object_id}">#{self}</span>
                <script type="text/javascript"> 
                ......}
    end
  end

The only flaw is that you can't use this on the labels of buttons or in javascript alert()s. Instead of showing a translated string, it would display a huge mess of javascript. I don't think there's a simple workaround for this, though, since the ".t" method can't know what context it is being called in. So in your views, make sure all of your translated buttons use something like

<input type="submit" value="<%= "Submit".translate_without_google %>" />

The Caching

This part nearly killed me. How do you cache the result of a google translation? It never goes through our server! The solution was a little convoluted, but very educational to a guy who had never written a plugin before.

First, we need to make the Javascript report the result of each translation back to our server. Fortunately, Google's "translate" function offers a callback once the translation is complete. So I just told it to execute the following:

new Ajax.Request('/cache_google_translation',{method: 'post', parameters: "phrase=#{self}&translation="+result.translation});

Next, we need a way for our Rails app to recognize the request for caching. But how can a plugin respond to a request like a controller does? It takes two steps. First you need to make a pseudo-controller that will do the caching:

class TricksController < ActionController::Base
  def cache_google_translation
    bound_vars = [params[:translation], params[:phrase]]
    ActiveRecord::Base.connection.execute("UPDATE globalize_translations SET built_in = 2, text = ? WHERE tr_key = ? AND language_id = #{Locale.language.id}".gsub('?'){ActiveRecord::Base.connection.quote(bound_vars.shift)})
    Locale.translator.put_in_cache(params[:phrase],Locale.language.iso_639_1,params[:translation])
    render :text => ''
  end
end

And then you need to extend Rails' route parser to attach a URL to your controller. (alias_method_chain to the rescue!)

module MapperExtensions
  def self.included(base)
    base.send :alias_method_chain, :initialize, :google_caching
  end
  def initialize_with_google_caching(set)
    #we have to add ours FIRST, otherwise the final line of the regular routes.rb is usually a catchall that would intercept OUR route
    set.add_route('/cache_google_translation',{:controller => 'google/tricks', :action => 'cache_google_translation'})
    initialize_without_google_caching(set)
  end
end

Finally, in your plugin's init file you just attach these classes into Rails:

ActionController::Routing::RouteSet::Mapper.send :include, Google::MapperExtensions
ActionView::Helpers::AssetTagHelper.send :include, Google::Javascript

And that's it! Well, not quite. Did you notice the reference to Locale.translator.put_in_cache? If you want to make sure that the auto-translations in your database are easily distinguishable so that you can have them manually translated later (machine translation isn't quite there yet!) then you have to add an extra step. It was easy enough to use a manual update statement instead of Locale.set_translation, which allowed me to set "built_in = 2" (that's how you recognize the auto-translations). But then the 800 lb. gorilla gets in the way. Globalize maintains a separate cache of translations in memory to avoid wear and tear on the database, but if you don't update the copy in memory as well, Globalize will never actually USE your cached version! It's a protected variable, so one more module extension:

module LocalizeCacheAccess
  def put_in_cache(key,language,translation)
    @cache["#{key}:#{language}:1"] = translation
  end
end

and then include it in your app with

Globalize::DbViewTranslator.send :include, Google::LocalizeCacheAccess

And that's it! Now you're REALLY done! To get all of this code in a simple Rails plugin, download globalize_with_google.zip and unpack it in #{RAILS_ROOT}/vendor/plugins/.




RSS Feed


CATEGORIES


ARCHIVES


BOOKMARKED


Add to Technorati Favorites