twitter RSS twitter Twitter

Building Rails/ExtJS reusable components with Netzke, part 2

UPDATE (2009-09-07): This tutorial is outdated with the release of netzke-basepack v0.5.0. Please, refer to part 3 which is an up-to-date and extended version of this tutorial.
This tutorial will show you how to build a composite reusable Netzke component which consists of 2 grids (glued together by Ext’s border layout) that display data from Rails models connected by one-to-many relationship. We’ll build it in the context of the already existing netzke-demo project (this demo runs live on http://netzke-demo.writelesscode.com/), so that we can spare some time reusing its models and infrastructure. The resulting widget will contain a grid with bosses and a grid with clerks. Selecting a boss will update the clerks grid with the clerks working under that boss. Adding a clerk will automatically bind him to the selected boss. You’ll learn how to create a custom Netzke widget using GridPanel and BorderLayoutPanel widgets from netzke-basepack (a gem that contains several pre-built, extendible Netzke widgets). If you prefer to quickly see the results rather than to follow this tutorial step-by-step (an added value of which would be to see several functional iterations of our widget), you can go straight to its live demo. For those that want to be led through the process of creating a Netzke widget and get a deeper understanding of how Netzke works - read on, and don’t let the volume of the post scare you too much, as you can easily skip over some details and have a coffee between the sections :)

Getting netzke-demo up and running locally

First of all, clone netzke-demo project from GitHub if you haven’t done so yet:

git clone git://github.com/nomadcoder/netzke-demo.git && cd netzke-demo

Run the migrations:

rake db:migrate

Start the server (you may also consider to configure Passenger, which I would definitely recommend):

./script/server

Netzke-demo is up and running as you can see on http://localhost:3000/

Now go to the GridPanel demo/tutorial page (which is worth studying, too), and click the “regenerate test data” link at the bottom of the page to create some random bosses and clerks. Now you should see the grids showing some data.

Creating new widget, first steps

We’ll call our widget OneToManyGridSetPoc (“poc” stands for “proof of concept”: it will be a simplified version of OneToManyGridSet widget that may later appear in netzke-basepack). Go to netzke-demo/lib/netzke folder and create the file

one_to_many_grid_set_poc.rb

(actually you’ll see that the file is already there, as the same code is being naturally used for the live demo, but you can simply remove or rename it)

Define the class for our widget:

module Netzke
  class OneToManyGridSetPoc < BorderLayoutPanel
  end
end

We inherit from BorderLayoutPanel because it can serve as aggregator for other widgets, and that’s what we need.

It woudn’t work just yet, because we didn’t even specify the center-region, which at least is required for Ext’s BorderLayout to work. It’s very easy to do:

module Netzke
  class OneToManyGridSetPoc < BorderLayoutPanel
    def initial_aggregatees
      {
        :center => {
          :widget_class_name    => "GridPanel", 
          :data_class_name      => "Boss"
        }
      }
    end
  end
end

In Netzke::Base-based classes (which BorderLayoutPanel is), we specify aggregated widgets as a hash in the method “initial_aggregatees”. BorderLayoutPanel is following a naming convention for its aggregatees: if you name them :center, :east, :west, :north, or :south, it will put them into respective regions. And what’s inside that hash? It’s the configuration for the aggregated widget: we want a GridPanel configured to use “Boss” as underlying Rails model.

Now our widget can be seen working… if we declare it somewhere. We can do this in a Rails controller like this:

netzke :bosses_and_clerks, :widget_class_name => "OneToManyGridSetPoc"

… and then access it at the bosses_and_clerks_test action or integrate it into our views (see the details in netzke-basepack readme). However, let’s integrate our widget into BasicAppDemo - a Netzke::BasicApp-based widget that is already present in netzke-demo and represents our desktop-like web-app. Go to lib/netzke/basic_app_demo and add the following to the initial_late_aggregatees method (in short, late aggregatees differ from normal aggregatees by that they don’t get instantiated along with the aggregator, but can be instantiated on request later):

:bosses_and_clerks => {
  :widget_class_name    => "OneToManyGridSetPoc"
}

(yes, it’s also there already, as well as the code to add the access to our widget from the BasicAppDemo’s menu - we won’t talk about this in this tutorial, but you may refer to this blog post)

Now, go and see our widget in action.

(you may do so by navigating BasicAppDemo menu)

Adding clerks grid to the east region

It hasn’t been much coding so far, has it? It doesn’t take much more to add the clerks grid, and you may already guess how it can be done:

def initial_aggregatees
  {
    :center => {
      :widget_class_name    => "GridPanel", 
      :data_class_name      => "Boss"
    },

    :east => {
      :widget_class_name    => "GridPanel", 
      :data_class_name      => "Clerk", 
      :region_config        => {
        :width  => 300, 
        :split  => true
      }
    }
  }
end

The only new thing here is the :region_config option for the east widget. What you put in here, is compatible with Ext.layout.BorderLayout.Region (or Ext.layout.BorderLayout.SplitRegion). For example, you can enable split bar tips like this:

:region_config => {
  :width          => 300,
  :split          => true,
  :use_split_tips => true,
  :split_tip      => "Drag to resize"
}

Putting some in-place configuration for the grids

After we have seen the result, we immediately may want to tweak some things in the representation. For instance, let’s change the titles of the panels, add pagination to the grids, and specify which columns should be displayed in the grids:

def initial_aggregatees
  {
    :center => {
      :widget_class_name    => "GridPanel", 
      :data_class_name      => "Boss",
      :ext_config => {
        :title         => "Bosses",
        :rows_per_page => 20
      },
      :columns => %w{ id first_name last_name email salary }
    },

    :east => {
      :widget_class_name    => "GridPanel", 
      :data_class_name      => "Clerk", 
      :region_config        => {
        :width  => 300, 
        :split  => true
      },
      :ext_config => {
        :title         => "Clerks",
        :rows_per_page => 20
      },
      :columns => %w{ id first_name last_name name updated }
    }
  }
end

But when you reload the page, you’ll see, that while some things have changed, the columns have stayed the same. It’s because the first time you load a GridPanel, Netzke by default stores the column configuration in the database to later retrieve it from there rather then from the code.

Touching the topic of persistent column configuration

With other words, the columns you specify in the code are simply the defaults, for the case when those can’t be retrieved from the database. What can be done about it? There are 2 ways. First, you may set the persistent_layout option for the bosses grid to false:

:center => {
  :widget_class_name    => "GridPanel", 
  :data_class_name      => "Boss",
  :ext_config => {
    :title         => "Bosses",
    :rows_per_page => 20
  },
  :columns           => %w{ id first_name last_name email salary },
  :persistent_layout => false
},

This will disable retrieving the columns from the database, and always the defaults will be used. While it can be very helpful during the development, that may not be what we would like to see now, as we loose the ability to dynamically configure the grid columns. So, a better way would be to leave the code unchanged, and go straight to the bosses GridPanel column configuration panel, by clicking the “gear”-tool button on the right-top corner. Meet the configuration widget for GridPanel! It itself is a composite widget based on Netzke::AccordionLayoutPanel. We directly see a panel that shows the column configuration, and on the bottom there’s the “Restore defaults” button, which is what we can press now to populate the database with the new defaults from the code. Do it, then press Submit and see the effect (notice how the bosses widgets gets ajaxically reloaded without any need for reloading the page, or even the host widget!)

One more decorative change before we continue: let’s change the title of our widget itself. I would actually prefer to remove it completely along with the title bar. It can be done by specifying default (initial) configuration for our widget:

def initial_config
  super.merge({
    :ext_config => {
      :title => false
    }
  })
end

Adding interaction between the 2 grids

Now, that we have something working and good looking, notice that we haven’t written a single line of Javascript. However, my ample experience with integrating Ext and Rails makes me convinced that it’s something that simply cannot - and shouldn’t - be avoided. So, now it’s a good moment to add some Javascript, as we want some custom functionality for our widget: namely, we want it to react on selecting a row in bosses grid by reloading the other grid with the clerks that “belong_to” the selected boss.

First, we need to subscribe to the click event of the GridPanel:

def self.js_after_constructor
  super << <<-JS
    this.getCenterWidget().on("rowclick", this.onRowClick, this);
  JS
end

In this function we put the javascript code that gets appended after the super-class’ constructor is called in our widget’s constructor (for details, see the source code for netzke-core). It’s just the right place to subscribe to events. The getCenterWidget function is provided by BorderLayoutPanel, and in this case will return the GridPanel instance (in the Javascript domain, of course).

Now we need to implement the handler, onRowClick:

def self.js_extend_properties
  super.merge({
    :on_row_click => <<-JS.l
      function(grid, index, e){
        // get id of the selected boss
        var id = this.getCenterWidget().getStore().getAt(index).get("id");

        // load the east grid, appending to the request the id of the selected boss
        var contentGrid = this.getEastWidget();
        contentGrid.store.baseParams = {container_id:id};
        contentGrid.store.reload();
      }
    JS
  })
end

In the js_extend_properties class we specify all the public functions for our widget class in the Javascript domain. Function names get automatically translated by Netzke from Ruby style to Javascript style (this way on_row_click becomes onRowClick).

The idea behind wrapping Javascript functions into Ruby hash is the extensibility: when you inherit from another Netzke::Base-based class, you can easily replace its Javascript methods with new ones. And if you worry that mixing 2 languages in one source file wouldn’t read well, turn to the technique that I describe here - and I guarantee that your code will read very well in TextMate.

In the onRowClick function we detect the id of the selected boss and append it to the parameters that get sent with the Store load request. Then the clerks grid gets reloaded. With Firebug you can easily see how it works.

There’s a little caveat here related to the dynamic widget loading. If you press the gear button of the bosses grid, and then click Submit (thus commanding the bosses GridPanel to reload), you’ll discover that the clerks grid stopped reacting on the clicks at the bosses. This happens due to that we subscribed to the rowclick event of the GridPanel that is not there anymore. To fix that, slightly modify the way how we do that (we want to subscribe to rowclick every time that a new GridPanel is loaded into the central region):

def self.js_after_constructor
  super << <<-JS
    var setCentralWidgetEvents = function(){
      this.getCenterWidget().on("rowclick", this.onRowClick, this);
    };
    this.getCenterWidget().ownerCt.on("add", setCentralWidgetEvents, this);
    setCentralWidgetEvents.call(this);
  JS
end

While the clerks grid now gets reloaded on each click in the bosses grid, there’s still no filtering of clerks taking place. To fix that, let’s do some coding for the server side of our widget.

Server side filtering of clerks by specified boss ID

When a request comes for clerks data, how does the clerks instance of the GridPanel on the server know that the request is directed exactly to it, and not, for example, to bosses grid? This information is encoded in the name of the Rails’ action that handles the AJAX call. In our specific case the action looks like this:

basic_app_demo__bosses_and_clerks__east__get_data

Basically, it’s the “address” of the widget plus the name of its (interface) method that the widget should execute. It goes like this: when the Netzke-enabled controller receives this request, it instantiates basic_app_demo widget and sends it the following method along with the parameters:

bosses_and_clerks__east__get_data

In its turn, basic_app_demo widget (our ‘application’) instantiates bosses_and_clerks widget (which belongs in its late aggregatees), and sends it the rest:

east__get_data

I hope you get the idea, and there’s only one more thing to mention here: this dispatching is done by means of the wonderful Ruby’s method_missing method. It means, that when bosses_and_clerks instance “sees” that it has no method called “east__get_data”, it knows that it should instantiate its aggregatee named “east” and send to it “the rest” of the method. Straight from here comes the ability of a Netzke widget to interact with any communication going down its internal hierarchy.

I hope you see how we are going to use it in order to override the default behavior of clerks get_data method right from our widget. Here’s how:

def east__get_data(params)
  # extract Ext filters from params (we want them keep on working)
  filters = params[:filter] ||= {}

  # calculate the foreign key based on container class
  foreign_key = aggregatees[:center][:data_class_name].
                constantize.table_name.singularize + "_id"

  # add the foreign key filter to the filters
  filters.merge!({:our_fkey_filter => {
    :data  => {:value => params[:container_id], :type => "integer"},
    :field => foreign_key}
  })

  # call the original get_data method, but with updated filters
  method_missing(:east__get_data, params.merge(:filter => filters))
end

By this, we can process the call to the east__get_data method, where before it was done by method_missing. It will let us interact with the get_data request coming to clerks grid, and extend the filters specified in the parameters with the boss_id set to the id of the currently selected boss. At the end we call the method_missing ourselves, but pass it the tweaked parameters. Go back to the browser and see how this works.

Similar way we implement east__post_data method:

def east__post_data(params)
  container_id = params[:base_params] && 
    ActiveSupport::JSON.decode(params[:base_params])["container_id"]

  foreign_key = aggregatees[:center][:data_class_name].
                constantize.table_name.singularize + "_id"

  # for new records, merge foreign key in
  new_records = params[:created_records] && 
    ActiveSupport::JSON.decode(params.delete(:created_records))
  if new_records
    for r in new_records
      r.merge!(foreign_key => container_id)
    end
  end

  # call the original get_data method, but with corrected params
  method_missing(:east__post_data, params.merge(:created_records => new_records.to_json))
end

Now when we create a clerk, it will be assigned to the currently selected boss (if any).

Making it reusable, or generic

For now our widget is not generic, as it has the bosses and clerks model names hard-coded along with some other configuration for the inner widgets. It would be nice to be able to configure OneToManyGridSetPoc widget like this (the simplest way):

netzke :bosses_and_clerks, 
  :widget_class_name    => "OneToManyGridSetPoc",
  :container_class_name => "Boss",
  :element_class_name   => "Clerk"

All we need to do is to replace the hard-coded values with corresponding config hash values:

def initial_aggregatees
  {
    :center => {
      :widget_class_name    => "GridPanel", 
      :data_class_name      => config[:container_class_name],
      :ext_config => {
        :title         => config[:container_class_name].pluralize,
        :rows_per_page => 20
      }
    },

    :east => {
      :widget_class_name    => "GridPanel", 
      :data_class_name      => config[:element_class_name],
      :region_config        => {
        :width  => 300, 
        :split  => true
      },
      :ext_config => {
        :title         => config[:element_class_name].pluralize,
        :rows_per_page => 20
      }
    }
  }
end

And if we want even more configuration freedom? Say, something like this:

netzke :bosses_and_clerks, 
  :widget_class_name    => "OneToManyGridSetPoc",
  :container_config => {
    :data_class_name => "Boss",
    :ext_config      => {
      :rows_per_page => 10
    }
  },
  :element_config => {
    :data_class_name => "Clerk",
    :columns         => [:id, :name, :salary, :updated],
    :region_config   => {
      :width => 500
    }
  }

It’s also easy: just recursively merge the region widgets configuration with the corresponding config:

def initial_aggregatees
  {
    :center => {
      :widget_class_name    => "GridPanel", 
      :data_class_name      => config[:container_class_name],
      :ext_config => {
        :rows_per_page => 20
      }
    }.recursive_merge(config[:container_config] || {}),

    :east => {
      :widget_class_name    => "GridPanel", 
      :data_class_name      => config[:element_class_name],
      :region_config        => {
        :width  => 300, 
        :split  => true
      },
      :ext_config => {
        :rows_per_page => 20
      }
    }.recursive_merge(config[:element_config] || {})
  }
end

Wrapping it up

OneToManyGridSetPoc

Well, I hope you managed to follow it all all the way trough, because if you did, you now have a pretty thorough understanding of design decisions lying behind Netzke. The topics we touched here include:

  • Combining multiple pre-built widgets into a composite widget with the help of Netzke::BorderLayoutPanel
  • Setting up interaction between sub-widgets
  • Persistent dynamic configuration of a widget
  • Overriding default behavior of a sub-widget
  • Making a widget generic by introducing new configuration options

Don’t hesitate to comment both on technical and non-technical (e.g. readability) aspects of this post.