prototype-rails 0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. data/Gemfile +7 -0
  2. data/README +20 -0
  3. data/Rakefile +10 -0
  4. data/lib/action_view/helpers/prototype_helper.rb +852 -0
  5. data/lib/action_view/helpers/scriptaculous_helper.rb +263 -0
  6. data/lib/action_view/template/handlers/rjs.rb +14 -0
  7. data/lib/prototype-rails.rb +17 -0
  8. data/lib/prototype-rails/javascript_helper.rb +65 -0
  9. data/lib/prototype-rails/on_load_action_controller.rb +2 -0
  10. data/lib/prototype-rails/on_load_action_view.rb +22 -0
  11. data/lib/prototype-rails/renderers.rb +12 -0
  12. data/lib/prototype-rails/rendering.rb +13 -0
  13. data/lib/prototype-rails/selector_assertions.rb +208 -0
  14. data/test/abstract_unit.rb +235 -0
  15. data/test/assert_select_test.rb +452 -0
  16. data/test/controller/caching_test.rb +46 -0
  17. data/test/controller/content_type_test.rb +16 -0
  18. data/test/controller/mime_responds_test.rb +213 -0
  19. data/test/controller/new_base/content_type_test.rb +19 -0
  20. data/test/controller/new_base/render_rjs_test.rb +71 -0
  21. data/test/controller/render_js_test.rb +22 -0
  22. data/test/fixtures/functional_caching/_partial.erb +3 -0
  23. data/test/fixtures/functional_caching/formatted_fragment_cached.js.rjs +6 -0
  24. data/test/fixtures/functional_caching/js_fragment_cached_with_partial.js.rjs +1 -0
  25. data/test/fixtures/old_content_type/render_default_for_rjs.rjs +1 -0
  26. data/test/fixtures/respond_to/all_types_with_layout.js.rjs +1 -0
  27. data/test/fixtures/respond_to/layouts/standard.html.erb +1 -0
  28. data/test/fixtures/respond_to/using_defaults.js.rjs +1 -0
  29. data/test/fixtures/respond_to/using_defaults_with_type_list.js.rjs +1 -0
  30. data/test/fixtures/respond_with/using_resource.js.rjs +1 -0
  31. data/test/fixtures/test/_one.html.erb +1 -0
  32. data/test/fixtures/test/_partial.html.erb +1 -0
  33. data/test/fixtures/test/_partial.js.erb +1 -0
  34. data/test/fixtures/test/_two.html.erb +1 -0
  35. data/test/fixtures/test/delete_with_js.rjs +2 -0
  36. data/test/fixtures/test/enum_rjs_test.rjs +6 -0
  37. data/test/fixtures/test/greeting.js.rjs +1 -0
  38. data/test/fixtures/test/render_explicit_html_template.js.rjs +1 -0
  39. data/test/fixtures/test/render_implicit_html_template.js.rjs +1 -0
  40. data/test/javascript_helper_test.rb +61 -0
  41. data/test/lib/controller/fake_models.rb +29 -0
  42. data/test/render_other_test.rb +257 -0
  43. data/test/template/prototype_helper_test.rb +476 -0
  44. data/test/template/render_test.rb +24 -0
  45. data/test/template/scriptaculous_helper_test.rb +86 -0
  46. data/vendor/assets/javascripts/controls.js +965 -0
  47. data/vendor/assets/javascripts/dragdrop.js +974 -0
  48. data/vendor/assets/javascripts/effects.js +1123 -0
  49. data/vendor/assets/javascripts/prototype.js +6082 -0
  50. data/vendor/assets/javascripts/prototype_ujs.js +208 -0
  51. metadata +127 -0
@@ -0,0 +1,263 @@
1
+ require 'action_view/helpers/javascript_helper'
2
+ require 'active_support/json'
3
+
4
+ module ActionView
5
+ # = Action View Scriptaculous Helpers
6
+ module Helpers
7
+ # Provides a set of helpers for calling Scriptaculous[http://script.aculo.us/]
8
+ # JavaScript functions, including those which create Ajax controls and visual
9
+ # effects.
10
+ #
11
+ # To be able to use these helpers, you must include the Prototype
12
+ # JavaScript framework and the Scriptaculous JavaScript library in your
13
+ # pages. See the documentation for ActionView::Helpers::JavaScriptHelper
14
+ # for more information on including the necessary JavaScript.
15
+ #
16
+ # The Scriptaculous helpers' behavior can be tweaked with various options.
17
+ #
18
+ # See the documentation at http://script.aculo.us for more information on
19
+ # using these helpers in your application.
20
+ module ScriptaculousHelper
21
+ TOGGLE_EFFECTS = [:toggle_appear, :toggle_slide, :toggle_blind]
22
+
23
+ # Returns a JavaScript snippet to be used on the Ajax callbacks for
24
+ # starting visual effects.
25
+ #
26
+ # If no +element_id+ is given, it assumes "element" which should be a local
27
+ # variable in the generated JavaScript execution context. This can be
28
+ # used for example with +drop_receiving_element+:
29
+ #
30
+ # <%= drop_receiving_element (...), :loading => visual_effect(:fade) %>
31
+ #
32
+ # This would fade the element that was dropped on the drop receiving
33
+ # element.
34
+ #
35
+ # For toggling visual effects, you can use <tt>:toggle_appear</tt>, <tt>:toggle_slide</tt>, and
36
+ # <tt>:toggle_blind</tt> which will alternate between appear/fade, slidedown/slideup, and
37
+ # blinddown/blindup respectively.
38
+ #
39
+ # You can change the behaviour with various options, see
40
+ # http://script.aculo.us for more documentation.
41
+ def visual_effect(name, element_id = false, js_options = {})
42
+ element = element_id ? ActiveSupport::JSON.encode(element_id) : "element"
43
+
44
+ js_options[:queue] = if js_options[:queue].is_a?(Hash)
45
+ '{' + js_options[:queue].map {|k, v| k == :limit ? "#{k}:#{v}" : "#{k}:'#{v}'" }.join(',') + '}'
46
+ elsif js_options[:queue]
47
+ "'#{js_options[:queue]}'"
48
+ end if js_options[:queue]
49
+
50
+ [:endcolor, :direction, :startcolor, :scaleMode, :restorecolor].each do |option|
51
+ js_options[option] = "'#{js_options[option]}'" if js_options[option]
52
+ end
53
+
54
+ if TOGGLE_EFFECTS.include? name.to_sym
55
+ "Effect.toggle(#{element},'#{name.to_s.gsub(/^toggle_/,'')}',#{options_for_javascript(js_options)});"
56
+ else
57
+ "new Effect.#{name.to_s.camelize}(#{element},#{options_for_javascript(js_options)});"
58
+ end
59
+ end
60
+
61
+ # Makes the element with the DOM ID specified by +element_id+ sortable
62
+ # by drag-and-drop and make an Ajax call whenever the sort order has
63
+ # changed. By default, the action called gets the serialized sortable
64
+ # element as parameters.
65
+ #
66
+ # Example:
67
+ #
68
+ # <%= sortable_element("my_list", :url => { :action => "order" }) %>
69
+ #
70
+ # In the example, the action gets a "my_list" array parameter
71
+ # containing the values of the ids of elements the sortable consists
72
+ # of, in the current order.
73
+ #
74
+ # Important: For this to work, the sortable elements must have id
75
+ # attributes in the form "string_identifier". For example, "item_1". Only
76
+ # the identifier part of the id attribute will be serialized.
77
+ #
78
+ # Additional +options+ are:
79
+ #
80
+ # * <tt>:format</tt> - A regular expression to determine what to send as the
81
+ # serialized id to the server (the default is <tt>/^[^_]*_(.*)$/</tt>).
82
+ #
83
+ # * <tt>:constraint</tt> - Whether to constrain the dragging to either
84
+ # <tt>:horizontal</tt> or <tt>:vertical</tt> (or false to make it unconstrained).
85
+ #
86
+ # * <tt>:overlap</tt> - Calculate the item overlap in the <tt>:horizontal</tt>
87
+ # or <tt>:vertical</tt> direction.
88
+ #
89
+ # * <tt>:tag</tt> - Which children of the container element to treat as
90
+ # sortable (default is <tt>li</tt>).
91
+ #
92
+ # * <tt>:containment</tt> - Takes an element or array of elements to treat as
93
+ # potential drop targets (defaults to the original target element).
94
+ #
95
+ # * <tt>:only</tt> - A CSS class name or array of class names used to filter
96
+ # out child elements as candidates.
97
+ #
98
+ # * <tt>:scroll</tt> - Determines whether to scroll the list during drag
99
+ # operations if the list runs past the visual border.
100
+ #
101
+ # * <tt>:tree</tt> - Determines whether to treat nested lists as part of the
102
+ # main sortable list. This means that you can create multi-layer lists,
103
+ # and not only sort items at the same level, but drag and sort items
104
+ # between levels.
105
+ #
106
+ # * <tt>:hoverclass</tt> - If set, the Droppable will have this additional CSS class
107
+ # when an accepted Draggable is hovered over it.
108
+ #
109
+ # * <tt>:handle</tt> - Sets whether the element should only be draggable by an
110
+ # embedded handle. The value may be a string referencing a CSS class value
111
+ # (as of script.aculo.us V1.5). The first child/grandchild/etc. element
112
+ # found within the element that has this CSS class value will be used as
113
+ # the handle.
114
+ #
115
+ # * <tt>:ghosting</tt> - Clones the element and drags the clone, leaving
116
+ # the original in place until the clone is dropped (default is <tt>false</tt>).
117
+ #
118
+ # * <tt>:dropOnEmpty</tt> - If true the Sortable container will be made into
119
+ # a Droppable, that can receive a Draggable (as according to the containment
120
+ # rules) as a child element when there are no more elements inside (default
121
+ # is <tt>false</tt>).
122
+ #
123
+ # * <tt>:onChange</tt> - Called whenever the sort order changes while dragging. When
124
+ # dragging from one Sortable to another, the callback is called once on each
125
+ # Sortable. Gets the affected element as its parameter.
126
+ #
127
+ # * <tt>:onUpdate</tt> - Called when the drag ends and the Sortable's order is
128
+ # changed in any way. When dragging from one Sortable to another, the callback
129
+ # is called once on each Sortable. Gets the container as its parameter.
130
+ #
131
+ # See http://script.aculo.us for more documentation.
132
+ def sortable_element(element_id, options = {})
133
+ javascript_tag(sortable_element_js(element_id, options).chop!)
134
+ end
135
+
136
+ def sortable_element_js(element_id, options = {}) #:nodoc:
137
+ options[:with] ||= "Sortable.serialize(#{ActiveSupport::JSON.encode(element_id)})"
138
+ options[:onUpdate] ||= "function(){" + remote_function(options) + "}"
139
+ options.delete_if { |key, value| PrototypeHelper::AJAX_OPTIONS.include?(key) }
140
+
141
+ [:tag, :overlap, :constraint, :handle].each do |option|
142
+ options[option] = "'#{options[option]}'" if options[option]
143
+ end
144
+
145
+ options[:containment] = array_or_string_for_javascript(options[:containment]) if options[:containment]
146
+ options[:only] = array_or_string_for_javascript(options[:only]) if options[:only]
147
+
148
+ %(Sortable.create(#{ActiveSupport::JSON.encode(element_id)}, #{options_for_javascript(options)});)
149
+ end
150
+
151
+ # Makes the element with the DOM ID specified by +element_id+ draggable.
152
+ #
153
+ # Example:
154
+ # <%= draggable_element("my_image", :revert => true)
155
+ #
156
+ # You can change the behaviour with various options, see
157
+ # http://script.aculo.us for more documentation.
158
+ def draggable_element(element_id, options = {})
159
+ javascript_tag(draggable_element_js(element_id, options).chop!)
160
+ end
161
+
162
+ def draggable_element_js(element_id, options = {}) #:nodoc:
163
+ %(new Draggable(#{ActiveSupport::JSON.encode(element_id)}, #{options_for_javascript(options)});)
164
+ end
165
+
166
+ # Makes the element with the DOM ID specified by +element_id+ receive
167
+ # dropped draggable elements (created by +draggable_element+).
168
+ # and make an AJAX call. By default, the action called gets the DOM ID
169
+ # of the element as parameter.
170
+ #
171
+ # Example:
172
+ # <%= drop_receiving_element("my_cart", :url =>
173
+ # { :controller => "cart", :action => "add" }) %>
174
+ #
175
+ # You can change the behaviour with various options, see
176
+ # http://script.aculo.us for more documentation.
177
+ #
178
+ # Some of these +options+ include:
179
+ # * <tt>:accept</tt> - Set this to a string or an array of strings describing the
180
+ # allowable CSS classes that the +draggable_element+ must have in order
181
+ # to be accepted by this +drop_receiving_element+.
182
+ #
183
+ # * <tt>:confirm</tt> - Adds a confirmation dialog. Example:
184
+ #
185
+ # :confirm => "Are you sure you want to do this?"
186
+ #
187
+ # * <tt>:hoverclass</tt> - If set, the +drop_receiving_element+ will have
188
+ # this additional CSS class when an accepted +draggable_element+ is
189
+ # hovered over it.
190
+ #
191
+ # * <tt>:onDrop</tt> - Called when a +draggable_element+ is dropped onto
192
+ # this element. Override this callback with a JavaScript expression to
193
+ # change the default drop behaviour. Example:
194
+ #
195
+ # :onDrop => "function(draggable_element, droppable_element, event) { alert('I like bananas') }"
196
+ #
197
+ # This callback gets three parameters: The Draggable element, the Droppable
198
+ # element and the Event object. You can extract additional information about
199
+ # the drop - like if the Ctrl or Shift keys were pressed - from the Event object.
200
+ #
201
+ # * <tt>:with</tt> - A JavaScript expression specifying the parameters for
202
+ # the XMLHttpRequest. Any expressions should return a valid URL query string.
203
+ def drop_receiving_element(element_id, options = {})
204
+ javascript_tag(drop_receiving_element_js(element_id, options).chop!)
205
+ end
206
+
207
+ def drop_receiving_element_js(element_id, options = {}) #:nodoc:
208
+ options[:with] ||= "'id=' + encodeURIComponent(element.id)"
209
+ options[:onDrop] ||= "function(element){" + remote_function(options) + "}"
210
+ options.delete_if { |key, value| PrototypeHelper::AJAX_OPTIONS.include?(key) }
211
+
212
+ options[:accept] = array_or_string_for_javascript(options[:accept]) if options[:accept]
213
+ options[:hoverclass] = "'#{options[:hoverclass]}'" if options[:hoverclass]
214
+
215
+ # Confirmation happens during the onDrop callback, so it can be removed from the options
216
+ options.delete(:confirm) if options[:confirm]
217
+
218
+ %(Droppables.add(#{ActiveSupport::JSON.encode(element_id)}, #{options_for_javascript(options)});)
219
+ end
220
+
221
+ protected
222
+ def array_or_string_for_javascript(option)
223
+ if option.kind_of?(Array)
224
+ "['#{option.join('\',\'')}']"
225
+ elsif !option.nil?
226
+ "'#{option}'"
227
+ end
228
+ end
229
+ end
230
+
231
+ module PrototypeHelper
232
+ class JavaScriptGenerator
233
+ module GeneratorMethods
234
+ # Starts a script.aculo.us visual effect. See
235
+ # ActionView::Helpers::ScriptaculousHelper for more information.
236
+ def visual_effect(name, id = nil, options = {})
237
+ record @context.send(:visual_effect, name, id, options)
238
+ end
239
+
240
+ # Creates a script.aculo.us sortable element. Useful
241
+ # to recreate sortable elements after items get added
242
+ # or deleted.
243
+ # See ActionView::Helpers::ScriptaculousHelper for more information.
244
+ def sortable(id, options = {})
245
+ record @context.send(:sortable_element_js, id, options)
246
+ end
247
+
248
+ # Creates a script.aculo.us draggable element.
249
+ # See ActionView::Helpers::ScriptaculousHelper for more information.
250
+ def draggable(id, options = {})
251
+ record @context.send(:draggable_element_js, id, options)
252
+ end
253
+
254
+ # Creates a script.aculo.us drop receiving element.
255
+ # See ActionView::Helpers::ScriptaculousHelper for more information.
256
+ def drop_receiving(id, options = {})
257
+ record @context.send(:drop_receiving_element_js, id, options)
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,14 @@
1
+ module ActionView
2
+ module Template::Handlers
3
+ class RJS
4
+ # Default format used by RJS.
5
+ class_attribute :default_format
6
+ self.default_format = Mime::JS
7
+
8
+ def call(template)
9
+ "update_page do |page|;#{template.source}\nend"
10
+ end
11
+ end
12
+ end
13
+ end
14
+
@@ -0,0 +1,17 @@
1
+ require 'rails'
2
+ require 'active_support'
3
+
4
+ module PrototypeRails
5
+ class Engine < Rails::Engine
6
+ initializer 'prototype-rails.initialize' do
7
+ ActiveSupport.on_load(:action_controller) do
8
+ require 'prototype-rails/on_load_action_controller'
9
+ end
10
+
11
+ ActiveSupport.on_load(:action_view) do
12
+ require 'prototype-rails/on_load_action_view'
13
+ end
14
+ end
15
+ end
16
+ end
17
+
@@ -0,0 +1,65 @@
1
+ require 'action_view/helpers/javascript_helper'
2
+
3
+ ActionView::Helpers::JavaScriptHelper.module_eval do
4
+ include ActionView::Helpers::PrototypeHelper
5
+
6
+ # Returns a button with the given +name+ text that'll trigger a JavaScript +function+ using the
7
+ # onclick handler.
8
+ #
9
+ # The first argument +name+ is used as the button's value or display text.
10
+ #
11
+ # The next arguments are optional and may include the javascript function definition and a hash of html_options.
12
+ #
13
+ # The +function+ argument can be omitted in favor of an +update_page+
14
+ # block, which evaluates to a string when the template is rendered
15
+ # (instead of making an Ajax request first).
16
+ #
17
+ # The +html_options+ will accept a hash of html attributes for the link tag. Some examples are :class => "nav_button", :id => "articles_nav_button"
18
+ #
19
+ # Note: if you choose to specify the javascript function in a block, but would like to pass html_options, set the +function+ parameter to nil
20
+ #
21
+ # Examples:
22
+ # button_to_function "Greeting", "alert('Hello world!')"
23
+ # button_to_function "Delete", "if (confirm('Really?')) do_delete()"
24
+ # button_to_function "Details" do |page|
25
+ # page[:details].visual_effect :toggle_slide
26
+ # end
27
+ # button_to_function "Details", :class => "details_button" do |page|
28
+ # page[:details].visual_effect :toggle_slide
29
+ # end
30
+ def button_to_function(name, *args, &block)
31
+ html_options = args.extract_options!.symbolize_keys
32
+
33
+ function = block_given? ? update_page(&block) : args[0] || ''
34
+ onclick = "#{"#{html_options[:onclick]}; " if html_options[:onclick]}#{function};"
35
+
36
+ tag(:input, html_options.merge(:type => 'button', :value => name, :onclick => onclick))
37
+ end
38
+
39
+ # link_to_function("Show me more", nil, :id => "more_link") do |page|
40
+ # page[:details].visual_effect :toggle_blind
41
+ # page[:more_link].replace_html "Show me less"
42
+ # end
43
+ # Produces:
44
+ # <a href="#" id="more_link" onclick="try {
45
+ # $(&quot;details&quot;).visualEffect(&quot;toggle_blind&quot;);
46
+ # $(&quot;more_link&quot;).update(&quot;Show me less&quot;);
47
+ # }
48
+ # catch (e) {
49
+ # alert('RJS error:\n\n' + e.toString());
50
+ # alert('$(\&quot;details\&quot;).visualEffect(\&quot;toggle_blind\&quot;);
51
+ # \n$(\&quot;more_link\&quot;).update(\&quot;Show me less\&quot;);');
52
+ # throw e
53
+ # };
54
+ # return false;">Show me more</a>
55
+ #
56
+ def link_to_function(name, *args, &block)
57
+ html_options = args.extract_options!.symbolize_keys
58
+
59
+ function = block_given? ? update_page(&block) : args[0] || ''
60
+ onclick = "#{"#{html_options[:onclick]}; " if html_options[:onclick]}#{function}; return false;"
61
+ href = html_options[:href] || '#'
62
+
63
+ content_tag(:a, name, html_options.merge(:href => href, :onclick => onclick))
64
+ end
65
+ end
@@ -0,0 +1,2 @@
1
+ require 'prototype-rails/selector_assertions'
2
+ require 'prototype-rails/renderers'
@@ -0,0 +1,22 @@
1
+ require 'action_view/helpers/prototype_helper'
2
+ require 'action_view/helpers/scriptaculous_helper'
3
+ require 'action_view/template/handlers/rjs'
4
+ require 'prototype-rails/javascript_helper'
5
+ require 'prototype-rails/rendering'
6
+
7
+ ActionView::Base.class_eval do
8
+ cattr_accessor :debug_rjs
9
+ self.debug_rjs = false
10
+ end
11
+
12
+ ActionView::Base.class_eval do
13
+ include ActionView::Helpers::PrototypeHelper
14
+ include ActionView::Helpers::ScriptaculousHelper
15
+ end
16
+
17
+ ActionView::TestCase.class_eval do
18
+ include ActionView::Helpers::PrototypeHelper
19
+ include ActionView::Helpers::ScriptaculousHelper
20
+ end
21
+
22
+ ActionView::Template.register_template_handler :rjs, ActionView::Template::Handlers::RJS.new
@@ -0,0 +1,12 @@
1
+ require 'action_controller/metal/renderers'
2
+
3
+ module ActionController
4
+ module Renderers
5
+ add :update do |proc, options|
6
+ view_context = self.view_context
7
+ generator = ActionView::Helpers::PrototypeHelper::JavaScriptGenerator.new(view_context, &proc)
8
+ self.content_type = Mime::JS
9
+ self.response_body = generator.to_s
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ require 'action_view/rendering'
2
+
3
+ ActionView::Rendering.module_eval do
4
+ def render_with_update(options = {}, locals = {}, &block)
5
+ if options == :update
6
+ update_page(&block)
7
+ else
8
+ render_without_update(options, locals, &block)
9
+ end
10
+ end
11
+
12
+ alias_method_chain :render, :update
13
+ end
@@ -0,0 +1,208 @@
1
+ require 'action_controller/vendor/html-scanner'
2
+ require 'action_dispatch/testing/assertions/selector'
3
+
4
+ #--
5
+ # Copyright (c) 2006 Assaf Arkin (http://labnotes.org)
6
+ # Under MIT and/or CC By license.
7
+ #++
8
+
9
+ ActionDispatch::Assertions::SelectorAssertions.module_eval do
10
+ # Selects content from the RJS response.
11
+ #
12
+ # === Narrowing down
13
+ #
14
+ # With no arguments, asserts that one or more elements are updated or
15
+ # inserted by RJS statements.
16
+ #
17
+ # Use the +id+ argument to narrow down the assertion to only statements
18
+ # that update or insert an element with that identifier.
19
+ #
20
+ # Use the first argument to narrow down assertions to only statements
21
+ # of that type. Possible values are <tt>:replace</tt>, <tt>:replace_html</tt>,
22
+ # <tt>:show</tt>, <tt>:hide</tt>, <tt>:toggle</tt>, <tt>:remove</tta>,
23
+ # <tt>:insert_html</tt> and <tt>:redirect</tt>.
24
+ #
25
+ # Use the argument <tt>:insert</tt> followed by an insertion position to narrow
26
+ # down the assertion to only statements that insert elements in that
27
+ # position. Possible values are <tt>:top</tt>, <tt>:bottom</tt>, <tt>:before</tt>
28
+ # and <tt>:after</tt>.
29
+ #
30
+ # Use the argument <tt>:redirect</tt> followed by a path to check that an statement
31
+ # which redirects to the specified path is generated.
32
+ #
33
+ # Using the <tt>:remove</tt> statement, you will be able to pass a block, but it will
34
+ # be ignored as there is no HTML passed for this statement.
35
+ #
36
+ # === Using blocks
37
+ #
38
+ # Without a block, +assert_select_rjs+ merely asserts that the response
39
+ # contains one or more RJS statements that replace or update content.
40
+ #
41
+ # With a block, +assert_select_rjs+ also selects all elements used in
42
+ # these statements and passes them to the block. Nested assertions are
43
+ # supported.
44
+ #
45
+ # Calling +assert_select_rjs+ with no arguments and using nested asserts
46
+ # asserts that the HTML content is returned by one or more RJS statements.
47
+ # Using +assert_select+ directly makes the same assertion on the content,
48
+ # but without distinguishing whether the content is returned in an HTML
49
+ # or JavaScript.
50
+ #
51
+ # ==== Examples
52
+ #
53
+ # # Replacing the element foo.
54
+ # # page.replace 'foo', ...
55
+ # assert_select_rjs :replace, "foo"
56
+ #
57
+ # # Replacing with the chained RJS proxy.
58
+ # # page[:foo].replace ...
59
+ # assert_select_rjs :chained_replace, 'foo'
60
+ #
61
+ # # Inserting into the element bar, top position.
62
+ # assert_select_rjs :insert, :top, "bar"
63
+ #
64
+ # # Remove the element bar
65
+ # assert_select_rjs :remove, "bar"
66
+ #
67
+ # # Changing the element foo, with an image.
68
+ # assert_select_rjs "foo" do
69
+ # assert_select "img[src=/images/logo.gif""
70
+ # end
71
+ #
72
+ # # RJS inserts or updates a list with four items.
73
+ # assert_select_rjs do
74
+ # assert_select "ol>li", 4
75
+ # end
76
+ #
77
+ # # The same, but shorter.
78
+ # assert_select "ol>li", 4
79
+ #
80
+ # # Checking for a redirect.
81
+ # assert_select_rjs :redirect, root_path
82
+ def assert_select_rjs(*args, &block)
83
+ rjs_type = args.first.is_a?(Symbol) ? args.shift : nil
84
+ id = args.first.is_a?(String) ? args.shift : nil
85
+
86
+ # If the first argument is a symbol, it's the type of RJS statement we're looking
87
+ # for (update, replace, insertion, etc). Otherwise, we're looking for just about
88
+ # any RJS statement.
89
+ if rjs_type
90
+ if rjs_type == :insert
91
+ position = args.shift
92
+ id = args.shift
93
+ insertion = "insert_#{position}".to_sym
94
+ raise ArgumentError, "Unknown RJS insertion type #{position}" unless RJS_STATEMENTS[insertion]
95
+ statement = "(#{RJS_STATEMENTS[insertion]})"
96
+ else
97
+ raise ArgumentError, "Unknown RJS statement type #{rjs_type}" unless RJS_STATEMENTS[rjs_type]
98
+ statement = "(#{RJS_STATEMENTS[rjs_type]})"
99
+ end
100
+ else
101
+ statement = "#{RJS_STATEMENTS[:any]}"
102
+ end
103
+
104
+ # Next argument we're looking for is the element identifier. If missing, we pick
105
+ # any element, otherwise we replace it in the statement.
106
+ pattern = Regexp.new(
107
+ id ? statement.gsub(RJS_ANY_ID, "\"#{id}\"") : statement
108
+ )
109
+
110
+ # Duplicate the body since the next step involves destroying it.
111
+ matches = nil
112
+ case rjs_type
113
+ when :remove, :show, :hide, :toggle
114
+ matches = @response.body.match(pattern)
115
+ else
116
+ @response.body.gsub(pattern) do |match|
117
+ html = unescape_rjs(match)
118
+ matches ||= []
119
+ matches.concat HTML::Document.new(html).root.children.select { |n| n.tag? }
120
+ ""
121
+ end
122
+ end
123
+
124
+ if matches
125
+ assert_block("") { true } # to count the assertion
126
+ if block_given? && !([:remove, :show, :hide, :toggle].include? rjs_type)
127
+ begin
128
+ @selected ||= nil
129
+ in_scope, @selected = @selected, matches
130
+ yield matches
131
+ ensure
132
+ @selected = in_scope
133
+ end
134
+ end
135
+ matches
136
+ else
137
+ # RJS statement not found.
138
+ case rjs_type
139
+ when :remove, :show, :hide, :toggle
140
+ flunk_message = "No RJS statement that #{rjs_type.to_s}s '#{id}' was rendered."
141
+ else
142
+ flunk_message = "No RJS statement that replaces or inserts HTML content."
143
+ end
144
+ flunk args.shift || flunk_message
145
+ end
146
+ end
147
+
148
+ protected
149
+
150
+ RJS_PATTERN_HTML = "\"((\\\\\"|[^\"])*)\""
151
+ RJS_ANY_ID = "\"([^\"])*\""
152
+ RJS_STATEMENTS = {
153
+ :chained_replace => "\\$\\(#{RJS_ANY_ID}\\)\\.replace\\(#{RJS_PATTERN_HTML}\\)",
154
+ :chained_replace_html => "\\$\\(#{RJS_ANY_ID}\\)\\.update\\(#{RJS_PATTERN_HTML}\\)",
155
+ :replace_html => "Element\\.update\\(#{RJS_ANY_ID}, #{RJS_PATTERN_HTML}\\)",
156
+ :replace => "Element\\.replace\\(#{RJS_ANY_ID}, #{RJS_PATTERN_HTML}\\)",
157
+ :redirect => "window.location.href = #{RJS_ANY_ID}"
158
+ }
159
+ [:remove, :show, :hide, :toggle].each do |action|
160
+ RJS_STATEMENTS[action] = "Element\\.#{action}\\(#{RJS_ANY_ID}\\)"
161
+ end
162
+ RJS_INSERTIONS = ["top", "bottom", "before", "after"]
163
+ RJS_INSERTIONS.each do |insertion|
164
+ RJS_STATEMENTS["insert_#{insertion}".to_sym] = "Element.insert\\(#{RJS_ANY_ID}, \\{ #{insertion}: #{RJS_PATTERN_HTML} \\}\\)"
165
+ end
166
+ RJS_STATEMENTS[:insert_html] = "Element.insert\\(#{RJS_ANY_ID}, \\{ (#{RJS_INSERTIONS.join('|')}): #{RJS_PATTERN_HTML} \\}\\)"
167
+ RJS_STATEMENTS[:any] = Regexp.new("(#{RJS_STATEMENTS.values.join('|')})")
168
+ RJS_PATTERN_UNICODE_ESCAPED_CHAR = /\\u([0-9a-zA-Z]{4})/
169
+
170
+ # +assert_select+ and +css_select+ call this to obtain the content in the HTML
171
+ # page, or from all the RJS statements, depending on the type of response.
172
+ def response_from_page_with_rjs
173
+ content_type = @response.content_type
174
+
175
+ if content_type && Mime::JS =~ content_type
176
+ body = @response.body.dup
177
+ root = HTML::Node.new(nil)
178
+
179
+ while true
180
+ next if body.sub!(RJS_STATEMENTS[:any]) do |match|
181
+ html = unescape_rjs(match)
182
+ matches = HTML::Document.new(html).root.children.select { |n| n.tag? }
183
+ root.children.concat matches
184
+ ""
185
+ end
186
+ break
187
+ end
188
+
189
+ root
190
+ else
191
+ response_from_page_without_rjs
192
+ end
193
+ end
194
+ alias_method_chain :response_from_page, :rjs
195
+
196
+ # Unescapes a RJS string.
197
+ def unescape_rjs(rjs_string)
198
+ # RJS encodes double quotes and line breaks.
199
+ unescaped= rjs_string.gsub('\"', '"')
200
+ unescaped.gsub!(/\\\//, '/')
201
+ unescaped.gsub!('\n', "\n")
202
+ unescaped.gsub!('\076', '>')
203
+ unescaped.gsub!('\074', '<')
204
+ # RJS encodes non-ascii characters.
205
+ unescaped.gsub!(RJS_PATTERN_UNICODE_ESCAPED_CHAR) {|u| [$1.hex].pack('U*')}
206
+ unescaped
207
+ end
208
+ end