| 1 | """ |
|---|
| 2 | Prototype Helpers |
|---|
| 3 | |
|---|
| 4 | Provides a set of helpers for calling Prototype JavaScript functions, |
|---|
| 5 | including functionality to call remote methods using |
|---|
| 6 | `Ajax <http://www.adaptivepath.com/publications/essays/archives/000385.php>`_. |
|---|
| 7 | This means that you can call actions in your controllers without |
|---|
| 8 | reloading the page, but still update certain parts of it using |
|---|
| 9 | injections into the DOM. The common use case is having a form that adds |
|---|
| 10 | a new element to a list without reloading the page. |
|---|
| 11 | |
|---|
| 12 | To be able to use these helpers, you must include the Prototype |
|---|
| 13 | JavaScript framework in your pages. |
|---|
| 14 | |
|---|
| 15 | See `link_to_remote <module-railshelpers.helpers.javascript.html#link_to_function>`_ |
|---|
| 16 | for documentation of options common to all Ajax helpers. |
|---|
| 17 | |
|---|
| 18 | See also `Scriptaculous <module-railshelpers.helpers.scriptaculous.html>`_ for |
|---|
| 19 | helpers which work with the Scriptaculous controls and visual effects library. |
|---|
| 20 | """ |
|---|
| 21 | # Last synced with Rails copy at Revision 4235 on Aug 19th, 2006. |
|---|
| 22 | |
|---|
| 23 | import sys |
|---|
| 24 | if sys.version < '2.4': |
|---|
| 25 | from sets import ImmutableSet as frozenset |
|---|
| 26 | |
|---|
| 27 | from javascript import * |
|---|
| 28 | from javascript import options_for_javascript |
|---|
| 29 | from form_tag import form |
|---|
| 30 | from tags import tag, camelize |
|---|
| 31 | from urls import get_url |
|---|
| 32 | |
|---|
| 33 | CALLBACKS = frozenset(['uninitialized','loading','loaded', |
|---|
| 34 | 'interactive','complete','failure','success'] + [str(x) for x in range(100,599)]) |
|---|
| 35 | AJAX_OPTIONS = frozenset(['before','after','condition','url', |
|---|
| 36 | 'asynchronous','method','insertion','position', |
|---|
| 37 | 'form','with','update','script'] + list(CALLBACKS)) |
|---|
| 38 | |
|---|
| 39 | def link_to_remote(name, options={}, **html_options): |
|---|
| 40 | """ |
|---|
| 41 | Links to a remote function |
|---|
| 42 | |
|---|
| 43 | Returns a link to a remote action defined ``dict(url=url())`` |
|---|
| 44 | (using the url() format) that's called in the background using |
|---|
| 45 | XMLHttpRequest. The result of that request can then be inserted into a |
|---|
| 46 | DOM object whose id can be specified with the ``update`` keyword. |
|---|
| 47 | Usually, the result would be a partial prepared by the controller with |
|---|
| 48 | either render_partial or render_partial_collection. |
|---|
| 49 | |
|---|
| 50 | Any keywords given after the second dict argument are considered html options |
|---|
| 51 | and assigned as html attributes/values for the element. |
|---|
| 52 | |
|---|
| 53 | Example:: |
|---|
| 54 | |
|---|
| 55 | link_to_remote("Delete this post", dict(update="posts", |
|---|
| 56 | url=url(action="destroy", id=post.id))) |
|---|
| 57 | |
|---|
| 58 | You can also specify a dict for ``update`` to allow for easy redirection |
|---|
| 59 | of output to an other DOM element if a server-side error occurs: |
|---|
| 60 | |
|---|
| 61 | Example:: |
|---|
| 62 | |
|---|
| 63 | link_to_remote("Delete this post", |
|---|
| 64 | dict(url=url(action="destroy", id=post.id), |
|---|
| 65 | update=dict(success="posts", failure="error"))) |
|---|
| 66 | |
|---|
| 67 | Optionally, you can use the ``position`` parameter to influence how the |
|---|
| 68 | target DOM element is updated. It must be one of 'before', 'top', 'bottom', |
|---|
| 69 | or 'after'. |
|---|
| 70 | |
|---|
| 71 | By default, these remote requests are processed asynchronous during |
|---|
| 72 | which various JavaScript callbacks can be triggered (for progress |
|---|
| 73 | indicators and the likes). All callbacks get access to the |
|---|
| 74 | ``request`` object, which holds the underlying XMLHttpRequest. |
|---|
| 75 | |
|---|
| 76 | To access the server response, use ``request.responseText``, to |
|---|
| 77 | find out the HTTP status, use ``request.status``. |
|---|
| 78 | |
|---|
| 79 | Example:: |
|---|
| 80 | |
|---|
| 81 | link_to_remote(word, |
|---|
| 82 | dict(url=url(action="undo", n=word_counter), |
|---|
| 83 | complete="undoRequestCompleted(request)")) |
|---|
| 84 | |
|---|
| 85 | The callbacks that may be specified are (in order): |
|---|
| 86 | |
|---|
| 87 | ``loading`` |
|---|
| 88 | Called when the remote document is being loaded with data by the browser. |
|---|
| 89 | ``loaded`` |
|---|
| 90 | Called when the browser has finished loading the remote document. |
|---|
| 91 | ``interactive`` |
|---|
| 92 | Called when the user can interact with the remote document, even |
|---|
| 93 | though it has not finished loading. |
|---|
| 94 | ``success`` |
|---|
| 95 | Called when the XMLHttpRequest is completed, and the HTTP status |
|---|
| 96 | code is in the 2XX range. |
|---|
| 97 | ``failure`` |
|---|
| 98 | Called when the XMLHttpRequest is completed, and the HTTP status code is |
|---|
| 99 | not in the 2XX range. |
|---|
| 100 | ``complete`` |
|---|
| 101 | Called when the XMLHttpRequest is complete (fires after success/failure |
|---|
| 102 | if they are present). |
|---|
| 103 | |
|---|
| 104 | You can further refine ``success`` and ``failure`` by |
|---|
| 105 | adding additional callbacks for specific status codes. |
|---|
| 106 | |
|---|
| 107 | Example:: |
|---|
| 108 | |
|---|
| 109 | link_to_remote(word, |
|---|
| 110 | dict(url=url(action="action"), |
|---|
| 111 | 404="alert('Not found...? Wrong URL...?')", |
|---|
| 112 | failure="alert('HTTP Error ' + request.status + '!')")) |
|---|
| 113 | |
|---|
| 114 | A status code callback overrides the success/failure handlers if |
|---|
| 115 | present. |
|---|
| 116 | |
|---|
| 117 | If you for some reason or another need synchronous processing (that'll |
|---|
| 118 | block the browser while the request is happening), you can specify |
|---|
| 119 | ``type='synchronous'``. |
|---|
| 120 | |
|---|
| 121 | You can customize further browser side call logic by passing in |
|---|
| 122 | JavaScript code snippets via some optional parameters. In their order |
|---|
| 123 | of use these are: |
|---|
| 124 | |
|---|
| 125 | ``confirm`` |
|---|
| 126 | Adds confirmation dialog. |
|---|
| 127 | ``condition`` |
|---|
| 128 | Perform remote request conditionally by this expression. Use this to |
|---|
| 129 | describe browser-side conditions when request should not be initiated. |
|---|
| 130 | ``before`` |
|---|
| 131 | Called before request is initiated. |
|---|
| 132 | ``after`` |
|---|
| 133 | Called immediately after request was initiated and before ``loading``. |
|---|
| 134 | ``submit`` |
|---|
| 135 | Specifies the DOM element ID that's used as the parent of the form |
|---|
| 136 | elements. By default this is the current form, but it could just as |
|---|
| 137 | well be the ID of a table row or any other DOM element. |
|---|
| 138 | """ |
|---|
| 139 | return link_to_function(name, remote_function(**options), **html_options) |
|---|
| 140 | |
|---|
| 141 | def periodically_call_remote(**options): |
|---|
| 142 | """ |
|---|
| 143 | Periodically calls a remote function |
|---|
| 144 | |
|---|
| 145 | Periodically calls the specified ``url`` every ``frequency`` seconds |
|---|
| 146 | (default is 10). Usually used to update a specified div ``update`` |
|---|
| 147 | with the results of the remote call. The options for specifying the |
|---|
| 148 | target with ``url`` and defining callbacks is the same as `link_to_remote <#link_to_remote>`_. |
|---|
| 149 | """ |
|---|
| 150 | frequency = options.get('frequency') or 10 |
|---|
| 151 | code = "new PeriodicalExecuter(function() {%s}, %s)" % (remote_function(**options), frequency) |
|---|
| 152 | return javascript_tag(code) |
|---|
| 153 | |
|---|
| 154 | def form_remote_tag(**options): |
|---|
| 155 | """ |
|---|
| 156 | Create a form tag using a remote function to submit the request |
|---|
| 157 | |
|---|
| 158 | Returns a form tag that will submit using XMLHttpRequest in the |
|---|
| 159 | background instead of the regular reloading POST arrangement. Even |
|---|
| 160 | though it's using JavaScript to serialize the form elements, the form |
|---|
| 161 | submission will work just like a regular submission as viewed by the |
|---|
| 162 | receiving side. The options for specifying the target with ``url`` |
|---|
| 163 | and defining callbacks is the same as `link_to_remote <#link_to_remote>`_. |
|---|
| 164 | |
|---|
| 165 | A "fall-through" target for browsers that doesn't do JavaScript can be |
|---|
| 166 | specified with the ``action/method`` options on ``html``. |
|---|
| 167 | |
|---|
| 168 | Example:: |
|---|
| 169 | |
|---|
| 170 | form_remote_tag(html=dict(action=url( |
|---|
| 171 | controller="some", action="place"))) |
|---|
| 172 | |
|---|
| 173 | By default the fall-through action is the same as the one specified in |
|---|
| 174 | the ``url`` (and the default method is ``post``). |
|---|
| 175 | """ |
|---|
| 176 | options['form'] = True |
|---|
| 177 | if 'html' not in options: options['html'] = {} |
|---|
| 178 | options['html']['onsubmit'] = "%s; return false;" % remote_function(**options) |
|---|
| 179 | action = options['html'].get('action', get_url(options['url'])) |
|---|
| 180 | options['html']['method'] = options['html'].get('method', 'post') |
|---|
| 181 | |
|---|
| 182 | return form(action, **options['html']) |
|---|
| 183 | |
|---|
| 184 | def submit_to_remote(name, value, **options): |
|---|
| 185 | """ |
|---|
| 186 | A submit button that submits via an XMLHttpRequest call |
|---|
| 187 | |
|---|
| 188 | Returns a button input tag that will submit form using XMLHttpRequest |
|---|
| 189 | in the background instead of regular reloading POST arrangement. |
|---|
| 190 | Keyword args are the same as in ``form_remote_tag``. |
|---|
| 191 | """ |
|---|
| 192 | options['with'] = options.get('form') or 'Form.serialize(this.form)' |
|---|
| 193 | |
|---|
| 194 | options['html'] = options.get('html') or {} |
|---|
| 195 | options['html']['type'] = 'button' |
|---|
| 196 | options['html']['onclick'] = "%s; return false;" % remote_function(**options) |
|---|
| 197 | options['html']['name_'] = name |
|---|
| 198 | options['html']['value'] = str(value) |
|---|
| 199 | |
|---|
| 200 | return tag("input", open=False, **options['html']) |
|---|
| 201 | |
|---|
| 202 | def update_element_function(element_id, **options): |
|---|
| 203 | """ |
|---|
| 204 | Returns a JavaScript function (or expression) that'll update a DOM |
|---|
| 205 | element. |
|---|
| 206 | |
|---|
| 207 | ``content`` |
|---|
| 208 | The content to use for updating. |
|---|
| 209 | ``action`` |
|---|
| 210 | Valid options are 'update' (assumed by default), 'empty', 'remove' |
|---|
| 211 | ``position`` |
|---|
| 212 | If the ``action`` is 'update', you can optionally specify one of the |
|---|
| 213 | following positions: 'before', 'top', 'bottom', 'after'. |
|---|
| 214 | |
|---|
| 215 | Example:: |
|---|
| 216 | |
|---|
| 217 | <% javascript_tag(update_element_function("products", |
|---|
| 218 | position='bottom', content="<p>New product!</p>")) %> |
|---|
| 219 | |
|---|
| 220 | This method can also be used in combination with remote method call |
|---|
| 221 | where the result is evaluated afterwards to cause multiple updates on |
|---|
| 222 | a page. Example:: |
|---|
| 223 | |
|---|
| 224 | # Calling view |
|---|
| 225 | <% form_remote_tag(url=url(action="buy"), |
|---|
| 226 | complete=evaluate_remote_response()) %> |
|---|
| 227 | all the inputs here... |
|---|
| 228 | |
|---|
| 229 | # Controller action |
|---|
| 230 | def buy(self, **params): |
|---|
| 231 | c.product = Product.find(1) |
|---|
| 232 | m.subexec('/buy.myt') |
|---|
| 233 | |
|---|
| 234 | # Returning view |
|---|
| 235 | <% update_element_function( |
|---|
| 236 | "cart", action='update', position='bottom', |
|---|
| 237 | content="<p>New Product: %s</p>" % c.product.name) %> |
|---|
| 238 | <% update_element_function("status", binding='binding', |
|---|
| 239 | content="You've bought a new product!") %> |
|---|
| 240 | """ |
|---|
| 241 | content = escape_javascript(options.get('content', '')) |
|---|
| 242 | opval = options.get('action', 'update') |
|---|
| 243 | if opval == 'update': |
|---|
| 244 | if options.get('position'): |
|---|
| 245 | jsf = "new Insertion.%s('%s','%s')" % (camelize(options['position']), element_id, content) |
|---|
| 246 | else: |
|---|
| 247 | jsf = "$('%s').innerHTML = '%s'" % (element_id, content) |
|---|
| 248 | elif opval == 'empty': |
|---|
| 249 | jsf = "$('%s').innerHTML = ''" % element_id |
|---|
| 250 | elif opval == 'remove': |
|---|
| 251 | jsf = "Element.remove('%s')" % element_id |
|---|
| 252 | else: |
|---|
| 253 | raise "Invalid action, choose one of update, remove, or empty" |
|---|
| 254 | |
|---|
| 255 | jsf += ";\n" |
|---|
| 256 | if options.get('binding'): |
|---|
| 257 | return jsf + options['binding'] |
|---|
| 258 | else: |
|---|
| 259 | return jsf |
|---|
| 260 | |
|---|
| 261 | def evaluate_remote_response(): |
|---|
| 262 | """ |
|---|
| 263 | Returns a Javascript function that evals a request response |
|---|
| 264 | |
|---|
| 265 | Returns 'eval(request.responseText)' which is the JavaScript function |
|---|
| 266 | that ``form_remote_tag`` can call in *complete* to evaluate a multiple |
|---|
| 267 | update return document using ``update_element_function`` calls. |
|---|
| 268 | """ |
|---|
| 269 | return "eval(request.responseText)" |
|---|
| 270 | |
|---|
| 271 | def remote_function(**options): |
|---|
| 272 | """ |
|---|
| 273 | Returns the JavaScript needed for a remote function. |
|---|
| 274 | |
|---|
| 275 | Takes the same arguments as `link_to_remote <#link_to_remote>`_. |
|---|
| 276 | |
|---|
| 277 | Example:: |
|---|
| 278 | |
|---|
| 279 | <select id="options" onchange="<% remote_function(update="options", |
|---|
| 280 | url=url(action='update_options')) %>"> |
|---|
| 281 | <option value="0">Hello</option> |
|---|
| 282 | <option value="1">World</option> |
|---|
| 283 | </select> |
|---|
| 284 | """ |
|---|
| 285 | javascript_options = options_for_ajax(options) |
|---|
| 286 | |
|---|
| 287 | update = '' |
|---|
| 288 | if options.get('update') and isinstance(options['update'], dict): |
|---|
| 289 | update = [] |
|---|
| 290 | if options['update'].has_key('success'): |
|---|
| 291 | update.append("success:'%s'" % options['update']['success']) |
|---|
| 292 | if options['update'].has_key('failure'): |
|---|
| 293 | update.append("failure:'%s'" % options['update']['failure']) |
|---|
| 294 | update = '{' + ','.join(update) + '}' |
|---|
| 295 | elif options.get('update'): |
|---|
| 296 | update += "'%s'" % options['update'] |
|---|
| 297 | |
|---|
| 298 | function = "new Ajax.Request(" |
|---|
| 299 | if update: function = "new Ajax.Updater(%s, " % update |
|---|
| 300 | |
|---|
| 301 | function += "'%s'" % get_url(options['url']) |
|---|
| 302 | function += ", %s)" % javascript_options |
|---|
| 303 | |
|---|
| 304 | if options.get('before'): |
|---|
| 305 | function = "%s; %s" % (options['before'], function) |
|---|
| 306 | if options.get('after'): |
|---|
| 307 | function = "%s; %s" % (function, options['after']) |
|---|
| 308 | if options.get('condition'): |
|---|
| 309 | function = "if (%s) { %s; }" % (options['condition'], function) |
|---|
| 310 | if options.get('confirm'): |
|---|
| 311 | function = "if (confirm('%s')) { %s; }" % (escape_javascript(options['confirm']), function) |
|---|
| 312 | |
|---|
| 313 | return function |
|---|
| 314 | |
|---|
| 315 | def observe_field(field_id, **options): |
|---|
| 316 | """ |
|---|
| 317 | Observes the field with the DOM ID specified by ``field_id`` and makes |
|---|
| 318 | an Ajax call when its contents have changed. |
|---|
| 319 | |
|---|
| 320 | Required keyword args are: |
|---|
| 321 | |
|---|
| 322 | ``url`` |
|---|
| 323 | ``url()``-style options for the action to call when the |
|---|
| 324 | field has changed. |
|---|
| 325 | |
|---|
| 326 | Additional keyword args are: |
|---|
| 327 | |
|---|
| 328 | ``frequency`` |
|---|
| 329 | The frequency (in seconds) at which changes to this field will be |
|---|
| 330 | detected. Not setting this option at all or to a value equal to or |
|---|
| 331 | less than zero will use event based observation instead of time |
|---|
| 332 | based observation. |
|---|
| 333 | ``update`` |
|---|
| 334 | Specifies the DOM ID of the element whose innerHTML should be |
|---|
| 335 | updated with the XMLHttpRequest response text. |
|---|
| 336 | ``with`` |
|---|
| 337 | A JavaScript expression specifying the parameters for the |
|---|
| 338 | XMLHttpRequest. This defaults to 'value', which in the evaluated |
|---|
| 339 | context refers to the new field value. |
|---|
| 340 | ``on`` |
|---|
| 341 | Specifies which event handler to observe. By default, it's set to |
|---|
| 342 | "changed" for text fields and areas and "click" for radio buttons |
|---|
| 343 | and checkboxes. With this, you can specify it instead to be "blur" |
|---|
| 344 | or "focus" or any other event. |
|---|
| 345 | |
|---|
| 346 | Additionally, you may specify any of the options documented in |
|---|
| 347 | `link_to_remote <#link_to_remote>`_. |
|---|
| 348 | """ |
|---|
| 349 | if options.get('frequency') > 0: |
|---|
| 350 | return build_observer('Form.Element.Observer', field_id, **options) |
|---|
| 351 | else: |
|---|
| 352 | return build_observer('Form.Element.EventObserver', field_id, **options) |
|---|
| 353 | |
|---|
| 354 | def observe_form(form_id, **options): |
|---|
| 355 | """ |
|---|
| 356 | Like `observe_field <#observe_field>`_, but operates on an entire form |
|---|
| 357 | identified by the DOM ID ``form_id``. |
|---|
| 358 | |
|---|
| 359 | Keyword args are the same as observe_field, except the default value of |
|---|
| 360 | the ``with`` keyword evaluates to the serialized (request string) value |
|---|
| 361 | of the form. |
|---|
| 362 | """ |
|---|
| 363 | if options.get('frequency'): |
|---|
| 364 | return build_observer('Form.Observer', form_id, **options) |
|---|
| 365 | else: |
|---|
| 366 | return build_observer('Form.EventObserver', form_id, **options) |
|---|
| 367 | |
|---|
| 368 | def options_for_ajax(options): |
|---|
| 369 | js_options = build_callbacks(options) |
|---|
| 370 | |
|---|
| 371 | js_options['asynchronous'] = str(options.get('type') != 'synchronous').lower() |
|---|
| 372 | if options.get('method'): |
|---|
| 373 | if isinstance(options['method'], str) and options['method'].startswith("'"): |
|---|
| 374 | js_options['method'] = options['method'] |
|---|
| 375 | else: |
|---|
| 376 | js_options['method'] = "'%s'" % options['method'] |
|---|
| 377 | if options.get('position'): |
|---|
| 378 | js_options['insertion'] = "Insertion.%s" % camelize(options['position']) |
|---|
| 379 | js_options['evalScripts'] = str(options.get('script') is None or options['script']).lower() |
|---|
| 380 | |
|---|
| 381 | if options.get('form'): |
|---|
| 382 | js_options['parameters'] = 'Form.serialize(this)' |
|---|
| 383 | elif options.get('submit'): |
|---|
| 384 | js_options['parameters'] = "Form.serialize('%s')" % options['submit'] |
|---|
| 385 | elif options.get('with'): |
|---|
| 386 | js_options['parameters'] = options['with'] |
|---|
| 387 | |
|---|
| 388 | return options_for_javascript(js_options) |
|---|
| 389 | |
|---|
| 390 | def build_observer(cls, name, **options): |
|---|
| 391 | if options.get('update') is True: |
|---|
| 392 | options['with'] = options.get('with', 'value') |
|---|
| 393 | callback = remote_function(**options) |
|---|
| 394 | javascript = "new %s('%s', " % (cls, name) |
|---|
| 395 | if options.get('frequency'): |
|---|
| 396 | javascript += "%s, " % options['frequency'] |
|---|
| 397 | javascript += "function(element, value) {%s})" % callback |
|---|
| 398 | return javascript_tag(javascript) |
|---|
| 399 | |
|---|
| 400 | def build_callbacks(options): |
|---|
| 401 | callbacks = {} |
|---|
| 402 | for callback, code in options.iteritems(): |
|---|
| 403 | if callback in CALLBACKS: |
|---|
| 404 | name = 'on' + callback.title() |
|---|
| 405 | callbacks[name] = "function(request){%s}" % code |
|---|
| 406 | return callbacks |
|---|
| 407 | |
|---|
| 408 | __all__ = ['link_to_remote', 'periodically_call_remote', 'form_remote_tag', 'submit_to_remote', 'update_element_function', |
|---|
| 409 | 'evaluate_remote_response', 'remote_function', 'observe_field', 'observe_form'] |
|---|