prototype-rails 0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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