Objects rarely stand on their own. Commonly they refer to others via associations, resulting in the equally common requirement to choose an associated—referred to—object from a list of candidates. The code on this page helps with just that. It provides helper methods for inserting fields and popups for displaying and selecting associated objects. Eligible candidates are retrieved from the server behind the scenes with an “AJAX” request; only candidates matching given search criteria are returned. For the time being, only objects related through belongs_to are supported.
—MichaelSchuerig
lib/association_helper.rb:
# Author: Michael Schuerig, <a href="mailto:michael@schuerig.de">michael@schuerig.de</a>, 2005 # # Free for all uses. No warranty or anything. Comments welcome. # # What is it? # # The Association Helper methods create widgets for choosing # associated objects. Available choices are retrieved "AJAX-style" # behind the scenes. # # Currently, only +belongs_to+ references are supported. # Keep in mind, though, that +has_one+ references are just the other end # of +belongs_to+ references. # # Installation # # public/ # javascripts/ # association-helper.js # prototype.js – included in Rails distribution # # Then, in the head section of your page templates, possibly in a layout, # include the necessary files like this: # # <= javascript_include_tag ‘prototype’, ‘association-helper’ > # # Usage # # = In Views # # See the documentation of the individual methods. # # === In Controllers # # In controllers of views that use the helper methods, you have to make # these methods known like this. # # class SomethingController < ApplicationController # helper :association # # In the controllers queried for candidate associate objects, you need # to provide an action method that handles the respective requests. # You can implement them manually, of course. # For a large number of cases this will not be necessary, hopefully, # when you use RailsExtensions?::AjaxSupport.define_search_action. # # # == TODO # # All the other kinds of associations # Unit tests… #
module AssociationHelper # Creates a popup for choosing a “belongs_to”-associated object # displayed in a preexisting field. # The popup is triggered by clicking or focussing the display field. # # Options are: # # ontroller – controller to be used for AJAX requests; default: downcased name of the associated class. # ction – action to be used for AJAX requests; default: ‘ajax’. (FIXME: really?) # uery_param – request parameter name for the query string; default: ‘query’. # opup_class – class attribute for the main popup div; default: ‘popup’. # ptional – is the associated object optional (allowed to be null)? default: false. # def belongs_to_popup(object_name, association_name, options = {}) controller = options[:controller] || controller_for_search(object_name, association_name) action = options[:action] || ‘ajax’ query_param = options[:query_param] || ‘query’ popup_div_class = options[:popup_class] || ‘popup’ optional = options[:optional] || false fk_attribute = fk_attribute_for(object_name, association_name) target_field_id = object_name ’_’ association_name popup_id = target_field_id + ’_popup’ query_field_id = popup_id + ’_query’ result_list_id = popup_id + ’_list’ fkField = hidden_field(object_name, fk_attribute) observer = observe_field(query_field_id, :frequency => 0.5, :update => result_list_id, :url => { :controller => controller, :action => action }, :with => ”’#{query_param}=’ + escape(value)”)<
#{observer}
END endontroller – controller to be used for AJAX requests; default: downcased name of the associated class.
- Creates a text field with associated popup for choosing a
- “belongs_to”-associated object.
- The popup is triggered by clicking or focussing the display field. #
- A block, when given, is used to set the initial contents of
- the text field. The block receives the initially selected object,
- which may be nil. #
- Example: #
- <= belongs_to_field(‘project’, ‘manager’) { |it| it.name if it } > #
- This snippet creates a read-only text field. When that field
- gains the focus or is clicked, a query input field pops up
- on top of it. As soon as the user starts to type, matching
- objects are retrieved from the server and displayed in a
- list below the query field. #
- Options are: #
ction – action to be used for AJAX requests; default: ‘ajax’. (FIXME: really?) uery_param – request parameter name for the query string; default: ‘query’. opup_class – class attribute for the main popup div; default: ‘popup’. ptional – is the associated object optional (allowed to be null)? default: false. +:size – size of the created field; default: none. # def belongs_to_field(object_name, association_name, options = {}) raw_value = value_for(object_name, association_name) value = block_given? ? yield(raw_value) : raw_value.to_s field = text_field(object_name, association_name, :size => options[:size], :readonly => true, :name => ’’, :value => value)
field + belongs_to_popup(object_name, association_name, options)
endprivatedef object_for(object_name) self.instance_variable_get("@#{object_name}") enddef class_for(object_name) if object = object_for(object_name) object.class end enddef value_for(object_name, method_name) if object = object_for(object_name) object.send(method_name) end enddef options_for_association(object_name, association_name, &block) if klass = class_for(object_name) if meta = klass.reflect_on_association(association_name.to_sym) options = meta.options if block_given? yield options else options end end end end
- Heuristically uses the downcased name of the associated class
- as the controller name. def controller_for_search(object_name, association_name) options_for_association(object_name, association_name) do |options| Inflector.demodulize(options[:class_name] || association_name).downcase end end
def fk_attribute_for(object_name, association_name) options_for_association(object_name, association_name) do |options| options[:foreign_key] || association_name + '_id' end enddef association_for(object_name, association_name) if object = object_for(object_name) object.send(association_name) end endend
public/javascripts/association-helper.js:
// AUTHOR: Michael Schuerig, <a href="mailto:michael@schuerig.de">michael@schuerig.de</a>, 2005 // Free for all uses. No warranty or anything. Comments welcome. // // REQUIREMENTS: // * prototype.js - <a href="http://prototype.conio.net/">http://prototype.conio.net/</a> (included with Ruby on Rails) // var BelongsToPopup = Class.create(); BelongsToPopup.getTargetElement = function(evt) { var f = (window.event) ? window.event.srcElement : evt.target; while (f.nodeType != 1) f = f.parentNode; return f; }; BelongsToPopup.stopEvent = function(evt) { evt || (evt = window.event); if (evt.preventDefault) { evt.preventDefault(); evt.stopPropagation(); } else if (evt.cancelBubble) { evt.cancelBubble = true; evt.returnValue = false; } return false; }; BelongsToPopup.addEvent = function(el, evtname, func) { if (el.attachEvent) { el.attachEvent("on" + evtname, func); } else { el["on" + evtname] = func; } }; BelongsToPopup.removeEvent = function(el, evtname, func) { if (el.detachEvent) { el.detachEvent("on" + evtname, func); } else if (el.removeEventListener) { el.removeEventListener(evtname, func, true); } else { el["on" + evtname] = null; } }; BelongsToPopup.getAbsolutePos = function(el) { var SL = 0, ST = 0; var is_div = /^div$/i.test(el.tagName); if (is_div && el.scrollLeft) SL = el.scrollLeft; if (is_div && el.scrollTop) ST = el.scrollTop; var r = { x: el.offsetLeft - SL, y: el.offsetTop - ST }; if (el.offsetParent) { var tmp = this.getAbsolutePos(el.offsetParent); r.x += tmp.x; r.y += tmp.y; } return r; }; BelongsToPopup.prototype = { initialize: function(targetFieldId, optional, targetFkFieldId, popupId, queryId, listId) { this.targetTextField = $(targetFieldId); this.optional = optional || false; this.targetFkField = targetFkFieldId ? $(targetFkFieldId) : $(targetFieldId + '_id'); var realPopupId = popupId || targetFieldId + '_popup'; this.popup = $(realPopupId); this.queryField = queryId ? $(queryId) : $(realPopupId + '_query'); this.listDiv = listId ? $(listId) : $(realPopupId + '_list'); if (! (this.popup && this.targetTextField && this.targetFkField && this.queryField && this.listDiv)) { this.popup = null; // mark as invalid return; } this.showPopup = this._showPopup.bindAsEventListener(this); this.hidePopup = this._hidePopup.bindAsEventListener(this); this.handleClick = this._handleClick.bindAsEventListener(this); this.handleKeyDown = this._handleKeyDown.bindAsEventListener(this); this.handleMouseDown = this._handleMouseDown.bindAsEventListener(this); BelongsToPopup.addEvent(this.targetTextField, 'focus', this.showPopup); this._reparentToBody(); }, _reparentToBody: function() { if (!this.popup) return; var __reparent = function(popup) { popup.parentNode.removeChild(popup); document.body.appendChild(popup); } setTimeout(__reparent, 250, this.popup); }, _showPopup: function(evt) { if (!this.popup) return; if (this.popup.style.display == 'block') return; // For whatever reason, control gets here twice in khtml/kjs // Apparently visibility lags behind when setting the display property. // There's just a single thread -- or not? __showPopup = function() { if (this.popup.style.display == 'block') return; var pos = BelongsToPopup.getAbsolutePos(this.targetTextField); this.popup.style.top = pos.y + 'px'; this.popup.style.left = pos.x + 'px'; this.queryField.value = this.targetTextField.value BelongsToPopup.addEvent(this.listDiv, 'click', this.handleClick); BelongsToPopup.addEvent(document, 'keydown', this.handleKeyDown); BelongsToPopup.addEvent(document, 'mousedown', this.handleMouseDown); this.popup.style.display = 'block'; Field.activate(this.queryField); } setTimeout(__showPopup.bind(this), 10); BelongsToPopup.stopEvent(evt); }, _hidePopup: function() { this.popup.style.display = 'none'; this.queryField.value = ''; this.listDiv.innerHTML = ''; BelongsToPopup.removeEvent(this.listDiv, 'click', this.handleClick); BelongsToPopup.removeEvent(document, 'keydown', this.handleKeyDown); BelongsToPopup.removeEvent(document, 'mousedown', this.handleMouseDown); }, _handleClick: function(evt) { var target = BelongsToPopup.getTargetElement(evt); var choice = null; if (target.tagName == 'OPTION') { choice = target; } else if (target.tagName == 'SELECT') { var opts = target.options; for (var i = 0; i < opts.length; i++) { if (opts[i].selected) { choice = opts[i]; break; } } } if (choice) { this.targetTextField.value = choice.text; this.targetFkField.value = choice.value; } else if (this.optional) { this.targetTextField.value = ''; this.targetFkField.value = ''; } this.hidePopup(); BelongsToPopup.stopEvent(evt); }, _handleMouseDown: function(evt) { var el = BelongsToPopup.getTargetElement(evt); while (el != null && el != this.popup) { el = el.parentNode; } if (el == null) { this.hidePopup(); } }, _handleKeyDown: function(evt) { switch (evt.keyCode) { case 27: // esc this.hidePopup(); break; case 13: // enter this.handleClick(evt); break; default: return true; } return BelongsToPopup.stopEvent(evt); } }
lib/ajax_support.rb:
# Author: Michael Schuerig, <a href="mailto:michael@schuerig.de">michael@schuerig.de</a>, 2005 # # Free for all uses. No warranty or anything. Comments welcome. # module RailsExtension module AjaxSupport # :nodoc: def self.append_features(base) super base.extend(ClassMethods) end # == Installation # # Either # # require_dependency 'ajax_support' # # class SomethingController < ApplicationController # include RailsExtension::AjaxSupport # # # in your controller source files # or, at the end of +config+/+environment+.+rb+ put # # require 'ajax_support' # # ActionController::Base.class_eval do # include RailsExtension::AjaxSupport # end # # in order to have this facility available in all controllers. # module ClassMethods # Adds an action method to a controller. # The added method is suitable for handling AJAX requests # sending a query containing search values for attributes # and expecting a response consisting of a +select+ element # with #option#s matching the query. # # In the controller use this method like this: # # class SomethingElseController < ApplicationController # define_search_action :ajax, Person, '%lastname%, %firstname%', :limit => 10, :select_size => 10 # # The above code endows your controller with an action method that # handles 'ajax' requests by calling +find+ on the +Person+ +ActiveRecord+ # class. # The third parameter is the +format+ string used for parsing queries # and formatting result items. In the format string, placeholders for # attributes are marked as +%++attribute_name++%+ (yes, '+%+' at both ends.) # # The +query+ parameter string is split into items separated by non-word # characters (such as punctuation). The items, unless empty, are used # to search in the attributes with the corresponding positions in the # format string. # Found objects are sorted by the attributes given in the # format string with attributes present in the query given higher # importance. # Also, the format string is used to format the response text. # def define_search_action(name, klass, format, options = {}) columns = format.scan(/%(\w+?)%/iou).map { |c| c[0] } template = format.gsub('%', '%%').gsub(/%%\w+?%%/iou, '%s') define_method(name) do do_ajax_search(klass, columns, template, options) end end end private def do_ajax_search(klass, columns, template, options) query = @params[:query] if query.nil? or (query_values = query.split(/\s*\W\s*/iou)).empty? render_nothing return end select_size = options[:select_size] || '5' condition_items = [] values = [] order = [] columns.zip(query_values).each do |col, value| if value and !value.empty? condition_items << "lower(#{col}) like ?" values << '%' + value.downcase + '%' order << col end end conditions = [ condition_items.join(' and ') ] + values order += (columns - order) order = order.join(',') hits = klass.find(:all, options.merge(:conditions => conditions, :order => order)) response = "<select size=\"#{select_size}\">\n" hits.each do |hit| response << "<option value=\"#{hit.id}\">" response << template % columns.collect { |c| hit.send(c) } response << "</option>\n" end response << "</select>\n" render_text response end end end