From 5d5f0bad6e934d9d4fad7d0fa4643d04c13709a9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 21 Mar 2005 00:57:08 +0000 Subject: Added a JavascriptHelper and accompanying prototype.js library that opens the world of Ajax to Action Pack with a large array of options for dynamically interacting with an application without reloading the page #884 [Sam Stephenson/David] git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@955 5ecf4fe2-1ee6-0310-87b1-e25e094e27de --- .../lib/action_view/helpers/javascript_helper.rb | 329 +++++++-------------- .../action_view/helpers/javascripts/prototype.js | 326 ++++++++++++++++++++ 2 files changed, 440 insertions(+), 215 deletions(-) create mode 100644 actionpack/lib/action_view/helpers/javascripts/prototype.js (limited to 'actionpack/lib') diff --git a/actionpack/lib/action_view/helpers/javascript_helper.rb b/actionpack/lib/action_view/helpers/javascript_helper.rb index 814314a4be..1570a363a7 100644 --- a/actionpack/lib/action_view/helpers/javascript_helper.rb +++ b/actionpack/lib/action_view/helpers/javascript_helper.rb @@ -2,9 +2,18 @@ require File.dirname(__FILE__) + '/tag_helper' module ActionView module Helpers - # You must call <%= define_javascript_functions %> in your application before using these helpers. + # You must call <%= define_javascript_functions %> in your application, + # or copy the included Javascript libraries into your application's + # public/javascripts/ directory, before using these helpers. module JavascriptHelper - # Returns a link that'll trigger a javascript +function+ using the onclick handler and return false after the fact. + + unless const_defined? :CALLBACKS + CALLBACKS = [:uninitialized, :loading, :loaded, :interactive, :complete] + JAVASCRIPT_PATH = File.join(File.dirname(__FILE__), 'javascripts') + end + + # Returns a link that'll trigger a javascript +function+ using the + # onclick handler and return false after the fact. # # Examples: # link_to_function "Greeting", "alert('Hello world!')" @@ -16,17 +25,20 @@ module ActionView ) end - # Returns a link to a remote action defined by options[:url] (using the url_for format) that's called in the background - # using XMLHttpRequest. The result of that request can then be inserted into a DOM object who's id can be specified - # with options[:update]. Usually, the result would be a partial prepared by the controller with either render_partial - # or render_partial_collection. + # Returns a link to a remote action defined by options[:url] + # (using the url_for format) that's called in the background using + # XMLHttpRequest. The result of that request can then be inserted into a + # DOM object whose id can be specified with options[:update]. + # Usually, the result would be a partial prepared by the controller with + # either render_partial or render_partial_collection. # # Examples: # link_to_remote "Delete this post", :update => "posts", :url => { :action => "destroy", :id => post.id } # link_to_remote(image_tag("refresh"), :update => "emails", :url => { :action => "list_emails" }) # - # By default, these remote requests are processed asynchronous during which various callbacks can be triggered (for progress indicators - # and the likes). + # By default, these remote requests are processed asynchronous during + # which various callbacks can be triggered (for progress indicators and + # the likes). # # Example: # link_to_remote word, @@ -35,14 +47,20 @@ module ActionView # # The complete list of callbacks that may be specified are: # - # * :uninitialized -- EXPLAIN ME! - # * :loading -- EXPLAIN ME! - # * :loaded -- EXPLAIN ME! - # * :interactive -- EXPLAIN ME! - # * :complete -- EXPLAIN ME! + # :uninitialized:: Called before the remote document is + # initialized with data. + # :loading:: Called when the remote document is being + # loaded with data by the browser. + # :loaded:: Called when the browser has finished loading + # the remote document. + # :interactive:: Called when the user can interact with the + # remote document, even though it has not + # finished loading. + # :complete:: Called when the XMLHttpRequest is complete. # - # If you for some reason or another needs synchronous processing (that'll block the browser while the request is happening), you - # can specify options[:type] = :sync. + # If you for some reason or another need synchronous processing (that'll + # block the browser while the request is happening), you can specify + # options[:type] = :synchronous. def link_to_remote(name, options = {}, html_options = {}) link_to_function(name, remote_function(options), html_options) end @@ -56,20 +74,16 @@ module ActionView tag("form", options[:html], true) end - def remote_function(options) - callbacks = build_callbacks(options) + def remote_function(options) #:nodoc: for now + javascript_options = options_for_ajax(options) function = options[:update] ? - "update_with_response('#{options[:update]}', " : - "xml_request(" + "new Ajax.Updater('#{options[:update]}', " : + "new Ajax.Request(" function << "'#{url_for(options[:url])}'" - function << ', Form.serialize(this)' if options[:form] - function << ', null' if !options[:form] && callbacks - function << ", true" if callbacks || options[:type] != :sync - function << ", #{callbacks}" if callbacks - function << ')' - + function << ", #{javascript_options})" + function = "#{options[:before]}; #{function}" if options[:before] function = "#{function}; #{options[:after]}" if options[:after] function = "if (#{options[:condition]}) { #{function}; }" if options[:condition] @@ -77,205 +91,90 @@ module ActionView return function end + # Includes the Action Pack Javascript library inside a single ' + end - function xml_request() { - var url = arguments[0]; - var parameters = arguments[1]; - var async = arguments[2]; - var callbacks = arguments[3]; - var type = parameters ? "POST" : "GET"; + # Observes the field with the DOM ID specified by +field_id+ and makes + # an Ajax when its contents have changed. + # + # Required +options+ are: + # :frequency:: The frequency (in seconds) at which changes to + # this field will be detected. + # :url:: +url_for+-style options for the action to call + # when the field has changed. + # + # Additional options are: + # :update:: Specifies the DOM ID of the element whose + # innerHTML should be updated with the + # XMLHttpRequest response text. + # :with:: A Javascript expression specifying the + # parameters for the XMLHttpRequest. This defaults + # to 'value', which in the evaluated context + # refers to the new field value. + # + # Additionally, you may specify any of the options documented in + # +link_to_remote. + def observe_field(field_id, options = {}) + build_observer('Form.Element.Observer', name, options) + end - req = xml_http_request_object(); - req.open(type, url, async); - - if (async) { - invoke_callback = function(which) { - if(callbacks && callbacks[which]) callbacks[which](req) - } - - req.onreadystatechange = function() { - switch(req.readyState) { - case 0: invoke_callback('uninitialized'); break - case 1: invoke_callback('loading'); break - case 2: invoke_callback('loaded'); break - case 3: invoke_callback('interactive'); break - case 4: invoke_callback('complete'); break - } - } - } + # Like +observe_field+, but operates on an entire form identified by the + # DOM ID +form_id+. +options+ are the same as +observe_field+, except + # the default value of the :with option evaluates to the + # serialized (request string) value of the form. + def observe_form(form_id, options = {}) + build_observer('Form.Observer', name, options) + end - req.send(parameters ? parameters + "&_=" : parameters); + private + def escape_javascript(javascript) + (javascript || '').gsub('"', '\"') + end - if(!async) return req.responseText; - } - - function xml_http_request_object() { - var req = false; - try { - req = new ActiveXObject("Msxml2.XMLHTTP"); - } catch (e) { - try { - req = new ActiveXObject("Microsoft.XMLHTTP"); - } catch (E) { - req = false; - } - } - - if (!req && typeof XMLHttpRequest!='undefined') { - req = new XMLHttpRequest(); - } - - return req; - } - - - /* Common methods ------------------------------ */ - - function toggle_display_by_id(id) { - o(id).style.display = (o(id).style.display == "none") ? "" : "none"; - } - - function toggle_display() { - for(i = 0; i < arguments.length; i++) { - o(arguments[i]).style.display = (o(arguments[i]).style.display == "none") ? "" : "none"; - } - } - - function o(id) { - return document.getElementById(id); - } - - function get_elements_by_class(tag_name, class_name) { - var all = document.all ? document.all : document.getElementsByTagName(tag_name); - var elements = new Array(); - - for (var e = 0; e < all.length; e++) - if (all[e].className == class_name) - elements[elements.length] = all[e]; - - return elements; - } - - - /* Serialize a form by Sam Stephenson ------------------------------ */ - - Form = { - Serializers: { - input: function(element) { - switch (element.type.toLowerCase()) { - case 'hidden': - case 'text': - return Form.Serializers.textarea(element); - case 'checkbox': - case 'radio': - return Form.Serializers.inputSelector(element); - } - }, - - inputSelector: function(element) { - if (element.checked) - return [element.name, element.value]; - }, - - textarea: function(element) { - return [element.name, element.value]; - }, - - select: function(element) { - var index = element.selectedIndex; - return [element.name, element.options[index].value]; - } - }, - - serialize: function(form) { - var elements = Form.getFormElements(form); - var queryComponents = new Array(); - - for (var i = 0; i < elements.length; i++) { - var element = elements[i]; - var method = element.tagName.toLowerCase(); + def options_for_ajax(options) + js_options = build_callbacks(options) + + js_options['asynchronous'] = options[:type] != :synchronous + js_options['method'] = options[:method] if options[:method] + + if options[:form] + js_options['parameters'] = 'Form.serialize(this)' + elsif options[:with] + js_options['parameters'] = options[:with] + end + + '{' + js_options.map {|k, v| "#{k}:#{v}"}.join(', ') + '}' + end - var parameter = Form.Serializers[method](element); - if (parameter) { - var queryComponent = encodeURIComponent(parameter[0]) + '=' + - encodeURIComponent(parameter[1]); - queryComponents.push(queryComponent); - } - } - - return queryComponents.join('&'); - }, - - getFormElements: function(form) { - var elements = new Array(); - for (tagName in Form.Serializers) { - var tagElements = form.getElementsByTagName(tagName); - for (var j = 0; j < tagElements.length; j++) - elements.push(tagElements[j]); - } - return elements; - } - } - - EOF + def build_observer(klass, name, options = {}) + options[:with] ||= 'value' if options[:update] + callback = remote_function(options) + javascript = '" end - - private - def build_callbacks(options) - callbacks = nil - %w{uninitialized loading loaded interactive complete}.each do |cb| - cb = cb.to_sym - if options[cb] - callbacks ? callbacks << "," : callbacks = "{" - callbacks << - "#{cb}:function(request){#{options[cb].gsub(/"/){'\"'}}}" - end - end - callbacks << "}" if callbacks + + def build_callbacks(options) + CALLBACKS.inject({}) do |callbacks, callback| + name = 'on' + callback.to_s.capitalize + code = escape_javascript(options[callback]) + callbacks[name] = "function(request){#{code}}" if callbacks[name] callbacks end + end end end end diff --git a/actionpack/lib/action_view/helpers/javascripts/prototype.js b/actionpack/lib/action_view/helpers/javascripts/prototype.js new file mode 100644 index 0000000000..f559317b5e --- /dev/null +++ b/actionpack/lib/action_view/helpers/javascripts/prototype.js @@ -0,0 +1,326 @@ +/* Prototype: an object-oriented Javascript library, version 1.0.0 + * (c) 2005 Sam Stephenson + * + * Prototype is freely distributable under the terms of an MIT-style license. + * For details, see http://prototype.conio.net/ + */ + + +Prototype = { + Version = '1.0.0' +} + +Class = { + create: function() { + return function() { + this.initialize.apply(this, arguments); + } + } +} + +Abstract = new Object(); + +Object.prototype.extend = function(object) { + for (property in object) { + this[property] = object[property]; + } + return this; +} + +Function.prototype.bind = function(object) { + var method = this; + return function() { + method.apply(object, arguments); + } +} + +Function.prototype.bindAsEventListener = function(object) { + var method = this; + return function(event) { + method.call(object, event || window.event); + } +} + +Try = { + these: function() { + var returnValue; + + for (var i = 0; i < arguments.length; i++) { + var lambda = arguments[i]; + try { + returnValue = lambda(); + break; + } catch (e) {} + } + + return returnValue; + } +} + +Toggle = { + visibility: function() { + for (i = 0; i < arguments.length; i++) { + var element = $(arguments[i]); + element.style.display = + (element.style.display == 'none' ? '' : 'none'); + } + } +} + +/*--------------------------------------------------------------------------*/ + +function $() { + var elements = new Array(); + + for (i = 0; i < arguments.length; i++) { + var element = arguments[i]; + if (typeof element == 'string') + element = document.getElementById(element); + + if (arguments.length == 1) + return element; + + elements.push(element); + } + + return elements; +} + +function getElementsByClassName(className, element) { + var children = (element || document).getElementsByTagName('*'); + var elements = new Array(); + + for (var i = 0; i < children.length; i++) { + var child = children[i]; + var classNames = child.className.split(' '); + for (var j = 0; j < classNames.length; j++) { + if (classNames[j] == className) { + elements.push(child); + break; + } + } + } + + return elements; +} + +/*--------------------------------------------------------------------------*/ + +Ajax = { + getTransport: function() { + return Try.these( + function() {return new ActiveXObject('Msxml2.XMLHTTP')}, + function() {return new ActiveXObject('Microsoft.XMLHTTP')}, + function() {return new XMLHttpRequest()} + ) || false; + }, + + emptyFunction: function() {} +} + +Ajax.Base = function() {}; +Ajax.Base.prototype = { + setOptions: function(options) { + this.options = { + method: 'post', + asynchronous: true, + parameters: '' + }.extend(options || {}); + } +} + +Ajax.Request = Class.create(); +Ajax.Request.Events = + ['Uninitialized', 'Loading', 'Loaded', 'Interactive', 'Complete']; + +Ajax.Request.prototype = (new Ajax.Base()).extend({ + initialize: function(url, options) { + this.transport = Ajax.getTransport(); + this.setOptions(options); + + try { + if (this.options.method == 'get') + url += '?' + this.options.parameters + '&_='; + + this.transport.open(this.options.method, url, true); + + if (this.options.asynchronous) + this.transport.onreadystatechange = this.onStateChange.bind(this); + + if (this.options.method == 'post') { + this.transport.setRequestHeader('Connection', 'close'); + this.transport.setRequestHeader('Content-type', + 'application/x-www-form-urlencoded'); + } + + this.transport.send(this.options.parameters); + + } catch (e) { + } + }, + + onStateChange: function() { + var event = Ajax.Request.Events[this.transport.readyState]; + (this.options['on' + event] || Ajax.emptyFunction)(this.transport); + } +}); + +Ajax.Updater = Class.create(); +Ajax.Updater.prototype = (new Ajax.Base()).extend({ + initialize: function(container, url, options) { + this.container = $(container); + this.setOptions(options); + + if (this.options.asynchronous) { + this.onComplete = this.options.onComplete; + this.options.onComplete = this.updateContent.bind(this); + } + + this.request = new Ajax.Request(url, this.options); + + if (!this.options.asynchronous) + this.updateContent(); + }, + + updateContent: function() { + this.container.innerHTML = this.request.transport.responseText; + if (this.onComplete) this.onComplete(this.request); + } +}); + +Field = { + clear: function() { + for (i = 0; i < arguments.length; i++) + $(arguments[i]).value = ''; + }, + + focus: function(element) { + $(element).focus(); + }, + + present: function() { + for (i = 0; i < arguments.length; i++) + if ($(arguments[i]).value == '') return false; + return true; + } +} + +/*--------------------------------------------------------------------------*/ + +Form = { + serialize: function(form) { + var elements = Form.getElements($(form)); + var queryComponents = new Array(); + + for (var i = 0; i < elements.length; i++) { + var queryComponent = Form.Element.serialize(elements[i]); + if (queryComponent) + queryComponents.push(queryComponent); + } + + return queryComponents.join('&'); + }, + + getElements: function(form) { + form = $(form); + var elements = new Array(); + + for (tagName in Form.Element.Serializers) { + var tagElements = form.getElementsByTagName(tagName); + for (var j = 0; j < tagElements.length; j++) + elements.push(tagElements[j]); + } + return elements; + } +} + +Form.Element = { + serialize: function(element) { + element = $(element); + var method = element.tagName.toLowerCase(); + var parameter = Form.Element.Serializers[method](element); + + if (parameter) + return encodeURIComponent(parameter[0]) + '=' + + encodeURIComponent(parameter[1]); + }, + + getValue: function(element) { + element = $(element); + var method = element.tagName.toLowerCase(); + var parameter = Form.Element.Serializers[method](element); + + if (parameter) + return parameter[1]; + } +} + +Form.Element.Serializers = { + input: function(element) { + switch (element.type.toLowerCase()) { + case 'hidden': + case 'text': + return Form.Element.Serializers.textarea(element); + case 'checkbox': + case 'radio': + return Form.Element.Serializers.inputSelector(element); + } + }, + + inputSelector: function(element) { + if (element.checked) + return [element.name, element.value]; + }, + + textarea: function(element) { + return [element.name, element.value]; + }, + + select: function(element) { + var index = element.selectedIndex; + return [element.name, element.options[index].value]; + } +} + +/*--------------------------------------------------------------------------*/ + +Abstract.TimedObserver = function() {} +Abstract.TimedObserver.prototype = { + initialize: function(element, frequency, callback) { + this.frequency = frequency; + this.element = $(element); + this.callback = callback; + + this.lastValue = this.getValue(); + this.registerCallback(); + }, + + registerCallback: function() { + setTimeout(this.onTimerEvent.bind(this), this.frequency * 1000); + }, + + onTimerEvent: function() { + var value = this.getValue(); + if (this.lastValue != value) { + this.callback(this.element, value); + this.lastValue = value; + } + + this.registerCallback(); + } +} + +Form.Element.Observer = Class.create(); +Form.Element.Observer.prototype = (new Abstract.TimedObserver()).extend({ + getValue: function() { + return Form.Element.getValue(this.element); + } +}); + +Form.Observer = Class.create(); +Form.Observer.prototype = (new Abstract.TimedObserver()).extend({ + getValue: function() { + return Form.serialize(this.element); + } +}); + -- cgit v1.2.3