aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.travis.yml29
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock34
-rw-r--r--RELEASING_RAILS.md16
-rw-r--r--actioncable/test/javascript/src/test_helpers/consumer_test_helper.coffee10
-rw-r--r--actioncable/test/javascript/src/test_helpers/index.coffee3
-rw-r--r--actioncable/test/javascript/src/unit/subscription_test.coffee40
-rw-r--r--actioncable/test/javascript/src/unit/subscriptions_test.coffee25
-rwxr-xr-xactionmailer/bin/test2
-rw-r--r--actionpack/CHANGELOG.md11
-rwxr-xr-xactionpack/bin/test2
-rw-r--r--actionpack/lib/action_controller/metal/force_ssl.rb2
-rw-r--r--actionpack/lib/action_dispatch/testing/integration.rb16
-rw-r--r--actionpack/lib/action_dispatch/testing/request_encoder.rb31
-rw-r--r--actionpack/test/controller/integration_test.rb29
-rw-r--r--actionview/.gitignore2
-rw-r--r--actionview/CHANGELOG.md30
-rw-r--r--actionview/RUNNING_UJS_TESTS.rdoc7
-rw-r--r--actionview/Rakefile17
-rw-r--r--actionview/app/assets/javascripts/config.coffee37
-rw-r--r--actionview/app/assets/javascripts/features/confirm.coffee26
-rw-r--r--actionview/app/assets/javascripts/features/disable.coffee78
-rw-r--r--actionview/app/assets/javascripts/features/method.coffee34
-rw-r--r--actionview/app/assets/javascripts/features/remote.coffee100
-rw-r--r--actionview/app/assets/javascripts/rails-ujs.coffee76
-rw-r--r--actionview/app/assets/javascripts/utils/ajax.coffee95
-rw-r--r--actionview/app/assets/javascripts/utils/csrf.coffee25
-rw-r--r--actionview/app/assets/javascripts/utils/dom.coffee28
-rw-r--r--actionview/app/assets/javascripts/utils/event.coffee40
-rw-r--r--actionview/app/assets/javascripts/utils/form.coffee61
-rwxr-xr-xactionview/bin/test2
-rw-r--r--actionview/blade.yml11
-rw-r--r--actionview/lib/action_view/helpers/form_helper.rb345
-rw-r--r--actionview/lib/action_view/railtie.rb2
-rw-r--r--actionview/package.json36
-rw-r--r--actionview/test/actionpack/abstract/helper_test.rb2
-rw-r--r--actionview/test/template/form_helper/form_with_test.rb2134
-rw-r--r--actionview/test/ujs/.gitignore1
-rw-r--r--actionview/test/ujs/config.ru3
-rw-r--r--actionview/test/ujs/public/test/.eslintrc.yml21
-rw-r--r--actionview/test/ujs/public/test/call-remote-callbacks.js469
-rw-r--r--actionview/test/ujs/public/test/call-remote.js247
-rw-r--r--actionview/test/ujs/public/test/csrf-refresh.js24
-rw-r--r--actionview/test/ujs/public/test/csrf-token.js27
-rw-r--r--actionview/test/ujs/public/test/data-confirm.js288
-rw-r--r--actionview/test/ujs/public/test/data-disable-with.js391
-rw-r--r--actionview/test/ujs/public/test/data-disable.js321
-rw-r--r--actionview/test/ujs/public/test/data-method.js85
-rw-r--r--actionview/test/ujs/public/test/data-remote.js400
-rw-r--r--actionview/test/ujs/public/test/override.js56
-rw-r--r--actionview/test/ujs/public/test/settings.js116
-rw-r--r--actionview/test/ujs/public/vendor/jquery.metadata.js122
-rw-r--r--actionview/test/ujs/public/vendor/qunit.css237
-rw-r--r--actionview/test/ujs/public/vendor/qunit.js2288
-rw-r--r--actionview/test/ujs/server.rb105
-rw-r--r--actionview/test/ujs/views/layouts/application.html.erb40
-rw-r--r--actionview/test/ujs/views/tests/index.html.erb25
-rw-r--r--activemodel/CHANGELOG.md6
-rwxr-xr-xactivemodel/bin/test2
-rw-r--r--activemodel/lib/active_model/errors.rb7
-rw-r--r--activemodel/test/cases/errors_test.rb11
-rw-r--r--activerecord/CHANGELOG.md37
-rwxr-xr-xactiverecord/bin/test2
-rw-r--r--activerecord/lib/active_record/associations.rb5
-rw-r--r--activerecord/lib/active_record/associations/builder/singular_association.rb11
-rw-r--r--activerecord/lib/active_record/associations/collection_association.rb10
-rw-r--r--activerecord/lib/active_record/associations/singular_association.rb7
-rw-r--r--activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb34
-rw-r--r--activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb27
-rw-r--r--activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb4
-rw-r--r--activerecord/lib/active_record/explain.rb1
-rw-r--r--activerecord/lib/active_record/migration/compatibility.rb8
-rw-r--r--activerecord/lib/active_record/reflection.rb4
-rw-r--r--activerecord/lib/active_record/relation.rb8
-rw-r--r--activerecord/lib/active_record/relation/finder_methods.rb8
-rw-r--r--activerecord/lib/active_record/validations/uniqueness.rb7
-rw-r--r--activerecord/test/cases/adapters/mysql2/transaction_test.rb24
-rw-r--r--activerecord/test/cases/adapters/postgresql/transaction_test.rb4
-rw-r--r--activerecord/test/cases/adapters/postgresql/uuid_test.rb56
-rw-r--r--activerecord/test/cases/associations/belongs_to_associations_test.rb10
-rw-r--r--activerecord/test/cases/associations/has_many_through_associations_test.rb17
-rw-r--r--activerecord/test/cases/associations/has_one_associations_test.rb10
-rw-r--r--activerecord/test/cases/connection_pool_test.rb96
-rw-r--r--activerecord/test/cases/persistence_test.rb22
-rw-r--r--activerecord/test/cases/test_fixtures_test.rb2
-rw-r--r--activerecord/test/cases/validations/uniqueness_validation_test.rb16
-rw-r--r--activerecord/test/schema/postgresql_specific_schema.rb1
-rwxr-xr-xactivesupport/bin/test2
-rw-r--r--activesupport/lib/active_support/dependencies.rb2
-rw-r--r--activesupport/lib/active_support/logger.rb4
-rw-r--r--activesupport/test/broadcast_logger_test.rb22
-rw-r--r--activesupport/test/concern_test.rb2
-rw-r--r--[-rwxr-xr-x]guides/assets/stylesheets/responsive-tables.css0
-rw-r--r--guides/source/active_record_postgresql.md2
-rw-r--r--guides/source/getting_started.md7
-rw-r--r--railties/CHANGELOG.md11
-rw-r--r--railties/lib/rails/application.rb4
-rw-r--r--railties/lib/rails/generators/app_base.rb11
-rw-r--r--railties/lib/rails/generators/rails/app/templates/app/assets/javascripts/application.js.tt4
-rw-r--r--railties/test/application/configuration_test.rb33
-rw-r--r--railties/test/backtrace_cleaner_test.rb2
-rw-r--r--railties/test/code_statistics_calculator_test.rb2
-rw-r--r--railties/test/generators/api_app_generator_test.rb2
-rw-r--r--railties/test/generators/app_generator_test.rb20
-rw-r--r--railties/test/generators/plugin_generator_test.rb1
-rw-r--r--railties/test/isolation/abstract_unit.rb1
-rw-r--r--tools/test.rb2
107 files changed, 9088 insertions, 208 deletions
diff --git a/.travis.yml b/.travis.yml
index af207f4f5d..86328e0ef6 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -10,7 +10,6 @@ cache:
services:
- memcached
- redis
- - rabbitmq
addons:
postgresql: "9.4"
@@ -23,8 +22,6 @@ before_install:
- "pushd /tmp/beanstalkd-1.10 && make && (./beanstalkd &); popd"
before_script:
- - bundle update
-
# Set Sauce Labs username and access key. Obfuscated, purposefully not encrypted.
# Decodes to e.g. `export VARIABLE=VALUE`
- $(base64 --decode <<< "ZXhwb3J0IFNBVUNFX0FDQ0VTU19LRVk9YTAzNTM0M2YtZTkyMi00MGIzLWFhM2MtMDZiM2VhNjM1YzQ4")
@@ -45,20 +42,36 @@ env:
- "GEM=ar:mysql2"
- "GEM=ar:sqlite3"
- "GEM=ar:postgresql"
- - "GEM=aj:integration"
- "GEM=guides"
rvm:
- 2.2.6
- - 2.3.2
+ - 2.3.3
- ruby-head
matrix:
include:
- # Latest compiled version in http://rubies.travis-ci.org
- - rvm: 2.3.2
+ - rvm: 2.2.6
+ env: "GEM=aj:integration"
+ services:
+ - memcached
+ - redis
+ - rabbitmq
+ - rvm: 2.3.3
+ env: "GEM=aj:integration"
+ services:
+ - memcached
+ - redis
+ - rabbitmq
+ - rvm: ruby-head
+ env: "GEM=aj:integration"
+ services:
+ - memcached
+ - redis
+ - rabbitmq
+ - rvm: 2.3.3
env:
- - "GEM=ar:mysql2"
+ - "GEM=ar:mysql2 MYSQL=mariadb"
addons:
mariadb: 10.0
- rvm: jruby-9.1.5.0
diff --git a/Gemfile b/Gemfile
index e30858a124..99dd23006b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -19,6 +19,7 @@ gem "jquery-rails"
gem "coffee-rails"
gem "sass-rails"
gem "turbolinks", "~> 5"
+gem "rails-ujs", github: "rails/rails-ujs"
# require: false so bcrypt is loaded only when has_secure_password is used.
# This is to avoid Active Model (and by extension the entire framework)
diff --git a/Gemfile.lock b/Gemfile.lock
index 03e3a02d99..d2e968c817 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -39,6 +39,13 @@ GIT
websocket
GIT
+ remote: https://github.com/rails/rails-ujs.git
+ revision: 767692f53dec79d42928029a55fdfcced35681e8
+ specs:
+ rails-ujs (0.0.1)
+ railties (>= 3.1)
+
+GIT
remote: https://github.com/resque/resque.git
revision: 20d885065ac19e7f7d7a982f4ed1296083db0300
specs:
@@ -118,7 +125,8 @@ PATH
GEM
remote: https://rubygems.org/
specs:
- addressable (2.4.0)
+ addressable (2.5.0)
+ public_suffix (~> 2.0, >= 2.0.2)
amq-protocol (2.0.1)
arel (7.1.2)
ast (2.3.0)
@@ -131,9 +139,9 @@ GEM
bcrypt (3.1.11-x86-mingw32)
beaneater (1.0.0)
benchmark-ips (2.7.2)
- blade (0.5.6)
+ blade (0.6.1)
activesupport (>= 3.0.0)
- blade-qunit_adapter (~> 1.20.0)
+ blade-qunit_adapter (~> 2.0.1)
coffee-script
coffee-script-source
curses (~> 1.0.0)
@@ -144,8 +152,8 @@ GEM
thin (>= 1.6.0)
thor (~> 0.19.1)
useragent (~> 0.16.7)
- blade-qunit_adapter (1.20.0)
- blade-sauce_labs_plugin (0.5.3)
+ blade-qunit_adapter (2.0.1)
+ blade-sauce_labs_plugin (0.6.1)
childprocess
faraday
selenium-webdriver
@@ -182,13 +190,13 @@ GEM
eventmachine (>= 1.0.0.beta.4)
erubis (2.7.0)
event_emitter (0.2.5)
- eventmachine (1.2.0.1)
- eventmachine (1.2.0.1-x64-mingw32)
- eventmachine (1.2.0.1-x86-mingw32)
+ eventmachine (1.2.1)
+ eventmachine (1.2.1-x64-mingw32)
+ eventmachine (1.2.1-x86-mingw32)
execjs (2.7.0)
- faraday (0.9.2)
+ faraday (0.10.0)
multipart-post (>= 1.2, < 3)
- faye (1.2.2)
+ faye (1.2.3)
cookiejar (>= 0.3.0)
em-http-request (>= 0.3.0)
eventmachine (>= 0.12.0)
@@ -196,7 +204,7 @@ GEM
multi_json (>= 1.0.0)
rack (>= 1.0.0)
websocket-driver (>= 0.5.1)
- faye-websocket (0.10.4)
+ faye-websocket (0.10.5)
eventmachine (>= 0.12.0)
websocket-driver (>= 0.5.1)
ffi (1.9.14)
@@ -254,6 +262,7 @@ GEM
pg (0.19.0-x86-mingw32)
powerpack (0.1.1)
psych (2.1.1)
+ public_suffix (2.0.4)
puma (3.6.0)
qu (0.2.0)
multi_json
@@ -306,7 +315,7 @@ GEM
tilt (>= 1.1, < 3)
sdoc (1.0.0.beta2)
rdoc (= 5.0.0.beta2)
- selenium-webdriver (2.53.4)
+ selenium-webdriver (3.0.1)
childprocess (~> 0.5)
rubyzip (~> 1.0)
websocket (~> 1.0)
@@ -410,6 +419,7 @@ DEPENDENCIES
racc (>= 1.4.6)
rack-cache (~> 1.2)
rails!
+ rails-ujs!
rake (>= 11.1)
rb-inotify!
redcarpet (~> 3.2.3)
diff --git a/RELEASING_RAILS.md b/RELEASING_RAILS.md
index 4e6b811d3e..10a8bca3b3 100644
--- a/RELEASING_RAILS.md
+++ b/RELEASING_RAILS.md
@@ -103,17 +103,19 @@ branch.
Run `rake install` to generate the gems and install them locally. Then try
generating a new app and ensure that nothing explodes.
-Verify that Action Cable's package.json is updated with the RC version.
+Verify that Action Cable and Action View's package.json files are updated with
+the RC version.
This will stop you from looking silly when you push an RC to rubygems.org and
then realize it is broken.
### Release to RubyGems and NPM.
-IMPORTANT: The Action Cable client is released as an NPM package, so you must
-have Node.js installed, have an NPM account (npmjs.com), and be an actioncable
-package owner (`npm owner ls actioncable`) to do a full release. Do not release
-until you're set up with NPM!
+IMPORTANT: The Action Cable client and Action View's UJS adapter are released
+as NPM packages, so you must have Node.js installed, have an NPM account
+(npmjs.com), and be a package owner for `actioncable` and `rails-ujs` (you can
+check this via `npm owner ls actioncable` and `npm owner ls rails-ujs`) in
+order to do a full release. Do not release until you're set up with NPM!
Run `rake release`. This will populate the gemspecs and NPM package.json with
the current RAILS_VERSION, commit the changes, tag it, and push the gems to
@@ -179,7 +181,7 @@ more explanation on a particular step, see the RC steps.
Today, do this stuff in this order:
* Apply security patches to the release branch
-* Update CHANGELOG with security fixes.
+* Update CHANGELOG with security fixes
* Update RAILS_VERSION to remove the rc
* Build and test the gem
* Release the gems
@@ -206,7 +208,7 @@ so we need to give them the security fixes in patch form.
* Blog announcements
* Twitter announcements
-* Merge the release branch to the stable branch.
+* Merge the release branch to the stable branch
* Drink beer (or other cocktail)
## Misc
diff --git a/actioncable/test/javascript/src/test_helpers/consumer_test_helper.coffee b/actioncable/test/javascript/src/test_helpers/consumer_test_helper.coffee
index 6b145dede8..a9e95c37f0 100644
--- a/actioncable/test/javascript/src/test_helpers/consumer_test_helper.coffee
+++ b/actioncable/test/javascript/src/test_helpers/consumer_test_helper.coffee
@@ -21,6 +21,16 @@ TestHelpers.consumerTest = (name, options = {}, callback) ->
assert.equal clients.length, 1
assert.equal clients[0].readyState, WebSocket.OPEN
+ server.broadcastTo = (subscription, data = {}, callback) ->
+ data.identifier = subscription.identifier
+
+ if data.message_type
+ data.type = ActionCable.INTERNAL.message_types[data.message_type]
+ delete data.message_type
+
+ server.send(JSON.stringify(data))
+ TestHelpers.defer(callback)
+
done = ->
consumer.disconnect()
server.close()
diff --git a/actioncable/test/javascript/src/test_helpers/index.coffee b/actioncable/test/javascript/src/test_helpers/index.coffee
index d36524d9cc..c84cbbcb2c 100644
--- a/actioncable/test/javascript/src/test_helpers/index.coffee
+++ b/actioncable/test/javascript/src/test_helpers/index.coffee
@@ -4,5 +4,8 @@
ActionCable.TestHelpers =
testURL: "ws://cable.example.com/"
+ defer: (callback) ->
+ setTimeout(callback, 1)
+
originalWebSocket = ActionCable.WebSocket
QUnit.testDone -> ActionCable.WebSocket = originalWebSocket
diff --git a/actioncable/test/javascript/src/unit/subscription_test.coffee b/actioncable/test/javascript/src/unit/subscription_test.coffee
new file mode 100644
index 0000000000..07027ed170
--- /dev/null
+++ b/actioncable/test/javascript/src/unit/subscription_test.coffee
@@ -0,0 +1,40 @@
+{module, test} = QUnit
+{consumerTest} = ActionCable.TestHelpers
+
+module "ActionCable.Subscription", ->
+ consumerTest "#initialized callback", ({server, consumer, assert, done}) ->
+ consumer.subscriptions.create "chat",
+ initialized: ->
+ assert.ok true
+ done()
+
+ consumerTest "#connected callback", ({server, consumer, assert, done}) ->
+ subscription = consumer.subscriptions.create "chat",
+ connected: ->
+ assert.ok true
+ done()
+
+ server.broadcastTo(subscription, message_type: "confirmation")
+
+ consumerTest "#disconnected callback", ({server, consumer, assert, done}) ->
+ subscription = consumer.subscriptions.create "chat",
+ disconnected: ->
+ assert.ok true
+ done()
+
+ server.broadcastTo subscription, message_type: "confirmation", ->
+ server.close()
+
+ consumerTest "#perform", ({consumer, server, assert, done}) ->
+ subscription = consumer.subscriptions.create "chat",
+ connected: ->
+ @perform(publish: "hi")
+
+ server.on "message", (message) ->
+ data = JSON.parse(message)
+ assert.equal data.identifier, subscription.identifier
+ assert.equal data.command, "message"
+ assert.deepEqual data.data, JSON.stringify(action: { publish: "hi" })
+ done()
+
+ server.broadcastTo(subscription, message_type: "confirmation")
diff --git a/actioncable/test/javascript/src/unit/subscriptions_test.coffee b/actioncable/test/javascript/src/unit/subscriptions_test.coffee
new file mode 100644
index 0000000000..170b370e4a
--- /dev/null
+++ b/actioncable/test/javascript/src/unit/subscriptions_test.coffee
@@ -0,0 +1,25 @@
+{module, test} = QUnit
+{consumerTest} = ActionCable.TestHelpers
+
+module "ActionCable.Subscriptions", ->
+ consumerTest "create subscription with channel string", ({consumer, server, assert, done}) ->
+ channel = "chat"
+
+ server.on "message", (message) ->
+ data = JSON.parse(message)
+ assert.equal data.command, "subscribe"
+ assert.equal data.identifier, JSON.stringify({channel})
+ done()
+
+ consumer.subscriptions.create(channel)
+
+ consumerTest "create subscription with channel object", ({consumer, server, assert, done}) ->
+ channel = channel: "chat", room: "action"
+
+ server.on "message", (message) ->
+ data = JSON.parse(message)
+ assert.equal data.command, "subscribe"
+ assert.equal data.identifier, JSON.stringify(channel)
+ done()
+
+ consumer.subscriptions.create(channel)
diff --git a/actionmailer/bin/test b/actionmailer/bin/test
index 84a05bba08..a7beb14b27 100755
--- a/actionmailer/bin/test
+++ b/actionmailer/bin/test
@@ -2,5 +2,3 @@
COMPONENT_ROOT = File.expand_path("..", __dir__)
require File.expand_path("../tools/test", COMPONENT_ROOT)
-
-exit Minitest.run(ARGV)
diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md
index 0c8625ae08..3123fc9786 100644
--- a/actionpack/CHANGELOG.md
+++ b/actionpack/CHANGELOG.md
@@ -1,3 +1,14 @@
+* Use accept header in integration tests with `as: :json`
+
+ Instead of appending the `format` to the request path. Rails will figure
+ out the format from the header instead.
+
+ This allows devs to use `:as` on routes that don't have a format.
+
+ Fixes #27144.
+
+ *Kasper Timm Hansen*
+
* Reset a new session directly after its creation in ActionDispatch::IntegrationTest#open_session.
Fixes #22742.
diff --git a/actionpack/bin/test b/actionpack/bin/test
index 84a05bba08..a7beb14b27 100755
--- a/actionpack/bin/test
+++ b/actionpack/bin/test
@@ -2,5 +2,3 @@
COMPONENT_ROOT = File.expand_path("..", __dir__)
require File.expand_path("../tools/test", COMPONENT_ROOT)
-
-exit Minitest.run(ARGV)
diff --git a/actionpack/lib/action_controller/metal/force_ssl.rb b/actionpack/lib/action_controller/metal/force_ssl.rb
index b8976497a4..9d43e752ac 100644
--- a/actionpack/lib/action_controller/metal/force_ssl.rb
+++ b/actionpack/lib/action_controller/metal/force_ssl.rb
@@ -89,7 +89,7 @@ module ActionController
end
secure_url = ActionDispatch::Http::URL.url_for(options.slice(*URL_OPTIONS))
- flash.keep if respond_to?(:flash)
+ flash.keep if respond_to?(:flash) && request.respond_to?(:flash)
redirect_to secure_url, options.slice(*REDIRECT_OPTIONS)
end
end
diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb
index a1c2a8858a..1ab6158c90 100644
--- a/actionpack/lib/action_dispatch/testing/integration.rb
+++ b/actionpack/lib/action_dispatch/testing/integration.rb
@@ -211,7 +211,7 @@ module ActionDispatch
end
if path =~ %r{://}
- path = build_expanded_path(path, request_encoder) do |location|
+ path = build_expanded_path(path) do |location|
https! URI::HTTPS === location if location.scheme
if url_host = location.host
@@ -220,8 +220,6 @@ module ActionDispatch
host! url_host
end
end
- elsif as
- path = build_expanded_path(path, request_encoder)
end
hostname, port = host.split(":")
@@ -239,7 +237,7 @@ module ActionDispatch
"HTTP_HOST" => host,
"REMOTE_ADDR" => remote_addr,
"CONTENT_TYPE" => request_encoder.content_type,
- "HTTP_ACCEPT" => accept
+ "HTTP_ACCEPT" => request_encoder.accept_header || accept
}
wrapped_headers = Http::Headers.from_hash({})
@@ -291,10 +289,10 @@ module ActionDispatch
"#{env['rack.url_scheme']}://#{env['SERVER_NAME']}:#{env['SERVER_PORT']}#{path}"
end
- def build_expanded_path(path, request_encoder)
+ def build_expanded_path(path)
location = URI.parse(path)
yield location if block_given?
- path = request_encoder.append_format_to location.path
+ path = location.path
location.query ? "#{path}?#{location.query}" : path
end
end
@@ -579,13 +577,15 @@ module ActionDispatch
# end
# end
#
- # The +as+ option sets the format to JSON, sets the content type to
+ # The +as+ option passes an "application/json" Accept header (thereby setting
+ # the request format to JSON unless overridden), sets the content type to
# "application/json" and encodes the parameters as JSON.
#
# Calling +parsed_body+ on the response parses the response body based on the
# last response MIME type.
#
- # For any custom MIME types you've registered, you can even add your own encoders with:
+ # Out of the box, only <tt>:json</tt> is supported. But for any custom MIME
+ # types you've registered, you can add your own encoders with:
#
# ActionDispatch::IntegrationTest.register_encoder :wibble,
# param_encoder: -> params { params.to_wibble },
diff --git a/actionpack/lib/action_dispatch/testing/request_encoder.rb b/actionpack/lib/action_dispatch/testing/request_encoder.rb
index b0b994b2d0..8c27e9ecb7 100644
--- a/actionpack/lib/action_dispatch/testing/request_encoder.rb
+++ b/actionpack/lib/action_dispatch/testing/request_encoder.rb
@@ -1,10 +1,17 @@
module ActionDispatch
class RequestEncoder # :nodoc:
- @encoders = {}
+ class IdentityEncoder
+ def content_type; end
+ def accept_header; end
+ def encode_params(params); params; end
+ def response_parser; -> body { body }; end
+ end
+
+ @encoders = { identity: IdentityEncoder.new }
attr_reader :response_parser
- def initialize(mime_name, param_encoder, response_parser, url_encoded_form = false)
+ def initialize(mime_name, param_encoder, response_parser)
@mime = Mime[mime_name]
unless @mime
@@ -12,21 +19,15 @@ module ActionDispatch
"unregistered MIME Type: #{mime_name}. See `Mime::Type.register`."
end
- @url_encoded_form = url_encoded_form
- @path_format = ".#{@mime.symbol}" unless @url_encoded_form
- @response_parser = response_parser || -> body { body }
- @param_encoder = param_encoder || :"to_#{@mime.symbol}".to_proc
+ @response_parser = response_parser || -> body { body }
+ @param_encoder = param_encoder || :"to_#{@mime.symbol}".to_proc
end
- def append_format_to(path)
- if @url_encoded_form
- path
- else
- path + @path_format
- end
+ def content_type
+ @mime.to_s
end
- def content_type
+ def accept_header
@mime.to_s
end
@@ -40,7 +41,7 @@ module ActionDispatch
end
def self.encoder(name)
- @encoders[name] || WWWFormEncoder
+ @encoders[name] || @encoders[:identity]
end
def self.register_encoder(mime_name, param_encoder: nil, response_parser: nil)
@@ -48,7 +49,5 @@ module ActionDispatch
end
register_encoder :json, response_parser: -> body { JSON.parse(body) }
-
- WWWFormEncoder = new(:url_encoded_form, -> params { params }, nil, true)
end
end
diff --git a/actionpack/test/controller/integration_test.rb b/actionpack/test/controller/integration_test.rb
index f89cfdb78c..d3aa81a0f7 100644
--- a/actionpack/test/controller/integration_test.rb
+++ b/actionpack/test/controller/integration_test.rb
@@ -145,7 +145,7 @@ class IntegrationTestTest < ActiveSupport::TestCase
name.to_s == "foo" ? "pass" : super
end
end
- @test.class.superclass.__send__(:include, mixin)
+ @test.class.superclass.include(mixin)
begin
assert_equal "pass", @test.foo
ensure
@@ -930,6 +930,10 @@ end
class IntegrationRequestEncodersTest < ActionDispatch::IntegrationTest
class FooController < ActionController::Base
+ def foos
+ render plain: "ok"
+ end
+
def foos_json
render json: params.permit(:foo)
end
@@ -958,13 +962,30 @@ class IntegrationRequestEncodersTest < ActionDispatch::IntegrationTest
def test_encoding_as_json
post_to_foos as: :json do
assert_response :success
- assert_match "foos_json.json", request.path
assert_equal "application/json", request.content_type
+ assert_equal "application/json", request.accepts.first.to_s
+ assert_equal :json, request.format.ref
assert_equal({ "foo" => "fighters" }, request.request_parameters)
assert_equal({ "foo" => "fighters" }, response.parsed_body)
end
end
+ def test_doesnt_mangle_request_path
+ with_routing do |routes|
+ routes.draw do
+ ActiveSupport::Deprecation.silence do
+ post ":action" => FooController
+ end
+ end
+
+ post "/foos"
+ assert_equal "/foos", request.path
+
+ post "/foos_json", as: :json
+ assert_equal "/foos_json", request.path
+ end
+ end
+
def test_encoding_as_without_mime_registration
assert_raise ArgumentError do
ActionDispatch::IntegrationTest.register_encoder :wibble
@@ -979,8 +1000,10 @@ class IntegrationRequestEncodersTest < ActionDispatch::IntegrationTest
post_to_foos as: :wibble do
assert_response :success
- assert_match "foos_wibble.wibble", request.path
+ assert_equal "/foos_wibble", request.path
assert_equal "text/wibble", request.content_type
+ assert_equal "text/wibble", request.accepts.first.to_s
+ assert_equal :wibble, request.format.ref
assert_equal Hash.new, request.request_parameters # Unregistered MIME Type can't be parsed.
assert_equal "ok", response.parsed_body
end
diff --git a/actionview/.gitignore b/actionview/.gitignore
new file mode 100644
index 0000000000..0a04b29786
--- /dev/null
+++ b/actionview/.gitignore
@@ -0,0 +1,2 @@
+/lib/assets/compiled
+/tmp
diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md
index 6e6ce64e72..558659dd77 100644
--- a/actionview/CHANGELOG.md
+++ b/actionview/CHANGELOG.md
@@ -1,3 +1,33 @@
+* Add `form_with` to unify `form_tag` and `form_for` usage.
+
+ Used like `form_tag` (where just the open tag is output):
+
+ ```erb
+ <%= form_with scope: :post, url: super_special_posts_path %>
+ ```
+
+ Used like `form_for`:
+
+ ```erb
+ <%= form_with model: @post do |form| %>
+ <%= form.text_field :title %>
+ <% end %>
+ ```
+
+ *Kasper Timm Hansen*, *Marek Kirejczyk*
+
+* Add `fields` form helper method.
+
+ ```erb
+ <%= fields :comment, model: @comment do |fields| %>
+ <%= fields.text_field :title %>
+ <% end %>
+ ```
+
+ Can also be used within form helpers such as `form_with`.
+
+ *Kasper Timm Hansen*
+
* Removed deprecated `#original_exception` in `ActionView::Template::Error`.
*Rafael Mendonça França*
diff --git a/actionview/RUNNING_UJS_TESTS.rdoc b/actionview/RUNNING_UJS_TESTS.rdoc
new file mode 100644
index 0000000000..cbf6bdccc6
--- /dev/null
+++ b/actionview/RUNNING_UJS_TESTS.rdoc
@@ -0,0 +1,7 @@
+== Running UJS tests
+
+Ensure that you can build the project and run tests.
+Run rake ujs:server first, and then run the web tests by
+visiting http://localhost:4567 in your browser.
+
+rake test:server
diff --git a/actionview/Rakefile b/actionview/Rakefile
index 6c3fc59b0a..48f17062ce 100644
--- a/actionview/Rakefile
+++ b/actionview/Rakefile
@@ -3,7 +3,7 @@ require "rake/testtask"
desc "Default Task"
task default: :test
-task :package
+task package: "assets:compile"
# Run the unit tests
@@ -46,6 +46,21 @@ namespace :test do
end
end
+namespace :ujs do
+ desc "Starts the test server"
+ task :server do
+ system 'bundle exec rackup test/ujs/config.ru -p 4567 -s puma'
+ end
+end
+
+namespace :assets do
+ desc "Compile Action View assets"
+ task :compile do
+ require "blade"
+ Blade.build
+ end
+end
+
task :lines do
load File.expand_path("..", File.dirname(__FILE__)) + "/tools/line_statistics"
files = FileList["lib/**/*.rb"]
diff --git a/actionview/app/assets/javascripts/config.coffee b/actionview/app/assets/javascripts/config.coffee
new file mode 100644
index 0000000000..3d4706b0e1
--- /dev/null
+++ b/actionview/app/assets/javascripts/config.coffee
@@ -0,0 +1,37 @@
+#= export Rails
+
+@Rails =
+ # Link elements bound by jquery-ujs
+ linkClickSelector: 'a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]'
+
+ # Button elements bound by jquery-ujs
+ buttonClickSelector:
+ selector: 'button[data-remote]:not([form]), button[data-confirm]:not([form])'
+ exclude: 'form button'
+
+ # Select elements bound by jquery-ujs
+ inputChangeSelector: 'select[data-remote], input[data-remote], textarea[data-remote]'
+
+ # Form elements bound by jquery-ujs
+ formSubmitSelector: 'form'
+
+ # Form input elements bound by jquery-ujs
+ formInputClickSelector: 'form input[type=submit], form input[type=image], form button[type=submit], form button:not([type]), input[type=submit][form], input[type=image][form], button[type=submit][form], button[form]:not([type])'
+
+ # Form input elements disabled during form submission
+ formDisableSelector: 'input[data-disable-with]:enabled, button[data-disable-with]:enabled, textarea[data-disable-with]:enabled, input[data-disable]:enabled, button[data-disable]:enabled, textarea[data-disable]:enabled'
+
+ # Form input elements re-enabled after form submission
+ formEnableSelector: 'input[data-disable-with]:disabled, button[data-disable-with]:disabled, textarea[data-disable-with]:disabled, input[data-disable]:disabled, button[data-disable]:disabled, textarea[data-disable]:disabled'
+
+ # Form required input elements
+ requiredInputSelector: 'input[name][required]:not([disabled]), textarea[name][required]:not([disabled])'
+
+ # Form file input elements
+ fileInputSelector: 'input[name][type=file]:not([disabled])'
+
+ # Link onClick disable selector with possible reenable after remote submission
+ linkDisableSelector: 'a[data-disable-with], a[data-disable]'
+
+ # Button onClick disable selector with possible reenable after remote submission
+ buttonDisableSelector: 'button[data-remote][data-disable-with], button[data-remote][data-disable]'
diff --git a/actionview/app/assets/javascripts/features/confirm.coffee b/actionview/app/assets/javascripts/features/confirm.coffee
new file mode 100644
index 0000000000..72b5aaa218
--- /dev/null
+++ b/actionview/app/assets/javascripts/features/confirm.coffee
@@ -0,0 +1,26 @@
+#= require_tree ../utils
+
+{ fire, stopEverything } = Rails
+
+Rails.handleConfirm = (e) ->
+ stopEverything(e) unless allowAction(this)
+
+# For 'data-confirm' attribute:
+# - Fires `confirm` event
+# - Shows the confirmation dialog
+# - Fires the `confirm:complete` event
+#
+# Returns `true` if no function stops the chain and user chose yes `false` otherwise.
+# Attaching a handler to the element's `confirm` event that returns a `falsy` value cancels the confirmation dialog.
+# Attaching a handler to the element's `confirm:complete` event that returns a `falsy` value makes this function
+# return false. The `confirm:complete` event is fired whether or not the user answered true or false to the dialog.
+allowAction = (element) ->
+ message = element.getAttribute('data-confirm')
+ return true unless message
+
+ answer = false
+ if fire(element, 'confirm')
+ try answer = confirm(message)
+ callback = fire(element, 'confirm:complete', [answer])
+
+ answer and callback
diff --git a/actionview/app/assets/javascripts/features/disable.coffee b/actionview/app/assets/javascripts/features/disable.coffee
new file mode 100644
index 0000000000..e8cce7da40
--- /dev/null
+++ b/actionview/app/assets/javascripts/features/disable.coffee
@@ -0,0 +1,78 @@
+#= require_tree ../utils
+
+{ matches, getData, setData, stopEverything, formElements } = Rails
+
+# Unified function to enable an element (link, button and form)
+Rails.enableElement = (e) ->
+ element = if e instanceof Event then e.target else e
+ if matches(element, Rails.linkDisableSelector)
+ enableLinkElement(element)
+ else if matches(element, Rails.buttonDisableSelector) or matches(element, Rails.formEnableSelector)
+ enableFormElement(element)
+ else if matches(element, Rails.formSubmitSelector)
+ enableFormElements(element)
+
+# Unified function to disable an element (link, button and form)
+Rails.disableElement = (e) ->
+ element = if e instanceof Event then e.target else e
+ if matches(element, Rails.linkDisableSelector)
+ disableLinkElement(element)
+ else if matches(element, Rails.buttonDisableSelector) or matches(element, Rails.formDisableSelector)
+ disableFormElement(element)
+ else if matches(element, Rails.formSubmitSelector)
+ disableFormElements(element)
+
+# Replace element's html with the 'data-disable-with' after storing original html
+# and prevent clicking on it
+disableLinkElement = (element) ->
+ replacement = element.getAttribute('data-disable-with')
+ if replacement?
+ setData(element, 'ujs:enable-with', element.innerHTML) # store enabled state
+ element.innerHTML = replacement
+ element.addEventListener('click', stopEverything) # prevent further clicking
+ setData(element, 'ujs:disabled', true)
+
+# Restore element to its original state which was disabled by 'disableLinkElement' above
+enableLinkElement = (element) ->
+ originalText = getData(element, 'ujs:enable-with')
+ if originalText?
+ element.innerHTML = originalText # set to old enabled state
+ setData(element, 'ujs:enable-with', null) # clean up cache
+ element.removeEventListener('click', stopEverything) # enable element
+ setData(element, 'ujs:disabled', null)
+
+# Disables form elements:
+# - Caches element value in 'ujs:enable-with' data store
+# - Replaces element text with value of 'data-disable-with' attribute
+# - Sets disabled property to true
+disableFormElements = (form) ->
+ formElements(form, Rails.formDisableSelector).forEach(disableFormElement)
+
+disableFormElement = (element) ->
+ replacement = element.getAttribute('data-disable-with')
+ if replacement?
+ if matches(element, 'button')
+ setData(element, 'ujs:enable-with', element.innerHTML)
+ element.innerHTML = replacement
+ else
+ setData(element, 'ujs:enable-with', element.value)
+ element.value = replacement
+ element.disabled = true
+ setData(element, 'ujs:disabled', true)
+
+# Re-enables disabled form elements:
+# - Replaces element text with cached value from 'ujs:enable-with' data store (created in `disableFormElements`)
+# - Sets disabled property to false
+enableFormElements = (form) ->
+ formElements(form, Rails.formEnableSelector).forEach(enableFormElement)
+
+enableFormElement = (element) ->
+ originalText = getData(element, 'ujs:enable-with')
+ if originalText?
+ if matches(element, 'button')
+ element.innerHTML = originalText
+ else
+ element.value = originalText
+ setData(element, 'ujs:enable-with', null) # clean up cache
+ element.disabled = false
+ setData(element, 'ujs:disabled', null)
diff --git a/actionview/app/assets/javascripts/features/method.coffee b/actionview/app/assets/javascripts/features/method.coffee
new file mode 100644
index 0000000000..d04d9414dd
--- /dev/null
+++ b/actionview/app/assets/javascripts/features/method.coffee
@@ -0,0 +1,34 @@
+#= require_tree ../utils
+
+{ stopEverything } = Rails
+
+# Handles "data-method" on links such as:
+# <a href="/users/5" data-method="delete" rel="nofollow" data-confirm="Are you sure?">Delete</a>
+Rails.handleMethod = (e) ->
+ link = this
+ method = link.getAttribute('data-method')
+ return unless method
+
+ href = Rails.href(link)
+ csrfToken = Rails.csrfToken()
+ csrfParam = Rails.csrfParam()
+ form = document.createElement('form')
+ formContent = "<input name='_method' value='#{method}' type='hidden' />"
+
+ if csrfParam? and csrfToken? and not Rails.isCrossDomain(href)
+ formContent += "<input name='#{csrfParam}' value='#{csrfToken}' type='hidden' />"
+
+ # Must trigger submit by click on a button, else "submit" event handler won't work!
+ # https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit
+ formContent += '<input type="submit" />'
+
+ form.method = 'post'
+ form.action = href
+ form.target = link.target
+ form.innerHTML = formContent
+ form.style.display = 'none'
+
+ document.body.appendChild(form)
+ form.querySelector('[type="submit"]').click()
+
+ stopEverything(e)
diff --git a/actionview/app/assets/javascripts/features/remote.coffee b/actionview/app/assets/javascripts/features/remote.coffee
new file mode 100644
index 0000000000..30a5dc21fa
--- /dev/null
+++ b/actionview/app/assets/javascripts/features/remote.coffee
@@ -0,0 +1,100 @@
+#= require_tree ../utils
+
+{
+ matches, getData, setData
+ fire, stopEverything
+ ajax, isCrossDomain
+ blankInputs, serializeElement
+} = Rails
+
+# Checks "data-remote" if true to handle the request through a XHR request.
+isRemote = (element) ->
+ value = element.getAttribute('data-remote')
+ value? and value isnt 'false'
+
+# Submits "remote" forms and links with ajax
+Rails.handleRemote = (e) ->
+ element = this
+
+ return true unless isRemote(element)
+ unless fire(element, 'ajax:before')
+ fire(element, 'ajax:stopped')
+ return false
+
+ withCredentials = element.getAttribute('data-with-credentials')
+ dataType = element.getAttribute('data-type') or 'script'
+
+ if matches(element, Rails.formSubmitSelector)
+ # memoized value from clicked submit button
+ button = getData(element, 'ujs:submit-button')
+ method = getData(element, 'ujs:submit-button-formmethod') or element.method
+ url = getData(element, 'ujs:submit-button-formaction') or element.getAttribute('action') or location.href
+
+ # strip query string if it's a GET request
+ url = url.replace(/\?.*$/, '') if method.toUpperCase() is 'GET'
+
+ if element.enctype is 'multipart/form-data'
+ data = new FormData(element)
+ data.append(button.name, button.value) if button?
+ else
+ data = serializeElement(element, button)
+
+ setData(element, 'ujs:submit-button', null)
+ setData(element, 'ujs:submit-button-formmethod', null)
+ setData(element, 'ujs:submit-button-formaction', null)
+ else if matches(element, Rails.buttonClickSelector) or matches(element, Rails.inputChangeSelector)
+ method = element.getAttribute('data-method')
+ url = element.getAttribute('data-url')
+ data = serializeElement(element, element.getAttribute('data-params'))
+ else
+ method = element.getAttribute('data-method')
+ url = Rails.href(element)
+ data = element.getAttribute('data-params')
+
+ ajax(
+ type: method or 'GET'
+ url: url
+ data: data
+ dataType: dataType
+ # stopping the "ajax:beforeSend" event will cancel the ajax request
+ beforeSend: (xhr, options) ->
+ if fire(element, 'ajax:beforeSend', [xhr, options])
+ fire(element, 'ajax:send', [xhr])
+ else
+ fire(element, 'ajax:stopped')
+ xhr.abort()
+ success: (args...) -> fire(element, 'ajax:success', args)
+ error: (args...) -> fire(element, 'ajax:error', args)
+ complete: (args...) -> fire(element, 'ajax:complete', args)
+ crossDomain: isCrossDomain(url)
+ withCredentials: withCredentials? and withCredentials isnt 'false'
+ )
+ stopEverything(e)
+
+# Check whether any required fields are empty
+# In both ajax mode and normal mode
+Rails.validateForm = (e) ->
+ form = this
+ return if form.noValidate or getData(form, 'ujs:formnovalidate-button')
+ # Skip other logic when required values are missing or file upload is present
+ blankRequiredInputs = blankInputs(form, Rails.requiredInputSelector, false)
+ if blankRequiredInputs.length > 0 and fire(form, 'ajax:aborted:required', [blankRequiredInputs])
+ stopEverything(e)
+
+Rails.formSubmitButtonClick = (e) ->
+ button = this
+ form = button.form
+ return unless form
+ # Register the pressed submit button
+ setData(form, 'ujs:submit-button', name: button.name, value: button.value) if button.name
+ # Save attributes from button
+ setData(form, 'ujs:formnovalidate-button', button.formNoValidate)
+ setData(form, 'ujs:submit-button-formaction', button.getAttribute('formaction'))
+ setData(form, 'ujs:submit-button-formmethod', button.getAttribute('formmethod'))
+
+Rails.handleMetaClick = (e) ->
+ link = this
+ method = (link.getAttribute('data-method') or 'GET').toUpperCase()
+ data = link.getAttribute('data-params')
+ metaClick = e.metaKey or e.ctrlKey
+ e.stopImmediatePropagation() if metaClick and method is 'GET' and not data
diff --git a/actionview/app/assets/javascripts/rails-ujs.coffee b/actionview/app/assets/javascripts/rails-ujs.coffee
new file mode 100644
index 0000000000..f96d2eb6fd
--- /dev/null
+++ b/actionview/app/assets/javascripts/rails-ujs.coffee
@@ -0,0 +1,76 @@
+#
+# Unobtrusive JavaScript
+# https://github.com/rails/rails-ujs
+#
+# Released under the MIT license
+#
+#= require ./config
+#= require_tree ./utils
+#= require_tree ./features
+
+{
+ fire, delegate
+ getData, $
+ refreshCSRFTokens, CSRFProtection
+ enableElement, disableElement
+ handleConfirm
+ handleRemote, validateForm, formSubmitButtonClick, handleMetaClick
+ handleMethod
+} = Rails
+
+# For backward compatibility
+if jQuery? and not jQuery.rails
+ jQuery.rails = Rails
+ jQuery.ajaxPrefilter (options, originalOptions, xhr) ->
+ CSRFProtection(xhr) unless options.crossDomain
+
+Rails.start = ->
+ # Cut down on the number of issues from people inadvertently including jquery_ujs twice
+ # by detecting and raising an error when it happens.
+ throw new Error('jquery-ujs has already been loaded!') if window._rails_loaded
+
+ # This event works the same as the load event, except that it fires every
+ # time the page is loaded.
+ # See https://github.com/rails/jquery-ujs/issues/357
+ # See https://developer.mozilla.org/en-US/docs/Using_Firefox_1.5_caching
+ window.addEventListener 'pageshow', ->
+ $(Rails.formEnableSelector).forEach (el) ->
+ enableElement(el) if getData(el, 'ujs:disabled')
+ $(Rails.linkDisableSelector).forEach (el) ->
+ enableElement(el) if getData(el, 'ujs:disabled')
+
+ delegate document, Rails.linkDisableSelector, 'ajax:complete', enableElement
+ delegate document, Rails.linkDisableSelector, 'ajax:stopped', enableElement
+ delegate document, Rails.buttonDisableSelector, 'ajax:complete', enableElement
+ delegate document, Rails.buttonDisableSelector, 'ajax:stopped', enableElement
+
+ delegate document, Rails.linkClickSelector, 'click', handleConfirm
+ delegate document, Rails.linkClickSelector, 'click', handleMetaClick
+ delegate document, Rails.linkClickSelector, 'click', disableElement
+ delegate document, Rails.linkClickSelector, 'click', handleRemote
+ delegate document, Rails.linkClickSelector, 'click', handleMethod
+
+ delegate document, Rails.buttonClickSelector, 'click', handleConfirm
+ delegate document, Rails.buttonClickSelector, 'click', disableElement
+ delegate document, Rails.buttonClickSelector, 'click', handleRemote
+
+ delegate document, Rails.inputChangeSelector, 'change', handleConfirm
+ delegate document, Rails.inputChangeSelector, 'change', handleRemote
+
+ delegate document, Rails.formSubmitSelector, 'submit', handleConfirm
+ delegate document, Rails.formSubmitSelector, 'submit', validateForm
+ delegate document, Rails.formSubmitSelector, 'submit', handleRemote
+ # Normal mode submit
+ # Slight timeout so that the submit button gets properly serialized
+ delegate document, Rails.formSubmitSelector, 'submit', (e) -> setTimeout((-> disableElement(e)), 13)
+ delegate document, Rails.formSubmitSelector, 'ajax:send', disableElement
+ delegate document, Rails.formSubmitSelector, 'ajax:complete', enableElement
+
+ delegate document, Rails.formInputClickSelector, 'click', handleConfirm
+ delegate document, Rails.formInputClickSelector, 'click', formSubmitButtonClick
+
+ document.addEventListener('DOMContentLoaded', refreshCSRFTokens)
+ window._rails_loaded = true
+
+if window.Rails is Rails and fire(document, 'rails:attachBindings')
+ Rails.start()
diff --git a/actionview/app/assets/javascripts/utils/ajax.coffee b/actionview/app/assets/javascripts/utils/ajax.coffee
new file mode 100644
index 0000000000..9af515beda
--- /dev/null
+++ b/actionview/app/assets/javascripts/utils/ajax.coffee
@@ -0,0 +1,95 @@
+#= require ./csrf
+#= require ./event
+
+{ CSRFProtection, fire } = Rails
+
+AcceptHeaders =
+ '*': '*/*'
+ text: 'text/plain'
+ html: 'text/html'
+ xml: 'application/xml, text/xml'
+ json: 'application/json, text/javascript'
+ script: 'text/javascript, application/javascript, application/ecmascript, application/x-ecmascript'
+
+Rails.ajax = (options) ->
+ options = prepareOptions(options)
+ xhr = createXHR options, ->
+ response = processResponse(xhr.response, xhr.getResponseHeader('Content-Type'))
+ if xhr.status // 100 == 2
+ options.success?(response, xhr.statusText, xhr)
+ else
+ options.error?(response, xhr.statusText, xhr)
+ options.complete?(xhr, xhr.statusText)
+ # Call beforeSend hook
+ options.beforeSend?(xhr, options)
+ # Send the request
+ if xhr.readyState is XMLHttpRequest.OPENED
+ xhr.send(options.data)
+ else
+ fire(document, 'ajaxStop') # to be compatible with jQuery.ajax
+
+prepareOptions = (options) ->
+ options.type = options.type.toUpperCase()
+ # append data to url if it's a GET request
+ if options.type is 'GET' and options.data
+ if options.url.indexOf('?') < 0
+ options.url += '?' + options.data
+ else
+ options.url += '&' + options.data
+ # Use "*" as default dataType
+ options.dataType = '*' unless AcceptHeaders[options.dataType]?
+ options.accept = AcceptHeaders[options.dataType]
+ options.accept += ', */*; q=0.01' if options.dataType isnt '*'
+ options
+
+createXHR = (options, done) ->
+ xhr = new XMLHttpRequest()
+ # Open and setup xhr
+ xhr.open(options.type, options.url, true)
+ xhr.setRequestHeader('Accept', options.accept)
+ # Set Content-Type only when sending a string
+ # Sending FormData will automatically set Content-Type to multipart/form-data
+ if typeof options.data is 'string'
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8')
+ xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest') unless options.crossDomain
+ # Add X-CSRF-Token
+ CSRFProtection(xhr)
+ xhr.withCredentials = !!options.withCredentials
+ xhr.onreadystatechange = ->
+ done(xhr) if xhr.readyState is XMLHttpRequest.DONE
+ xhr
+
+processResponse = (response, type) ->
+ if typeof response is 'string' and typeof type is 'string'
+ if type.match(/\bjson\b/)
+ try response = JSON.parse(response)
+ else if type.match(/\bjavascript\b/)
+ script = document.createElement('script')
+ script.innerHTML = response
+ document.body.appendChild(script)
+ else if type.match(/\b(xml|html|svg)\b/)
+ parser = new DOMParser()
+ type = type.replace(/;.+/, '') # remove something like ';charset=utf-8'
+ try response = parser.parseFromString(response, type)
+ response
+
+# Default way to get an element's href. May be overridden at Rails.href.
+Rails.href = (element) -> element.href
+
+# Determines if the request is a cross domain request.
+Rails.isCrossDomain = (url) ->
+ originAnchor = document.createElement('a')
+ originAnchor.href = location.href
+ urlAnchor = document.createElement('a')
+ try
+ urlAnchor.href = url
+ # If URL protocol is false or is a string containing a single colon
+ # *and* host are false, assume it is not a cross-domain request
+ # (should only be the case for IE7 and IE compatibility mode).
+ # Otherwise, evaluate protocol and host of the URL against the origin
+ # protocol and host.
+ !(((!urlAnchor.protocol || urlAnchor.protocol == ':') && !urlAnchor.host) ||
+ (originAnchor.protocol + '//' + originAnchor.host == urlAnchor.protocol + '//' + urlAnchor.host))
+ catch e
+ # If there is an error parsing the URL, assume it is crossDomain.
+ true
diff --git a/actionview/app/assets/javascripts/utils/csrf.coffee b/actionview/app/assets/javascripts/utils/csrf.coffee
new file mode 100644
index 0000000000..4eb5ebb414
--- /dev/null
+++ b/actionview/app/assets/javascripts/utils/csrf.coffee
@@ -0,0 +1,25 @@
+#= require ./dom
+
+{ $ } = Rails
+
+# Up-to-date Cross-Site Request Forgery token
+csrfToken = Rails.csrfToken = ->
+ meta = document.querySelector('meta[name=csrf-token]')
+ meta and meta.content
+
+# URL param that must contain the CSRF token
+csrfParam = Rails.csrfParam = ->
+ meta = document.querySelector('meta[name=csrf-param]')
+ meta and meta.content
+
+# Make sure that every Ajax request sends the CSRF token
+Rails.CSRFProtection = (xhr) ->
+ token = csrfToken()
+ xhr.setRequestHeader('X-CSRF-Token', token) if token?
+
+# Make sure that all forms have actual up-to-date tokens (cached forms contain old ones)
+Rails.refreshCSRFTokens = ->
+ token = csrfToken()
+ param = csrfParam()
+ if token? and param?
+ $('form input[name="' + param + '"]').forEach (input) -> input.value = token
diff --git a/actionview/app/assets/javascripts/utils/dom.coffee b/actionview/app/assets/javascripts/utils/dom.coffee
new file mode 100644
index 0000000000..6bef618147
--- /dev/null
+++ b/actionview/app/assets/javascripts/utils/dom.coffee
@@ -0,0 +1,28 @@
+m = Element.prototype.matches or
+ Element.prototype.matchesSelector or
+ Element.prototype.mozMatchesSelector or
+ Element.prototype.msMatchesSelector or
+ Element.prototype.oMatchesSelector or
+ Element.prototype.webkitMatchesSelector
+
+Rails.matches = (element, selector) ->
+ if selector.exclude?
+ m.call(element, selector.selector) and not m.call(element, selector.exclude)
+ else
+ m.call(element, selector)
+
+# get and set data on a given element using "expando properties"
+# See: https://developer.mozilla.org/en-US/docs/Glossary/Expando
+expando = '_ujsData'
+
+Rails.getData = (element, key) ->
+ element[expando]?[key]
+
+Rails.setData = (element, key, value) ->
+ element[expando] ?= {}
+ element[expando][key] = value
+
+# a wrapper for document.querySelectorAll
+# returns an Array
+Rails.$ = (selector) ->
+ Array.prototype.slice.call(document.querySelectorAll(selector))
diff --git a/actionview/app/assets/javascripts/utils/event.coffee b/actionview/app/assets/javascripts/utils/event.coffee
new file mode 100644
index 0000000000..049b2a3ecd
--- /dev/null
+++ b/actionview/app/assets/javascripts/utils/event.coffee
@@ -0,0 +1,40 @@
+#= require ./dom
+
+{ matches } = Rails
+
+# Polyfill for CustomEvent in IE9+
+# https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent#Polyfill
+CustomEvent = window.CustomEvent
+
+if typeof CustomEvent is 'function'
+ CustomEvent = (event, params) ->
+ evt = document.createEvent('CustomEvent')
+ evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail)
+ evt
+ CustomEvent.prototype = window.Event.prototype
+
+# Triggers an custom event on an element and returns false if the event result is false
+fire = Rails.fire = (obj, name, data) ->
+ event = new CustomEvent(
+ name,
+ bubbles: true,
+ cancelable: true,
+ detail: data,
+ )
+ obj.dispatchEvent(event)
+ !event.defaultPrevented
+
+# Helper function, needed to provide consistent behavior in IE
+Rails.stopEverything = (e) ->
+ fire(e.target, 'ujs:everythingStopped')
+ e.preventDefault()
+ e.stopPropagation()
+ e.stopImmediatePropagation()
+
+Rails.delegate = (element, selector, eventType, handler) ->
+ element.addEventListener eventType, (e) ->
+ target = e.target
+ target = target.parentNode until not (target instanceof Element) or matches(target, selector)
+ if target instanceof Element and handler.call(target, e) == false
+ e.preventDefault()
+ e.stopPropagation()
diff --git a/actionview/app/assets/javascripts/utils/form.coffee b/actionview/app/assets/javascripts/utils/form.coffee
new file mode 100644
index 0000000000..251113deda
--- /dev/null
+++ b/actionview/app/assets/javascripts/utils/form.coffee
@@ -0,0 +1,61 @@
+#= require ./dom
+
+{ matches } = Rails
+
+toArray = (e) -> Array.prototype.slice.call(e)
+
+Rails.serializeElement = (element, additionalParam) ->
+ inputs = [element]
+ inputs = toArray(element.elements) if matches(element, 'form')
+ params = []
+
+ inputs.forEach (input) ->
+ return unless input.name
+ if matches(input, 'select')
+ toArray(input.options).forEach (option) ->
+ params.push(name: input.name, value: option.value) if option.selected
+ else if input.type isnt 'radio' and input.type isnt 'checkbox' or input.checked
+ params.push(name: input.name, value: input.value)
+
+ params.push(additionalParam) if additionalParam
+
+ params.map (param) ->
+ if param.name?
+ "#{encodeURIComponent(param.name)}=#{encodeURIComponent(param.value)}"
+ else
+ param
+ .join('&')
+
+# Helper function that returns form elements that match the specified CSS selector
+# If form is actually a "form" element this will return associated elements outside the from that have
+# the html form attribute set
+Rails.formElements = (form, selector) ->
+ if matches(form, 'form')
+ toArray(form.elements).filter (el) -> matches(el, selector)
+ else
+ toArray(form.querySelectorAll(selector))
+
+# Helper function which checks for blank inputs in a form that match the specified CSS selector
+Rails.blankInputs = (form, selector, nonBlank) ->
+ foundInputs = []
+ requiredInputs = toArray(form.querySelectorAll(selector or 'input, textarea'))
+ checkedRadioButtonNames = {}
+
+ requiredInputs.forEach (input) ->
+ if input.type is 'radio'
+ # Don't count unchecked required radio as blank if other radio with same name is checked,
+ # regardless of whether same-name radio input has required attribute or not. The spec
+ # states https://www.w3.org/TR/html5/forms.html#the-required-attribute
+ radioName = input.name
+ # Skip if we've already seen the radio with this name.
+ unless checkedRadioButtonNames[radioName]
+ # If none checked
+ if form.querySelectorAll("input[type=radio][name='#{radioName}']:checked").length == 0
+ radios = form.querySelectorAll("input[type=radio][name='#{radioName}']")
+ foundInputs = foundInputs.concat(toArray(radios))
+ # We only need to check each name once.
+ checkedRadioButtonNames[radioName] = radioName
+ else
+ valueToCheck = if input.type is 'checkbox' then input.checked else !!input.value
+ foundInputs.push(input) if valueToCheck is nonBlank
+ foundInputs
diff --git a/actionview/bin/test b/actionview/bin/test
index 84a05bba08..a7beb14b27 100755
--- a/actionview/bin/test
+++ b/actionview/bin/test
@@ -2,5 +2,3 @@
COMPONENT_ROOT = File.expand_path("..", __dir__)
require File.expand_path("../tools/test", COMPONENT_ROOT)
-
-exit Minitest.run(ARGV)
diff --git a/actionview/blade.yml b/actionview/blade.yml
new file mode 100644
index 0000000000..9e5eb953a4
--- /dev/null
+++ b/actionview/blade.yml
@@ -0,0 +1,11 @@
+load_paths:
+ - app/assets/javascripts
+
+logical_paths:
+ - rails-ujs.js
+
+build:
+ logical_paths:
+ - rails-ujs.js
+ path: lib/assets/compiled
+ clean: true
diff --git a/actionview/lib/action_view/helpers/form_helper.rb b/actionview/lib/action_view/helpers/form_helper.rb
index 9bffe860db..e7ea267211 100644
--- a/actionview/lib/action_view/helpers/form_helper.rb
+++ b/actionview/lib/action_view/helpers/form_helper.rb
@@ -474,6 +474,242 @@ module ActionView
end
private :apply_form_for_options!
+ # Creates a form tag based on mixing URLs, scopes, or models.
+ #
+ # # Using just a URL:
+ # <%= form_with url: posts_path do |form| %>
+ # <%= form.text_field :title %>
+ # <% end %>
+ # # =>
+ # <form action="/posts" method="post" data-remote="true">
+ # <input type="text" name="title">
+ # </form>
+ #
+ # # Adding a scope prefixes the input field names:
+ # <%= form_with scope: :post, url: posts_path do |form| %>
+ # <%= form.text_field :title %>
+ # <% end %>
+ # # =>
+ # <form action="/posts" method="post" data-remote="true">
+ # <input type="text" name="post[title]">
+ # </form>
+ #
+ # # Using a model infers both the URL and scope:
+ # <%= form_with model: Post.new do |form| %>
+ # <%= form.text_field :title %>
+ # <% end %>
+ # # =>
+ # <form action="/posts" method="post" data-remote="true">
+ # <input type="text" name="post[title]">
+ # </form>
+ #
+ # # An existing model makes an update form and fills out field values:
+ # <%= form_with model: Post.first do |form| %>
+ # <%= form.text_field :title %>
+ # <% end %>
+ # # =>
+ # <form action="/posts/1" method="post" data-remote="true">
+ # <input type="hidden" name="_method" value="patch">
+ # <input type="text" name="post[title]" value="<the title of the post>">
+ # </form>
+ #
+ # The parameters in the forms are accessible in controllers according to
+ # their name nesting. So inputs named +title+ and <tt>post[title]</tt> are
+ # accessible as <tt>params[:title]</tt> and <tt>params[:post][:title]</tt>
+ # respectively.
+ #
+ # By default +form_with+ attaches the <tt>data-remote</tt> attribute
+ # submitting the form via an XMLHTTPRequest in the background if an
+ # Unobtrusive JavaScript driver, like rails-ujs, is used. See the
+ # <tt>:remote</tt> option for more.
+ #
+ # For ease of comparison the examples above left out the submit button,
+ # as well as the auto generated hidden fields that enable UTF-8 support
+ # and adds an authenticity token needed for cross site request forgery
+ # protection.
+ #
+ # ==== +form_with+ options
+ #
+ # * <tt>:url</tt> - The URL the form submits to. Akin to values passed to
+ # +url_for+ or +link_to+. For example, you may use a named route
+ # directly. When a <tt>:scope</tt> is passed without a <tt>:url</tt> the
+ # form just submits to the current URL.
+ # * <tt>:method</tt> - The method to use when submitting the form, usually
+ # either "get" or "post". If "patch", "put", "delete", or another verb
+ # is used, a hidden input named <tt>_method</tt> is added to
+ # simulate the verb over post.
+ # * <tt>:format</tt> - The format of the route the form submits to.
+ # Useful when submitting to another resource type, like <tt>:json</tt>.
+ # Skipped if a <tt>:url</tt> is passed.
+ # * <tt>:scope</tt> - The scope to prefix input field names with and
+ # thereby how the submitted parameters are grouped in controllers.
+ # * <tt>:model</tt> - A model object to infer the <tt>:url</tt> and
+ # <tt>:scope</tt> by, plus fill out input field values.
+ # So if a +title+ attribute is set to "Ahoy!" then a +title+ input
+ # field's value would be "Ahoy!".
+ # If the model is a new record a create form is generated, if an
+ # existing record, however, an update form is generated.
+ # Pass <tt>:scope</tt> or <tt>:url</tt> to override the defaults.
+ # E.g. turn <tt>params[:post]</tt> into <tt>params[:article]</tt>.
+ # * <tt>:authenticity_token</tt> - Authenticity token to use in the form.
+ # Override with a custom authenticity token or pass <tt>false</tt> to
+ # skip the authenticity token field altogether.
+ # Useful when submitting to an external resource like a payment gateway
+ # that might limit the valid fields.
+ # Remote forms may omit the embedded authenticity token by setting
+ # <tt>config.action_view.embed_authenticity_token_in_remote_forms = false</tt>.
+ # This is helpful when fragment-caching the form. Remote forms
+ # get the authenticity token from the <tt>meta</tt> tag, so embedding is
+ # unnecessary unless you support browsers without JavaScript.
+ # * <tt>:local</tt> - By default form submits are remote and unobstrusive XHRs.
+ # Disable remote submits with <tt>local: true</tt>.
+ # * <tt>:skip_enforcing_utf8</tt> - By default a hidden field named +utf8+
+ # is output to enforce UTF-8 submits. Set to true to skip the field.
+ # * <tt>:builder</tt> - Override the object used to build the form.
+ # * <tt>:id</tt> - Optional HTML id attribute.
+ # * <tt>:class</tt> - Optional HTML class attribute.
+ # * <tt>:data</tt> - Optional HTML data attributes.
+ # * <tt>:html</tt> - Other optional HTML attributes for the form tag.
+ #
+ # === Examples
+ #
+ # When not passing a block, +form_with+ just generates an opening form tag.
+ #
+ # <%= form_with(model: @post, url: super_posts_path) %>
+ # <%= form_with(model: @post, scope: :article) %>
+ # <%= form_with(model: @post, format: :json) %>
+ # <%= form_with(model: @post, authenticity_token: false) %> # Disables the token.
+ #
+ # For namespaced routes, like +admin_post_url+:
+ #
+ # <%= form_with(model: [ :admin, @post ]) do |form| %>
+ # ...
+ # <% end %>
+ #
+ # If your resource has associations defined, for example, you want to add comments
+ # to the document given that the routes are set correctly:
+ #
+ # <%= form_with(model: [ @document, Comment.new ]) do |form| %>
+ # ...
+ # <% end %>
+ #
+ # Where <tt>@document = Document.find(params[:id])</tt>.
+ #
+ # === Mixing with other form helpers
+ #
+ # While +form_with+ uses a FormBuilder object it's possible to mix and
+ # match the stand-alone FormHelper methods and methods
+ # from FormTagHelper:
+ #
+ # <%= form_with scope: :person do |form| %>
+ # <%= form.text_field :first_name %>
+ # <%= form.text_field :last_name %>
+ #
+ # <%= text_area :person, :biography %>
+ # <%= check_box_tag "person[admin]", "1", @person.company.admin? %>
+ #
+ # <%= form.submit %>
+ # <% end %>
+ #
+ # Same goes for the methods in FormOptionHelper and DateHelper designed
+ # to work with an object as a base, like
+ # FormOptionHelper#collection_select and DateHelper#datetime_select.
+ #
+ # === Setting the method
+ #
+ # You can force the form to use the full array of HTTP verbs by setting
+ #
+ # method: (:get|:post|:patch|:put|:delete)
+ #
+ # in the options hash. If the verb is not GET or POST, which are natively
+ # supported by HTML forms, the form will be set to POST and a hidden input
+ # called _method will carry the intended verb for the server to interpret.
+ #
+ # === Setting HTML options
+ #
+ # You can set data attributes directly in a data hash, but HTML options
+ # besides id and class must be wrapped in an HTML key:
+ #
+ # <%= form_with(model: @post, data: { behavior: "autosave" }, html: { name: "go" }) do |form| %>
+ # ...
+ # <% end %>
+ #
+ # generates
+ #
+ # <form action="/posts/123" method="post" data-behavior="autosave" name="go">
+ # <input name="_method" type="hidden" value="patch" />
+ # ...
+ # </form>
+ #
+ # === Removing hidden model id's
+ #
+ # The +form_with+ method automatically includes the model id as a hidden field in the form.
+ # This is used to maintain the correlation between the form data and its associated model.
+ # Some ORM systems do not use IDs on nested models so in this case you want to be able
+ # to disable the hidden id.
+ #
+ # In the following example the Post model has many Comments stored within it in a NoSQL database,
+ # thus there is no primary key for comments.
+ #
+ # <%= form_with(model: @post) do |form| %>
+ # <%= form.fields(:comments, skip_id: true) do |fields| %>
+ # ...
+ # <% end %>
+ # <% end %>
+ #
+ # === Customized form builders
+ #
+ # You can also build forms using a customized FormBuilder class. Subclass
+ # FormBuilder and override or define some more helpers, then use your
+ # custom builder. For example, let's say you made a helper to
+ # automatically add labels to form inputs.
+ #
+ # <%= form_with model: @person, url: { action: "create" }, builder: LabellingFormBuilder do |form| %>
+ # <%= form.text_field :first_name %>
+ # <%= form.text_field :last_name %>
+ # <%= form.text_area :biography %>
+ # <%= form.check_box :admin %>
+ # <%= form.submit %>
+ # <% end %>
+ #
+ # In this case, if you use:
+ #
+ # <%= render form %>
+ #
+ # The rendered template is <tt>people/_labelling_form</tt> and the local
+ # variable referencing the form builder is called
+ # <tt>labelling_form</tt>.
+ #
+ # The custom FormBuilder class is automatically merged with the options
+ # of a nested +fields+ call, unless it's explicitly set.
+ #
+ # In many cases you will want to wrap the above in another helper, so you
+ # could do something like the following:
+ #
+ # def labelled_form_with(**options, &block)
+ # form_with(**options.merge(builder: LabellingFormBuilder), &block)
+ # end
+ def form_with(model: nil, scope: nil, url: nil, format: nil, **options)
+ if model
+ url ||= polymorphic_path(model, format: format)
+
+ model = model.last if model.is_a?(Array)
+ scope ||= model_name_from_record_or_class(model).param_key
+ end
+
+ if block_given?
+ builder = instantiate_builder(scope, model, options)
+ output = capture(builder, &Proc.new)
+ options[:multipart] ||= builder.multipart?
+
+ html_options = html_options_for_form_with(url, model, options)
+ form_tag_with_body(html_options, output)
+ else
+ html_options = html_options_for_form_with(url, model, options)
+ form_tag_html(html_options)
+ end
+ end
+
# Creates a scope around a specific model object like form_for, but
# doesn't create the form tags themselves. This makes fields_for suitable
# for specifying additional model objects in the same form.
@@ -720,6 +956,62 @@ module ActionView
capture(builder, &block)
end
+ # Scopes input fields with either an explicit scope or model.
+ # Like +form_with+ does with <tt>:scope</tt> or <tt>:model</tt>,
+ # except it doesn't output the form tags.
+ #
+ # # Using a scope prefixes the input field names:
+ # <%= fields :comment do |fields| %>
+ # <%= fields.text_field :body %>
+ # <% end %>
+ # # => <input type="text" name="comment[body] id="comment_body">
+ #
+ # # Using a model infers the scope and assigns field values:
+ # <%= fields model: Comment.new(body: "full bodied") do |fields| %<
+ # <%= fields.text_field :body %>
+ # <% end %>
+ # # =>
+ # <input type="text" name="comment[body] id="comment_body" value="full bodied">
+ #
+ # # Using +fields+ with +form_with+:
+ # <%= form_with model: @post do |form| %>
+ # <%= form.text_field :title %>
+ #
+ # <%= form.fields :comment do |fields| %>
+ # <%= fields.text_field :body %>
+ # <% end %>
+ # <% end %>
+ #
+ # Much like +form_with+ a FormBuilder instance associated with the scope
+ # or model is yielded, so any generated field names are prefixed with
+ # either the passed scope or the scope inferred from the <tt>:model</tt>.
+ #
+ # === Mixing with other form helpers
+ #
+ # While +form_with+ uses a FormBuilder object it's possible to mix and
+ # match the stand-alone FormHelper methods and methods
+ # from FormTagHelper:
+ #
+ # <%= fields model: @comment do |fields| %>
+ # <%= fields.text_field :body %>
+ #
+ # <%= text_area :commenter, :biography %>
+ # <%= check_box_tag "comment[all_caps]", "1", @comment.commenter.hulk_mode? %>
+ # <% end %>
+ #
+ # Same goes for the methods in FormOptionHelper and DateHelper designed
+ # to work with an object as a base, like
+ # FormOptionHelper#collection_select and DateHelper#datetime_select.
+ def fields(scope = nil, model: nil, **options, &block)
+ # TODO: Remove when ids and classes are no longer output by default.
+ if model
+ scope ||= model_name_from_record_or_class(model).param_key
+ end
+
+ builder = instantiate_builder(scope, model, options)
+ capture(builder, &block)
+ end
+
# Returns a label tag tailored for labelling an input field for a specified attribute (identified by +method+) on an object
# assigned to the template (identified by +object+). The text of label will default to the attribute name unless a translation
# is found in the current I18n locale (through helpers.label.<modelname>.<attribute>) or you specify it explicitly.
@@ -1175,6 +1467,32 @@ module ActionView
end
private
+ def html_options_for_form_with(url_for_options = nil, model = nil, html: {}, local: false,
+ skip_enforcing_utf8: false, **options)
+ html_options = options.except(:index, :include_id, :builder).merge(html)
+ html_options[:method] ||= :patch if model.respond_to?(:persisted?) && model.persisted?
+ html_options[:enforce_utf8] = !skip_enforcing_utf8
+
+ html_options[:enctype] = "multipart/form-data" if html_options.delete(:multipart)
+
+ # The following URL is unescaped, this is just a hash of options, and it is the
+ # responsibility of the caller to escape all the values.
+ html_options[:action] = url_for(url_for_options || {})
+ html_options[:"accept-charset"] = "UTF-8"
+ html_options[:"data-remote"] = true unless local
+
+ if !local && !embed_authenticity_token_in_remote_forms &&
+ html_options[:authenticity_token].blank?
+ # The authenticity token is taken from the meta tag in this case
+ html_options[:authenticity_token] = false
+ elsif html_options[:authenticity_token] == true
+ # Include the default authenticity_token, which is only generated when its set to nil,
+ # but we needed the true value to override the default of no authenticity_token on data-remote.
+ html_options[:authenticity_token] = nil
+ end
+
+ html_options.stringify_keys!
+ end
def instantiate_builder(record_name, record_object, options)
case record_name
@@ -1183,7 +1501,7 @@ module ActionView
object_name = record_name
else
object = record_name
- object_name = model_name_from_record_or_class(object).param_key
+ object_name = model_name_from_record_or_class(object).param_key if object
end
builder = options[:builder] || default_form_builder_class
@@ -1249,7 +1567,7 @@ module ActionView
# The methods which wrap a form helper call.
class_attribute :field_helpers
- self.field_helpers = [:fields_for, :label, :text_field, :password_field,
+ self.field_helpers = [:fields_for, :fields, :label, :text_field, :password_field,
:hidden_field, :file_field, :text_area, :check_box,
:radio_button, :color_field, :search_field,
:telephone_field, :phone_field, :date_field,
@@ -1286,6 +1604,9 @@ module ActionView
@nested_child_index = {}
@object_name, @object, @template, @options = object_name, object, template, options
@default_options = @options ? @options.slice(:index, :namespace) : {}
+
+ convert_to_legacy_options(@options)
+
if @object_name.to_s.match(/\[\]$/)
if (object ||= @template.instance_variable_get("@#{Regexp.last_match.pre_match}")) && object.respond_to?(:to_param)
@auto_index = object.to_param
@@ -1293,11 +1614,12 @@ module ActionView
raise ArgumentError, "object[] naming but object param and @object var don't exist or don't respond to to_param: #{object.inspect}"
end
end
+
@multipart = nil
@index = options[:index] || options[:child_index]
end
- (field_helpers - [:label, :check_box, :radio_button, :fields_for, :hidden_field, :file_field]).each do |selector|
+ (field_helpers - [:label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field]).each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {}) # def text_field(method, options = {})
@template.send( # @template.send(
@@ -1586,6 +1908,13 @@ module ActionView
@template.fields_for(record_name, record_object, fields_options, &block)
end
+ # See the docs for the <tt>ActionView::FormHelper.fields</tt> helper method.
+ def fields(scope = nil, model: nil, **options, &block)
+ convert_to_legacy_options(options)
+
+ fields_for(scope || model, model, **options, &block)
+ end
+
# Returns a label tag tailored for labelling an input field for a specified attribute (identified by +method+) on an object
# assigned to the template (identified by +object+). The text of label will default to the attribute name unless a translation
# is found in the current I18n locale (through helpers.label.<modelname>.<attribute>) or you specify it explicitly.
@@ -1934,6 +2263,16 @@ module ActionView
@nested_child_index[name] ||= -1
@nested_child_index[name] += 1
end
+
+ def convert_to_legacy_options(options)
+ if options.key?(:skip_id)
+ options[:include_id] = !options.delete(:skip_id)
+ end
+
+ if options.key?(:local)
+ options[:remote] = !options.delete(:local)
+ end
+ end
end
end
diff --git a/actionview/lib/action_view/railtie.rb b/actionview/lib/action_view/railtie.rb
index 68205cb720..ae72cea404 100644
--- a/actionview/lib/action_view/railtie.rb
+++ b/actionview/lib/action_view/railtie.rb
@@ -3,7 +3,7 @@ require "rails"
module ActionView
# = Action View Railtie
- class Railtie < Rails::Railtie # :nodoc:
+ class Railtie < Rails::Engine # :nodoc:
config.action_view = ActiveSupport::OrderedOptions.new
config.action_view.embed_authenticity_token_in_remote_forms = false
config.action_view.debug_missing_translation = true
diff --git a/actionview/package.json b/actionview/package.json
new file mode 100644
index 0000000000..ec3306c299
--- /dev/null
+++ b/actionview/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "rails-ujs",
+ "version": "0.0.1",
+ "description": "Ruby on Rails unobtrusive scripting adapter",
+ "main": "lib/assets/compiled/rails-ujs.js",
+ "files": [
+ "lib/assets/compiled/*.js"
+ ],
+ "directories": {
+ "test": "test"
+ },
+ "scripts": {
+ "build": "bundle exec blade build",
+ "test": "echo \"See the README: https://github.com/rails/rails-ujs#how-to-run-tests\" && exit 1",
+ "lint": "coffeelint src && eslint test/public/test",
+ },
+ "repository": {
+ "type": "git",
+ "url": "rails/rails"
+ },
+ "contributors": [
+ "Stephen St. Martin",
+ "Steve Schwartz",
+ "Dangyi Liu",
+ "All contributors"
+ ],
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/rails/rails/issues"
+ },
+ "homepage": "http://rubyonrails.org/",
+ "devDependencies": {
+ "coffeelint": "^1.15.7",
+ "eslint": "^2.13.1"
+ }
+}
diff --git a/actionview/test/actionpack/abstract/helper_test.rb b/actionview/test/actionpack/abstract/helper_test.rb
index 5a2f0839e5..83237518d7 100644
--- a/actionview/test/actionpack/abstract/helper_test.rb
+++ b/actionview/test/actionpack/abstract/helper_test.rb
@@ -109,7 +109,7 @@ module AbstractController
class InvalidHelpersTest < ActiveSupport::TestCase
def test_controller_raise_error_about_real_require_problem
e = assert_raise(LoadError) { AbstractInvalidHelpers.helper(:invalid_require) }
- assert_equal "No such file to load -- very_invalid_file_name", e.message
+ assert_equal "No such file to load -- very_invalid_file_name.rb", e.message
end
def test_controller_raise_error_about_missing_helper
diff --git a/actionview/test/template/form_helper/form_with_test.rb b/actionview/test/template/form_helper/form_with_test.rb
new file mode 100644
index 0000000000..c80a2f61b9
--- /dev/null
+++ b/actionview/test/template/form_helper/form_with_test.rb
@@ -0,0 +1,2134 @@
+require "abstract_unit"
+require "controller/fake_models"
+
+class FormWithTest < ActionView::TestCase
+ include RenderERBUtils
+end
+
+class FormWithActsLikeFormTagTest < FormWithTest
+ tests ActionView::Helpers::FormTagHelper
+
+ setup do
+ @controller = BasicController.new
+ end
+
+ def hidden_fields(options = {})
+ method = options[:method]
+ skip_enforcing_utf8 = options.fetch(:skip_enforcing_utf8, false)
+
+ "".tap do |txt|
+ unless skip_enforcing_utf8
+ txt << %{<input name="utf8" type="hidden" value="&#x2713;" />}
+ end
+
+ if method && !%w(get post).include?(method.to_s)
+ txt << %{<input name="_method" type="hidden" value="#{method}" />}
+ end
+ end
+ end
+
+ def form_text(action = "http://www.example.com", local: false, **options)
+ enctype, html_class, id, method = options.values_at(:enctype, :html_class, :id, :method)
+
+ method = method.to_s == "get" ? "get" : "post"
+
+ txt = %{<form accept-charset="UTF-8" action="#{action}"}
+ txt << %{ enctype="multipart/form-data"} if enctype
+ txt << %{ data-remote="true"} unless local
+ txt << %{ class="#{html_class}"} if html_class
+ txt << %{ id="#{id}"} if id
+ txt << %{ method="#{method}">}
+ end
+
+ def whole_form(action = "http://www.example.com", options = {})
+ out = form_text(action, options) + hidden_fields(options)
+
+ if block_given?
+ out << yield << "</form>"
+ end
+
+ out
+ end
+
+ def url_for(options)
+ if options.is_a?(Hash)
+ "http://www.example.com"
+ else
+ super
+ end
+ end
+
+ def test_form_with_multipart
+ actual = form_with(multipart: true)
+
+ expected = whole_form("http://www.example.com", enctype: true)
+ assert_dom_equal expected, actual
+ end
+
+ def test_form_with_with_method_patch
+ actual = form_with(method: :patch)
+
+ expected = whole_form("http://www.example.com", method: :patch)
+ assert_dom_equal expected, actual
+ end
+
+ def test_form_with_with_method_put
+ actual = form_with(method: :put)
+
+ expected = whole_form("http://www.example.com", method: :put)
+ assert_dom_equal expected, actual
+ end
+
+ def test_form_with_with_method_delete
+ actual = form_with(method: :delete)
+
+ expected = whole_form("http://www.example.com", method: :delete)
+ assert_dom_equal expected, actual
+ end
+
+ def test_form_with_with_local_true
+ actual = form_with(local: true)
+
+ expected = whole_form("http://www.example.com", local: true)
+ assert_dom_equal expected, actual
+ end
+
+ def test_form_with_skip_enforcing_utf8_true
+ actual = form_with(skip_enforcing_utf8: true)
+ expected = whole_form("http://www.example.com", skip_enforcing_utf8: true)
+ assert_dom_equal expected, actual
+ assert actual.html_safe?
+ end
+
+ def test_form_with_with_block_in_erb
+ output_buffer = render_erb("<%= form_with(url: 'http://www.example.com') do %>Hello world!<% end %>")
+
+ expected = whole_form { "Hello world!" }
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_block_and_method_in_erb
+ output_buffer = render_erb("<%= form_with(url: 'http://www.example.com', method: :put) do %>Hello world!<% end %>")
+
+ expected = whole_form("http://www.example.com", method: "put") do
+ "Hello world!"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+end
+
+class FormWithActsLikeFormForTest < FormWithTest
+ def form_with(*)
+ @output_buffer = super
+ end
+
+ teardown do
+ I18n.backend.reload!
+ end
+
+ setup do
+ # Create "label" locale for testing I18n label helpers
+ I18n.backend.store_translations "label",
+ activemodel: {
+ attributes: {
+ post: {
+ cost: "Total cost"
+ },
+ "post/language": {
+ spanish: "Espanol"
+ }
+ }
+ },
+ helpers: {
+ label: {
+ post: {
+ body: "Write entire text here",
+ color: {
+ red: "Rojo"
+ },
+ comments: {
+ body: "Write body here"
+ }
+ },
+ tag: {
+ value: "Tag"
+ },
+ post_delegate: {
+ title: "Delegate model_name title"
+ }
+ }
+ }
+
+ # Create "submit" locale for testing I18n submit helpers
+ I18n.backend.store_translations "submit",
+ helpers: {
+ submit: {
+ create: "Create %{model}",
+ update: "Confirm %{model} changes",
+ submit: "Save changes",
+ another_post: {
+ update: "Update your %{model}"
+ }
+ }
+ }
+
+ I18n.backend.store_translations "placeholder",
+ activemodel: {
+ attributes: {
+ post: {
+ cost: "Total cost"
+ },
+ "post/cost": {
+ uk: "Pounds"
+ }
+ }
+ },
+ helpers: {
+ placeholder: {
+ post: {
+ title: "What is this about?",
+ written_on: {
+ spanish: "Escrito en"
+ },
+ comments: {
+ body: "Write body here"
+ }
+ },
+ post_delegate: {
+ title: "Delegate model_name title"
+ },
+ tag: {
+ value: "Tag"
+ }
+ }
+ }
+
+ @post = Post.new
+ @comment = Comment.new
+ def @post.errors()
+ Class.new {
+ def [](field); field == "author_name" ? ["can't be empty"] : [] end
+ def empty?() false end
+ def count() 1 end
+ def full_messages() ["Author name can't be empty"] end
+ }.new
+ end
+ def @post.to_key; [123]; end
+ def @post.id; 0; end
+ def @post.id_before_type_cast; "omg"; end
+ def @post.id_came_from_user?; true; end
+ def @post.to_param; "123"; end
+
+ @post.persisted = true
+ @post.title = "Hello World"
+ @post.author_name = ""
+ @post.body = "Back to the hill and over it again!"
+ @post.secret = 1
+ @post.written_on = Date.new(2004, 6, 15)
+
+ @post.comments = []
+ @post.comments << @comment
+
+ @post.tags = []
+ @post.tags << Tag.new
+
+ @post_delegator = PostDelegator.new
+
+ @post_delegator.title = "Hello World"
+
+ @car = Car.new("#000FFF")
+ end
+
+ Routes = ActionDispatch::Routing::RouteSet.new
+ Routes.draw do
+ resources :posts do
+ resources :comments
+ end
+
+ namespace :admin do
+ resources :posts do
+ resources :comments
+ end
+ end
+
+ get "/foo", to: "controller#action"
+ root to: "main#index"
+ end
+
+ def _routes
+ Routes
+ end
+
+ include Routes.url_helpers
+
+ def url_for(object)
+ @url_for_options = object
+
+ if object.is_a?(Hash) && object[:use_route].blank? && object[:controller].blank?
+ object.merge!(controller: "main", action: "index")
+ end
+
+ super
+ end
+
+ def test_form_with_requires_arguments
+ error = assert_raises(ArgumentError) do
+ form_for(nil, html: { id: "create-post" }) do
+ end
+ end
+ assert_equal "First argument in form cannot contain nil or be empty", error.message
+
+ error = assert_raises(ArgumentError) do
+ form_for([nil, nil], html: { id: "create-post" }) do
+ end
+ end
+ assert_equal "First argument in form cannot contain nil or be empty", error.message
+ end
+
+ def test_form_with
+ form_with(model: @post, id: "create-post") do |f|
+ concat f.label(:title) { "The Title" }
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ concat f.submit("Create post")
+ concat f.button("Create post")
+ concat f.button {
+ concat content_tag(:span, "Create post")
+ }
+ end
+
+ expected = whole_form("/posts/123", "create-post", method: "patch") do
+ "<label for='post_title'>The Title</label>" +
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />" +
+ "<textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[secret]' type='hidden' value='0' />" +
+ "<input name='post[secret]' checked='checked' type='checkbox' id='post_secret' value='1' />" +
+ "<input name='commit' data-disable-with='Create post' type='submit' value='Create post' />" +
+ "<button name='button' type='submit'>Create post</button>" +
+ "<button name='button' type='submit'><span>Create post</span></button>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_collection_radio_buttons
+ post = Post.new
+ def post.active; false; end
+ form_with(model: post) do |f|
+ concat f.collection_radio_buttons(:active, [true, false], :to_s, :to_s)
+ end
+
+ expected = whole_form("/posts") do
+ "<input type='hidden' name='post[active]' value='' />" +
+ "<input id='post_active_true' name='post[active]' type='radio' value='true' />" +
+ "<label for='post_active_true'>true</label>" +
+ "<input checked='checked' id='post_active_false' name='post[active]' type='radio' value='false' />" +
+ "<label for='post_active_false'>false</label>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_collection_radio_buttons_with_custom_builder_block
+ post = Post.new
+ def post.active; false; end
+
+ form_with(model: post) do |f|
+ rendered_radio_buttons = f.collection_radio_buttons(:active, [true, false], :to_s, :to_s) do |b|
+ b.label { b.radio_button + b.text }
+ end
+ concat rendered_radio_buttons
+ end
+
+ expected = whole_form("/posts") do
+ "<input type='hidden' name='post[active]' value='' />" +
+ "<label for='post_active_true'>" +
+ "<input id='post_active_true' name='post[active]' type='radio' value='true' />" +
+ "true</label>" +
+ "<label for='post_active_false'>" +
+ "<input checked='checked' id='post_active_false' name='post[active]' type='radio' value='false' />" +
+ "false</label>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_collection_radio_buttons_with_custom_builder_block_does_not_leak_the_template
+ post = Post.new
+ def post.active; false; end
+ def post.id; 1; end
+
+ form_with(model: post) do |f|
+ rendered_radio_buttons = f.collection_radio_buttons(:active, [true, false], :to_s, :to_s) do |b|
+ b.label { b.radio_button + b.text }
+ end
+ concat rendered_radio_buttons
+ concat f.hidden_field :id
+ end
+
+ expected = whole_form("/posts") do
+ "<input type='hidden' name='post[active]' value='' />" +
+ "<label for='post_active_true'>" +
+ "<input id='post_active_true' name='post[active]' type='radio' value='true' />" +
+ "true</label>" +
+ "<label for='post_active_false'>" +
+ "<input checked='checked' id='post_active_false' name='post[active]' type='radio' value='false' />" +
+ "false</label>" +
+ "<input id='post_id' name='post[id]' type='hidden' value='1' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_index_and_with_collection_radio_buttons
+ post = Post.new
+ def post.active; false; end
+
+ form_with(model: post, index: "1") do |f|
+ concat f.collection_radio_buttons(:active, [true, false], :to_s, :to_s)
+ end
+
+ expected = whole_form("/posts") do
+ "<input type='hidden' name='post[1][active]' value='' />" +
+ "<input id='post_1_active_true' name='post[1][active]' type='radio' value='true' />" +
+ "<label for='post_1_active_true'>true</label>" +
+ "<input checked='checked' id='post_1_active_false' name='post[1][active]' type='radio' value='false' />" +
+ "<label for='post_1_active_false'>false</label>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_collection_check_boxes
+ post = Post.new
+ def post.tag_ids; [1, 3]; end
+ collection = (1..3).map { |i| [i, "Tag #{i}"] }
+ form_with(model: post) do |f|
+ concat f.collection_check_boxes(:tag_ids, collection, :first, :last)
+ end
+
+ expected = whole_form("/posts") do
+ "<input name='post[tag_ids][]' type='hidden' value='' />" +
+ "<input checked='checked' id='post_tag_ids_1' name='post[tag_ids][]' type='checkbox' value='1' />" +
+ "<label for='post_tag_ids_1'>Tag 1</label>" +
+ "<input id='post_tag_ids_2' name='post[tag_ids][]' type='checkbox' value='2' />" +
+ "<label for='post_tag_ids_2'>Tag 2</label>" +
+ "<input checked='checked' id='post_tag_ids_3' name='post[tag_ids][]' type='checkbox' value='3' />" +
+ "<label for='post_tag_ids_3'>Tag 3</label>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_collection_check_boxes_with_custom_builder_block
+ post = Post.new
+ def post.tag_ids; [1, 3]; end
+ collection = (1..3).map { |i| [i, "Tag #{i}"] }
+ form_with(model: post) do |f|
+ rendered_check_boxes = f.collection_check_boxes(:tag_ids, collection, :first, :last) do |b|
+ b.label { b.check_box + b.text }
+ end
+ concat rendered_check_boxes
+ end
+
+ expected = whole_form("/posts") do
+ "<input name='post[tag_ids][]' type='hidden' value='' />" +
+ "<label for='post_tag_ids_1'>" +
+ "<input checked='checked' id='post_tag_ids_1' name='post[tag_ids][]' type='checkbox' value='1' />" +
+ "Tag 1</label>" +
+ "<label for='post_tag_ids_2'>" +
+ "<input id='post_tag_ids_2' name='post[tag_ids][]' type='checkbox' value='2' />" +
+ "Tag 2</label>" +
+ "<label for='post_tag_ids_3'>" +
+ "<input checked='checked' id='post_tag_ids_3' name='post[tag_ids][]' type='checkbox' value='3' />" +
+ "Tag 3</label>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_collection_check_boxes_with_custom_builder_block_does_not_leak_the_template
+ post = Post.new
+ def post.tag_ids; [1, 3]; end
+ def post.id; 1; end
+ collection = (1..3).map { |i| [i, "Tag #{i}"] }
+
+ form_with(model: post) do |f|
+ rendered_check_boxes = f.collection_check_boxes(:tag_ids, collection, :first, :last) do |b|
+ b.label { b.check_box + b.text }
+ end
+ concat rendered_check_boxes
+ concat f.hidden_field :id
+ end
+
+ expected = whole_form("/posts") do
+ "<input name='post[tag_ids][]' type='hidden' value='' />" +
+ "<label for='post_tag_ids_1'>" +
+ "<input checked='checked' id='post_tag_ids_1' name='post[tag_ids][]' type='checkbox' value='1' />" +
+ "Tag 1</label>" +
+ "<label for='post_tag_ids_2'>" +
+ "<input id='post_tag_ids_2' name='post[tag_ids][]' type='checkbox' value='2' />" +
+ "Tag 2</label>" +
+ "<label for='post_tag_ids_3'>" +
+ "<input checked='checked' id='post_tag_ids_3' name='post[tag_ids][]' type='checkbox' value='3' />" +
+ "Tag 3</label>" +
+ "<input id='post_id' name='post[id]' type='hidden' value='1' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_index_and_with_collection_check_boxes
+ post = Post.new
+ def post.tag_ids; [1]; end
+ collection = [[1, "Tag 1"]]
+
+ form_with(model: post, index: "1") do |f|
+ concat f.collection_check_boxes(:tag_ids, collection, :first, :last)
+ end
+
+ expected = whole_form("/posts") do
+ "<input name='post[1][tag_ids][]' type='hidden' value='' />" +
+ "<input checked='checked' id='post_1_tag_ids_1' name='post[1][tag_ids][]' type='checkbox' value='1' />" +
+ "<label for='post_1_tag_ids_1'>Tag 1</label>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_file_field_generate_multipart
+ Post.send :attr_accessor, :file
+
+ form_with(model: @post, id: "create-post") do |f|
+ concat f.file_field(:file)
+ end
+
+ expected = whole_form("/posts/123", "create-post", method: "patch", multipart: true) do
+ "<input name='post[file]' type='file' id='post_file' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_fields_with_file_field_generate_multipart
+ Comment.send :attr_accessor, :file
+
+ form_with(model: @post) do |f|
+ concat f.fields(:comment, model: @post) { |c|
+ concat c.file_field(:file)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch", multipart: true) do
+ "<input name='post[comment][file]' type='file' id='post_comment_file' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_format
+ form_with(model: @post, format: :json, id: "edit_post_123", class: "edit_post") do |f|
+ concat f.label(:title)
+ end
+
+ expected = whole_form("/posts/123.json", "edit_post_123", "edit_post", method: "patch") do
+ "<label for='post_title'>Title</label>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_format_and_url
+ form_with(model: @post, format: :json, url: "/") do |f|
+ concat f.label(:title)
+ end
+
+ expected = whole_form("/", method: "patch") do
+ "<label for='post_title'>Title</label>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_model_using_relative_model_naming
+ blog_post = Blog::Post.new("And his name will be forty and four.", 44)
+
+ form_with(model: blog_post) do |f|
+ concat f.text_field :title
+ concat f.submit("Edit post")
+ end
+
+ expected = whole_form("/posts/44", method: "patch") do
+ "<input name='post[title]' type='text' id='post_title' value='And his name will be forty and four.' />" +
+ "<input name='commit' data-disable-with='Edit post' type='submit' value='Edit post' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_symbol_scope
+ form_with(model: @post, scope: "other_name", id: "create-post") do |f|
+ concat f.label(:title, class: "post_title")
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ concat f.submit("Create post")
+ end
+
+ expected = whole_form("/posts/123", "create-post", method: "patch") do
+ "<label for='other_name_title' class='post_title'>Title</label>" +
+ "<input name='other_name[title]' id='other_name_title' value='Hello World' type='text' />" +
+ "<textarea name='other_name[body]' id='other_name_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='other_name[secret]' value='0' type='hidden' />" +
+ "<input name='other_name[secret]' checked='checked' id='other_name_secret' value='1' type='checkbox' />" +
+ "<input name='commit' value='Create post' data-disable-with='Create post' type='submit' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_tags_do_not_call_private_properties_on_form_object
+ obj = Class.new do
+ private def private_property
+ raise "This method should not be called."
+ end
+ end.new
+
+ form_with(model: obj, scope: "other_name", url: "/", id: "edit-other-name") do |f|
+ assert_raise(NoMethodError) { f.hidden_field(:private_property) }
+ end
+ end
+
+ def test_form_with_with_method_as_part_of_html_options
+ form_with(model: @post, url: "/", id: "create-post", html: { method: :delete }) do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected = whole_form("/", "create-post", method: "delete") do
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />" +
+ "<textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[secret]' type='hidden' value='0' />" +
+ "<input name='post[secret]' checked='checked' type='checkbox' id='post_secret' value='1' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_method
+ form_with(model: @post, url: "/", method: :delete, id: "create-post") do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected = whole_form("/", "create-post", method: "delete") do
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />" +
+ "<textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[secret]' type='hidden' value='0' />" +
+ "<input name='post[secret]' checked='checked' type='checkbox' id='post_secret' value='1' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_search_field
+ # Test case for bug which would emit an "object" attribute
+ # when used with form_for using a search_field form helper
+ form_with(model: Post.new, url: "/search", id: "search-post", method: :get) do |f|
+ concat f.search_field(:title)
+ end
+
+ expected = whole_form("/search", "search-post", method: "get") do
+ "<input name='post[title]' type='search' id='post_title' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_enables_remote_by_default
+ form_with(model: @post, url: "/", id: "create-post", method: :patch) do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected = whole_form("/", "create-post", method: "patch") do
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />" +
+ "<textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[secret]' type='hidden' value='0' />" +
+ "<input name='post[secret]' checked='checked' type='checkbox' id='post_secret' value='1' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_skip_enforcing_utf8_true
+ form_with(scope: :post, skip_enforcing_utf8: true) do |f|
+ concat f.text_field(:title)
+ end
+
+ expected = whole_form("/", skip_enforcing_utf8: true) do
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_skip_enforcing_utf8_false
+ form_with(scope: :post, skip_enforcing_utf8: false) do |f|
+ concat f.text_field(:title)
+ end
+
+ expected = whole_form("/", skip_enforcing_utf8: false) do
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_without_object
+ form_with(scope: :post, id: "create-post") do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected = whole_form("/", "create-post") do
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />" +
+ "<textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[secret]' type='hidden' value='0' />" +
+ "<input name='post[secret]' checked='checked' type='checkbox' id='post_secret' value='1' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_index
+ form_with(model: @post, scope: "post[]") do |f|
+ concat f.label(:title)
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<label for='post_123_title'>Title</label>" +
+ "<input name='post[123][title]' type='text' id='post_123_title' value='Hello World' />" +
+ "<textarea name='post[123][body]' id='post_123_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[123][secret]' type='hidden' value='0' />" +
+ "<input name='post[123][secret]' checked='checked' type='checkbox' id='post_123_secret' value='1' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_nil_index_option_override
+ form_with(model: @post, scope: "post[]", index: nil) do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='post[][title]' type='text' id='post__title' value='Hello World' />" +
+ "<textarea name='post[][body]' id='post__body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[][secret]' type='hidden' value='0' />" +
+ "<input name='post[][secret]' checked='checked' type='checkbox' id='post__secret' value='1' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_label_error_wrapping
+ form_with(model: @post) do |f|
+ concat f.label(:author_name, class: "label")
+ concat f.text_field(:author_name)
+ concat f.submit("Create post")
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<div class='field_with_errors'><label for='post_author_name' class='label'>Author name</label></div>" +
+ "<div class='field_with_errors'><input name='post[author_name]' type='text' id='post_author_name' value='' /></div>" +
+ "<input name='commit' data-disable-with='Create post' type='submit' value='Create post' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_label_error_wrapping_without_conventional_instance_variable
+ post = remove_instance_variable :@post
+
+ form_with(model: post) do |f|
+ concat f.label(:author_name, class: "label")
+ concat f.text_field(:author_name)
+ concat f.submit("Create post")
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<div class='field_with_errors'><label for='post_author_name' class='label'>Author name</label></div>" +
+ "<div class='field_with_errors'><input name='post[author_name]' type='text' id='post_author_name' value='' /></div>" +
+ "<input name='commit' data-disable-with='Create post' type='submit' value='Create post' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_label_error_wrapping_block_and_non_block_versions
+ form_with(model: @post) do |f|
+ concat f.label(:author_name, "Name", class: "label")
+ concat f.label(:author_name, class: "label") { "Name" }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<div class='field_with_errors'><label for='post_author_name' class='label'>Name</label></div>" +
+ "<div class='field_with_errors'><label for='post_author_name' class='label'>Name</label></div>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_namespace
+ skip "Do namespaces still make sense?"
+ form_for(@post, namespace: "namespace") do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected = whole_form("/posts/123", "namespace_edit_post_123", "edit_post", method: "patch") do
+ "<input name='post[title]' type='text' id='namespace_post_title' value='Hello World' />" +
+ "<textarea name='post[body]' id='namespace_post_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[secret]' type='hidden' value='0' />" +
+ "<input name='post[secret]' checked='checked' type='checkbox' id='namespace_post_secret' value='1' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_submit_with_object_as_new_record_and_locale_strings
+ with_locale :submit do
+ @post.persisted = false
+ @post.stub(:to_key, nil) do
+ form_with(model: @post) do |f|
+ concat f.submit
+ end
+
+ expected = whole_form("/posts") do
+ "<input name='commit' data-disable-with='Create Post' type='submit' value='Create Post' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+ end
+ end
+
+ def test_submit_with_object_as_existing_record_and_locale_strings
+ with_locale :submit do
+ form_with(model: @post) do |f|
+ concat f.submit
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='commit' data-disable-with='Confirm Post changes' type='submit' value='Confirm Post changes' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+ end
+
+ def test_submit_without_object_and_locale_strings
+ with_locale :submit do
+ form_with(scope: :post) do |f|
+ concat f.submit class: "extra"
+ end
+
+ expected = whole_form do
+ "<input name='commit' class='extra' data-disable-with='Save changes' type='submit' value='Save changes' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+ end
+
+ def test_submit_with_object_and_nested_lookup
+ with_locale :submit do
+ form_with(model: @post, scope: :another_post) do |f|
+ concat f.submit
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='commit' data-disable-with='Update your Post' type='submit' value='Update your Post' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+ end
+
+ def test_nested_fields
+ @comment.body = "Hello World"
+ form_with(model: @post) do |f|
+ concat f.fields(model: @comment) { |c|
+ concat c.text_field(:body)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='post[comment][body]' type='text' id='post_comment_body' value='Hello World' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_deep_nested_fields
+ @comment.save
+ form_with(scope: :posts) do |f|
+ f.fields("post[]", model: @post) do |f2|
+ f2.text_field(:id)
+ @post.comments.each do |comment|
+ concat f2.fields("comment[]", model: comment) { |c|
+ concat c.text_field(:name)
+ }
+ end
+ end
+ end
+
+ expected = whole_form do
+ "<input name='posts[post][0][comment][1][name]' type='text' id='posts_post_0_comment_1_name' value='comment #1' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_nested_collections
+ form_with(model: @post, scope: "post[]") do |f|
+ concat f.text_field(:title)
+ concat f.fields("comment[]", model: @comment) { |c|
+ concat c.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='post[123][title]' type='text' id='post_123_title' value='Hello World' />" +
+ "<input name='post[123][comment][][name]' type='text' id='post_123_comment__name' value='new comment' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_index_and_parent_fields
+ form_with(model: @post, index: 1) do |c|
+ concat c.text_field(:title)
+ concat c.fields("comment", model: @comment, index: 1) { |r|
+ concat r.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='post[1][title]' type='text' id='post_1_title' value='Hello World' />" +
+ "<input name='post[1][comment][1][name]' type='text' id='post_1_comment_1_name' value='new comment' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_index_and_nested_fields
+ output_buffer = form_with(model: @post, index: 1) do |f|
+ concat f.fields(:comment, model: @post) { |c|
+ concat c.text_field(:title)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='post[1][comment][title]' type='text' id='post_1_comment_title' value='Hello World' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_index_on_both
+ form_with(model: @post, index: 1) do |f|
+ concat f.fields(:comment, model: @post, index: 5) { |c|
+ concat c.text_field(:title)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='post[1][comment][5][title]' type='text' id='post_1_comment_5_title' value='Hello World' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_auto_index
+ form_with(model: @post, scope: "post[]") do |f|
+ concat f.fields(:comment, model: @post) { |c|
+ concat c.text_field(:title)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='post[123][comment][title]' type='text' id='post_123_comment_title' value='Hello World' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_index_radio_button
+ form_with(model: @post) do |f|
+ concat f.fields(:comment, model: @post, index: 5) { |c|
+ concat c.radio_button(:title, "hello")
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='post[comment][5][title]' type='radio' id='post_comment_5_title_hello' value='hello' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_auto_index_on_both
+ form_with(model: @post, scope: "post[]") do |f|
+ concat f.fields("comment[]", model: @post) { |c|
+ concat c.text_field(:title)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='post[123][comment][123][title]' type='text' id='post_123_comment_123_title' value='Hello World' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_index_and_auto_index
+ output_buffer = form_with(model: @post, scope: "post[]") do |f|
+ concat f.fields(:comment, model: @post, index: 5) { |c|
+ concat c.text_field(:title)
+ }
+ end
+
+ output_buffer << form_with(model: @post, index: 1) do |f|
+ concat f.fields("comment[]", model: @post) { |c|
+ concat c.text_field(:title)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='post[123][comment][5][title]' type='text' id='post_123_comment_5_title' value='Hello World' />"
+ end + whole_form("/posts/123", method: "patch") do
+ "<input name='post[1][comment][123][title]' type='text' id='post_1_comment_123_title' value='Hello World' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_a_new_record_on_a_nested_attributes_one_to_one_association
+ @post.author = Author.new
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:author) { |af|
+ concat af.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" type="text" value="new author" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_explicitly_passed_object_on_a_nested_attributes_one_to_one_association
+ form_with(model: @post) do |f|
+ f.fields(:author, model: Author.new(123)) do |af|
+ assert_not_nil af.object
+ assert_equal 123, af.object.id
+ end
+ end
+ end
+
+ def test_nested_fields_with_an_existing_record_on_a_nested_attributes_one_to_one_association
+ @post.author = Author.new(321)
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:author) { |af|
+ concat af.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" type="text" value="author #321" />' +
+ '<input id="post_author_attributes_id" name="post[author_attributes][id]" type="hidden" value="321" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_an_existing_record_on_a_nested_attributes_one_to_one_association_using_erb_and_inline_block
+ @post.author = Author.new(321)
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:author) { |af|
+ af.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" type="text" value="author #321" />' +
+ '<input id="post_author_attributes_id" name="post[author_attributes][id]" type="hidden" value="321" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_an_existing_record_on_a_nested_attributes_one_to_one_association_with_disabled_hidden_id
+ @post.author = Author.new(321)
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:author, skip_id: true) { |af|
+ af.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" type="text" value="author #321" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_an_existing_record_on_a_nested_attributes_one_to_one_association_with_disabled_hidden_id_inherited
+ @post.author = Author.new(321)
+
+ form_with(model: @post, skip_id: true) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:author) { |af|
+ af.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" type="text" value="author #321" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_an_existing_record_on_a_nested_attributes_one_to_one_association_with_disabled_hidden_id_override
+ @post.author = Author.new(321)
+
+ form_with(model: @post, skip_id: true) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:author, skip_id: false) { |af|
+ af.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" type="text" value="author #321" />' +
+ '<input id="post_author_attributes_id" name="post[author_attributes][id]" type="hidden" value="321" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_existing_records_on_a_nested_attributes_one_to_one_association_with_explicit_hidden_field_placement
+ @post.author = Author.new(321)
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:author) { |af|
+ concat af.hidden_field(:id)
+ concat af.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_id" name="post[author_attributes][id]" type="hidden" value="321" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" type="text" value="author #321" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_existing_records_on_a_nested_attributes_collection_association
+ @post.comments = Array.new(2) { |id| Comment.new(id + 1) }
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ @post.comments.each do |comment|
+ concat f.fields(:comments, model: comment) { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #1" />' +
+ '<input id="post_comments_attributes_0_id" name="post[comments_attributes][0][id]" type="hidden" value="1" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="comment #2" />' +
+ '<input id="post_comments_attributes_1_id" name="post[comments_attributes][1][id]" type="hidden" value="2" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_existing_records_on_a_nested_attributes_collection_association_with_disabled_hidden_id
+ @post.comments = Array.new(2) { |id| Comment.new(id + 1) }
+ @post.author = Author.new(321)
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:author) { |af|
+ concat af.text_field(:name)
+ }
+ @post.comments.each do |comment|
+ concat f.fields(:comments, model: comment, skip_id: true) { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" type="text" value="author #321" />' +
+ '<input id="post_author_attributes_id" name="post[author_attributes][id]" type="hidden" value="321" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #1" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="comment #2" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_existing_records_on_a_nested_attributes_collection_association_with_disabled_hidden_id_inherited
+ @post.comments = Array.new(2) { |id| Comment.new(id + 1) }
+ @post.author = Author.new(321)
+
+ form_with(model: @post, skip_id: true) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:author) { |af|
+ concat af.text_field(:name)
+ }
+ @post.comments.each do |comment|
+ concat f.fields(:comments, model: comment) { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" type="text" value="author #321" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #1" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="comment #2" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_existing_records_on_a_nested_attributes_collection_association_with_disabled_hidden_id_override
+ @post.comments = Array.new(2) { |id| Comment.new(id + 1) }
+ @post.author = Author.new(321)
+
+ form_with(model: @post, skip_id: true) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:author, skip_id: false) { |af|
+ concat af.text_field(:name)
+ }
+ @post.comments.each do |comment|
+ concat f.fields(:comments, model: comment) { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" type="text" value="author #321" />' +
+ '<input id="post_author_attributes_id" name="post[author_attributes][id]" type="hidden" value="321" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #1" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="comment #2" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_existing_records_on_a_nested_attributes_collection_association_using_erb_and_inline_block
+ @post.comments = Array.new(2) { |id| Comment.new(id + 1) }
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ @post.comments.each do |comment|
+ concat f.fields(:comments, model: comment) { |cf|
+ cf.text_field(:name)
+ }
+ end
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #1" />' +
+ '<input id="post_comments_attributes_0_id" name="post[comments_attributes][0][id]" type="hidden" value="1" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="comment #2" />' +
+ '<input id="post_comments_attributes_1_id" name="post[comments_attributes][1][id]" type="hidden" value="2" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_existing_records_on_a_nested_attributes_collection_association_with_explicit_hidden_field_placement
+ @post.comments = Array.new(2) { |id| Comment.new(id + 1) }
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ @post.comments.each do |comment|
+ concat f.fields(:comments, model: comment) { |cf|
+ concat cf.hidden_field(:id)
+ concat cf.text_field(:name)
+ }
+ end
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_0_id" name="post[comments_attributes][0][id]" type="hidden" value="1" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #1" />' +
+ '<input id="post_comments_attributes_1_id" name="post[comments_attributes][1][id]" type="hidden" value="2" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="comment #2" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_new_records_on_a_nested_attributes_collection_association
+ @post.comments = [Comment.new, Comment.new]
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ @post.comments.each do |comment|
+ concat f.fields(:comments, model: comment) { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="new comment" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="new comment" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_existing_and_new_records_on_a_nested_attributes_collection_association
+ @post.comments = [Comment.new(321), Comment.new]
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ @post.comments.each do |comment|
+ concat f.fields(:comments, model: comment) { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #321" />' +
+ '<input id="post_comments_attributes_0_id" name="post[comments_attributes][0][id]" type="hidden" value="321" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="new comment" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_an_empty_supplied_attributes_collection
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ f.fields(:comments, model: []) do |cf|
+ concat cf.text_field(:name)
+ end
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_existing_records_on_a_supplied_nested_attributes_collection
+ @post.comments = Array.new(2) { |id| Comment.new(id + 1) }
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:comments, model: @post.comments) { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #1" />' +
+ '<input id="post_comments_attributes_0_id" name="post[comments_attributes][0][id]" type="hidden" value="1" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="comment #2" />' +
+ '<input id="post_comments_attributes_1_id" name="post[comments_attributes][1][id]" type="hidden" value="2" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_arel_like
+ @post.comments = ArelLike.new
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:comments, model: @post.comments) { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #1" />' +
+ '<input id="post_comments_attributes_0_id" name="post[comments_attributes][0][id]" type="hidden" value="1" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="comment #2" />' +
+ '<input id="post_comments_attributes_1_id" name="post[comments_attributes][1][id]" type="hidden" value="2" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_label_translation_with_more_than_10_records
+ @post.comments = Array.new(11) { |id| Comment.new(id + 1) }
+
+ params = 11.times.map { ["post.comments.body", default: [:"comment.body", ""], scope: "helpers.label"] }
+ assert_called_with(I18n, :t, params, returns: "Write body here") do
+ form_with(model: @post) do |f|
+ f.fields(:comments) do |cf|
+ concat cf.label(:body)
+ end
+ end
+ end
+ end
+
+ def test_nested_fields_with_existing_records_on_a_supplied_nested_attributes_collection_different_from_record_one
+ comments = Array.new(2) { |id| Comment.new(id + 1) }
+ @post.comments = []
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:comments, model: comments) { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #1" />' +
+ '<input id="post_comments_attributes_0_id" name="post[comments_attributes][0][id]" type="hidden" value="1" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="comment #2" />' +
+ '<input id="post_comments_attributes_1_id" name="post[comments_attributes][1][id]" type="hidden" value="2" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_on_a_nested_attributes_collection_association_yields_only_builder
+ @post.comments = [Comment.new(321), Comment.new]
+ yielded_comments = []
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.fields(:comments) { |cf|
+ concat cf.text_field(:name)
+ yielded_comments << cf.object
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input name="post[title]" type="text" id="post_title" value="Hello World" />' +
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #321" />' +
+ '<input id="post_comments_attributes_0_id" name="post[comments_attributes][0][id]" type="hidden" value="321" />' +
+ '<input id="post_comments_attributes_1_name" name="post[comments_attributes][1][name]" type="text" value="new comment" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ assert_equal yielded_comments, @post.comments
+ end
+
+ def test_nested_fields_with_child_index_option_override_on_a_nested_attributes_collection_association
+ @post.comments = []
+
+ form_with(model: @post) do |f|
+ concat f.fields(:comments, model: Comment.new(321), child_index: "abc") { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input id="post_comments_attributes_abc_name" name="post[comments_attributes][abc][name]" type="text" value="comment #321" />' +
+ '<input id="post_comments_attributes_abc_id" name="post[comments_attributes][abc][id]" type="hidden" value="321" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_child_index_as_lambda_option_override_on_a_nested_attributes_collection_association
+ @post.comments = []
+
+ form_with(model: @post) do |f|
+ concat f.fields(:comments, model: Comment.new(321), child_index: -> { "abc" }) { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input id="post_comments_attributes_abc_name" name="post[comments_attributes][abc][name]" type="text" value="comment #321" />' +
+ '<input id="post_comments_attributes_abc_id" name="post[comments_attributes][abc][id]" type="hidden" value="321" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ class FakeAssociationProxy
+ def to_ary
+ [1, 2, 3]
+ end
+ end
+
+ def test_nested_fields_with_child_index_option_override_on_a_nested_attributes_collection_association_with_proxy
+ @post.comments = FakeAssociationProxy.new
+
+ form_with(model: @post) do |f|
+ concat f.fields(:comments, model: Comment.new(321), child_index: "abc") { |cf|
+ concat cf.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input id="post_comments_attributes_abc_name" name="post[comments_attributes][abc][name]" type="text" value="comment #321" />' +
+ '<input id="post_comments_attributes_abc_id" name="post[comments_attributes][abc][id]" type="hidden" value="321" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_index_method_with_existing_records_on_a_nested_attributes_collection_association
+ @post.comments = Array.new(2) { |id| Comment.new(id + 1) }
+
+ form_with(model: @post) do |f|
+ expected = 0
+ @post.comments.each do |comment|
+ f.fields(:comments, model: comment) { |cf|
+ assert_equal cf.index, expected
+ expected += 1
+ }
+ end
+ end
+ end
+
+ def test_nested_fields_index_method_with_existing_and_new_records_on_a_nested_attributes_collection_association
+ @post.comments = [Comment.new(321), Comment.new]
+
+ form_with(model: @post) do |f|
+ expected = 0
+ @post.comments.each do |comment|
+ f.fields(:comments, model: comment) { |cf|
+ assert_equal cf.index, expected
+ expected += 1
+ }
+ end
+ end
+ end
+
+ def test_nested_fields_index_method_with_existing_records_on_a_supplied_nested_attributes_collection
+ @post.comments = Array.new(2) { |id| Comment.new(id + 1) }
+
+ form_with(model: @post) do |f|
+ expected = 0
+ f.fields(:comments, model: @post.comments) { |cf|
+ assert_equal cf.index, expected
+ expected += 1
+ }
+ end
+ end
+
+ def test_nested_fields_index_method_with_child_index_option_override_on_a_nested_attributes_collection_association
+ @post.comments = []
+
+ form_with(model: @post) do |f|
+ f.fields(:comments, model: Comment.new(321), child_index: "abc") { |cf|
+ assert_equal cf.index, "abc"
+ }
+ end
+ end
+
+ def test_nested_fields_uses_unique_indices_for_different_collection_associations
+ @post.comments = [Comment.new(321)]
+ @post.tags = [Tag.new(123), Tag.new(456)]
+ @post.comments[0].relevances = []
+ @post.tags[0].relevances = []
+ @post.tags[1].relevances = []
+
+ form_with(model: @post) do |f|
+ concat f.fields(:comments, model: @post.comments[0]) { |cf|
+ concat cf.text_field(:name)
+ concat cf.fields(:relevances, model: CommentRelevance.new(314)) { |crf|
+ concat crf.text_field(:value)
+ }
+ }
+ concat f.fields(:tags, model: @post.tags[0]) { |tf|
+ concat tf.text_field(:value)
+ concat tf.fields(:relevances, model: TagRelevance.new(3141)) { |trf|
+ concat trf.text_field(:value)
+ }
+ }
+ concat f.fields("tags", model: @post.tags[1]) { |tf|
+ concat tf.text_field(:value)
+ concat tf.fields(:relevances, model: TagRelevance.new(31415)) { |trf|
+ concat trf.text_field(:value)
+ }
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input id="post_comments_attributes_0_name" name="post[comments_attributes][0][name]" type="text" value="comment #321" />' +
+ '<input id="post_comments_attributes_0_relevances_attributes_0_value" name="post[comments_attributes][0][relevances_attributes][0][value]" type="text" value="commentrelevance #314" />' +
+ '<input id="post_comments_attributes_0_relevances_attributes_0_id" name="post[comments_attributes][0][relevances_attributes][0][id]" type="hidden" value="314" />' +
+ '<input id="post_comments_attributes_0_id" name="post[comments_attributes][0][id]" type="hidden" value="321" />' +
+ '<input id="post_tags_attributes_0_value" name="post[tags_attributes][0][value]" type="text" value="tag #123" />' +
+ '<input id="post_tags_attributes_0_relevances_attributes_0_value" name="post[tags_attributes][0][relevances_attributes][0][value]" type="text" value="tagrelevance #3141" />' +
+ '<input id="post_tags_attributes_0_relevances_attributes_0_id" name="post[tags_attributes][0][relevances_attributes][0][id]" type="hidden" value="3141" />' +
+ '<input id="post_tags_attributes_0_id" name="post[tags_attributes][0][id]" type="hidden" value="123" />' +
+ '<input id="post_tags_attributes_1_value" name="post[tags_attributes][1][value]" type="text" value="tag #456" />' +
+ '<input id="post_tags_attributes_1_relevances_attributes_0_value" name="post[tags_attributes][1][relevances_attributes][0][value]" type="text" value="tagrelevance #31415" />' +
+ '<input id="post_tags_attributes_1_relevances_attributes_0_id" name="post[tags_attributes][1][relevances_attributes][0][id]" type="hidden" value="31415" />' +
+ '<input id="post_tags_attributes_1_id" name="post[tags_attributes][1][id]" type="hidden" value="456" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_nested_fields_with_hash_like_model
+ @author = HashBackedAuthor.new
+
+ form_with(model: @post) do |f|
+ concat f.fields(:author, model: @author) { |af|
+ concat af.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ '<input id="post_author_attributes_name" name="post[author_attributes][name]" type="text" value="hash backed author" />'
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_fields
+ output_buffer = fields(:post, model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected =
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />" +
+ "<textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[secret]' type='hidden' value='0' />" +
+ "<input name='post[secret]' checked='checked' type='checkbox' id='post_secret' value='1' />"
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_fields_with_index
+ output_buffer = fields("post[]", model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected =
+ "<input name='post[123][title]' type='text' id='post_123_title' value='Hello World' />" +
+ "<textarea name='post[123][body]' id='post_123_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[123][secret]' type='hidden' value='0' />" +
+ "<input name='post[123][secret]' checked='checked' type='checkbox' id='post_123_secret' value='1' />"
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_fields_with_nil_index_option_override
+ output_buffer = fields("post[]", model: @post, index: nil) do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected =
+ "<input name='post[][title]' type='text' id='post__title' value='Hello World' />" +
+ "<textarea name='post[][body]' id='post__body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[][secret]' type='hidden' value='0' />" +
+ "<input name='post[][secret]' checked='checked' type='checkbox' id='post__secret' value='1' />"
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_fields_with_index_option_override
+ output_buffer = fields("post[]", model: @post, index: "abc") do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected =
+ "<input name='post[abc][title]' type='text' id='post_abc_title' value='Hello World' />" +
+ "<textarea name='post[abc][body]' id='post_abc_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[abc][secret]' type='hidden' value='0' />" +
+ "<input name='post[abc][secret]' checked='checked' type='checkbox' id='post_abc_secret' value='1' />"
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_fields_without_object
+ output_buffer = fields(:post) do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected =
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />" +
+ "<textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[secret]' type='hidden' value='0' />" +
+ "<input name='post[secret]' checked='checked' type='checkbox' id='post_secret' value='1' />"
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_fields_with_only_object
+ output_buffer = fields(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected =
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />" +
+ "<textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[secret]' type='hidden' value='0' />" +
+ "<input name='post[secret]' checked='checked' type='checkbox' id='post_secret' value='1' />"
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_fields_object_with_bracketed_name
+ output_buffer = fields("author[post]", model: @post) do |f|
+ concat f.label(:title)
+ concat f.text_field(:title)
+ end
+
+ assert_dom_equal "<label for=\"author_post_title\">Title</label>" +
+ "<input name='author[post][title]' type='text' id='author_post_title' value='Hello World' />",
+ output_buffer
+ end
+
+ def test_fields_object_with_bracketed_name_and_index
+ output_buffer = fields("author[post]", model: @post, index: 1) do |f|
+ concat f.label(:title)
+ concat f.text_field(:title)
+ end
+
+ assert_dom_equal "<label for=\"author_post_1_title\">Title</label>" +
+ "<input name='author[post][1][title]' type='text' id='author_post_1_title' value='Hello World' />",
+ output_buffer
+ end
+
+ def test_form_builder_does_not_have_form_with_method
+ assert_not_includes ActionView::Helpers::FormBuilder.instance_methods, :form_with
+ end
+
+ def test_form_with_and_fields
+ form_with(model: @post, scope: :post, id: "create-post") do |post_form|
+ concat post_form.text_field(:title)
+ concat post_form.text_area(:body)
+
+ concat fields(:parent_post, model: @post) { |parent_fields|
+ concat parent_fields.check_box(:secret)
+ }
+ end
+
+ expected = whole_form("/posts/123", "create-post", method: "patch") do
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />" +
+ "<textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='parent_post[secret]' type='hidden' value='0' />" +
+ "<input name='parent_post[secret]' checked='checked' type='checkbox' id='parent_post_secret' value='1' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_and_fields_with_object
+ form_with(model: @post, scope: :post, id: "create-post") do |post_form|
+ concat post_form.text_field(:title)
+ concat post_form.text_area(:body)
+
+ concat post_form.fields(model: @comment) { |comment_fields|
+ concat comment_fields.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", "create-post", method: "patch") do
+ "<input name='post[title]' type='text' id='post_title' value='Hello World' />" +
+ "<textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea>" +
+ "<input name='post[comment][name]' type='text' id='post_comment_name' value='new comment' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_and_fields_with_non_nested_association_and_without_object
+ form_with(model: @post) do |f|
+ concat f.fields(:category) { |c|
+ concat c.text_field(:name)
+ }
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<input name='post[category][name]' type='text' id='post_category_name' />"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ class LabelledFormBuilder < ActionView::Helpers::FormBuilder
+ (field_helpers - %w(hidden_field)).each do |selector|
+ class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
+ def #{selector}(field, *args, &proc)
+ ("<label for='\#{field}'>\#{field.to_s.humanize}:</label> " + super + "<br/>").html_safe
+ end
+ RUBY_EVAL
+ end
+ end
+
+ def test_form_with_with_labelled_builder
+ form_with(model: @post, builder: LabelledFormBuilder) do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<label for='title'>Title:</label> <input name='post[title]' type='text' id='post_title' value='Hello World' /><br/>" +
+ "<label for='body'>Body:</label> <textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea><br/>" +
+ "<label for='secret'>Secret:</label> <input name='post[secret]' type='hidden' value='0' /><input name='post[secret]' checked='checked' type='checkbox' id='post_secret' value='1' /><br/>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_default_form_builder
+ old_default_form_builder, ActionView::Base.default_form_builder =
+ ActionView::Base.default_form_builder, LabelledFormBuilder
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<label for='title'>Title:</label> <input name='post[title]' type='text' id='post_title' value='Hello World' /><br/>" +
+ "<label for='body'>Body:</label> <textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea><br/>" +
+ "<label for='secret'>Secret:</label> <input name='post[secret]' type='hidden' value='0' /><input name='post[secret]' checked='checked' type='checkbox' id='post_secret' value='1' /><br/>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ ensure
+ ActionView::Base.default_form_builder = old_default_form_builder
+ end
+
+ def test_lazy_loading_default_form_builder
+ old_default_form_builder, ActionView::Base.default_form_builder =
+ ActionView::Base.default_form_builder, "FormWithActsLikeFormForTest::LabelledFormBuilder"
+
+ form_with(model: @post) do |f|
+ concat f.text_field(:title)
+ end
+
+ expected = whole_form("/posts/123", method: "patch") do
+ "<label for='title'>Title:</label> <input name='post[title]' type='text' id='post_title' value='Hello World' /><br/>"
+ end
+
+ assert_dom_equal expected, output_buffer
+ ensure
+ ActionView::Base.default_form_builder = old_default_form_builder
+ end
+
+ def test_form_builder_override
+ self.default_form_builder = LabelledFormBuilder
+
+ output_buffer = fields(:post, model: @post) do |f|
+ concat f.text_field(:title)
+ end
+
+ expected = "<label for='title'>Title:</label> <input name='post[title]' type='text' id='post_title' value='Hello World' /><br/>"
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_lazy_loading_form_builder_override
+ self.default_form_builder = "FormWithActsLikeFormForTest::LabelledFormBuilder"
+
+ output_buffer = fields(:post, model: @post) do |f|
+ concat f.text_field(:title)
+ end
+
+ expected = "<label for='title'>Title:</label> <input name='post[title]' type='text' id='post_title' value='Hello World' /><br/>"
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_fields_with_labelled_builder
+ output_buffer = fields(:post, model: @post, builder: LabelledFormBuilder) do |f|
+ concat f.text_field(:title)
+ concat f.text_area(:body)
+ concat f.check_box(:secret)
+ end
+
+ expected =
+ "<label for='title'>Title:</label> <input name='post[title]' type='text' id='post_title' value='Hello World' /><br/>" +
+ "<label for='body'>Body:</label> <textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea><br/>" +
+ "<label for='secret'>Secret:</label> <input name='post[secret]' type='hidden' value='0' /><input name='post[secret]' checked='checked' type='checkbox' id='post_secret' value='1' /><br/>"
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_labelled_builder_with_nested_fields_without_options_hash
+ klass = nil
+
+ form_with(model: @post, builder: LabelledFormBuilder) do |f|
+ f.fields(:comments, model: Comment.new) do |nested_fields|
+ klass = nested_fields.class
+ ""
+ end
+ end
+
+ assert_equal LabelledFormBuilder, klass
+ end
+
+ def test_form_with_with_labelled_builder_with_nested_fields_with_options_hash
+ klass = nil
+
+ form_with(model: @post, builder: LabelledFormBuilder) do |f|
+ f.fields(:comments, model: Comment.new, index: "foo") do |nested_fields|
+ klass = nested_fields.class
+ ""
+ end
+ end
+
+ assert_equal LabelledFormBuilder, klass
+ end
+
+ def test_form_with_with_labelled_builder_path
+ path = nil
+
+ form_with(model: @post, builder: LabelledFormBuilder) do |f|
+ path = f.to_partial_path
+ ""
+ end
+
+ assert_equal "labelled_form", path
+ end
+
+ class LabelledFormBuilderSubclass < LabelledFormBuilder; end
+
+ def test_form_with_with_labelled_builder_with_nested_fields_with_custom_builder
+ klass = nil
+
+ form_with(model: @post, builder: LabelledFormBuilder) do |f|
+ f.fields(:comments, model: Comment.new, builder: LabelledFormBuilderSubclass) do |nested_fields|
+ klass = nested_fields.class
+ ""
+ end
+ end
+
+ assert_equal LabelledFormBuilderSubclass, klass
+ end
+
+ def test_form_with_with_html_options_adds_options_to_form_tag
+ form_with(model: @post, html: { id: "some_form", class: "some_class", multipart: true }) do |f| end
+ expected = whole_form("/posts/123", "some_form", "some_class", method: "patch", multipart: "multipart/form-data")
+
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_string_url_option
+ form_with(model: @post, url: "http://www.otherdomain.com") do |f| end
+
+ assert_dom_equal whole_form("http://www.otherdomain.com", method: "patch"), output_buffer
+ end
+
+ def test_form_with_with_hash_url_option
+ form_with(model: @post, url: { controller: "controller", action: "action" }) do |f| end
+
+ assert_equal "controller", @url_for_options[:controller]
+ assert_equal "action", @url_for_options[:action]
+ end
+
+ def test_form_with_with_record_url_option
+ form_with(model: @post, url: @post) do |f| end
+
+ expected = whole_form("/posts/123", method: "patch")
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_existing_object
+ form_with(model: @post) do |f| end
+
+ expected = whole_form("/posts/123", method: "patch")
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_new_object
+ post = Post.new
+ post.persisted = false
+ def post.to_key; nil; end
+
+ form_with(model: post) {}
+
+ expected = whole_form("/posts")
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_existing_object_in_list
+ @comment.save
+ form_with(model: [@post, @comment]) {}
+
+ expected = whole_form(post_comment_path(@post, @comment), method: "patch")
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_new_object_in_list
+ form_with(model: [@post, @comment]) {}
+
+ expected = whole_form(post_comments_path(@post))
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_existing_object_and_namespace_in_list
+ @comment.save
+ form_with(model: [:admin, @post, @comment]) {}
+
+ expected = whole_form(admin_post_comment_path(@post, @comment), method: "patch")
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_new_object_and_namespace_in_list
+ form_with(model: [:admin, @post, @comment]) {}
+
+ expected = whole_form(admin_post_comments_path(@post))
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_existing_object_and_custom_url
+ form_with(model: @post, url: "/super_posts") do |f| end
+
+ expected = whole_form("/super_posts", method: "patch")
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_default_method_as_patch
+ form_with(model: @post) {}
+ expected = whole_form("/posts/123", method: "patch")
+ assert_dom_equal expected, output_buffer
+ end
+
+ def test_form_with_with_data_attributes
+ form_with(model: @post, data: { behavior: "stuff" }) {}
+ assert_match %r|data-behavior="stuff"|, output_buffer
+ assert_match %r|data-remote="true"|, output_buffer
+ end
+
+ def test_fields_returns_block_result
+ output = fields(model: Post.new) { |f| "fields" }
+ assert_equal "fields", output
+ end
+
+ def test_form_with_only_instantiates_builder_once
+ initialization_count = 0
+ builder_class = Class.new(ActionView::Helpers::FormBuilder) do
+ define_method :initialize do |*args|
+ super(*args)
+ initialization_count += 1
+ end
+ end
+
+ form_with(model: @post, builder: builder_class) {}
+ assert_equal 1, initialization_count, "form builder instantiated more than once"
+ end
+
+ protected
+ def hidden_fields(options = {})
+ method = options[:method]
+
+ if options.fetch(:skip_enforcing_utf8, false)
+ txt = ""
+ else
+ txt = %{<input name="utf8" type="hidden" value="&#x2713;" />}
+ end
+
+ if method && !%w(get post).include?(method.to_s)
+ txt << %{<input name="_method" type="hidden" value="#{method}" />}
+ end
+
+ txt
+ end
+
+ def form_text(action = "/", id = nil, html_class = nil, local = nil, multipart = nil, method = nil)
+ txt = %{<form accept-charset="UTF-8" action="#{action}"}
+ txt << %{ enctype="multipart/form-data"} if multipart
+ txt << %{ data-remote="true"} unless local
+ txt << %{ class="#{html_class}"} if html_class
+ txt << %{ id="#{id}"} if id
+ method = method.to_s == "get" ? "get" : "post"
+ txt << %{ method="#{method}">}
+ end
+
+ def whole_form(action = "/", id = nil, html_class = nil, local: false, **options)
+ contents = block_given? ? yield : ""
+
+ method, multipart = options.values_at(:method, :multipart)
+
+ form_text(action, id, html_class, local, multipart, method) + hidden_fields(options.slice :method, :skip_enforcing_utf8) + contents + "</form>"
+ end
+
+ def protect_against_forgery?
+ false
+ end
+
+ def with_locale(testing_locale = :label)
+ old_locale, I18n.locale = I18n.locale, testing_locale
+ yield
+ ensure
+ I18n.locale = old_locale
+ end
+end
diff --git a/actionview/test/ujs/.gitignore b/actionview/test/ujs/.gitignore
new file mode 100644
index 0000000000..31dbbff57c
--- /dev/null
+++ b/actionview/test/ujs/.gitignore
@@ -0,0 +1 @@
+/log
diff --git a/actionview/test/ujs/config.ru b/actionview/test/ujs/config.ru
new file mode 100644
index 0000000000..cb961dc140
--- /dev/null
+++ b/actionview/test/ujs/config.ru
@@ -0,0 +1,3 @@
+$LOAD_PATH.unshift File.expand_path('..', __FILE__)
+require 'server'
+run UJS::Server
diff --git a/actionview/test/ujs/public/test/.eslintrc.yml b/actionview/test/ujs/public/test/.eslintrc.yml
new file mode 100644
index 0000000000..06d7dd36ea
--- /dev/null
+++ b/actionview/test/ujs/public/test/.eslintrc.yml
@@ -0,0 +1,21 @@
+env:
+ browser: true
+extends: eslint:recommended
+rules:
+ no-undef: off
+ no-unused-vars: off
+ indent: off
+ linebreak-style: ['error', 'unix']
+ quotes: ['error', 'single']
+ semi: ['error', 'never']
+ no-shadow: ['error'] # Prevent potential errors
+ no-console: 'off'
+ # styles
+ space-before-function-paren: ['error', 'never']
+ space-before-blocks: 'error'
+ brace-style: ['error', '1tbs', { allowSingleLine: true }]
+ key-spacing: 'error'
+ array-bracket-spacing: 'error'
+ comma-spacing: 'error'
+ comma-dangle: 'off'
+ eol-last: 'error'
diff --git a/actionview/test/ujs/public/test/call-remote-callbacks.js b/actionview/test/ujs/public/test/call-remote-callbacks.js
new file mode 100644
index 0000000000..082d10bfbd
--- /dev/null
+++ b/actionview/test/ujs/public/test/call-remote-callbacks.js
@@ -0,0 +1,469 @@
+(function() {
+
+module('call-remote-callbacks', {
+ setup: function() {
+ $('#qunit-fixture').append($('<form />', {
+ action: '/echo', method: 'get', 'data-remote': 'true'
+ }))
+ },
+ teardown: function() {
+ $(document).undelegate('form[data-remote]', 'ajax:beforeSend')
+ $(document).undelegate('form[data-remote]', 'ajax:before')
+ $(document).undelegate('form[data-remote]', 'ajax:send')
+ $(document).undelegate('form[data-remote]', 'ajax:complete')
+ $(document).undelegate('form[data-remote]', 'ajax:success')
+ $(document).unbind('ajaxStop')
+ $(document).unbind('iframe:loading')
+ }
+})
+
+function start_after_submit(form) {
+ form.bindNative('ajax:complete', function() {
+ ok(true, 'ajax:complete')
+ start()
+ })
+}
+
+function submit(fn) {
+ var form = $('form')
+ start_after_submit(form)
+
+ if (fn) fn(form)
+ form.triggerNative('submit')
+}
+
+function submit_with_button(submit_button) {
+ var form = $('form')
+ start_after_submit(form)
+
+ submit_button.triggerNative('click')
+}
+
+asyncTest('modifying form fields with "ajax:before" sends modified data in request', 4, function() {
+ $('form[data-remote]')
+ .append($('<input type="text" name="user_name" value="john">'))
+ .append($('<input type="text" name="removed_user_name" value="john">'))
+ .bindNative('ajax:before', function() {
+ var form = $(this)
+ form
+ .append($('<input />', {name: 'other_user_name', value: 'jonathan'}))
+ .find('input[name="removed_user_name"]').remove()
+ form
+ .find('input[name="user_name"]').val('steve')
+ })
+
+ submit(function(form) {
+ form.bindNative('ajax:success', function(e, data, status, xhr) {
+ equal(data.params.user_name, 'steve', 'modified field value should have been submitted')
+ equal(data.params.other_user_name, 'jonathan', 'added field value should have been submitted')
+ equal(data.params.removed_user_name, undefined, 'removed field value should be undefined')
+ })
+ })
+})
+
+asyncTest('modifying data("type") with "ajax:before" requests new dataType in request', 2, function() {
+ $('form[data-remote]').data('type', 'html')
+ .bindNative('ajax:before', function() {
+ this.setAttribute('data-type', 'xml')
+ })
+
+ submit(function(form) {
+ form.bindNative('ajax:beforeSend', function(e, xhr, settings) {
+ equal(settings.dataType, 'xml', 'modified dataType should have been requested')
+ })
+ })
+})
+
+asyncTest('setting data("with-credentials",true) with "ajax:before" uses new setting in request', 2, function() {
+ $('form[data-remote]').data('with-credentials', false)
+ .bindNative('ajax:before', function() {
+ this.setAttribute('data-with-credentials', true)
+ })
+
+ submit(function(form) {
+ form.bindNative('ajax:beforeSend', function(e, xhr, settings) {
+ equal(settings.withCredentials, true, 'setting modified in ajax:before should have forced withCredentials request')
+ })
+ })
+})
+
+asyncTest('stopping the "ajax:beforeSend" event aborts the request', 1, function() {
+ submit(function(form) {
+ form.bindNative('ajax:beforeSend', function() {
+ ok(true, 'aborting request in ajax:beforeSend')
+ return false
+ })
+ form.unbind('ajax:send').bindNative('ajax:send', function() {
+ ok(false, 'ajax:send should not run')
+ })
+ form.unbind('ajax:complete').bindNative('ajax:complete', function() {
+ ok(false, 'ajax:complete should not run')
+ })
+ form.bindNative('ajax:error', function(e, xhr, status, error) {
+ ok(false, 'ajax:error should not run')
+ })
+ $(document).bindNative('ajaxStop', function() {
+ start()
+ })
+ })
+})
+
+asyncTest('blank required form input field should abort request and trigger "ajax:aborted:required" event', 5, function() {
+ $(document).bind('iframe:loading', function() {
+ ok(false, 'form should not get submitted')
+ })
+
+ var form = $('form[data-remote]')
+ .append($('<input type="text" name="user_name" required="required">'))
+ .append($('<textarea name="user_bio" required="required"></textarea>'))
+ .bindNative('ajax:beforeSend', function() {
+ ok(false, 'ajax:beforeSend should not run')
+ })
+ .bindNative('ajax:aborted:required', function(e, data) {
+ data = $(data)
+ ok(data.length == 2, 'ajax:aborted:required event is passed all blank required inputs (jQuery objects)')
+ ok(data.first().is('input[name="user_name"]'), 'ajax:aborted:required adds blank required input to data')
+ ok(data.last().is('textarea[name="user_bio"]'), 'ajax:aborted:required adds blank required textarea to data')
+ ok(true, 'ajax:aborted:required should run')
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() {
+ form.find('input[required],textarea[required]').val('Tyler')
+ form.unbind('ajax:beforeSend')
+ submit()
+ }, 13)
+})
+
+asyncTest('blank required form input for non-remote form should abort normal submission', 1, function() {
+ var form = $('form[data-remote]')
+ .append($('<input type="text" name="user_name" required="required">'))
+ .removeAttr('data-remote')
+ .bindNative('ujs:everythingStopped', function() {
+ ok(true, 'ujs:everythingStopped should run')
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() {
+ start()
+ }, 13)
+})
+
+asyncTest('form should be submitted with blank required fields if handler is bound to "ajax:aborted:required" event that returns false', 1, function() {
+ var form = $('form[data-remote]')
+ .append($('<input type="text" name="user_name" required="required">'))
+ .bindNative('ajax:beforeSend', function() {
+ ok(true, 'ajax:beforeSend should run')
+ })
+ .bindNative('ajax:aborted:required', function() {
+ return false
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() {
+ start()
+ }, 13)
+})
+
+asyncTest('disabled fields should not be included in blank required check', 2, function() {
+ var form = $('form[data-remote]')
+ .append($('<input type="text" name="user_name" required="required" disabled="disabled">'))
+ .append($('<textarea name="user_bio" required="required" disabled="disabled"></textarea>'))
+ .bindNative('ajax:beforeSend', function() {
+ ok(true, 'ajax:beforeSend should run')
+ })
+ .bindNative('ajax:aborted:required', function() {
+ ok(false, 'ajax:aborted:required should not run')
+ })
+
+ submit()
+})
+
+asyncTest('form should be submitted with blank required fields if it has the "novalidate" attribute', 2, function() {
+ var form = $('form[data-remote]')
+ .append($('<input type="text" name="user_name" required="required">'))
+ .attr('novalidate', 'novalidate')
+ .bindNative('ajax:beforeSend', function() {
+ ok(true, 'ajax:beforeSend should run')
+ })
+ .bindNative('ajax:aborted:required', function() {
+ ok(false, 'ajax:aborted:required should not run')
+ })
+
+ submit()
+})
+
+asyncTest('form should be submitted with blank required fields if the button has the "formnovalidate" attribute', 2, function() {
+ var submit_button = $('<input type="submit" formnovalidate>')
+ var form = $('form[data-remote]')
+ .append($('<input type="text" name="user_name" required="required">'))
+ .append(submit_button)
+ .bindNative('ajax:beforeSend', function() {
+ ok(true, 'ajax:beforeSend should run')
+ })
+ .bindNative('ajax:aborted:required', function() {
+ ok(false, 'ajax:aborted:required should not run')
+ })
+
+ submit_with_button(submit_button)
+})
+
+asyncTest('blank required form input for non-remote form with "novalidate" attribute should not abort normal submission', 1, function() {
+ $(document).bind('iframe:loading', function() {
+ ok(true, 'form should get submitted')
+ })
+
+ var form = $('form[data-remote]')
+ .append($('<input type="text" name="user_name" required="required">'))
+ .removeAttr('data-remote')
+ .attr('novalidate', 'novalidate')
+ .triggerNative('submit')
+
+ setTimeout(function() {
+ start()
+ }, 13)
+})
+
+asyncTest('unchecked required checkbox should abort form submission', 1, function() {
+ var form = $('form[data-remote]')
+ .append($('<input type="checkbox" name="agree" required="required">'))
+ .removeAttr('data-remote')
+ .bindNative('ujs:everythingStopped', function() {
+ ok(true, 'ujs:everythingStopped should run')
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() {
+ start()
+ }, 13)
+})
+
+asyncTest('unchecked required radio should abort form submission', 3, function() {
+ var form = $('form[data-remote]')
+ .append($('<input type="radio" name="yes_no_none" required="required" value=1>'))
+ .append($('<input type="radio" name="yes_no_none" required="required" value=2>'))
+ .removeAttr('data-remote')
+ .bindNative('ujs:everythingStopped', function() {
+ ok(true, 'ujs:everythingStopped should run')
+ })
+ .bindNative('ajax:aborted:required', function(e, data) {
+ data = $(data)
+ equal(data.length, 2, 'blankRequiredInputs should include both radios')
+ ok(data.first().is('input[type=radio][value=1]'), 'blankRequiredInputs[0] should be the first radio')
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() {
+ start()
+ }, 13)
+})
+
+asyncTest('required radio should only require one to be checked', 1, function() {
+ $(document).bind('iframe:loading', function() {
+ ok(true, 'form should get submitted')
+ })
+
+ var form = $('form[data-remote]')
+ .append($('<input type="radio" name="yes_no" required="required" value=1 id="checkme">'))
+ .append($('<input type="radio" name="yes_no" required="required" value=2>'))
+ .removeAttr('data-remote')
+ .bindNative('ujs:everythingStopped', function() {
+ ok(false, 'ujs:everythingStopped should not run')
+ })
+ .find('#checkme').prop('checked', true)
+ .end()
+ .triggerNative('submit')
+
+ setTimeout(function() {
+ start()
+ }, 13)
+})
+
+asyncTest('required radio should only require one to be checked if not all radios are required', 1, function() {
+ $(document).bind('iframe:loading', function() {
+ ok(true, 'form should get submitted')
+ })
+
+ var form = $('form[data-remote]')
+ // Check the radio that is not required
+ .append($('<input type="radio" name="yes_no_maybe" value=1 >'))
+ // Check the radio that is not required
+ .append($('<input type="radio" name="yes_no_maybe" value=2 id="checkme">'))
+ // Only one needs to be required
+ .append($('<input type="radio" name="yes_no_maybe" required="required" value=3>'))
+ .removeAttr('data-remote')
+ .bindNative('ujs:everythingStopped', function() {
+ ok(false, 'ujs:everythingStopped should not run')
+ })
+ .find('#checkme').prop('checked', true)
+ .end()
+ .triggerNative('submit')
+
+ setTimeout(function() {
+ start()
+ }, 13)
+})
+
+function skipIt() {
+ // This test cannot work due to the security feature in browsers which makes the value
+ // attribute of file input fields readonly, so it cannot be set with default value.
+ // This is what the test would look like though if browsers let us automate this test.
+ asyncTest('non-blank file form input field should abort remote request, but submit normally', 5, function() {
+ var form = $('form[data-remote]')
+ .append($('<input type="file" name="attachment" value="default.png">'))
+ .bindNative('ajax:beforeSend', function() {
+ ok(false, 'ajax:beforeSend should not run')
+ })
+ .bind('iframe:loading', function() {
+ ok(true, 'form should get submitted')
+ })
+ .bindNative('ajax:aborted:file', function(e, data) {
+ ok(data.length == 1, 'ajax:aborted:file event is passed all non-blank file inputs (jQuery objects)')
+ ok(data.first().is('input[name="attachment"]'), 'ajax:aborted:file adds non-blank file input to data')
+ ok(true, 'ajax:aborted:file event should run')
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() {
+ form.find('input[type="file"]').val('')
+ form.unbind('ajax:beforeSend')
+ submit()
+ }, 13)
+ })
+
+ asyncTest('file form input field should not abort remote request if file form input does not have a name attribute', 5, function() {
+ var form = $('form[data-remote]')
+ .append($('<input type="file" value="default.png">'))
+ .bindNative('ajax:beforeSend', function() {
+ ok(true, 'ajax:beforeSend should run')
+ })
+ .bind('iframe:loading', function() {
+ ok(true, 'form should get submitted')
+ })
+ .bindNative('ajax:aborted:file', function(e, data) {
+ ok(false, 'ajax:aborted:file should not run')
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() {
+ form.find('input[type="file"]').val('')
+ form.unbind('ajax:beforeSend')
+ submit()
+ }, 13)
+ })
+
+ asyncTest('blank file input field should abort request entirely if handler bound to "ajax:aborted:file" event that returns false', 1, function() {
+ var form = $('form[data-remote]')
+ .append($('<input type="file" name="attachment" value="default.png">'))
+ .bindNative('ajax:beforeSend', function() {
+ ok(false, 'ajax:beforeSend should not run')
+ })
+ .bind('iframe:loading', function() {
+ ok(false, 'form should not get submitted')
+ })
+ .bindNative('ajax:aborted:file', function() {
+ return false
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() {
+ form.find('input[type="file"]').val('')
+ form.unbind('ajax:beforeSend')
+ submit()
+ }, 13)
+ })
+}
+
+asyncTest('"ajax:beforeSend" can be observed and stopped with event delegation', 1, function() {
+ $(document).delegate('form[data-remote]', 'ajax:beforeSend', function() {
+ ok(true, 'ajax:beforeSend observed with event delegation')
+ return false
+ })
+
+ submit(function(form) {
+ form.unbind('ajax:send').bindNative('ajax:send', function() {
+ ok(false, 'ajax:send should not run')
+ })
+ form.unbind('ajax:complete').bindNative('ajax:complete', function() {
+ ok(false, 'ajax:complete should not run')
+ })
+ $(document).bindNative('ajaxStop', function() {
+ start()
+ })
+ })
+})
+
+asyncTest('"ajax:beforeSend", "ajax:send", "ajax:success" and "ajax:complete" are triggered', 9, function() {
+ submit(function(form) {
+ form.bindNative('ajax:beforeSend', function(e, xhr, settings) {
+ ok(xhr.setRequestHeader, 'first argument to "ajax:beforeSend" should be an XHR object')
+ equal(settings.url, '/echo', 'second argument to "ajax:beforeSend" should be a settings object')
+ })
+ form.bindNative('ajax:send', function(e, xhr) {
+ ok(xhr.abort, 'first argument to "ajax:send" should be an XHR object')
+ })
+ form.bindNative('ajax:success', function(e, data, status, xhr) {
+ ok(data.REQUEST_METHOD, 'first argument to ajax:success should be a data object')
+ equal(status, 'OK', 'second argument to ajax:success should be a status string')
+ ok(xhr.getResponseHeader, 'third argument to "ajax:success" should be an XHR object')
+ })
+ form.bindNative('ajax:complete', function(e, xhr, status) {
+ ok(xhr.getResponseHeader, 'first argument to "ajax:complete" should be an XHR object')
+ equal(status, 'OK', 'second argument to ajax:complete should be a status string')
+ })
+ })
+})
+
+if(window.phantom !== undefined) {
+ asyncTest('"ajax:beforeSend", "ajax:send", "ajax:error" and "ajax:complete" are triggered on error', 7, function() {
+ submit(function(form) {
+ form.attr('action', '/error')
+ form.bindNative('ajax:beforeSend', function(arg) { ok(true, 'ajax:beforeSend') })
+ form.bindNative('ajax:send', function(arg) { ok(true, 'ajax:send') })
+ form.bindNative('ajax:error', function(e, xhr, status, error) {
+ ok(xhr.getResponseHeader, 'first argument to "ajax:error" should be an XHR object')
+ equal(status, 'error', 'second argument to ajax:error should be a status string')
+ // Firefox 8 returns "Forbidden " with trailing space
+ equal($.trim(error), 'Forbidden', 'third argument to ajax:error should be an HTTP status response')
+ // Opera returns "0" for HTTP code
+ equal(xhr.status, window.opera ? 0 : 403, 'status code should be 403')
+ })
+ })
+ })
+}
+
+// IF THIS TEST IS FAILING, TRY INCREASING THE TIMEOUT AT THE BOTTOM TO > 100
+asyncTest('binding to ajax callbacks via .delegate() triggers handlers properly', 4, function() {
+ $(document)
+ .delegate('form[data-remote]', 'ajax:beforeSend', function() {
+ ok(true, 'ajax:beforeSend handler is triggered')
+ })
+ .delegate('form[data-remote]', 'ajax:send', function() {
+ ok(true, 'ajax:send handler is triggered')
+ })
+ .delegate('form[data-remote]', 'ajax:complete', function() {
+ ok(true, 'ajax:complete handler is triggered')
+ })
+ .delegate('form[data-remote]', 'ajax:success', function() {
+ ok(true, 'ajax:success handler is triggered')
+ })
+ $('form[data-remote]').triggerNative('submit')
+
+ setTimeout(function() {
+ start()
+ }, 63)
+})
+
+asyncTest('binding to ajax:send event to call jquery methods on ajax object', 2, function() {
+ $('form[data-remote]')
+ .bindNative('ajax:send', function(e, xhr) {
+ ok(true, 'event should fire')
+ equal(typeof(xhr.abort), 'function', 'event should pass jqXHR object')
+ xhr.abort()
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() { start() }, 35)
+})
+
+})()
diff --git a/actionview/test/ujs/public/test/call-remote.js b/actionview/test/ujs/public/test/call-remote.js
new file mode 100644
index 0000000000..dbeb8ad832
--- /dev/null
+++ b/actionview/test/ujs/public/test/call-remote.js
@@ -0,0 +1,247 @@
+(function() {
+
+function buildForm(attrs) {
+ attrs = $.extend({ action: '/echo', 'data-remote': 'true' }, attrs)
+
+ $('#qunit-fixture').append($('<form />', attrs))
+ .find('form').append($('<input type="text" name="user_name" value="john">'))
+}
+
+module('call-remote')
+
+function submit(fn) {
+ $('form')
+ .bindNative('ajax:success', fn)
+ .bindNative('ajax:complete', function() { start() })
+ .triggerNative('submit')
+}
+
+asyncTest('form method is read from "method" and not from "data-method"', 1, function() {
+ buildForm({ method: 'post', 'data-method': 'get' })
+
+ submit(function(e, data, status, xhr) {
+ App.assertPostRequest(data)
+ })
+})
+
+asyncTest('form method is not read from "data-method" attribute in case of missing "method"', 1, function() {
+ buildForm({ 'data-method': 'put' })
+
+ submit(function(e, data, status, xhr) {
+ App.assertGetRequest(data)
+ })
+})
+
+asyncTest('form method is read from submit button "formmethod" if submit is triggered by that button', 1, function() {
+ var submitButton = $('<input type="submit" formmethod="get">')
+ buildForm({ method: 'post' })
+
+ $('#qunit-fixture').find('form').append(submitButton)
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertGetRequest(data)
+ })
+ .bindNative('ajax:complete', function() { start() })
+
+ submitButton.triggerNative('click')
+})
+
+asyncTest('form default method is GET', 1, function() {
+ buildForm()
+
+ submit(function(e, data, status, xhr) {
+ App.assertGetRequest(data)
+ })
+})
+
+asyncTest('form url is picked up from "action"', 1, function() {
+ buildForm({ method: 'post' })
+
+ submit(function(e, data, status, xhr) {
+ App.assertRequestPath(data, '/echo')
+ })
+})
+
+asyncTest('form url is read from "action" not "href"', 1, function() {
+ buildForm({ method: 'post', href: '/echo2' })
+
+ submit(function(e, data, status, xhr) {
+ App.assertRequestPath(data, '/echo')
+ })
+})
+
+asyncTest('form url is read from submit button "formaction" if submit is triggered by that button', 1, function() {
+ var submitButton = $('<input type="submit" formaction="/echo">')
+ buildForm({ method: 'post', href: '/echo2' })
+
+ $('#qunit-fixture').find('form').append(submitButton)
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertRequestPath(data, '/echo')
+ })
+ .bindNative('ajax:complete', function() { start() })
+
+ submitButton.triggerNative('click')
+})
+
+asyncTest('prefer JS, but accept any format', 1, function() {
+ buildForm({ method: 'post' })
+
+ submit(function(e, data, status, xhr) {
+ var accept = data.HTTP_ACCEPT
+ ok(accept.match(/text\/javascript.+\*\/\*/), 'Accept: ' + accept)
+ })
+})
+
+asyncTest('JS code should be executed', 1, function() {
+ buildForm({ method: 'post', 'data-type': 'script' })
+
+ $('form').append('<input type="text" name="content_type" value="text/javascript">')
+ $('form').append('<input type="text" name="content" value="ok(true, \'remote code should be run\')">')
+
+ submit()
+})
+
+asyncTest('XML document should be parsed', 1, function() {
+ buildForm({ method: 'post', 'data-type': 'html' })
+
+ $('form').append('<input type="text" name="content_type" value="application/xml">')
+ $('form').append('<input type="text" name="content" value="<p>hello</p>">')
+
+ submit(function(e, data, status, xhr) {
+ ok(data instanceof Document, 'returned data should be an XML document')
+ })
+})
+
+asyncTest('accept application/json if "data-type" is json', 1, function() {
+ buildForm({ method: 'post', 'data-type': 'json' })
+
+ submit(function(e, data, status, xhr) {
+ equal(data.HTTP_ACCEPT, 'application/json, text/javascript, */*; q=0.01')
+ })
+})
+
+asyncTest('allow empty "data-remote" attribute', 1, function() {
+ var form = $('#qunit-fixture').append($('<form action="/echo" data-remote />')).find('form')
+
+ submit(function() {
+ ok(true, 'form with empty "data-remote" attribute is also allowed')
+ })
+})
+
+asyncTest('query string in form action should be stripped in a GET request in normal submit', 1, function() {
+ buildForm({ action: '/echo?param1=abc', 'data-remote': 'false' })
+
+ $(document).one('iframe:loaded', function(e, data) {
+ equal(data.params.param1, undefined, '"param1" should not be passed to server')
+ start()
+ })
+
+ $('#qunit-fixture form').triggerNative('submit')
+})
+
+asyncTest('query string in form action should be stripped in a GET request in ajax submit', 1, function() {
+ buildForm({ action: '/echo?param1=abc' })
+
+ submit(function(e, data, status, xhr) {
+ equal(data.params.param1, undefined, '"param1" should not be passed to server')
+ })
+})
+
+asyncTest('query string in form action should not be stripped in a POST request in normal submit', 1, function() {
+ buildForm({ action: '/echo?param1=abc', method: 'post', 'data-remote': 'false' })
+
+ $(document).one('iframe:loaded', function(e, data) {
+ equal(data.params.param1, 'abc', '"param1" should be passed to server')
+ start()
+ })
+
+ $('#qunit-fixture form').triggerNative('submit')
+})
+
+asyncTest('query string in form action should not be stripped in a POST request in ajax submit', 1, function() {
+ buildForm({ action: '/echo?param1=abc', method: 'post' })
+
+ submit(function(e, data, status, xhr) {
+ equal(data.params.param1, 'abc', '"param1" should be passed to server')
+ })
+})
+
+asyncTest('allow empty form "action"', 1, function() {
+ var currentLocation, ajaxLocation
+
+ buildForm({ action: '' })
+
+ $('#qunit-fixture').find('form')
+ .bindNative('ajax:beforeSend', function(e, xhr, settings) {
+ // Get current location (the same way jQuery does)
+ try {
+ currentLocation = location.href
+ } catch(err) {
+ currentLocation = document.createElement( 'a' )
+ currentLocation.href = ''
+ currentLocation = currentLocation.href
+ }
+ currentLocation = currentLocation.replace(/\?.*$/, '')
+
+ // Actual location (strip out settings.data that jQuery serializes and appends)
+ // HACK: can no longer use settings.data below to see what was appended to URL, as of
+ // jQuery 1.6.3 (see http://bugs.jquery.com/ticket/10202 and https://github.com/jquery/jquery/pull/544)
+ ajaxLocation = settings.url.replace('user_name=john', '').replace(/&$/, '').replace(/\?$/, '')
+ equal(ajaxLocation.match(/^(.*)/)[1], currentLocation, 'URL should be current page by default')
+
+ // Prevent the request from actually getting sent to the current page and
+ // causing an error.
+ return false
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() { start() }, 13)
+})
+
+asyncTest('sends CSRF token in custom header', 1, function() {
+ buildForm({ method: 'post' })
+ $('#qunit-fixture').append('<meta name="csrf-token" content="cf50faa3fe97702ca1ae" />')
+
+ submit(function(e, data, status, xhr) {
+ equal(data.HTTP_X_CSRF_TOKEN, 'cf50faa3fe97702ca1ae', 'X-CSRF-Token header should be sent')
+ })
+})
+
+asyncTest('intelligently guesses crossDomain behavior when target URL has a different protocol and/or hostname', 1, function() {
+
+ // Don't set data-cross-domain here, just set action to be a different domain than localhost
+ buildForm({ action: 'http://www.alfajango.com' })
+ $('#qunit-fixture').append('<meta name="csrf-token" content="cf50faa3fe97702ca1ae" />')
+
+ $('#qunit-fixture').find('form')
+ .bindNative('ajax:beforeSend', function(evt, req, settings) {
+
+ equal(settings.crossDomain, true, 'crossDomain should be set to true')
+
+ // prevent request from actually getting sent off-domain
+ return false
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() { start() }, 13)
+})
+
+asyncTest('intelligently guesses crossDomain behavior when target URL consists of only a path', 1, function() {
+
+ // Don't set data-cross-domain here, just set action to be a different domain than localhost
+ buildForm({ action: '/just/a/path' })
+ $('#qunit-fixture').append('<meta name="csrf-token" content="cf50faa3fe97702ca1ae" />')
+
+ $('#qunit-fixture').find('form')
+ .bindNative('ajax:beforeSend', function(evt, req, settings) {
+
+ equal(settings.crossDomain, false, 'crossDomain should be set to false')
+
+ // prevent request from actually getting sent off-domain
+ return false
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() { start() }, 13)
+})
+
+})()
diff --git a/actionview/test/ujs/public/test/csrf-refresh.js b/actionview/test/ujs/public/test/csrf-refresh.js
new file mode 100644
index 0000000000..e302042542
--- /dev/null
+++ b/actionview/test/ujs/public/test/csrf-refresh.js
@@ -0,0 +1,24 @@
+(function() {
+
+module('csrf-refresh', {})
+
+asyncTest('refresh all csrf tokens', 1, function() {
+ var correctToken = 'cf50faa3fe97702ca1ae'
+
+ var form = $('<form />')
+ var input = $('<input>').attr({ type: 'hidden', name: 'authenticity_token', id: 'authenticity_token', value: 'foo' })
+ input.appendTo(form)
+
+ $('#qunit-fixture')
+ .append('<meta name="csrf-param" content="authenticity_token"/>')
+ .append('<meta name="csrf-token" content="' + correctToken + '"/>')
+ .append(form)
+
+ $.rails.refreshCSRFTokens()
+ currentToken = $('#qunit-fixture #authenticity_token').val()
+
+ start()
+ equal(currentToken, correctToken)
+})
+
+})()
diff --git a/actionview/test/ujs/public/test/csrf-token.js b/actionview/test/ujs/public/test/csrf-token.js
new file mode 100644
index 0000000000..388b40e057
--- /dev/null
+++ b/actionview/test/ujs/public/test/csrf-token.js
@@ -0,0 +1,27 @@
+(function() {
+
+module('csrf-token', {})
+
+asyncTest('find csrf token', 1, function() {
+ var correctToken = 'cf50faa3fe97702ca1ae'
+
+ $('#qunit-fixture').append('<meta name="csrf-token" content="' + correctToken + '"/>')
+
+ currentToken = $.rails.csrfToken()
+
+ start()
+ equal(currentToken, correctToken)
+})
+
+asyncTest('find csrf param', 1, function() {
+ var correctParam = 'authenticity_token'
+
+ $('#qunit-fixture').append('<meta name="csrf-param" content="' + correctParam + '"/>')
+
+ currentParam = $.rails.csrfParam()
+
+ start()
+ equal(currentParam, correctParam)
+})
+
+})()
diff --git a/actionview/test/ujs/public/test/data-confirm.js b/actionview/test/ujs/public/test/data-confirm.js
new file mode 100644
index 0000000000..28190c2250
--- /dev/null
+++ b/actionview/test/ujs/public/test/data-confirm.js
@@ -0,0 +1,288 @@
+module('data-confirm', {
+ setup: function() {
+ $('#qunit-fixture').append($('<a />', {
+ href: '/echo',
+ 'data-remote': 'true',
+ 'data-confirm': 'Are you absolutely sure?',
+ text: 'my social security number'
+ }))
+
+ $('#qunit-fixture').append($('<button />', {
+ 'data-url': '/echo',
+ 'data-remote': 'true',
+ 'data-confirm': 'Are you absolutely sure?',
+ text: 'Click me'
+ }))
+
+ $('#qunit-fixture').append($('<form />', {
+ id: 'confirm',
+ action: '/echo',
+ 'data-remote': 'true'
+ }))
+
+ $('#qunit-fixture').append($('<input />', {
+ type: 'submit',
+ form: 'confirm',
+ 'data-confirm': 'Are you absolutely sure?'
+ }))
+
+ this.windowConfirm = window.confirm
+ },
+ teardown: function() {
+ window.confirm = this.windowConfirm
+ }
+})
+
+asyncTest('clicking on a link with data-confirm attribute. Confirm yes.', 6, function() {
+ var message
+ // auto-confirm:
+ window.confirm = function(msg) { message = msg; return true }
+
+ $('a[data-confirm]')
+ .bindNative('confirm:complete', function(e, data) {
+ App.assertCallbackInvoked('confirm:complete')
+ ok(data == true, 'confirm:complete passes in confirm answer (true)')
+ })
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertCallbackInvoked('ajax:success')
+ App.assertRequestPath(data, '/echo')
+ App.assertGetRequest(data)
+
+ equal(message, 'Are you absolutely sure?')
+ start()
+ })
+ .triggerNative('click')
+})
+
+asyncTest('clicking on a button with data-confirm attribute. Confirm yes.', 6, function() {
+ var message
+ // auto-confirm:
+ window.confirm = function(msg) { message = msg; return true }
+
+ $('button[data-confirm]')
+ .bindNative('confirm:complete', function(e, data) {
+ App.assertCallbackInvoked('confirm:complete')
+ ok(data == true, 'confirm:complete passes in confirm answer (true)')
+ })
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertCallbackInvoked('ajax:success')
+ App.assertRequestPath(data, '/echo')
+ App.assertGetRequest(data)
+
+ equal(message, 'Are you absolutely sure?')
+ start()
+ })
+ .triggerNative('click')
+})
+
+asyncTest('clicking on a link with data-confirm attribute. Confirm No.', 3, function() {
+ var message
+ // auto-decline:
+ window.confirm = function(msg) { message = msg; return false }
+
+ $('a[data-confirm]')
+ .bindNative('confirm:complete', function(e, data) {
+ App.assertCallbackInvoked('confirm:complete')
+ ok(data == false, 'confirm:complete passes in confirm answer (false)')
+ })
+ .bindNative('ajax:beforeSend', function(e, data, status, xhr) {
+ App.assertCallbackNotInvoked('ajax:beforeSend')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ equal(message, 'Are you absolutely sure?')
+ start()
+ }, 50)
+})
+
+asyncTest('clicking on a button with data-confirm attribute. Confirm No.', 3, function() {
+ var message
+ // auto-decline:
+ window.confirm = function(msg) { message = msg; return false }
+
+ $('button[data-confirm]')
+ .bindNative('confirm:complete', function(e, data) {
+ App.assertCallbackInvoked('confirm:complete')
+ ok(data == false, 'confirm:complete passes in confirm answer (false)')
+ })
+ .bindNative('ajax:beforeSend', function(e, data, status, xhr) {
+ App.assertCallbackNotInvoked('ajax:beforeSend')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ equal(message, 'Are you absolutely sure?')
+ start()
+ }, 50)
+})
+
+asyncTest('clicking on a button with data-confirm attribute. Confirm error.', 3, function() {
+ var message
+ // auto-decline:
+ window.confirm = function(msg) { message = msg; throw 'some random error' }
+
+ $('button[data-confirm]')
+ .bindNative('confirm:complete', function(e, data) {
+ App.assertCallbackInvoked('confirm:complete')
+ ok(data == false, 'confirm:complete passes in confirm answer (false)')
+ })
+ .bindNative('ajax:beforeSend', function(e, data, status, xhr) {
+ App.assertCallbackNotInvoked('ajax:beforeSend')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ equal(message, 'Are you absolutely sure?')
+ start()
+ }, 50)
+})
+
+asyncTest('clicking on a submit button with form and data-confirm attributes. Confirm No.', 3, function() {
+ var message
+ // auto-decline:
+ window.confirm = function(msg) { message = msg; return false }
+
+ $('input[type=submit][form]')
+ .bindNative('confirm:complete', function(e, data) {
+ App.assertCallbackInvoked('confirm:complete')
+ ok(data == false, 'confirm:complete passes in confirm answer (false)')
+ })
+ .bindNative('ajax:beforeSend', function(e, data, status, xhr) {
+ App.assertCallbackNotInvoked('ajax:beforeSend')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ equal(message, 'Are you absolutely sure?')
+ start()
+ }, 50)
+})
+
+asyncTest('binding to confirm event of a link and returning false', 1, function() {
+ // redefine confirm function so we can make sure it's not called
+ window.confirm = function(msg) {
+ ok(false, 'confirm dialog should not be called')
+ }
+
+ $('a[data-confirm]')
+ .bindNative('confirm', function() {
+ App.assertCallbackInvoked('confirm')
+ return false
+ })
+ .bindNative('confirm:complete', function() {
+ App.assertCallbackNotInvoked('confirm:complete')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ start()
+ }, 50)
+})
+
+asyncTest('binding to confirm event of a button and returning false', 1, function() {
+ // redefine confirm function so we can make sure it's not called
+ window.confirm = function(msg) {
+ ok(false, 'confirm dialog should not be called')
+ }
+
+ $('button[data-confirm]')
+ .bindNative('confirm', function() {
+ App.assertCallbackInvoked('confirm')
+ return false
+ })
+ .bindNative('confirm:complete', function() {
+ App.assertCallbackNotInvoked('confirm:complete')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ start()
+ }, 50)
+})
+
+asyncTest('binding to confirm:complete event of a link and returning false', 2, function() {
+ // auto-confirm:
+ window.confirm = function(msg) {
+ ok(true, 'confirm dialog should be called')
+ return true
+ }
+
+ $('a[data-confirm]')
+ .bindNative('confirm:complete', function() {
+ App.assertCallbackInvoked('confirm:complete')
+ return false
+ })
+ .bindNative('ajax:beforeSend', function() {
+ App.assertCallbackNotInvoked('ajax:beforeSend')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ start()
+ }, 50)
+})
+
+asyncTest('binding to confirm:complete event of a button and returning false', 2, function() {
+ // auto-confirm:
+ window.confirm = function(msg) {
+ ok(true, 'confirm dialog should be called')
+ return true
+ }
+
+ $('button[data-confirm]')
+ .bindNative('confirm:complete', function() {
+ App.assertCallbackInvoked('confirm:complete')
+ return false
+ })
+ .bindNative('ajax:beforeSend', function() {
+ App.assertCallbackNotInvoked('ajax:beforeSend')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ start()
+ }, 50)
+})
+
+asyncTest('a button inside a form only confirms once', 1, function() {
+ var confirmations = 0
+ window.confirm = function(msg) {
+ confirmations++
+ return true
+ }
+
+ $('#qunit-fixture').append($('<form />').append($('<button />', {
+ 'data-remote': 'true',
+ 'data-confirm': 'Are you absolutely sure?',
+ text: 'Click me'
+ })))
+
+ $('form > button[data-confirm]').triggerNative('click')
+
+ ok(confirmations === 1, 'confirmation counter should be 1, but it was ' + confirmations)
+ start()
+})
+
+asyncTest('clicking on the children of a link should also trigger a confirm', 6, function() {
+ var message
+ // auto-confirm:
+ window.confirm = function(msg) { message = msg; return true }
+
+ $('a[data-confirm]')
+ .html('<strong>Click me</strong>')
+ .bindNative('confirm:complete', function(e, data) {
+ App.assertCallbackInvoked('confirm:complete')
+ ok(data == true, 'confirm:complete passes in confirm answer (true)')
+ })
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertCallbackInvoked('ajax:success')
+ App.assertRequestPath(data, '/echo')
+ App.assertGetRequest(data)
+
+ equal(message, 'Are you absolutely sure?')
+ start()
+ })
+ .find('strong')
+ .triggerNative('click')
+})
diff --git a/actionview/test/ujs/public/test/data-disable-with.js b/actionview/test/ujs/public/test/data-disable-with.js
new file mode 100644
index 0000000000..b29cbbc867
--- /dev/null
+++ b/actionview/test/ujs/public/test/data-disable-with.js
@@ -0,0 +1,391 @@
+module('data-disable-with', {
+ setup: function() {
+ $('#qunit-fixture').append($('<form />', {
+ action: '/echo',
+ 'data-remote': 'true',
+ method: 'post'
+ }))
+ .find('form')
+ .append($('<input type="text" data-disable-with="processing ..." name="user_name" value="john" />'))
+
+ $('#qunit-fixture').append($('<form />', {
+ action: '/echo',
+ method: 'post',
+ id: 'not_remote'
+ }))
+ .find('form:last')
+ // WEEIRDD: the form won't submit to an iframe if the button is name="submit" (??!)
+ .append($('<input type="submit" data-disable-with="submitting ..." name="submit2" value="Submit" />'))
+
+ $('#qunit-fixture').append($('<a />', {
+ text: 'Click me',
+ href: '/echo',
+ 'data-disable-with': 'clicking...'
+ }))
+
+ $('#qunit-fixture').append($('<input />', {
+ type: 'submit',
+ form: 'not_remote',
+ 'data-disable-with': 'form attr submitting',
+ name: 'submit3',
+ value: 'Form Attr Submit'
+ }))
+
+ $('#qunit-fixture').append($('<button />', {
+ text: 'Click me',
+ 'data-remote': true,
+ 'data-url': '/echo',
+ 'data-disable-with': 'clicking...'
+ }))
+ },
+ teardown: function() {
+ $(document).unbind('iframe:loaded')
+ }
+})
+
+asyncTest('form input field with "data-disable-with" attribute', 7, function() {
+ var form = $('form[data-remote]'), input = form.find('input[type=text]')
+
+ App.checkEnabledState(input, 'john')
+
+ form.bindNative('ajax:success', function(e, data) {
+ setTimeout(function() {
+ App.checkEnabledState(input, 'john')
+ equal(data.params.user_name, 'john')
+ start()
+ }, 13)
+ })
+ form.triggerNative('submit')
+
+ App.checkDisabledState(input, 'processing ...')
+})
+
+asyncTest('blank form input field with "data-disable-with" attribute', 7, function() {
+ var form = $('form[data-remote]'), input = form.find('input[type=text]')
+
+ input.val('')
+ App.checkEnabledState(input, '')
+
+ form.bindNative('ajax:success', function(e, data) {
+ setTimeout(function() {
+ App.checkEnabledState(input, '')
+ equal(data.params.user_name, '')
+ start()
+ }, 13)
+ })
+ form.triggerNative('submit')
+
+ App.checkDisabledState(input, 'processing ...')
+})
+
+asyncTest('form button with "data-disable-with" attribute', 6, function() {
+ var form = $('form[data-remote]'), button = $('<button data-disable-with="submitting ..." name="submit2">Submit</button>')
+ form.append(button)
+
+ App.checkEnabledState(button, 'Submit')
+
+ form.bindNative('ajax:success', function(e, data) {
+ setTimeout(function() {
+ App.checkEnabledState(button, 'Submit')
+ start()
+ }, 13)
+ })
+ form.triggerNative('submit')
+
+ App.checkDisabledState(button, 'submitting ...')
+})
+
+asyncTest('form input[type=submit][data-disable-with] disables', 6, function() {
+ var form = $('form:not([data-remote])'), input = form.find('input[type=submit]')
+
+ App.checkEnabledState(input, 'Submit')
+
+ $(document).bind('iframe:loaded', function(e, data) {
+ setTimeout(function() {
+ App.checkDisabledState(input, 'submitting ...')
+ start()
+ }, 30)
+ })
+ form.triggerNative('submit')
+
+ setTimeout(function() {
+ App.checkDisabledState(input, 'submitting ...')
+ }, 30)
+})
+
+test('form input[type=submit][data-disable-with] re-enables when `pageshow` event is triggered', function() {
+ var form = $('form:not([data-remote])'), input = form.find('input[type=submit]')
+
+ App.checkEnabledState(input, 'Submit')
+
+ // Emulate the disabled state without submitting the form at all, what is the
+ // state after going back on firefox after submitting a form.
+ //
+ // See https://github.com/rails/jquery-ujs/issues/357
+ $.rails.disableElement(form[0])
+
+ App.checkDisabledState(input, 'submitting ...')
+
+ $(window).triggerNative('pageshow')
+
+ App.checkEnabledState(input, 'Submit')
+})
+
+asyncTest('form[data-remote] input[type=submit][data-disable-with] is replaced in ajax callback', 2, function() {
+ var form = $('form:not([data-remote])').attr('data-remote', 'true'), origFormContents = form.html()
+
+ form.bindNative('ajax:success', function() {
+ form.html(origFormContents)
+
+ setTimeout(function() {
+ var input = form.find('input[type=submit]')
+ App.checkEnabledState(input, 'Submit')
+ start()
+ }, 30)
+ }).triggerNative('submit')
+})
+
+asyncTest('form[data-remote] input[data-disable-with] is replaced with disabled field in ajax callback', 2, function() {
+ var form = $('form:not([data-remote])').attr('data-remote', 'true'), input = form.find('input[type=submit]'),
+ newDisabledInput = input.clone().attr('disabled', 'disabled')
+
+ form.bindNative('ajax:success', function() {
+ input.replaceWith(newDisabledInput)
+
+ setTimeout(function() {
+ App.checkEnabledState(newDisabledInput, 'Submit')
+ start()
+ }, 30)
+ }).triggerNative('submit')
+})
+
+asyncTest('form input[type=submit][data-disable-with] using "form" attribute disables', 6, function() {
+ var form = $('#not_remote'), input = $('input[form=not_remote]')
+ App.checkEnabledState(input, 'Form Attr Submit')
+
+ $(document).bind('iframe:loaded', function(e, data) {
+ setTimeout(function() {
+ App.checkDisabledState(input, 'form attr submitting')
+ start()
+ }, 30)
+ })
+ form.triggerNative('submit')
+
+ setTimeout(function() {
+ App.checkDisabledState(input, 'form attr submitting')
+ }, 30)
+
+})
+
+asyncTest('form[data-remote] textarea[data-disable-with] attribute', 3, function() {
+ var form = $('form[data-remote]'),
+ textarea = $('<textarea data-disable-with="processing ..." name="user_bio">born, lived, died.</textarea>').appendTo(form)
+
+ form.bindNative('ajax:success', function(e, data) {
+ setTimeout(function() {
+ equal(data.params.user_bio, 'born, lived, died.')
+ start()
+ }, 13)
+ })
+ form.triggerNative('submit')
+
+ App.checkDisabledState(textarea, 'processing ...')
+})
+
+asyncTest('a[data-disable-with] disables', 4, function() {
+ var link = $('a[data-disable-with]')
+
+ App.checkEnabledState(link, 'Click me')
+
+ link.triggerNative('click')
+ App.checkDisabledState(link, 'clicking...')
+ start()
+})
+
+test('a[data-disable-with] re-enables when `pageshow` event is triggered', function() {
+ var link = $('a[data-disable-with]')
+
+ App.checkEnabledState(link, 'Click me')
+
+ link.triggerNative('click')
+ App.checkDisabledState(link, 'clicking...')
+
+ $(window).triggerNative('pageshow')
+ App.checkEnabledState(link, 'Click me')
+})
+
+asyncTest('a[data-remote][data-disable-with] disables and re-enables', 6, function() {
+ var link = $('a[data-disable-with]').attr('data-remote', true)
+
+ App.checkEnabledState(link, 'Click me')
+
+ link
+ .bindNative('ajax:beforeSend', function() {
+ App.checkDisabledState(link, 'clicking...')
+ })
+ .bindNative('ajax:complete', function() {
+ setTimeout( function() {
+ App.checkEnabledState(link, 'Click me')
+ start()
+ }, 15)
+ })
+ .triggerNative('click')
+})
+
+asyncTest('a[data-remote][data-disable-with] re-enables when `ajax:before` event is cancelled', 6, function() {
+ var link = $('a[data-disable-with]').attr('data-remote', true)
+
+ App.checkEnabledState(link, 'Click me')
+
+ link
+ .bindNative('ajax:before', function() {
+ App.checkDisabledState(link, 'clicking...')
+ return false
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(link, 'Click me')
+ start()
+ }, 30)
+})
+
+asyncTest('a[data-remote][data-disable-with] re-enables when `ajax:beforeSend` event is cancelled', 6, function() {
+ var link = $('a[data-disable-with]').attr('data-remote', true)
+
+ App.checkEnabledState(link, 'Click me')
+
+ link
+ .bindNative('ajax:beforeSend', function() {
+ App.checkDisabledState(link, 'clicking...')
+ return false
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(link, 'Click me')
+ start()
+ }, 30)
+})
+
+asyncTest('a[data-remote][data-disable-with] re-enables when `ajax:error` event is triggered', 6, function() {
+ var link = $('a[data-disable-with]').attr('data-remote', true).attr('href', '/error')
+
+ App.checkEnabledState(link, 'Click me')
+
+ link
+ .bindNative('ajax:beforeSend', function() {
+ App.checkDisabledState(link, 'clicking...')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(link, 'Click me')
+ start()
+ }, 30)
+})
+
+asyncTest('form[data-remote] input|button|textarea[data-disable-with] does not disable when `ajax:beforeSend` event is cancelled', 8, function() {
+ var form = $('form[data-remote]'),
+ input = form.find('input:text'),
+ button = $('<button data-disable-with="submitting ..." name="submit2">Submit</button>').appendTo(form),
+ textarea = $('<textarea data-disable-with="processing ..." name="user_bio">born, lived, died.</textarea>').appendTo(form),
+ submit = $('<input type="submit" data-disable-with="submitting ..." name="submit2" value="Submit" />').appendTo(form)
+
+ form
+ .bindNative('ajax:beforeSend', function() {
+ return false
+ })
+ .triggerNative('submit')
+
+ App.checkEnabledState(input, 'john')
+ App.checkEnabledState(button, 'Submit')
+ App.checkEnabledState(textarea, 'born, lived, died.')
+ App.checkEnabledState(submit, 'Submit')
+
+ start()
+})
+
+asyncTest('ctrl-clicking on a link does not disables the link', 6, function() {
+ var link = $('a[data-disable-with]')
+
+ App.checkEnabledState(link, 'Click me')
+
+ link.triggerNative('click', { metaKey: true })
+ App.checkEnabledState(link, 'Click me')
+
+ link.triggerNative('click', { metaKey: true })
+ App.checkEnabledState(link, 'Click me')
+ start()
+})
+
+asyncTest('button[data-remote][data-disable-with] disables and re-enables', 6, function() {
+ var button = $('button[data-remote][data-disable-with]')
+
+ App.checkEnabledState(button, 'Click me')
+
+ button
+ .bindNative('ajax:send', function() {
+ App.checkDisabledState(button, 'clicking...')
+ })
+ .bindNative('ajax:complete', function() {
+ setTimeout( function() {
+ App.checkEnabledState(button, 'Click me')
+ start()
+ }, 15)
+ })
+ .triggerNative('click')
+})
+
+asyncTest('button[data-remote][data-disable-with] re-enables when `ajax:before` event is cancelled', 6, function() {
+ var button = $('button[data-remote][data-disable-with]')
+
+ App.checkEnabledState(button, 'Click me')
+
+ button
+ .bindNative('ajax:before', function() {
+ App.checkDisabledState(button, 'clicking...')
+ return false
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(button, 'Click me')
+ start()
+ }, 30)
+})
+
+asyncTest('button[data-remote][data-disable-with] re-enables when `ajax:beforeSend` event is cancelled', 6, function() {
+ var button = $('button[data-remote][data-disable-with]')
+
+ App.checkEnabledState(button, 'Click me')
+
+ button
+ .bindNative('ajax:beforeSend', function() {
+ App.checkDisabledState(button, 'clicking...')
+ return false
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(button, 'Click me')
+ start()
+ }, 30)
+})
+
+asyncTest('button[data-remote][data-disable-with] re-enables when `ajax:error` event is triggered', 6, function() {
+ var button = $('a[data-disable-with]').attr('data-remote', true).attr('href', '/error')
+
+ App.checkEnabledState(button, 'Click me')
+
+ button
+ .bindNative('ajax:send', function() {
+ App.checkDisabledState(button, 'clicking...')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(button, 'Click me')
+ start()
+ }, 30)
+})
diff --git a/actionview/test/ujs/public/test/data-disable.js b/actionview/test/ujs/public/test/data-disable.js
new file mode 100644
index 0000000000..ccc38cf9ae
--- /dev/null
+++ b/actionview/test/ujs/public/test/data-disable.js
@@ -0,0 +1,321 @@
+module('data-disable', {
+ setup: function() {
+ $('#qunit-fixture').append($('<form />', {
+ action: '/echo',
+ 'data-remote': 'true',
+ method: 'post'
+ }))
+ .find('form')
+ .append($('<input type="text" data-disable name="user_name" value="john" />'))
+
+ $('#qunit-fixture').append($('<form />', {
+ action: '/echo',
+ method: 'post'
+ }))
+ .find('form:last')
+ // WEEIRDD: the form won't submit to an iframe if the button is name="submit" (??!)
+ .append($('<input type="submit" data-disable name="submit2" value="Submit" />'))
+
+ $('#qunit-fixture').append($('<a />', {
+ text: 'Click me',
+ href: '/echo',
+ 'data-disable': 'true'
+ }))
+
+ $('#qunit-fixture').append($('<button />', {
+ text: 'Click me',
+ 'data-remote': true,
+ 'data-url': '/echo',
+ 'data-disable': 'true'
+ }))
+ },
+ teardown: function() {
+ $(document).unbind('iframe:loaded')
+ }
+})
+
+asyncTest('form input field with "data-disable" attribute', 7, function() {
+ var form = $('form[data-remote]'), input = form.find('input[type=text]')
+
+ App.checkEnabledState(input, 'john')
+
+ form.bindNative('ajax:success', function(e, data) {
+ setTimeout(function() {
+ App.checkEnabledState(input, 'john')
+ equal(data.params.user_name, 'john')
+ start()
+ }, 13)
+ })
+ form.triggerNative('submit')
+
+ App.checkDisabledState(input, 'john')
+})
+
+asyncTest('form button with "data-disable" attribute', 7, function() {
+ var form = $('form[data-remote]'), button = $('<button data-disable name="submit2">Submit</button>')
+ form.append(button)
+
+ App.checkEnabledState(button, 'Submit')
+
+ form.bindNative('ajax:success', function(e, data) {
+ setTimeout(function() {
+ App.checkEnabledState(button, 'Submit')
+ start()
+ }, 13)
+ })
+ form.triggerNative('submit')
+
+ App.checkDisabledState(button, 'Submit')
+ equal(button.data('ujs:enable-with'), undefined)
+})
+
+asyncTest('form input[type=submit][data-disable] disables', 6, function() {
+ var form = $('form:not([data-remote])'), input = form.find('input[type=submit]')
+
+ App.checkEnabledState(input, 'Submit')
+
+ // WEEIRDD: attaching this handler makes the test work in IE7
+ $(document).bind('iframe:loading', function(e, f) {})
+
+ $(document).bind('iframe:loaded', function(e, data) {
+ setTimeout(function() {
+ App.checkDisabledState(input, 'Submit')
+ start()
+ }, 30)
+ })
+ form.triggerNative('submit')
+
+ setTimeout(function() {
+ App.checkDisabledState(input, 'Submit')
+ }, 30)
+})
+
+asyncTest('form[data-remote] input[type=submit][data-disable] is replaced in ajax callback', 2, function() {
+ var form = $('form:not([data-remote])').attr('data-remote', 'true'), origFormContents = form.html()
+
+ form.bindNative('ajax:success', function() {
+ form.html(origFormContents)
+
+ setTimeout(function() {
+ var input = form.find('input[type=submit]')
+ App.checkEnabledState(input, 'Submit')
+ start()
+ }, 30)
+ }).triggerNative('submit')
+})
+
+asyncTest('form[data-remote] input[data-disable] is replaced with disabled field in ajax callback', 2, function() {
+ var form = $('form:not([data-remote])').attr('data-remote', 'true'), input = form.find('input[type=submit]'),
+ newDisabledInput = input.clone().attr('disabled', 'disabled')
+
+ form.bindNative('ajax:success', function() {
+ input.replaceWith(newDisabledInput)
+
+ setTimeout(function() {
+ App.checkEnabledState(newDisabledInput, 'Submit')
+ start()
+ }, 30)
+ }).triggerNative('submit')
+})
+
+asyncTest('form[data-remote] textarea[data-disable] attribute', 3, function() {
+ var form = $('form[data-remote]'),
+ textarea = $('<textarea data-disable name="user_bio">born, lived, died.</textarea>').appendTo(form)
+
+ form.bindNative('ajax:success', function(e, data) {
+ setTimeout(function() {
+ equal(data.params.user_bio, 'born, lived, died.')
+ start()
+ }, 13)
+ })
+ form.triggerNative('submit')
+
+ App.checkDisabledState(textarea, 'born, lived, died.')
+})
+
+asyncTest('a[data-disable] disables', 5, function() {
+ var link = $('a[data-disable]')
+
+ App.checkEnabledState(link, 'Click me')
+
+ link.triggerNative('click')
+ App.checkDisabledState(link, 'Click me')
+ equal(link.data('ujs:enable-with'), undefined)
+ start()
+})
+
+asyncTest('a[data-remote][data-disable] disables and re-enables', 6, function() {
+ var link = $('a[data-disable]').attr('data-remote', true)
+
+ App.checkEnabledState(link, 'Click me')
+
+ link
+ .bindNative('ajax:send', function() {
+ App.checkDisabledState(link, 'Click me')
+ })
+ .bindNative('ajax:complete', function() {
+ setTimeout( function() {
+ App.checkEnabledState(link, 'Click me')
+ start()
+ }, 15)
+ })
+ .triggerNative('click')
+})
+
+asyncTest('a[data-remote][data-disable] re-enables when `ajax:before` event is cancelled', 6, function() {
+ var link = $('a[data-disable]').attr('data-remote', true)
+
+ App.checkEnabledState(link, 'Click me')
+
+ link
+ .bindNative('ajax:before', function() {
+ App.checkDisabledState(link, 'Click me')
+ return false
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(link, 'Click me')
+ start()
+ }, 30)
+})
+
+asyncTest('a[data-remote][data-disable] re-enables when `ajax:beforeSend` event is cancelled', 6, function() {
+ var link = $('a[data-disable]').attr('data-remote', true)
+
+ App.checkEnabledState(link, 'Click me')
+
+ link
+ .bindNative('ajax:beforeSend', function() {
+ App.checkDisabledState(link, 'Click me')
+ return false
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(link, 'Click me')
+ start()
+ }, 30)
+})
+
+asyncTest('a[data-remote][data-disable] re-enables when `ajax:error` event is triggered', 6, function() {
+ var link = $('a[data-disable]').attr('data-remote', true).attr('href', '/error')
+
+ App.checkEnabledState(link, 'Click me')
+
+ link
+ .bindNative('ajax:send', function() {
+ App.checkDisabledState(link, 'Click me')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(link, 'Click me')
+ start()
+ }, 30)
+})
+
+asyncTest('form[data-remote] input|button|textarea[data-disable] does not disable when `ajax:beforeSend` event is cancelled', 8, function() {
+ var form = $('form[data-remote]'),
+ input = form.find('input:text'),
+ button = $('<button data-disable="submitting ..." name="submit2">Submit</button>').appendTo(form),
+ textarea = $('<textarea data-disable name="user_bio">born, lived, died.</textarea>').appendTo(form),
+ submit = $('<input type="submit" data-disable="submitting ..." name="submit2" value="Submit" />').appendTo(form)
+
+ form
+ .bindNative('ajax:beforeSend', function() {
+ return false
+ })
+ .triggerNative('submit')
+
+ App.checkEnabledState(input, 'john')
+ App.checkEnabledState(button, 'Submit')
+ App.checkEnabledState(textarea, 'born, lived, died.')
+ App.checkEnabledState(submit, 'Submit')
+
+ start()
+})
+
+asyncTest('ctrl-clicking on a link does not disables the link', 6, function() {
+ var link = $('a[data-disable]')
+
+ App.checkEnabledState(link, 'Click me')
+
+ link.triggerNative('click', { metaKey: true })
+ App.checkEnabledState(link, 'Click me')
+
+ link.triggerNative('click', { ctrlKey: true })
+ App.checkEnabledState(link, 'Click me')
+ start()
+})
+
+asyncTest('button[data-remote][data-disable] disables and re-enables', 6, function() {
+ var button = $('button[data-remote][data-disable]')
+
+ App.checkEnabledState(button, 'Click me')
+
+ button
+ .bindNative('ajax:send', function() {
+ App.checkDisabledState(button, 'Click me')
+ })
+ .bindNative('ajax:complete', function() {
+ setTimeout( function() {
+ App.checkEnabledState(button, 'Click me')
+ start()
+ }, 15)
+ })
+ .triggerNative('click')
+})
+
+asyncTest('button[data-remote][data-disable] re-enables when `ajax:before` event is cancelled', 6, function() {
+ var button = $('button[data-remote][data-disable]')
+
+ App.checkEnabledState(button, 'Click me')
+
+ button
+ .bindNative('ajax:before', function() {
+ App.checkDisabledState(button, 'Click me')
+ return false
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(button, 'Click me')
+ start()
+ }, 30)
+})
+
+asyncTest('button[data-remote][data-disable] re-enables when `ajax:beforeSend` event is cancelled', 6, function() {
+ var button = $('button[data-remote][data-disable]')
+
+ App.checkEnabledState(button, 'Click me')
+
+ button
+ .bindNative('ajax:beforeSend', function() {
+ App.checkDisabledState(button, 'Click me')
+ return false
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(button, 'Click me')
+ start()
+ }, 30)
+})
+
+asyncTest('button[data-remote][data-disable] re-enables when `ajax:error` event is triggered', 6, function() {
+ var button = $('a[data-disable]').attr('data-remote', true).attr('href', '/error')
+
+ App.checkEnabledState(button, 'Click me')
+
+ button
+ .bindNative('ajax:send', function() {
+ App.checkDisabledState(button, 'Click me')
+ })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ App.checkEnabledState(button, 'Click me')
+ start()
+ }, 30)
+})
diff --git a/actionview/test/ujs/public/test/data-method.js b/actionview/test/ujs/public/test/data-method.js
new file mode 100644
index 0000000000..47d940c577
--- /dev/null
+++ b/actionview/test/ujs/public/test/data-method.js
@@ -0,0 +1,85 @@
+(function() {
+
+module('data-method', {
+ setup: function() {
+ $('#qunit-fixture').append($('<a />', {
+ href: '/echo', 'data-method': 'delete', text: 'destroy!'
+ }))
+ },
+ teardown: function() {
+ $(document).unbind('iframe:loaded')
+ }
+})
+
+function submit(fn, options) {
+ $(document).bind('iframe:loaded', function(e, data) {
+ fn(data)
+ start()
+ })
+
+ $('#qunit-fixture').find('a')
+ .triggerNative('click')
+}
+
+asyncTest('link with "data-method" set to "delete"', 3, function() {
+ submit(function(data) {
+ equal(data.REQUEST_METHOD, 'DELETE')
+ strictEqual(data.params.authenticity_token, undefined)
+ strictEqual(data.HTTP_X_CSRF_TOKEN, undefined)
+ })
+})
+
+asyncTest('click on the child of link with "data-method"', 3, function() {
+ $(document).bind('iframe:loaded', function(e, data) {
+ equal(data.REQUEST_METHOD, 'DELETE')
+ strictEqual(data.params.authenticity_token, undefined)
+ strictEqual(data.HTTP_X_CSRF_TOKEN, undefined)
+ start()
+ })
+ $('#qunit-fixture a').html('<strong>destroy!</strong>').find('strong').triggerNative('click')
+})
+
+asyncTest('link with "data-method" and CSRF', 1, function() {
+ $('#qunit-fixture')
+ .append('<meta name="csrf-param" content="authenticity_token"/>')
+ .append('<meta name="csrf-token" content="cf50faa3fe97702ca1ae"/>')
+
+ submit(function(data) {
+ equal(data.params.authenticity_token, 'cf50faa3fe97702ca1ae')
+ })
+})
+
+asyncTest('link "target" should be carried over to generated form', 1, function() {
+ $('a[data-method]').attr('target', 'super-special-frame')
+ submit(function(data) {
+ equal(data.params._target, 'super-special-frame')
+ })
+})
+
+asyncTest('link with "data-method" and cross origin', 1, function() {
+ var data = {}
+
+ $('#qunit-fixture')
+ .append('<meta name="csrf-param" content="authenticity_token"/>')
+ .append('<meta name="csrf-token" content="cf50faa3fe97702ca1ae"/>')
+
+ $(document).on('submit', 'form', function(e) {
+ $(e.currentTarget).serializeArray().map(function(item) {
+ data[item.name] = item.value
+ })
+
+ return false
+ })
+
+ var link = $('#qunit-fixture').find('a')
+
+ link.attr('href', 'http://www.alfajango.com')
+
+ link.triggerNative('click')
+
+ start()
+
+ notEqual(data.authenticity_token, 'cf50faa3fe97702ca1ae')
+})
+
+})()
diff --git a/actionview/test/ujs/public/test/data-remote.js b/actionview/test/ujs/public/test/data-remote.js
new file mode 100644
index 0000000000..a51aa10417
--- /dev/null
+++ b/actionview/test/ujs/public/test/data-remote.js
@@ -0,0 +1,400 @@
+module('data-remote', {
+ setup: function() {
+ $('#qunit-fixture')
+ .append($('<a />', {
+ href: '/echo',
+ 'data-remote': 'true',
+ 'data-params': 'data1=value1&data2=value2',
+ text: 'my address'
+ }))
+ .append($('<button />', {
+ 'data-url': '/echo',
+ 'data-remote': 'true',
+ 'data-params': 'data1=value1&data2=value2',
+ text: 'my button'
+ }))
+ .append($('<form />', {
+ action: '/echo',
+ 'data-remote': 'true',
+ method: 'post',
+ id: 'my-remote-form'
+ }))
+ .append($('<a />', {
+ href: '/echo',
+ 'data-remote': 'true',
+ disabled: 'disabled',
+ text: 'Disabed link'
+ }))
+ .find('form').append($('<input type="text" name="user_name" value="john">'))
+
+ }
+})
+
+asyncTest('ctrl-clicking on a link does not fire ajaxyness', 0, function() {
+ var link = $('a[data-remote]')
+
+ // Ideally, we'd setup an iframe to intercept normal link clicks
+ // and add a test to make sure the iframe:loaded event is triggered.
+ // However, jquery doesn't actually cause a native `click` event and
+ // follow links using `trigger('click')`, it only fires bindings.
+ link
+ .removeAttr('data-params')
+ .bindNative('ajax:beforeSend', function() {
+ ok(false, 'ajax should not be triggered')
+ })
+
+ link.triggerNative('click', { metaKey: true })
+ link.triggerNative('click', { ctrlKey: true })
+
+ setTimeout(function() { start() }, 13)
+})
+
+asyncTest('ctrl-clicking on a link still fires ajax for non-GET links and for links with "data-params"', 2, function() {
+ var link = $('a[data-remote]')
+
+ link
+ .removeAttr('data-params')
+ .attr('data-method', 'POST')
+ .bindNative('ajax:beforeSend', function() {
+ ok(true, 'ajax should be triggered')
+ })
+ .triggerNative('click', { metaKey: true })
+
+ link
+ .removeAttr('data-method')
+ .attr('data-params', 'name=steve')
+ .triggerNative('click', { metaKey: true })
+
+ setTimeout(function() { start() }, 13)
+})
+
+asyncTest('clicking on a link with data-remote attribute', 5, function() {
+ $('a[data-remote]')
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertCallbackInvoked('ajax:success')
+ App.assertRequestPath(data, '/echo')
+ console.log(data.params)
+ equal(data.params.data1, 'value1', 'ajax arguments should have key data1 with right value')
+ equal(data.params.data2, 'value2', 'ajax arguments should have key data2 with right value')
+ App.assertGetRequest(data)
+ })
+ .bindNative('ajax:complete', function() { start() })
+ .triggerNative('click')
+})
+
+asyncTest('clicking on a link with both query string in href and data-params', 4, function() {
+ $('a[data-remote]')
+ .attr('href', '/echo?data3=value3')
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertGetRequest(data)
+ equal(data.params.data1, 'value1', 'ajax arguments should have key data1 with right value')
+ equal(data.params.data2, 'value2', 'ajax arguments should have key data2 with right value')
+ equal(data.params.data3, 'value3', 'query string in url should be passed to server with right value')
+ })
+ .bindNative('ajax:complete', function() { start() })
+ .triggerNative('click')
+})
+
+asyncTest('clicking on a link with both query string in href and data-params with POST method', 4, function() {
+ $('a[data-remote]')
+ .attr('href', '/echo?data3=value3')
+ .attr('data-method', 'post')
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertPostRequest(data)
+ equal(data.params.data1, 'value1', 'ajax arguments should have key data1 with right value')
+ equal(data.params.data2, 'value2', 'ajax arguments should have key data2 with right value')
+ equal(data.params.data3, 'value3', 'query string in url should be passed to server with right value')
+ })
+ .bindNative('ajax:complete', function() { start() })
+ .triggerNative('click')
+})
+
+asyncTest('clicking on a link with disabled attribute', 0, function() {
+ $('a[disabled]')
+ .bindNative('ajax:before', function(e, data, status, xhr) {
+ App.assertCallbackNotInvoked('ajax:success')
+ })
+ .bindNative('ajax:complete', function() { start() })
+ .triggerNative('click')
+
+ setTimeout(function() {
+ start()
+ }, 13)
+})
+
+asyncTest('clicking on a button with data-remote attribute', 5, function() {
+ $('button[data-remote]')
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertCallbackInvoked('ajax:success')
+ App.assertRequestPath(data, '/echo')
+ equal(data.params.data1, 'value1', 'ajax arguments should have key data1 with right value')
+ equal(data.params.data2, 'value2', 'ajax arguments should have key data2 with right value')
+ App.assertGetRequest(data)
+ })
+ .bindNative('ajax:complete', function() { start() })
+ .triggerNative('click')
+})
+
+asyncTest('changing a select option with data-remote attribute', 5, function() {
+ $('form')
+ .append(
+ $('<select />', {
+ 'name': 'user_data',
+ 'data-remote': 'true',
+ 'data-params': 'data1=value1',
+ 'data-url': '/echo'
+ })
+ .append($('<option />', {value: 'optionValue1', text: 'option1'}))
+ .append($('<option />', {value: 'optionValue2', text: 'option2'}))
+ )
+
+ $('select[data-remote]')
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertCallbackInvoked('ajax:success')
+ App.assertRequestPath(data, '/echo')
+ equal(data.params.user_data, 'optionValue2', 'ajax arguments should have key term with right value')
+ equal(data.params.data1, 'value1', 'ajax arguments should have key data1 with right value')
+ App.assertGetRequest(data)
+ })
+ .bindNative('ajax:complete', function() { start() })
+ .val('optionValue2')
+ .triggerNative('change')
+})
+
+asyncTest('submitting form with data-remote attribute', 4, function() {
+ $('form[data-remote]')
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertCallbackInvoked('ajax:success')
+ App.assertRequestPath(data, '/echo')
+ equal(data.params.user_name, 'john', 'ajax arguments should have key user_name with right value')
+ App.assertPostRequest(data)
+ })
+ .bindNative('ajax:complete', function() { start() })
+ .triggerNative('submit')
+})
+
+asyncTest('submitting form with data-remote attribute should include inputs in a fieldset only once', 3, function() {
+ $('form[data-remote]')
+ .append('<fieldset><input name="items[]" value="Item" /></fieldset>')
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertCallbackInvoked('ajax:success')
+ equal(data.params.items.length, 1, 'ajax arguments should only have the item once')
+ App.assertPostRequest(data)
+ })
+ .bindNative('ajax:complete', function() {
+ $('form[data-remote], fieldset').remove()
+ start()
+ })
+ .triggerNative('submit')
+})
+
+asyncTest('submitting form with data-remote attribute submits input with matching [form] attribute', 5, function() {
+ $('#qunit-fixture')
+ .append($('<input type="text" name="user_data" value="value1" form="my-remote-form">'))
+
+ $('form[data-remote]')
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertCallbackInvoked('ajax:success')
+ App.assertRequestPath(data, '/echo')
+ equal(data.params.user_name, 'john', 'ajax arguments should have key user_name with right value')
+ equal(data.params.user_data, 'value1', 'ajax arguments should have key user_data with right value')
+ App.assertPostRequest(data)
+ })
+ .bindNative('ajax:complete', function() { start() })
+ .triggerNative('submit')
+})
+
+asyncTest('submitting form with data-remote attribute by clicking button with matching [form] attribute', 5, function() {
+ $('form[data-remote]')
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ App.assertCallbackInvoked('ajax:success')
+ App.assertRequestPath(data, '/echo')
+ equal(data.params.user_name, 'john', 'ajax arguments should have key user_name with right value')
+ equal(data.params.user_data, 'value2', 'ajax arguments should have key user_data with right value')
+ App.assertPostRequest(data)
+ })
+ .bindNative('ajax:complete', function() { start() })
+
+ $('<button />', {
+ type: 'submit',
+ name: 'user_data',
+ value: 'value1',
+ form: 'my-remote-form'
+ })
+ .appendTo($('#qunit-fixture'))
+
+ $('<button />', {
+ type: 'submit',
+ name: 'user_data',
+ value: 'value2',
+ form: 'my-remote-form'
+ })
+ .appendTo($('#qunit-fixture'))
+ .triggerNative('click')
+})
+
+asyncTest('form\'s submit bindings in browsers that don\'t support submit bubbling', 5, function() {
+ var form = $('form[data-remote]'), directBindingCalled = false
+
+ ok(!directBindingCalled, 'nothing is called')
+
+ form
+ .append($('<input type="submit" />'))
+ .bindNative('submit', function(event) {
+ ok(event.type == 'submit', 'submit event handlers are called with submit event')
+ ok(true, 'binding handler is called')
+ directBindingCalled = true
+ })
+ .bindNative('ajax:beforeSend', function() {
+ ok(true, 'form being submitted via ajax')
+ ok(directBindingCalled, 'binding handler already called')
+ })
+ .bindNative('ajax:complete', function() {
+ start()
+ })
+
+ if(!$.support.submitBubbles) {
+ // Must indrectly submit form via click to trigger jQuery's manual submit bubbling in IE
+ form.find('input[type=submit]')
+ .triggerNative('click')
+ } else {
+ form.triggerNative('submit')
+ }
+})
+
+asyncTest('returning false in form\'s submit bindings in non-submit-bubbling browsers', 1, function() {
+ var form = $('form[data-remote]')
+
+ form
+ .append($('<input type="submit" />'))
+ .bindNative('submit', function() {
+ ok(true, 'binding handler is called')
+ return false
+ })
+ .bindNative('ajax:beforeSend', function() {
+ ok(false, 'form should not be submitted')
+ })
+
+ if (!$.support.submitBubbles) {
+ // Must indrectly submit form via click to trigger jQuery's manual submit bubbling in IE
+ form.find('input[type=submit]').triggerNative('click')
+ } else {
+ form.triggerNative('submit')
+ }
+
+ setTimeout(function() { start() }, 13)
+})
+
+asyncTest('clicking on a link with falsy "data-remote" attribute does not fire ajaxyness', 0, function() {
+ $('a[data-remote]')
+ .attr('data-remote', 'false')
+ .bindNative('ajax:beforeSend', function() {
+ ok(false, 'ajax should not be triggered')
+ })
+ .bindNative('click', function() {
+ return false
+ })
+ .triggerNative('click')
+
+ setTimeout(function() { start() }, 20)
+})
+
+asyncTest('ctrl-clicking on a link with falsy "data-remote" attribute does not fire ajaxyness even if "data-params" present', 0, function() {
+ var link = $('a[data-remote]')
+
+ link
+ .removeAttr('data-params')
+ .attr('data-remote', 'false')
+ .attr('data-method', 'POST')
+ .bindNative('ajax:beforeSend', function() {
+ ok(false, 'ajax should not be triggered')
+ })
+ .bindNative('click', function() {
+ return false
+ })
+ .triggerNative('click', { metaKey: true })
+
+ link
+ .removeAttr('data-method')
+ .attr('data-params', 'name=steve')
+ .triggerNative('click', { metaKey: true })
+
+ setTimeout(function() { start() }, 20)
+})
+
+asyncTest('clicking on a button with falsy "data-remote" attribute', 0, function() {
+ $('button[data-remote]:first')
+ .attr('data-remote', 'false')
+ .bindNative('ajax:beforeSend', function() {
+ ok(false, 'ajax should not be triggered')
+ })
+ .bindNative('click', function() {
+ return false
+ })
+ .triggerNative('click')
+
+ setTimeout(function() { start() }, 20)
+})
+
+asyncTest('submitting a form with falsy "data-remote" attribute', 0, function() {
+ $('form[data-remote]:first')
+ .attr('data-remote', 'false')
+ .bindNative('ajax:beforeSend', function() {
+ ok(false, 'ajax should not be triggered')
+ })
+ .bindNative('submit', function() {
+ return false
+ })
+ .triggerNative('submit')
+
+ setTimeout(function() { start() }, 20)
+})
+
+asyncTest('changing a select option with falsy "data-remote" attribute', 0, function() {
+ $('form')
+ .append(
+ $('<select />', {
+ 'name': 'user_data',
+ 'data-remote': 'false',
+ 'data-params': 'data1=value1',
+ 'data-url': '/echo'
+ })
+ .append($('<option />', {value: 'optionValue1', text: 'option1'}))
+ .append($('<option />', {value: 'optionValue2', text: 'option2'}))
+ )
+
+ $('select[data-remote=false]:first')
+ .bindNative('ajax:beforeSend', function() {
+ ok(false, 'ajax should not be triggered')
+ })
+ .val('optionValue2')
+ .triggerNative('change')
+
+ setTimeout(function() { start() }, 20)
+})
+
+asyncTest('form should be serialized correctly', 6, function() {
+ $('form')
+ .append('<textarea name="textarea">textarea</textarea>')
+ .append('<input type="checkbox" name="checkbox[]" value="0" />')
+ .append('<input type="checkbox" checked="checked" name="checkbox[]" value="1" />')
+ .append('<input type="radio" checked="checked" name="radio" value="0" />')
+ .append('<input type="radio" name="radio" value="1" />')
+ .append('<select multiple="multiple" name="select[]">\
+ <option value="1" selected>1</option>\
+ <option value="2" selected>2</option>\
+ <option value="3">3</option>\
+ <option selected>4</option>\
+ </select>')
+ .bindNative('ajax:success', function(e, data, status, xhr) {
+ equal(data.params.checkbox.length, 1)
+ equal(data.params.checkbox[0], '1')
+ equal(data.params.radio, '0')
+ equal(data.params.select.length, 3)
+ equal(data.params.select[2], '4')
+ equal(data.params.textarea, 'textarea')
+
+ start()
+ })
+ .triggerNative('submit')
+})
diff --git a/actionview/test/ujs/public/test/override.js b/actionview/test/ujs/public/test/override.js
new file mode 100644
index 0000000000..be6ec7749b
--- /dev/null
+++ b/actionview/test/ujs/public/test/override.js
@@ -0,0 +1,56 @@
+(function() {
+
+var realHref
+
+module('override', {
+ setup: function() {
+ realHref = $.rails.href
+ $('#qunit-fixture')
+ .append($('<a />', {
+ href: '/real/href', 'data-remote': 'true', 'data-method': 'delete', 'data-href': '/data/href'
+ }))
+ },
+ teardown: function() {
+ $.rails.href = realHref
+ }
+})
+
+asyncTest('the getter for an element\'s href is publicly accessible', 1, function() {
+ ok($.rails.href)
+ start()
+})
+
+asyncTest('the getter for an element\'s href is overridable', 1, function() {
+ $.rails.href = function(element) { return $(element).data('href') }
+ $('#qunit-fixture a')
+ .bindNative('ajax:beforeSend', function(e, xhr, options) {
+ equal('/data/href', options.url)
+ return false
+ })
+ .triggerNative('click')
+ start()
+})
+
+asyncTest('the getter for an element\'s href works normally if not overridden', 1, function() {
+ $('#qunit-fixture a')
+ .bindNative('ajax:beforeSend', function(e, xhr, options) {
+ equal(location.protocol + '//' + location.host + '/real/href', options.url)
+ return false
+ })
+ .triggerNative('click')
+ start()
+})
+
+asyncTest('the event selector strings are overridable', 1, function() {
+ ok($.rails.linkClickSelector.indexOf(', a[data-custom-remote-link]') != -1, 'linkClickSelector contains custom selector')
+ start()
+})
+
+asyncTest('including jquery-ujs multiple times throws error', 1, function() {
+ throws(function() {
+ Rails.start()
+ }, 'appending rails.js again throws error')
+ setTimeout(function() { start() }, 50)
+})
+
+})()
diff --git a/actionview/test/ujs/public/test/settings.js b/actionview/test/ujs/public/test/settings.js
new file mode 100644
index 0000000000..3375456f11
--- /dev/null
+++ b/actionview/test/ujs/public/test/settings.js
@@ -0,0 +1,116 @@
+var App = App || {}
+
+App.assertCallbackInvoked = function(callbackName) {
+ ok(true, callbackName + ' callback should have been invoked')
+}
+
+App.assertCallbackNotInvoked = function(callbackName) {
+ ok(false, callbackName + ' callback should not have been invoked')
+}
+
+App.assertGetRequest = function(requestEnv) {
+ equal(requestEnv['REQUEST_METHOD'], 'GET', 'request type should be GET')
+}
+
+App.assertPostRequest = function(requestEnv) {
+ equal(requestEnv['REQUEST_METHOD'], 'POST', 'request type should be POST')
+}
+
+App.assertRequestPath = function(requestEnv, path) {
+ equal(requestEnv['PATH_INFO'], path, 'request should be sent to right url')
+}
+
+App.getVal = function(el) {
+ return el.is('input,textarea,select') ? el.val() : el.text()
+}
+
+App.disabled = function(el) {
+ return el.is('input,textarea,select,button') ?
+ (el.is(':disabled') && $.rails.getData(el[0], 'ujs:disabled')) :
+ $.rails.getData(el[0], 'ujs:disabled')
+}
+
+App.checkEnabledState = function(el, text) {
+ ok(!App.disabled(el), el.get(0).tagName + ' should not be disabled')
+ equal(App.getVal(el), text, el.get(0).tagName + ' text should be original value')
+}
+
+App.checkDisabledState = function(el, text) {
+ ok(App.disabled(el), el.get(0).tagName + ' should be disabled')
+ equal(App.getVal(el), text, el.get(0).tagName + ' text should be disabled value')
+}
+
+// hijacks normal form submit; lets it submit to an iframe to prevent
+// navigating away from the test suite
+$(document).bind('submit', function(e) {
+ if (!e.isDefaultPrevented()) {
+ var form = $(e.target), action = form.attr('action'),
+ name = 'form-frame' + jQuery.guid++,
+ iframe = $('<iframe name="' + name + '" />'),
+ iframeInput = '<input name="iframe" value="true" type="hidden" />'
+ targetInput = '<input name="_target" value="' + (form.attr('target') || '') + '" type="hidden" />'
+
+ if (action && action.indexOf('iframe') < 0) {
+ if (action.indexOf('?') < 0) {
+ form.attr('action', action + '?iframe=true')
+ } else {
+ form.attr('action', action + '&iframe=true')
+ }
+ }
+ form.attr('target', name).append(iframeInput, targetInput)
+ $('#qunit-fixture').append(iframe)
+ $.event.trigger('iframe:loading', form)
+ }
+})
+
+var MouseEvent = window.MouseEvent
+
+try {
+ new MouseEvent()
+} catch (e) {
+ MouseEvent = function(type, options) {
+ var evt = document.createEvent('MouseEvents')
+ evt.initMouseEvent(type, options.bubbles, options.cancelable, window, options.detail, 0, 0, 80, 20, options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, 0, null)
+ return evt
+ }
+}
+
+$.fn.extend({
+ // trigger an native click event
+ triggerNative: function(type, options) {
+ var el = this[0],
+ event,
+ Evt = {
+ 'click': MouseEvent,
+ 'change': Event,
+ 'pageshow': PageTransitionEvent,
+ 'submit': Event
+ }[type]
+
+ options = options || {}
+ options.bubbles = true
+ options.cancelable = true
+
+ event = new Evt(type, options)
+
+ el.dispatchEvent(event)
+
+ if (type === 'submit' && !event.defaultPrevented) {
+ el.submit()
+ }
+ return this
+ },
+ bindNative: function(event, handler) {
+ if (!handler) return this
+
+ this.bind(event, function(e) {
+ var args = []
+ if (e.originalEvent.detail) {
+ args = e.originalEvent.detail.slice()
+ }
+ args.unshift(e)
+ return handler.apply(this, args)
+ })
+ return this
+ }
+})
diff --git a/actionview/test/ujs/public/vendor/jquery.metadata.js b/actionview/test/ujs/public/vendor/jquery.metadata.js
new file mode 100644
index 0000000000..ad8bfba404
--- /dev/null
+++ b/actionview/test/ujs/public/vendor/jquery.metadata.js
@@ -0,0 +1,122 @@
+/*
+ * Metadata - jQuery plugin for parsing metadata from elements
+ *
+ * Copyright (c) 2006 John Resig, Yehuda Katz, J�örn Zaefferer, Paul McLanahan
+ *
+ * Dual licensed under the MIT and GPL licenses:
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.gnu.org/licenses/gpl.html
+ *
+ * Revision: $Id: jquery.metadata.js 4187 2007-12-16 17:15:27Z joern.zaefferer $
+ *
+ */
+
+/**
+ * Sets the type of metadata to use. Metadata is encoded in JSON, and each property
+ * in the JSON will become a property of the element itself.
+ *
+ * There are three supported types of metadata storage:
+ *
+ * attr: Inside an attribute. The name parameter indicates *which* attribute.
+ *
+ * class: Inside the class attribute, wrapped in curly braces: { }
+ *
+ * elem: Inside a child element (e.g. a script tag). The
+ * name parameter indicates *which* element.
+ *
+ * The metadata for an element is loaded the first time the element is accessed via jQuery.
+ *
+ * As a result, you can define the metadata type, use $(expr) to load the metadata into the elements
+ * matched by expr, then redefine the metadata type and run another $(expr) for other elements.
+ *
+ * @name $.metadata.setType
+ *
+ * @example <p id="one" class="some_class {item_id: 1, item_label: 'Label'}">This is a p</p>
+ * @before $.metadata.setType("class")
+ * @after $("#one").metadata().item_id == 1; $("#one").metadata().item_label == "Label"
+ * @desc Reads metadata from the class attribute
+ *
+ * @example <p id="one" class="some_class" data="{item_id: 1, item_label: 'Label'}">This is a p</p>
+ * @before $.metadata.setType("attr", "data")
+ * @after $("#one").metadata().item_id == 1; $("#one").metadata().item_label == "Label"
+ * @desc Reads metadata from a "data" attribute
+ *
+ * @example <p id="one" class="some_class"><script>{item_id: 1, item_label: 'Label'}</script>This is a p</p>
+ * @before $.metadata.setType("elem", "script")
+ * @after $("#one").metadata().item_id == 1; $("#one").metadata().item_label == "Label"
+ * @desc Reads metadata from a nested script element
+ *
+ * @param String type The encoding type
+ * @param String name The name of the attribute to be used to get metadata (optional)
+ * @cat Plugins/Metadata
+ * @descr Sets the type of encoding to be used when loading metadata for the first time
+ * @type undefined
+ * @see metadata()
+ */
+
+(function($) {
+
+$.extend({
+ metadata : {
+ defaults : {
+ type: 'class',
+ name: 'metadata',
+ cre: /({.*})/,
+ single: 'metadata'
+ },
+ setType: function( type, name ){
+ this.defaults.type = type;
+ this.defaults.name = name;
+ },
+ get: function( elem, opts ){
+ var settings = $.extend({},this.defaults,opts);
+ // check for empty string in single property
+ if ( !settings.single.length ) settings.single = 'metadata';
+
+ var data = $.data(elem, settings.single);
+ // returned cached data if it already exists
+ if ( data ) return data;
+
+ data = "{}";
+
+ if ( settings.type == "class" ) {
+ var m = settings.cre.exec( elem.className );
+ if ( m )
+ data = m[1];
+ } else if ( settings.type == "elem" ) {
+ if( !elem.getElementsByTagName )
+ return undefined;
+ var e = elem.getElementsByTagName(settings.name);
+ if ( e.length )
+ data = $.trim(e[0].innerHTML);
+ } else if ( elem.getAttribute != undefined ) {
+ var attr = elem.getAttribute( settings.name );
+ if ( attr )
+ data = attr;
+ }
+
+ if ( data.indexOf( '{' ) <0 )
+ data = "{" + data + "}";
+
+ data = eval("(" + data + ")");
+
+ $.data( elem, settings.single, data );
+ return data;
+ }
+ }
+});
+
+/**
+ * Returns the metadata object for the first member of the jQuery object.
+ *
+ * @name metadata
+ * @descr Returns element's metadata object
+ * @param Object opts An object contianing settings to override the defaults
+ * @type jQuery
+ * @cat Plugins/Metadata
+ */
+$.fn.metadata = function( opts ){
+ return $.metadata.get( this[0], opts );
+};
+
+})(jQuery); \ No newline at end of file
diff --git a/actionview/test/ujs/public/vendor/qunit.css b/actionview/test/ujs/public/vendor/qunit.css
new file mode 100644
index 0000000000..93026e3ba3
--- /dev/null
+++ b/actionview/test/ujs/public/vendor/qunit.css
@@ -0,0 +1,237 @@
+/*!
+ * QUnit 1.14.0
+ * http://qunitjs.com/
+ *
+ * Copyright 2013 jQuery Foundation and other contributors
+ * Released under the MIT license
+ * http://jquery.org/license
+ *
+ * Date: 2014-01-31T16:40Z
+ */
+
+/** Font Family and Sizes */
+
+#qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult {
+ font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
+}
+
+#qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; }
+#qunit-tests { font-size: smaller; }
+
+
+/** Resets */
+
+#qunit-tests, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter {
+ margin: 0;
+ padding: 0;
+}
+
+
+/** Header */
+
+#qunit-header {
+ padding: 0.5em 0 0.5em 1em;
+
+ color: #8699A4;
+ background-color: #0D3349;
+
+ font-size: 1.5em;
+ line-height: 1em;
+ font-weight: 400;
+
+ border-radius: 5px 5px 0 0;
+}
+
+#qunit-header a {
+ text-decoration: none;
+ color: #C2CCD1;
+}
+
+#qunit-header a:hover,
+#qunit-header a:focus {
+ color: #FFF;
+}
+
+#qunit-testrunner-toolbar label {
+ display: inline-block;
+ padding: 0 0.5em 0 0.1em;
+}
+
+#qunit-banner {
+ height: 5px;
+}
+
+#qunit-testrunner-toolbar {
+ padding: 0.5em 0 0.5em 2em;
+ color: #5E740B;
+ background-color: #EEE;
+ overflow: hidden;
+}
+
+#qunit-userAgent {
+ padding: 0.5em 0 0.5em 2.5em;
+ background-color: #2B81AF;
+ color: #FFF;
+ text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px;
+}
+
+#qunit-modulefilter-container {
+ float: right;
+}
+
+/** Tests: Pass/Fail */
+
+#qunit-tests {
+ list-style-position: inside;
+}
+
+#qunit-tests li {
+ padding: 0.4em 0.5em 0.4em 2.5em;
+ border-bottom: 1px solid #FFF;
+ list-style-position: inside;
+}
+
+#qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running {
+ display: none;
+}
+
+#qunit-tests li strong {
+ cursor: pointer;
+}
+
+#qunit-tests li a {
+ padding: 0.5em;
+ color: #C2CCD1;
+ text-decoration: none;
+}
+#qunit-tests li a:hover,
+#qunit-tests li a:focus {
+ color: #000;
+}
+
+#qunit-tests li .runtime {
+ float: right;
+ font-size: smaller;
+}
+
+.qunit-assert-list {
+ margin-top: 0.5em;
+ padding: 0.5em;
+
+ background-color: #FFF;
+
+ border-radius: 5px;
+}
+
+.qunit-collapsed {
+ display: none;
+}
+
+#qunit-tests table {
+ border-collapse: collapse;
+ margin-top: 0.2em;
+}
+
+#qunit-tests th {
+ text-align: right;
+ vertical-align: top;
+ padding: 0 0.5em 0 0;
+}
+
+#qunit-tests td {
+ vertical-align: top;
+}
+
+#qunit-tests pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+#qunit-tests del {
+ background-color: #E0F2BE;
+ color: #374E0C;
+ text-decoration: none;
+}
+
+#qunit-tests ins {
+ background-color: #FFCACA;
+ color: #500;
+ text-decoration: none;
+}
+
+/*** Test Counts */
+
+#qunit-tests b.counts { color: #000; }
+#qunit-tests b.passed { color: #5E740B; }
+#qunit-tests b.failed { color: #710909; }
+
+#qunit-tests li li {
+ padding: 5px;
+ background-color: #FFF;
+ border-bottom: none;
+ list-style-position: inside;
+}
+
+/*** Passing Styles */
+
+#qunit-tests li li.pass {
+ color: #3C510C;
+ background-color: #FFF;
+ border-left: 10px solid #C6E746;
+}
+
+#qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; }
+#qunit-tests .pass .test-name { color: #366097; }
+
+#qunit-tests .pass .test-actual,
+#qunit-tests .pass .test-expected { color: #999; }
+
+#qunit-banner.qunit-pass { background-color: #C6E746; }
+
+/*** Failing Styles */
+
+#qunit-tests li li.fail {
+ color: #710909;
+ background-color: #FFF;
+ border-left: 10px solid #EE5757;
+ white-space: pre;
+}
+
+#qunit-tests > li:last-child {
+ border-radius: 0 0 5px 5px;
+}
+
+#qunit-tests .fail { color: #000; background-color: #EE5757; }
+#qunit-tests .fail .test-name,
+#qunit-tests .fail .module-name { color: #000; }
+
+#qunit-tests .fail .test-actual { color: #EE5757; }
+#qunit-tests .fail .test-expected { color: #008000; }
+
+#qunit-banner.qunit-fail { background-color: #EE5757; }
+
+
+/** Result */
+
+#qunit-testresult {
+ padding: 0.5em 0.5em 0.5em 2.5em;
+
+ color: #2B81AF;
+ background-color: #D2E0E6;
+
+ border-bottom: 1px solid #FFF;
+}
+#qunit-testresult .module-name {
+ font-weight: 700;
+}
+
+/** Fixture */
+
+#qunit-fixture {
+ position: absolute;
+ top: -10000px;
+ left: -10000px;
+ width: 1000px;
+ height: 1000px;
+}
diff --git a/actionview/test/ujs/public/vendor/qunit.js b/actionview/test/ujs/public/vendor/qunit.js
new file mode 100644
index 0000000000..0e279fde17
--- /dev/null
+++ b/actionview/test/ujs/public/vendor/qunit.js
@@ -0,0 +1,2288 @@
+/*!
+ * QUnit 1.14.0
+ * http://qunitjs.com/
+ *
+ * Copyright 2013 jQuery Foundation and other contributors
+ * Released under the MIT license
+ * http://jquery.org/license
+ *
+ * Date: 2014-01-31T16:40Z
+ */
+
+(function( window ) {
+
+var QUnit,
+ assert,
+ config,
+ onErrorFnPrev,
+ testId = 0,
+ fileName = (sourceFromStacktrace( 0 ) || "" ).replace(/(:\d+)+\)?/, "").replace(/.+\//, ""),
+ toString = Object.prototype.toString,
+ hasOwn = Object.prototype.hasOwnProperty,
+ // Keep a local reference to Date (GH-283)
+ Date = window.Date,
+ setTimeout = window.setTimeout,
+ clearTimeout = window.clearTimeout,
+ defined = {
+ document: typeof window.document !== "undefined",
+ setTimeout: typeof window.setTimeout !== "undefined",
+ sessionStorage: (function() {
+ var x = "qunit-test-string";
+ try {
+ sessionStorage.setItem( x, x );
+ sessionStorage.removeItem( x );
+ return true;
+ } catch( e ) {
+ return false;
+ }
+ }())
+ },
+ /**
+ * Provides a normalized error string, correcting an issue
+ * with IE 7 (and prior) where Error.prototype.toString is
+ * not properly implemented
+ *
+ * Based on http://es5.github.com/#x15.11.4.4
+ *
+ * @param {String|Error} error
+ * @return {String} error message
+ */
+ errorString = function( error ) {
+ var name, message,
+ errorString = error.toString();
+ if ( errorString.substring( 0, 7 ) === "[object" ) {
+ name = error.name ? error.name.toString() : "Error";
+ message = error.message ? error.message.toString() : "";
+ if ( name && message ) {
+ return name + ": " + message;
+ } else if ( name ) {
+ return name;
+ } else if ( message ) {
+ return message;
+ } else {
+ return "Error";
+ }
+ } else {
+ return errorString;
+ }
+ },
+ /**
+ * Makes a clone of an object using only Array or Object as base,
+ * and copies over the own enumerable properties.
+ *
+ * @param {Object} obj
+ * @return {Object} New object with only the own properties (recursively).
+ */
+ objectValues = function( obj ) {
+ // Grunt 0.3.x uses an older version of jshint that still has jshint/jshint#392.
+ /*jshint newcap: false */
+ var key, val,
+ vals = QUnit.is( "array", obj ) ? [] : {};
+ for ( key in obj ) {
+ if ( hasOwn.call( obj, key ) ) {
+ val = obj[key];
+ vals[key] = val === Object(val) ? objectValues(val) : val;
+ }
+ }
+ return vals;
+ };
+
+
+// Root QUnit object.
+// `QUnit` initialized at top of scope
+QUnit = {
+
+ // call on start of module test to prepend name to all tests
+ module: function( name, testEnvironment ) {
+ config.currentModule = name;
+ config.currentModuleTestEnvironment = testEnvironment;
+ config.modules[name] = true;
+ },
+
+ asyncTest: function( testName, expected, callback ) {
+ if ( arguments.length === 2 ) {
+ callback = expected;
+ expected = null;
+ }
+
+ QUnit.test( testName, expected, callback, true );
+ },
+
+ test: function( testName, expected, callback, async ) {
+ var test,
+ nameHtml = "<span class='test-name'>" + escapeText( testName ) + "</span>";
+
+ if ( arguments.length === 2 ) {
+ callback = expected;
+ expected = null;
+ }
+
+ if ( config.currentModule ) {
+ nameHtml = "<span class='module-name'>" + escapeText( config.currentModule ) + "</span>: " + nameHtml;
+ }
+
+ test = new Test({
+ nameHtml: nameHtml,
+ testName: testName,
+ expected: expected,
+ async: async,
+ callback: callback,
+ module: config.currentModule,
+ moduleTestEnvironment: config.currentModuleTestEnvironment,
+ stack: sourceFromStacktrace( 2 )
+ });
+
+ if ( !validTest( test ) ) {
+ return;
+ }
+
+ test.queue();
+ },
+
+ // Specify the number of expected assertions to guarantee that failed test (no assertions are run at all) don't slip through.
+ expect: function( asserts ) {
+ if (arguments.length === 1) {
+ config.current.expected = asserts;
+ } else {
+ return config.current.expected;
+ }
+ },
+
+ start: function( count ) {
+ // QUnit hasn't been initialized yet.
+ // Note: RequireJS (et al) may delay onLoad
+ if ( config.semaphore === undefined ) {
+ QUnit.begin(function() {
+ // This is triggered at the top of QUnit.load, push start() to the event loop, to allow QUnit.load to finish first
+ setTimeout(function() {
+ QUnit.start( count );
+ });
+ });
+ return;
+ }
+
+ config.semaphore -= count || 1;
+ // don't start until equal number of stop-calls
+ if ( config.semaphore > 0 ) {
+ return;
+ }
+ // ignore if start is called more often then stop
+ if ( config.semaphore < 0 ) {
+ config.semaphore = 0;
+ QUnit.pushFailure( "Called start() while already started (QUnit.config.semaphore was 0 already)", null, sourceFromStacktrace(2) );
+ return;
+ }
+ // A slight delay, to avoid any current callbacks
+ if ( defined.setTimeout ) {
+ setTimeout(function() {
+ if ( config.semaphore > 0 ) {
+ return;
+ }
+ if ( config.timeout ) {
+ clearTimeout( config.timeout );
+ }
+
+ config.blocking = false;
+ process( true );
+ }, 13);
+ } else {
+ config.blocking = false;
+ process( true );
+ }
+ },
+
+ stop: function( count ) {
+ config.semaphore += count || 1;
+ config.blocking = true;
+
+ if ( config.testTimeout && defined.setTimeout ) {
+ clearTimeout( config.timeout );
+ config.timeout = setTimeout(function() {
+ QUnit.ok( false, "Test timed out" );
+ config.semaphore = 1;
+ QUnit.start();
+ }, config.testTimeout );
+ }
+ }
+};
+
+// We use the prototype to distinguish between properties that should
+// be exposed as globals (and in exports) and those that shouldn't
+(function() {
+ function F() {}
+ F.prototype = QUnit;
+ QUnit = new F();
+ // Make F QUnit's constructor so that we can add to the prototype later
+ QUnit.constructor = F;
+}());
+
+/**
+ * Config object: Maintain internal state
+ * Later exposed as QUnit.config
+ * `config` initialized at top of scope
+ */
+config = {
+ // The queue of tests to run
+ queue: [],
+
+ // block until document ready
+ blocking: true,
+
+ // when enabled, show only failing tests
+ // gets persisted through sessionStorage and can be changed in UI via checkbox
+ hidepassed: false,
+
+ // by default, run previously failed tests first
+ // very useful in combination with "Hide passed tests" checked
+ reorder: true,
+
+ // by default, modify document.title when suite is done
+ altertitle: true,
+
+ // by default, scroll to top of the page when suite is done
+ scrolltop: true,
+
+ // when enabled, all tests must call expect()
+ requireExpects: false,
+
+ // add checkboxes that are persisted in the query-string
+ // when enabled, the id is set to `true` as a `QUnit.config` property
+ urlConfig: [
+ {
+ id: "noglobals",
+ label: "Check for Globals",
+ tooltip: "Enabling this will test if any test introduces new properties on the `window` object. Stored as query-strings."
+ },
+ {
+ id: "notrycatch",
+ label: "No try-catch",
+ tooltip: "Enabling this will run tests outside of a try-catch block. Makes debugging exceptions in IE reasonable. Stored as query-strings."
+ }
+ ],
+
+ // Set of all modules.
+ modules: {},
+
+ // logging callback queues
+ begin: [],
+ done: [],
+ log: [],
+ testStart: [],
+ testDone: [],
+ moduleStart: [],
+ moduleDone: []
+};
+
+// Initialize more QUnit.config and QUnit.urlParams
+(function() {
+ var i, current,
+ location = window.location || { search: "", protocol: "file:" },
+ params = location.search.slice( 1 ).split( "&" ),
+ length = params.length,
+ urlParams = {};
+
+ if ( params[ 0 ] ) {
+ for ( i = 0; i < length; i++ ) {
+ current = params[ i ].split( "=" );
+ current[ 0 ] = decodeURIComponent( current[ 0 ] );
+
+ // allow just a key to turn on a flag, e.g., test.html?noglobals
+ current[ 1 ] = current[ 1 ] ? decodeURIComponent( current[ 1 ] ) : true;
+ if ( urlParams[ current[ 0 ] ] ) {
+ urlParams[ current[ 0 ] ] = [].concat( urlParams[ current[ 0 ] ], current[ 1 ] );
+ } else {
+ urlParams[ current[ 0 ] ] = current[ 1 ];
+ }
+ }
+ }
+
+ QUnit.urlParams = urlParams;
+
+ // String search anywhere in moduleName+testName
+ config.filter = urlParams.filter;
+
+ // Exact match of the module name
+ config.module = urlParams.module;
+
+ config.testNumber = [];
+ if ( urlParams.testNumber ) {
+
+ // Ensure that urlParams.testNumber is an array
+ urlParams.testNumber = [].concat( urlParams.testNumber );
+ for ( i = 0; i < urlParams.testNumber.length; i++ ) {
+ current = urlParams.testNumber[ i ];
+ config.testNumber.push( parseInt( current, 10 ) );
+ }
+ }
+
+ // Figure out if we're running the tests from a server or not
+ QUnit.isLocal = location.protocol === "file:";
+}());
+
+extend( QUnit, {
+
+ config: config,
+
+ // Initialize the configuration options
+ init: function() {
+ extend( config, {
+ stats: { all: 0, bad: 0 },
+ moduleStats: { all: 0, bad: 0 },
+ started: +new Date(),
+ updateRate: 1000,
+ blocking: false,
+ autostart: true,
+ autorun: false,
+ filter: "",
+ queue: [],
+ semaphore: 1
+ });
+
+ var tests, banner, result,
+ qunit = id( "qunit" );
+
+ if ( qunit ) {
+ qunit.innerHTML =
+ "<h1 id='qunit-header'>" + escapeText( document.title ) + "</h1>" +
+ "<h2 id='qunit-banner'></h2>" +
+ "<div id='qunit-testrunner-toolbar'></div>" +
+ "<h2 id='qunit-userAgent'></h2>" +
+ "<ol id='qunit-tests'></ol>";
+ }
+
+ tests = id( "qunit-tests" );
+ banner = id( "qunit-banner" );
+ result = id( "qunit-testresult" );
+
+ if ( tests ) {
+ tests.innerHTML = "";
+ }
+
+ if ( banner ) {
+ banner.className = "";
+ }
+
+ if ( result ) {
+ result.parentNode.removeChild( result );
+ }
+
+ if ( tests ) {
+ result = document.createElement( "p" );
+ result.id = "qunit-testresult";
+ result.className = "result";
+ tests.parentNode.insertBefore( result, tests );
+ result.innerHTML = "Running...<br/>&nbsp;";
+ }
+ },
+
+ // Resets the test setup. Useful for tests that modify the DOM.
+ /*
+ DEPRECATED: Use multiple tests instead of resetting inside a test.
+ Use testStart or testDone for custom cleanup.
+ This method will throw an error in 2.0, and will be removed in 2.1
+ */
+ reset: function() {
+ var fixture = id( "qunit-fixture" );
+ if ( fixture ) {
+ fixture.innerHTML = config.fixture;
+ }
+ },
+
+ // Safe object type checking
+ is: function( type, obj ) {
+ return QUnit.objectType( obj ) === type;
+ },
+
+ objectType: function( obj ) {
+ if ( typeof obj === "undefined" ) {
+ return "undefined";
+ }
+
+ // Consider: typeof null === object
+ if ( obj === null ) {
+ return "null";
+ }
+
+ var match = toString.call( obj ).match(/^\[object\s(.*)\]$/),
+ type = match && match[1] || "";
+
+ switch ( type ) {
+ case "Number":
+ if ( isNaN(obj) ) {
+ return "nan";
+ }
+ return "number";
+ case "String":
+ case "Boolean":
+ case "Array":
+ case "Date":
+ case "RegExp":
+ case "Function":
+ return type.toLowerCase();
+ }
+ if ( typeof obj === "object" ) {
+ return "object";
+ }
+ return undefined;
+ },
+
+ push: function( result, actual, expected, message ) {
+ if ( !config.current ) {
+ throw new Error( "assertion outside test context, was " + sourceFromStacktrace() );
+ }
+
+ var output, source,
+ details = {
+ module: config.current.module,
+ name: config.current.testName,
+ result: result,
+ message: message,
+ actual: actual,
+ expected: expected
+ };
+
+ message = escapeText( message ) || ( result ? "okay" : "failed" );
+ message = "<span class='test-message'>" + message + "</span>";
+ output = message;
+
+ if ( !result ) {
+ expected = escapeText( QUnit.jsDump.parse(expected) );
+ actual = escapeText( QUnit.jsDump.parse(actual) );
+ output += "<table><tr class='test-expected'><th>Expected: </th><td><pre>" + expected + "</pre></td></tr>";
+
+ if ( actual !== expected ) {
+ output += "<tr class='test-actual'><th>Result: </th><td><pre>" + actual + "</pre></td></tr>";
+ output += "<tr class='test-diff'><th>Diff: </th><td><pre>" + QUnit.diff( expected, actual ) + "</pre></td></tr>";
+ }
+
+ source = sourceFromStacktrace();
+
+ if ( source ) {
+ details.source = source;
+ output += "<tr class='test-source'><th>Source: </th><td><pre>" + escapeText( source ) + "</pre></td></tr>";
+ }
+
+ output += "</table>";
+ }
+
+ runLoggingCallbacks( "log", QUnit, details );
+
+ config.current.assertions.push({
+ result: !!result,
+ message: output
+ });
+ },
+
+ pushFailure: function( message, source, actual ) {
+ if ( !config.current ) {
+ throw new Error( "pushFailure() assertion outside test context, was " + sourceFromStacktrace(2) );
+ }
+
+ var output,
+ details = {
+ module: config.current.module,
+ name: config.current.testName,
+ result: false,
+ message: message
+ };
+
+ message = escapeText( message ) || "error";
+ message = "<span class='test-message'>" + message + "</span>";
+ output = message;
+
+ output += "<table>";
+
+ if ( actual ) {
+ output += "<tr class='test-actual'><th>Result: </th><td><pre>" + escapeText( actual ) + "</pre></td></tr>";
+ }
+
+ if ( source ) {
+ details.source = source;
+ output += "<tr class='test-source'><th>Source: </th><td><pre>" + escapeText( source ) + "</pre></td></tr>";
+ }
+
+ output += "</table>";
+
+ runLoggingCallbacks( "log", QUnit, details );
+
+ config.current.assertions.push({
+ result: false,
+ message: output
+ });
+ },
+
+ url: function( params ) {
+ params = extend( extend( {}, QUnit.urlParams ), params );
+ var key,
+ querystring = "?";
+
+ for ( key in params ) {
+ if ( hasOwn.call( params, key ) ) {
+ querystring += encodeURIComponent( key ) + "=" +
+ encodeURIComponent( params[ key ] ) + "&";
+ }
+ }
+ return window.location.protocol + "//" + window.location.host +
+ window.location.pathname + querystring.slice( 0, -1 );
+ },
+
+ extend: extend,
+ id: id,
+ addEvent: addEvent,
+ addClass: addClass,
+ hasClass: hasClass,
+ removeClass: removeClass
+ // load, equiv, jsDump, diff: Attached later
+});
+
+/**
+ * @deprecated: Created for backwards compatibility with test runner that set the hook function
+ * into QUnit.{hook}, instead of invoking it and passing the hook function.
+ * QUnit.constructor is set to the empty F() above so that we can add to it's prototype here.
+ * Doing this allows us to tell if the following methods have been overwritten on the actual
+ * QUnit object.
+ */
+extend( QUnit.constructor.prototype, {
+
+ // Logging callbacks; all receive a single argument with the listed properties
+ // run test/logs.html for any related changes
+ begin: registerLoggingCallback( "begin" ),
+
+ // done: { failed, passed, total, runtime }
+ done: registerLoggingCallback( "done" ),
+
+ // log: { result, actual, expected, message }
+ log: registerLoggingCallback( "log" ),
+
+ // testStart: { name }
+ testStart: registerLoggingCallback( "testStart" ),
+
+ // testDone: { name, failed, passed, total, runtime }
+ testDone: registerLoggingCallback( "testDone" ),
+
+ // moduleStart: { name }
+ moduleStart: registerLoggingCallback( "moduleStart" ),
+
+ // moduleDone: { name, failed, passed, total }
+ moduleDone: registerLoggingCallback( "moduleDone" )
+});
+
+if ( !defined.document || document.readyState === "complete" ) {
+ config.autorun = true;
+}
+
+QUnit.load = function() {
+ runLoggingCallbacks( "begin", QUnit, {} );
+
+ // Initialize the config, saving the execution queue
+ var banner, filter, i, j, label, len, main, ol, toolbar, val, selection,
+ urlConfigContainer, moduleFilter, userAgent,
+ numModules = 0,
+ moduleNames = [],
+ moduleFilterHtml = "",
+ urlConfigHtml = "",
+ oldconfig = extend( {}, config );
+
+ QUnit.init();
+ extend(config, oldconfig);
+
+ config.blocking = false;
+
+ len = config.urlConfig.length;
+
+ for ( i = 0; i < len; i++ ) {
+ val = config.urlConfig[i];
+ if ( typeof val === "string" ) {
+ val = {
+ id: val,
+ label: val
+ };
+ }
+ config[ val.id ] = QUnit.urlParams[ val.id ];
+ if ( !val.value || typeof val.value === "string" ) {
+ urlConfigHtml += "<input id='qunit-urlconfig-" + escapeText( val.id ) +
+ "' name='" + escapeText( val.id ) +
+ "' type='checkbox'" +
+ ( val.value ? " value='" + escapeText( val.value ) + "'" : "" ) +
+ ( config[ val.id ] ? " checked='checked'" : "" ) +
+ " title='" + escapeText( val.tooltip ) +
+ "'><label for='qunit-urlconfig-" + escapeText( val.id ) +
+ "' title='" + escapeText( val.tooltip ) + "'>" + val.label + "</label>";
+ } else {
+ urlConfigHtml += "<label for='qunit-urlconfig-" + escapeText( val.id ) +
+ "' title='" + escapeText( val.tooltip ) +
+ "'>" + val.label +
+ ": </label><select id='qunit-urlconfig-" + escapeText( val.id ) +
+ "' name='" + escapeText( val.id ) +
+ "' title='" + escapeText( val.tooltip ) +
+ "'><option></option>";
+ selection = false;
+ if ( QUnit.is( "array", val.value ) ) {
+ for ( j = 0; j < val.value.length; j++ ) {
+ urlConfigHtml += "<option value='" + escapeText( val.value[j] ) + "'" +
+ ( config[ val.id ] === val.value[j] ?
+ (selection = true) && " selected='selected'" :
+ "" ) +
+ ">" + escapeText( val.value[j] ) + "</option>";
+ }
+ } else {
+ for ( j in val.value ) {
+ if ( hasOwn.call( val.value, j ) ) {
+ urlConfigHtml += "<option value='" + escapeText( j ) + "'" +
+ ( config[ val.id ] === j ?
+ (selection = true) && " selected='selected'" :
+ "" ) +
+ ">" + escapeText( val.value[j] ) + "</option>";
+ }
+ }
+ }
+ if ( config[ val.id ] && !selection ) {
+ urlConfigHtml += "<option value='" + escapeText( config[ val.id ] ) +
+ "' selected='selected' disabled='disabled'>" +
+ escapeText( config[ val.id ] ) +
+ "</option>";
+ }
+ urlConfigHtml += "</select>";
+ }
+ }
+ for ( i in config.modules ) {
+ if ( config.modules.hasOwnProperty( i ) ) {
+ moduleNames.push(i);
+ }
+ }
+ numModules = moduleNames.length;
+ moduleNames.sort( function( a, b ) {
+ return a.localeCompare( b );
+ });
+ moduleFilterHtml += "<label for='qunit-modulefilter'>Module: </label><select id='qunit-modulefilter' name='modulefilter'><option value='' " +
+ ( config.module === undefined ? "selected='selected'" : "" ) +
+ ">< All Modules ></option>";
+
+
+ for ( i = 0; i < numModules; i++) {
+ moduleFilterHtml += "<option value='" + escapeText( encodeURIComponent(moduleNames[i]) ) + "' " +
+ ( config.module === moduleNames[i] ? "selected='selected'" : "" ) +
+ ">" + escapeText(moduleNames[i]) + "</option>";
+ }
+ moduleFilterHtml += "</select>";
+
+ // `userAgent` initialized at top of scope
+ userAgent = id( "qunit-userAgent" );
+ if ( userAgent ) {
+ userAgent.innerHTML = navigator.userAgent;
+ }
+
+ // `banner` initialized at top of scope
+ banner = id( "qunit-header" );
+ if ( banner ) {
+ banner.innerHTML = "<a href='" + QUnit.url({ filter: undefined, module: undefined, testNumber: undefined }) + "'>" + banner.innerHTML + "</a> ";
+ }
+
+ // `toolbar` initialized at top of scope
+ toolbar = id( "qunit-testrunner-toolbar" );
+ if ( toolbar ) {
+ // `filter` initialized at top of scope
+ filter = document.createElement( "input" );
+ filter.type = "checkbox";
+ filter.id = "qunit-filter-pass";
+
+ addEvent( filter, "click", function() {
+ var tmp,
+ ol = id( "qunit-tests" );
+
+ if ( filter.checked ) {
+ ol.className = ol.className + " hidepass";
+ } else {
+ tmp = " " + ol.className.replace( /[\n\t\r]/g, " " ) + " ";
+ ol.className = tmp.replace( / hidepass /, " " );
+ }
+ if ( defined.sessionStorage ) {
+ if (filter.checked) {
+ sessionStorage.setItem( "qunit-filter-passed-tests", "true" );
+ } else {
+ sessionStorage.removeItem( "qunit-filter-passed-tests" );
+ }
+ }
+ });
+
+ if ( config.hidepassed || defined.sessionStorage && sessionStorage.getItem( "qunit-filter-passed-tests" ) ) {
+ filter.checked = true;
+ // `ol` initialized at top of scope
+ ol = id( "qunit-tests" );
+ ol.className = ol.className + " hidepass";
+ }
+ toolbar.appendChild( filter );
+
+ // `label` initialized at top of scope
+ label = document.createElement( "label" );
+ label.setAttribute( "for", "qunit-filter-pass" );
+ label.setAttribute( "title", "Only show tests and assertions that fail. Stored in sessionStorage." );
+ label.innerHTML = "Hide passed tests";
+ toolbar.appendChild( label );
+
+ urlConfigContainer = document.createElement("span");
+ urlConfigContainer.innerHTML = urlConfigHtml;
+ // For oldIE support:
+ // * Add handlers to the individual elements instead of the container
+ // * Use "click" instead of "change" for checkboxes
+ // * Fallback from event.target to event.srcElement
+ addEvents( urlConfigContainer.getElementsByTagName("input"), "click", function( event ) {
+ var params = {},
+ target = event.target || event.srcElement;
+ params[ target.name ] = target.checked ?
+ target.defaultValue || true :
+ undefined;
+ window.location = QUnit.url( params );
+ });
+ addEvents( urlConfigContainer.getElementsByTagName("select"), "change", function( event ) {
+ var params = {},
+ target = event.target || event.srcElement;
+ params[ target.name ] = target.options[ target.selectedIndex ].value || undefined;
+ window.location = QUnit.url( params );
+ });
+ toolbar.appendChild( urlConfigContainer );
+
+ if (numModules > 1) {
+ moduleFilter = document.createElement( "span" );
+ moduleFilter.setAttribute( "id", "qunit-modulefilter-container" );
+ moduleFilter.innerHTML = moduleFilterHtml;
+ addEvent( moduleFilter.lastChild, "change", function() {
+ var selectBox = moduleFilter.getElementsByTagName("select")[0],
+ selectedModule = decodeURIComponent(selectBox.options[selectBox.selectedIndex].value);
+
+ window.location = QUnit.url({
+ module: ( selectedModule === "" ) ? undefined : selectedModule,
+ // Remove any existing filters
+ filter: undefined,
+ testNumber: undefined
+ });
+ });
+ toolbar.appendChild(moduleFilter);
+ }
+ }
+
+ // `main` initialized at top of scope
+ main = id( "qunit-fixture" );
+ if ( main ) {
+ config.fixture = main.innerHTML;
+ }
+
+ if ( config.autostart ) {
+ QUnit.start();
+ }
+};
+
+if ( defined.document ) {
+ addEvent( window, "load", QUnit.load );
+}
+
+// `onErrorFnPrev` initialized at top of scope
+// Preserve other handlers
+onErrorFnPrev = window.onerror;
+
+// Cover uncaught exceptions
+// Returning true will suppress the default browser handler,
+// returning false will let it run.
+window.onerror = function ( error, filePath, linerNr ) {
+ var ret = false;
+ if ( onErrorFnPrev ) {
+ ret = onErrorFnPrev( error, filePath, linerNr );
+ }
+
+ // Treat return value as window.onerror itself does,
+ // Only do our handling if not suppressed.
+ if ( ret !== true ) {
+ if ( QUnit.config.current ) {
+ if ( QUnit.config.current.ignoreGlobalErrors ) {
+ return true;
+ }
+ QUnit.pushFailure( error, filePath + ":" + linerNr );
+ } else {
+ QUnit.test( "global failure", extend( function() {
+ QUnit.pushFailure( error, filePath + ":" + linerNr );
+ }, { validTest: validTest } ) );
+ }
+ return false;
+ }
+
+ return ret;
+};
+
+function done() {
+ config.autorun = true;
+
+ // Log the last module results
+ if ( config.previousModule ) {
+ runLoggingCallbacks( "moduleDone", QUnit, {
+ name: config.previousModule,
+ failed: config.moduleStats.bad,
+ passed: config.moduleStats.all - config.moduleStats.bad,
+ total: config.moduleStats.all
+ });
+ }
+ delete config.previousModule;
+
+ var i, key,
+ banner = id( "qunit-banner" ),
+ tests = id( "qunit-tests" ),
+ runtime = +new Date() - config.started,
+ passed = config.stats.all - config.stats.bad,
+ html = [
+ "Tests completed in ",
+ runtime,
+ " milliseconds.<br/>",
+ "<span class='passed'>",
+ passed,
+ "</span> assertions of <span class='total'>",
+ config.stats.all,
+ "</span> passed, <span class='failed'>",
+ config.stats.bad,
+ "</span> failed."
+ ].join( "" );
+
+ if ( banner ) {
+ banner.className = ( config.stats.bad ? "qunit-fail" : "qunit-pass" );
+ }
+
+ if ( tests ) {
+ id( "qunit-testresult" ).innerHTML = html;
+ }
+
+ if ( config.altertitle && defined.document && document.title ) {
+ // show ✖ for good, ✔ for bad suite result in title
+ // use escape sequences in case file gets loaded with non-utf-8-charset
+ document.title = [
+ ( config.stats.bad ? "\u2716" : "\u2714" ),
+ document.title.replace( /^[\u2714\u2716] /i, "" )
+ ].join( " " );
+ }
+
+ // clear own sessionStorage items if all tests passed
+ if ( config.reorder && defined.sessionStorage && config.stats.bad === 0 ) {
+ // `key` & `i` initialized at top of scope
+ for ( i = 0; i < sessionStorage.length; i++ ) {
+ key = sessionStorage.key( i++ );
+ if ( key.indexOf( "qunit-test-" ) === 0 ) {
+ sessionStorage.removeItem( key );
+ }
+ }
+ }
+
+ // scroll back to top to show results
+ if ( config.scrolltop && window.scrollTo ) {
+ window.scrollTo(0, 0);
+ }
+
+ runLoggingCallbacks( "done", QUnit, {
+ failed: config.stats.bad,
+ passed: passed,
+ total: config.stats.all,
+ runtime: runtime
+ });
+}
+
+/** @return Boolean: true if this test should be ran */
+function validTest( test ) {
+ var include,
+ filter = config.filter && config.filter.toLowerCase(),
+ module = config.module && config.module.toLowerCase(),
+ fullName = ( test.module + ": " + test.testName ).toLowerCase();
+
+ // Internally-generated tests are always valid
+ if ( test.callback && test.callback.validTest === validTest ) {
+ delete test.callback.validTest;
+ return true;
+ }
+
+ if ( config.testNumber.length > 0 ) {
+ if ( inArray( test.testNumber, config.testNumber ) < 0 ) {
+ return false;
+ }
+ }
+
+ if ( module && ( !test.module || test.module.toLowerCase() !== module ) ) {
+ return false;
+ }
+
+ if ( !filter ) {
+ return true;
+ }
+
+ include = filter.charAt( 0 ) !== "!";
+ if ( !include ) {
+ filter = filter.slice( 1 );
+ }
+
+ // If the filter matches, we need to honour include
+ if ( fullName.indexOf( filter ) !== -1 ) {
+ return include;
+ }
+
+ // Otherwise, do the opposite
+ return !include;
+}
+
+// so far supports only Firefox, Chrome and Opera (buggy), Safari (for real exceptions)
+// Later Safari and IE10 are supposed to support error.stack as well
+// See also https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Error/Stack
+function extractStacktrace( e, offset ) {
+ offset = offset === undefined ? 3 : offset;
+
+ var stack, include, i;
+
+ if ( e.stacktrace ) {
+ // Opera
+ return e.stacktrace.split( "\n" )[ offset + 3 ];
+ } else if ( e.stack ) {
+ // Firefox, Chrome
+ stack = e.stack.split( "\n" );
+ if (/^error$/i.test( stack[0] ) ) {
+ stack.shift();
+ }
+ if ( fileName ) {
+ include = [];
+ for ( i = offset; i < stack.length; i++ ) {
+ if ( stack[ i ].indexOf( fileName ) !== -1 ) {
+ break;
+ }
+ include.push( stack[ i ] );
+ }
+ if ( include.length ) {
+ return include.join( "\n" );
+ }
+ }
+ return stack[ offset ];
+ } else if ( e.sourceURL ) {
+ // Safari, PhantomJS
+ // hopefully one day Safari provides actual stacktraces
+ // exclude useless self-reference for generated Error objects
+ if ( /qunit.js$/.test( e.sourceURL ) ) {
+ return;
+ }
+ // for actual exceptions, this is useful
+ return e.sourceURL + ":" + e.line;
+ }
+}
+function sourceFromStacktrace( offset ) {
+ try {
+ throw new Error();
+ } catch ( e ) {
+ return extractStacktrace( e, offset );
+ }
+}
+
+/**
+ * Escape text for attribute or text content.
+ */
+function escapeText( s ) {
+ if ( !s ) {
+ return "";
+ }
+ s = s + "";
+ // Both single quotes and double quotes (for attributes)
+ return s.replace( /['"<>&]/g, function( s ) {
+ switch( s ) {
+ case "'":
+ return "&#039;";
+ case "\"":
+ return "&quot;";
+ case "<":
+ return "&lt;";
+ case ">":
+ return "&gt;";
+ case "&":
+ return "&amp;";
+ }
+ });
+}
+
+function synchronize( callback, last ) {
+ config.queue.push( callback );
+
+ if ( config.autorun && !config.blocking ) {
+ process( last );
+ }
+}
+
+function process( last ) {
+ function next() {
+ process( last );
+ }
+ var start = new Date().getTime();
+ config.depth = config.depth ? config.depth + 1 : 1;
+
+ while ( config.queue.length && !config.blocking ) {
+ if ( !defined.setTimeout || config.updateRate <= 0 || ( ( new Date().getTime() - start ) < config.updateRate ) ) {
+ config.queue.shift()();
+ } else {
+ setTimeout( next, 13 );
+ break;
+ }
+ }
+ config.depth--;
+ if ( last && !config.blocking && !config.queue.length && config.depth === 0 ) {
+ done();
+ }
+}
+
+function saveGlobal() {
+ config.pollution = [];
+
+ if ( config.noglobals ) {
+ for ( var key in window ) {
+ if ( hasOwn.call( window, key ) ) {
+ // in Opera sometimes DOM element ids show up here, ignore them
+ if ( /^qunit-test-output/.test( key ) ) {
+ continue;
+ }
+ config.pollution.push( key );
+ }
+ }
+ }
+}
+
+function checkPollution() {
+ var newGlobals,
+ deletedGlobals,
+ old = config.pollution;
+
+ saveGlobal();
+
+ newGlobals = diff( config.pollution, old );
+ if ( newGlobals.length > 0 ) {
+ QUnit.pushFailure( "Introduced global variable(s): " + newGlobals.join(", ") );
+ }
+
+ deletedGlobals = diff( old, config.pollution );
+ if ( deletedGlobals.length > 0 ) {
+ QUnit.pushFailure( "Deleted global variable(s): " + deletedGlobals.join(", ") );
+ }
+}
+
+// returns a new Array with the elements that are in a but not in b
+function diff( a, b ) {
+ var i, j,
+ result = a.slice();
+
+ for ( i = 0; i < result.length; i++ ) {
+ for ( j = 0; j < b.length; j++ ) {
+ if ( result[i] === b[j] ) {
+ result.splice( i, 1 );
+ i--;
+ break;
+ }
+ }
+ }
+ return result;
+}
+
+function extend( a, b ) {
+ for ( var prop in b ) {
+ if ( hasOwn.call( b, prop ) ) {
+ // Avoid "Member not found" error in IE8 caused by messing with window.constructor
+ if ( !( prop === "constructor" && a === window ) ) {
+ if ( b[ prop ] === undefined ) {
+ delete a[ prop ];
+ } else {
+ a[ prop ] = b[ prop ];
+ }
+ }
+ }
+ }
+
+ return a;
+}
+
+/**
+ * @param {HTMLElement} elem
+ * @param {string} type
+ * @param {Function} fn
+ */
+function addEvent( elem, type, fn ) {
+ if ( elem.addEventListener ) {
+
+ // Standards-based browsers
+ elem.addEventListener( type, fn, false );
+ } else if ( elem.attachEvent ) {
+
+ // support: IE <9
+ elem.attachEvent( "on" + type, fn );
+ } else {
+
+ // Caller must ensure support for event listeners is present
+ throw new Error( "addEvent() was called in a context without event listener support" );
+ }
+}
+
+/**
+ * @param {Array|NodeList} elems
+ * @param {string} type
+ * @param {Function} fn
+ */
+function addEvents( elems, type, fn ) {
+ var i = elems.length;
+ while ( i-- ) {
+ addEvent( elems[i], type, fn );
+ }
+}
+
+function hasClass( elem, name ) {
+ return (" " + elem.className + " ").indexOf(" " + name + " ") > -1;
+}
+
+function addClass( elem, name ) {
+ if ( !hasClass( elem, name ) ) {
+ elem.className += (elem.className ? " " : "") + name;
+ }
+}
+
+function removeClass( elem, name ) {
+ var set = " " + elem.className + " ";
+ // Class name may appear multiple times
+ while ( set.indexOf(" " + name + " ") > -1 ) {
+ set = set.replace(" " + name + " " , " ");
+ }
+ // If possible, trim it for prettiness, but not necessarily
+ elem.className = typeof set.trim === "function" ? set.trim() : set.replace(/^\s+|\s+$/g, "");
+}
+
+function id( name ) {
+ return defined.document && document.getElementById && document.getElementById( name );
+}
+
+function registerLoggingCallback( key ) {
+ return function( callback ) {
+ config[key].push( callback );
+ };
+}
+
+// Supports deprecated method of completely overwriting logging callbacks
+function runLoggingCallbacks( key, scope, args ) {
+ var i, callbacks;
+ if ( QUnit.hasOwnProperty( key ) ) {
+ QUnit[ key ].call(scope, args );
+ } else {
+ callbacks = config[ key ];
+ for ( i = 0; i < callbacks.length; i++ ) {
+ callbacks[ i ].call( scope, args );
+ }
+ }
+}
+
+// from jquery.js
+function inArray( elem, array ) {
+ if ( array.indexOf ) {
+ return array.indexOf( elem );
+ }
+
+ for ( var i = 0, length = array.length; i < length; i++ ) {
+ if ( array[ i ] === elem ) {
+ return i;
+ }
+ }
+
+ return -1;
+}
+
+function Test( settings ) {
+ extend( this, settings );
+ this.assertions = [];
+ this.testNumber = ++Test.count;
+}
+
+Test.count = 0;
+
+Test.prototype = {
+ init: function() {
+ var a, b, li,
+ tests = id( "qunit-tests" );
+
+ if ( tests ) {
+ b = document.createElement( "strong" );
+ b.innerHTML = this.nameHtml;
+
+ // `a` initialized at top of scope
+ a = document.createElement( "a" );
+ a.innerHTML = "Rerun";
+ a.href = QUnit.url({ testNumber: this.testNumber });
+
+ li = document.createElement( "li" );
+ li.appendChild( b );
+ li.appendChild( a );
+ li.className = "running";
+ li.id = this.id = "qunit-test-output" + testId++;
+
+ tests.appendChild( li );
+ }
+ },
+ setup: function() {
+ if (
+ // Emit moduleStart when we're switching from one module to another
+ this.module !== config.previousModule ||
+ // They could be equal (both undefined) but if the previousModule property doesn't
+ // yet exist it means this is the first test in a suite that isn't wrapped in a
+ // module, in which case we'll just emit a moduleStart event for 'undefined'.
+ // Without this, reporters can get testStart before moduleStart which is a problem.
+ !hasOwn.call( config, "previousModule" )
+ ) {
+ if ( hasOwn.call( config, "previousModule" ) ) {
+ runLoggingCallbacks( "moduleDone", QUnit, {
+ name: config.previousModule,
+ failed: config.moduleStats.bad,
+ passed: config.moduleStats.all - config.moduleStats.bad,
+ total: config.moduleStats.all
+ });
+ }
+ config.previousModule = this.module;
+ config.moduleStats = { all: 0, bad: 0 };
+ runLoggingCallbacks( "moduleStart", QUnit, {
+ name: this.module
+ });
+ }
+
+ config.current = this;
+
+ this.testEnvironment = extend({
+ setup: function() {},
+ teardown: function() {}
+ }, this.moduleTestEnvironment );
+
+ this.started = +new Date();
+ runLoggingCallbacks( "testStart", QUnit, {
+ name: this.testName,
+ module: this.module
+ });
+
+ /*jshint camelcase:false */
+
+
+ /**
+ * Expose the current test environment.
+ *
+ * @deprecated since 1.12.0: Use QUnit.config.current.testEnvironment instead.
+ */
+ QUnit.current_testEnvironment = this.testEnvironment;
+
+ /*jshint camelcase:true */
+
+ if ( !config.pollution ) {
+ saveGlobal();
+ }
+ if ( config.notrycatch ) {
+ this.testEnvironment.setup.call( this.testEnvironment, QUnit.assert );
+ return;
+ }
+ try {
+ this.testEnvironment.setup.call( this.testEnvironment, QUnit.assert );
+ } catch( e ) {
+ QUnit.pushFailure( "Setup failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 1 ) );
+ }
+ },
+ run: function() {
+ config.current = this;
+
+ var running = id( "qunit-testresult" );
+
+ if ( running ) {
+ running.innerHTML = "Running: <br/>" + this.nameHtml;
+ }
+
+ if ( this.async ) {
+ QUnit.stop();
+ }
+
+ this.callbackStarted = +new Date();
+
+ if ( config.notrycatch ) {
+ this.callback.call( this.testEnvironment, QUnit.assert );
+ this.callbackRuntime = +new Date() - this.callbackStarted;
+ return;
+ }
+
+ try {
+ this.callback.call( this.testEnvironment, QUnit.assert );
+ this.callbackRuntime = +new Date() - this.callbackStarted;
+ } catch( e ) {
+ this.callbackRuntime = +new Date() - this.callbackStarted;
+
+ QUnit.pushFailure( "Died on test #" + (this.assertions.length + 1) + " " + this.stack + ": " + ( e.message || e ), extractStacktrace( e, 0 ) );
+ // else next test will carry the responsibility
+ saveGlobal();
+
+ // Restart the tests if they're blocking
+ if ( config.blocking ) {
+ QUnit.start();
+ }
+ }
+ },
+ teardown: function() {
+ config.current = this;
+ if ( config.notrycatch ) {
+ if ( typeof this.callbackRuntime === "undefined" ) {
+ this.callbackRuntime = +new Date() - this.callbackStarted;
+ }
+ this.testEnvironment.teardown.call( this.testEnvironment, QUnit.assert );
+ return;
+ } else {
+ try {
+ this.testEnvironment.teardown.call( this.testEnvironment, QUnit.assert );
+ } catch( e ) {
+ QUnit.pushFailure( "Teardown failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 1 ) );
+ }
+ }
+ checkPollution();
+ },
+ finish: function() {
+ config.current = this;
+ if ( config.requireExpects && this.expected === null ) {
+ QUnit.pushFailure( "Expected number of assertions to be defined, but expect() was not called.", this.stack );
+ } else if ( this.expected !== null && this.expected !== this.assertions.length ) {
+ QUnit.pushFailure( "Expected " + this.expected + " assertions, but " + this.assertions.length + " were run", this.stack );
+ } else if ( this.expected === null && !this.assertions.length ) {
+ QUnit.pushFailure( "Expected at least one assertion, but none were run - call expect(0) to accept zero assertions.", this.stack );
+ }
+
+ var i, assertion, a, b, time, li, ol,
+ test = this,
+ good = 0,
+ bad = 0,
+ tests = id( "qunit-tests" );
+
+ this.runtime = +new Date() - this.started;
+ config.stats.all += this.assertions.length;
+ config.moduleStats.all += this.assertions.length;
+
+ if ( tests ) {
+ ol = document.createElement( "ol" );
+ ol.className = "qunit-assert-list";
+
+ for ( i = 0; i < this.assertions.length; i++ ) {
+ assertion = this.assertions[i];
+
+ li = document.createElement( "li" );
+ li.className = assertion.result ? "pass" : "fail";
+ li.innerHTML = assertion.message || ( assertion.result ? "okay" : "failed" );
+ ol.appendChild( li );
+
+ if ( assertion.result ) {
+ good++;
+ } else {
+ bad++;
+ config.stats.bad++;
+ config.moduleStats.bad++;
+ }
+ }
+
+ // store result when possible
+ if ( QUnit.config.reorder && defined.sessionStorage ) {
+ if ( bad ) {
+ sessionStorage.setItem( "qunit-test-" + this.module + "-" + this.testName, bad );
+ } else {
+ sessionStorage.removeItem( "qunit-test-" + this.module + "-" + this.testName );
+ }
+ }
+
+ if ( bad === 0 ) {
+ addClass( ol, "qunit-collapsed" );
+ }
+
+ // `b` initialized at top of scope
+ b = document.createElement( "strong" );
+ b.innerHTML = this.nameHtml + " <b class='counts'>(<b class='failed'>" + bad + "</b>, <b class='passed'>" + good + "</b>, " + this.assertions.length + ")</b>";
+
+ addEvent(b, "click", function() {
+ var next = b.parentNode.lastChild,
+ collapsed = hasClass( next, "qunit-collapsed" );
+ ( collapsed ? removeClass : addClass )( next, "qunit-collapsed" );
+ });
+
+ addEvent(b, "dblclick", function( e ) {
+ var target = e && e.target ? e.target : window.event.srcElement;
+ if ( target.nodeName.toLowerCase() === "span" || target.nodeName.toLowerCase() === "b" ) {
+ target = target.parentNode;
+ }
+ if ( window.location && target.nodeName.toLowerCase() === "strong" ) {
+ window.location = QUnit.url({ testNumber: test.testNumber });
+ }
+ });
+
+ // `time` initialized at top of scope
+ time = document.createElement( "span" );
+ time.className = "runtime";
+ time.innerHTML = this.runtime + " ms";
+
+ // `li` initialized at top of scope
+ li = id( this.id );
+ li.className = bad ? "fail" : "pass";
+ li.removeChild( li.firstChild );
+ a = li.firstChild;
+ li.appendChild( b );
+ li.appendChild( a );
+ li.appendChild( time );
+ li.appendChild( ol );
+
+ } else {
+ for ( i = 0; i < this.assertions.length; i++ ) {
+ if ( !this.assertions[i].result ) {
+ bad++;
+ config.stats.bad++;
+ config.moduleStats.bad++;
+ }
+ }
+ }
+
+ runLoggingCallbacks( "testDone", QUnit, {
+ name: this.testName,
+ module: this.module,
+ failed: bad,
+ passed: this.assertions.length - bad,
+ total: this.assertions.length,
+ runtime: this.runtime,
+ // DEPRECATED: this property will be removed in 2.0.0, use runtime instead
+ duration: this.runtime
+ });
+
+ QUnit.reset();
+
+ config.current = undefined;
+ },
+
+ queue: function() {
+ var bad,
+ test = this;
+
+ synchronize(function() {
+ test.init();
+ });
+ function run() {
+ // each of these can by async
+ synchronize(function() {
+ test.setup();
+ });
+ synchronize(function() {
+ test.run();
+ });
+ synchronize(function() {
+ test.teardown();
+ });
+ synchronize(function() {
+ test.finish();
+ });
+ }
+
+ // `bad` initialized at top of scope
+ // defer when previous test run passed, if storage is available
+ bad = QUnit.config.reorder && defined.sessionStorage &&
+ +sessionStorage.getItem( "qunit-test-" + this.module + "-" + this.testName );
+
+ if ( bad ) {
+ run();
+ } else {
+ synchronize( run, true );
+ }
+ }
+};
+
+// `assert` initialized at top of scope
+// Assert helpers
+// All of these must either call QUnit.push() or manually do:
+// - runLoggingCallbacks( "log", .. );
+// - config.current.assertions.push({ .. });
+assert = QUnit.assert = {
+ /**
+ * Asserts rough true-ish result.
+ * @name ok
+ * @function
+ * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" );
+ */
+ ok: function( result, msg ) {
+ if ( !config.current ) {
+ throw new Error( "ok() assertion outside test context, was " + sourceFromStacktrace(2) );
+ }
+ result = !!result;
+ msg = msg || ( result ? "okay" : "failed" );
+
+ var source,
+ details = {
+ module: config.current.module,
+ name: config.current.testName,
+ result: result,
+ message: msg
+ };
+
+ msg = "<span class='test-message'>" + escapeText( msg ) + "</span>";
+
+ if ( !result ) {
+ source = sourceFromStacktrace( 2 );
+ if ( source ) {
+ details.source = source;
+ msg += "<table><tr class='test-source'><th>Source: </th><td><pre>" +
+ escapeText( source ) +
+ "</pre></td></tr></table>";
+ }
+ }
+ runLoggingCallbacks( "log", QUnit, details );
+ config.current.assertions.push({
+ result: result,
+ message: msg
+ });
+ },
+
+ /**
+ * Assert that the first two arguments are equal, with an optional message.
+ * Prints out both actual and expected values.
+ * @name equal
+ * @function
+ * @example equal( format( "Received {0} bytes.", 2), "Received 2 bytes.", "format() replaces {0} with next argument" );
+ */
+ equal: function( actual, expected, message ) {
+ /*jshint eqeqeq:false */
+ QUnit.push( expected == actual, actual, expected, message );
+ },
+
+ /**
+ * @name notEqual
+ * @function
+ */
+ notEqual: function( actual, expected, message ) {
+ /*jshint eqeqeq:false */
+ QUnit.push( expected != actual, actual, expected, message );
+ },
+
+ /**
+ * @name propEqual
+ * @function
+ */
+ propEqual: function( actual, expected, message ) {
+ actual = objectValues(actual);
+ expected = objectValues(expected);
+ QUnit.push( QUnit.equiv(actual, expected), actual, expected, message );
+ },
+
+ /**
+ * @name notPropEqual
+ * @function
+ */
+ notPropEqual: function( actual, expected, message ) {
+ actual = objectValues(actual);
+ expected = objectValues(expected);
+ QUnit.push( !QUnit.equiv(actual, expected), actual, expected, message );
+ },
+
+ /**
+ * @name deepEqual
+ * @function
+ */
+ deepEqual: function( actual, expected, message ) {
+ QUnit.push( QUnit.equiv(actual, expected), actual, expected, message );
+ },
+
+ /**
+ * @name notDeepEqual
+ * @function
+ */
+ notDeepEqual: function( actual, expected, message ) {
+ QUnit.push( !QUnit.equiv(actual, expected), actual, expected, message );
+ },
+
+ /**
+ * @name strictEqual
+ * @function
+ */
+ strictEqual: function( actual, expected, message ) {
+ QUnit.push( expected === actual, actual, expected, message );
+ },
+
+ /**
+ * @name notStrictEqual
+ * @function
+ */
+ notStrictEqual: function( actual, expected, message ) {
+ QUnit.push( expected !== actual, actual, expected, message );
+ },
+
+ "throws": function( block, expected, message ) {
+ var actual,
+ expectedOutput = expected,
+ ok = false;
+
+ // 'expected' is optional
+ if ( !message && typeof expected === "string" ) {
+ message = expected;
+ expected = null;
+ }
+
+ config.current.ignoreGlobalErrors = true;
+ try {
+ block.call( config.current.testEnvironment );
+ } catch (e) {
+ actual = e;
+ }
+ config.current.ignoreGlobalErrors = false;
+
+ if ( actual ) {
+
+ // we don't want to validate thrown error
+ if ( !expected ) {
+ ok = true;
+ expectedOutput = null;
+
+ // expected is an Error object
+ } else if ( expected instanceof Error ) {
+ ok = actual instanceof Error &&
+ actual.name === expected.name &&
+ actual.message === expected.message;
+
+ // expected is a regexp
+ } else if ( QUnit.objectType( expected ) === "regexp" ) {
+ ok = expected.test( errorString( actual ) );
+
+ // expected is a string
+ } else if ( QUnit.objectType( expected ) === "string" ) {
+ ok = expected === errorString( actual );
+
+ // expected is a constructor
+ } else if ( actual instanceof expected ) {
+ ok = true;
+
+ // expected is a validation function which returns true is validation passed
+ } else if ( expected.call( {}, actual ) === true ) {
+ expectedOutput = null;
+ ok = true;
+ }
+
+ QUnit.push( ok, actual, expectedOutput, message );
+ } else {
+ QUnit.pushFailure( message, null, "No exception was thrown." );
+ }
+ }
+};
+
+/**
+ * @deprecated since 1.8.0
+ * Kept assertion helpers in root for backwards compatibility.
+ */
+extend( QUnit.constructor.prototype, assert );
+
+/**
+ * @deprecated since 1.9.0
+ * Kept to avoid TypeErrors for undefined methods.
+ */
+QUnit.constructor.prototype.raises = function() {
+ QUnit.push( false, false, false, "QUnit.raises has been deprecated since 2012 (fad3c1ea), use QUnit.throws instead" );
+};
+
+/**
+ * @deprecated since 1.0.0, replaced with error pushes since 1.3.0
+ * Kept to avoid TypeErrors for undefined methods.
+ */
+QUnit.constructor.prototype.equals = function() {
+ QUnit.push( false, false, false, "QUnit.equals has been deprecated since 2009 (e88049a0), use QUnit.equal instead" );
+};
+QUnit.constructor.prototype.same = function() {
+ QUnit.push( false, false, false, "QUnit.same has been deprecated since 2009 (e88049a0), use QUnit.deepEqual instead" );
+};
+
+// Test for equality any JavaScript type.
+// Author: Philippe Rathé <prathe@gmail.com>
+QUnit.equiv = (function() {
+
+ // Call the o related callback with the given arguments.
+ function bindCallbacks( o, callbacks, args ) {
+ var prop = QUnit.objectType( o );
+ if ( prop ) {
+ if ( QUnit.objectType( callbacks[ prop ] ) === "function" ) {
+ return callbacks[ prop ].apply( callbacks, args );
+ } else {
+ return callbacks[ prop ]; // or undefined
+ }
+ }
+ }
+
+ // the real equiv function
+ var innerEquiv,
+ // stack to decide between skip/abort functions
+ callers = [],
+ // stack to avoiding loops from circular referencing
+ parents = [],
+ parentsB = [],
+
+ getProto = Object.getPrototypeOf || function ( obj ) {
+ /*jshint camelcase:false */
+ return obj.__proto__;
+ },
+ callbacks = (function () {
+
+ // for string, boolean, number and null
+ function useStrictEquality( b, a ) {
+ /*jshint eqeqeq:false */
+ if ( b instanceof a.constructor || a instanceof b.constructor ) {
+ // to catch short annotation VS 'new' annotation of a
+ // declaration
+ // e.g. var i = 1;
+ // var j = new Number(1);
+ return a == b;
+ } else {
+ return a === b;
+ }
+ }
+
+ return {
+ "string": useStrictEquality,
+ "boolean": useStrictEquality,
+ "number": useStrictEquality,
+ "null": useStrictEquality,
+ "undefined": useStrictEquality,
+
+ "nan": function( b ) {
+ return isNaN( b );
+ },
+
+ "date": function( b, a ) {
+ return QUnit.objectType( b ) === "date" && a.valueOf() === b.valueOf();
+ },
+
+ "regexp": function( b, a ) {
+ return QUnit.objectType( b ) === "regexp" &&
+ // the regex itself
+ a.source === b.source &&
+ // and its modifiers
+ a.global === b.global &&
+ // (gmi) ...
+ a.ignoreCase === b.ignoreCase &&
+ a.multiline === b.multiline &&
+ a.sticky === b.sticky;
+ },
+
+ // - skip when the property is a method of an instance (OOP)
+ // - abort otherwise,
+ // initial === would have catch identical references anyway
+ "function": function() {
+ var caller = callers[callers.length - 1];
+ return caller !== Object && typeof caller !== "undefined";
+ },
+
+ "array": function( b, a ) {
+ var i, j, len, loop, aCircular, bCircular;
+
+ // b could be an object literal here
+ if ( QUnit.objectType( b ) !== "array" ) {
+ return false;
+ }
+
+ len = a.length;
+ if ( len !== b.length ) {
+ // safe and faster
+ return false;
+ }
+
+ // track reference to avoid circular references
+ parents.push( a );
+ parentsB.push( b );
+ for ( i = 0; i < len; i++ ) {
+ loop = false;
+ for ( j = 0; j < parents.length; j++ ) {
+ aCircular = parents[j] === a[i];
+ bCircular = parentsB[j] === b[i];
+ if ( aCircular || bCircular ) {
+ if ( a[i] === b[i] || aCircular && bCircular ) {
+ loop = true;
+ } else {
+ parents.pop();
+ parentsB.pop();
+ return false;
+ }
+ }
+ }
+ if ( !loop && !innerEquiv(a[i], b[i]) ) {
+ parents.pop();
+ parentsB.pop();
+ return false;
+ }
+ }
+ parents.pop();
+ parentsB.pop();
+ return true;
+ },
+
+ "object": function( b, a ) {
+ /*jshint forin:false */
+ var i, j, loop, aCircular, bCircular,
+ // Default to true
+ eq = true,
+ aProperties = [],
+ bProperties = [];
+
+ // comparing constructors is more strict than using
+ // instanceof
+ if ( a.constructor !== b.constructor ) {
+ // Allow objects with no prototype to be equivalent to
+ // objects with Object as their constructor.
+ if ( !(( getProto(a) === null && getProto(b) === Object.prototype ) ||
+ ( getProto(b) === null && getProto(a) === Object.prototype ) ) ) {
+ return false;
+ }
+ }
+
+ // stack constructor before traversing properties
+ callers.push( a.constructor );
+
+ // track reference to avoid circular references
+ parents.push( a );
+ parentsB.push( b );
+
+ // be strict: don't ensure hasOwnProperty and go deep
+ for ( i in a ) {
+ loop = false;
+ for ( j = 0; j < parents.length; j++ ) {
+ aCircular = parents[j] === a[i];
+ bCircular = parentsB[j] === b[i];
+ if ( aCircular || bCircular ) {
+ if ( a[i] === b[i] || aCircular && bCircular ) {
+ loop = true;
+ } else {
+ eq = false;
+ break;
+ }
+ }
+ }
+ aProperties.push(i);
+ if ( !loop && !innerEquiv(a[i], b[i]) ) {
+ eq = false;
+ break;
+ }
+ }
+
+ parents.pop();
+ parentsB.pop();
+ callers.pop(); // unstack, we are done
+
+ for ( i in b ) {
+ bProperties.push( i ); // collect b's properties
+ }
+
+ // Ensures identical properties name
+ return eq && innerEquiv( aProperties.sort(), bProperties.sort() );
+ }
+ };
+ }());
+
+ innerEquiv = function() { // can take multiple arguments
+ var args = [].slice.apply( arguments );
+ if ( args.length < 2 ) {
+ return true; // end transition
+ }
+
+ return (function( a, b ) {
+ if ( a === b ) {
+ return true; // catch the most you can
+ } else if ( a === null || b === null || typeof a === "undefined" ||
+ typeof b === "undefined" ||
+ QUnit.objectType(a) !== QUnit.objectType(b) ) {
+ return false; // don't lose time with error prone cases
+ } else {
+ return bindCallbacks(a, callbacks, [ b, a ]);
+ }
+
+ // apply transition with (1..n) arguments
+ }( args[0], args[1] ) && innerEquiv.apply( this, args.splice(1, args.length - 1 )) );
+ };
+
+ return innerEquiv;
+}());
+
+/**
+ * jsDump Copyright (c) 2008 Ariel Flesler - aflesler(at)gmail(dot)com |
+ * http://flesler.blogspot.com Licensed under BSD
+ * (http://www.opensource.org/licenses/bsd-license.php) Date: 5/15/2008
+ *
+ * @projectDescription Advanced and extensible data dumping for Javascript.
+ * @version 1.0.0
+ * @author Ariel Flesler
+ * @link {http://flesler.blogspot.com/2008/05/jsdump-pretty-dump-of-any-javascript.html}
+ */
+QUnit.jsDump = (function() {
+ function quote( str ) {
+ return "\"" + str.toString().replace( /"/g, "\\\"" ) + "\"";
+ }
+ function literal( o ) {
+ return o + "";
+ }
+ function join( pre, arr, post ) {
+ var s = jsDump.separator(),
+ base = jsDump.indent(),
+ inner = jsDump.indent(1);
+ if ( arr.join ) {
+ arr = arr.join( "," + s + inner );
+ }
+ if ( !arr ) {
+ return pre + post;
+ }
+ return [ pre, inner + arr, base + post ].join(s);
+ }
+ function array( arr, stack ) {
+ var i = arr.length, ret = new Array(i);
+ this.up();
+ while ( i-- ) {
+ ret[i] = this.parse( arr[i] , undefined , stack);
+ }
+ this.down();
+ return join( "[", ret, "]" );
+ }
+
+ var reName = /^function (\w+)/,
+ jsDump = {
+ // type is used mostly internally, you can fix a (custom)type in advance
+ parse: function( obj, type, stack ) {
+ stack = stack || [ ];
+ var inStack, res,
+ parser = this.parsers[ type || this.typeOf(obj) ];
+
+ type = typeof parser;
+ inStack = inArray( obj, stack );
+
+ if ( inStack !== -1 ) {
+ return "recursion(" + (inStack - stack.length) + ")";
+ }
+ if ( type === "function" ) {
+ stack.push( obj );
+ res = parser.call( this, obj, stack );
+ stack.pop();
+ return res;
+ }
+ return ( type === "string" ) ? parser : this.parsers.error;
+ },
+ typeOf: function( obj ) {
+ var type;
+ if ( obj === null ) {
+ type = "null";
+ } else if ( typeof obj === "undefined" ) {
+ type = "undefined";
+ } else if ( QUnit.is( "regexp", obj) ) {
+ type = "regexp";
+ } else if ( QUnit.is( "date", obj) ) {
+ type = "date";
+ } else if ( QUnit.is( "function", obj) ) {
+ type = "function";
+ } else if ( typeof obj.setInterval !== undefined && typeof obj.document !== "undefined" && typeof obj.nodeType === "undefined" ) {
+ type = "window";
+ } else if ( obj.nodeType === 9 ) {
+ type = "document";
+ } else if ( obj.nodeType ) {
+ type = "node";
+ } else if (
+ // native arrays
+ toString.call( obj ) === "[object Array]" ||
+ // NodeList objects
+ ( typeof obj.length === "number" && typeof obj.item !== "undefined" && ( obj.length ? obj.item(0) === obj[0] : ( obj.item( 0 ) === null && typeof obj[0] === "undefined" ) ) )
+ ) {
+ type = "array";
+ } else if ( obj.constructor === Error.prototype.constructor ) {
+ type = "error";
+ } else {
+ type = typeof obj;
+ }
+ return type;
+ },
+ separator: function() {
+ return this.multiline ? this.HTML ? "<br />" : "\n" : this.HTML ? "&nbsp;" : " ";
+ },
+ // extra can be a number, shortcut for increasing-calling-decreasing
+ indent: function( extra ) {
+ if ( !this.multiline ) {
+ return "";
+ }
+ var chr = this.indentChar;
+ if ( this.HTML ) {
+ chr = chr.replace( /\t/g, " " ).replace( / /g, "&nbsp;" );
+ }
+ return new Array( this.depth + ( extra || 0 ) ).join(chr);
+ },
+ up: function( a ) {
+ this.depth += a || 1;
+ },
+ down: function( a ) {
+ this.depth -= a || 1;
+ },
+ setParser: function( name, parser ) {
+ this.parsers[name] = parser;
+ },
+ // The next 3 are exposed so you can use them
+ quote: quote,
+ literal: literal,
+ join: join,
+ //
+ depth: 1,
+ // This is the list of parsers, to modify them, use jsDump.setParser
+ parsers: {
+ window: "[Window]",
+ document: "[Document]",
+ error: function(error) {
+ return "Error(\"" + error.message + "\")";
+ },
+ unknown: "[Unknown]",
+ "null": "null",
+ "undefined": "undefined",
+ "function": function( fn ) {
+ var ret = "function",
+ // functions never have name in IE
+ name = "name" in fn ? fn.name : (reName.exec(fn) || [])[1];
+
+ if ( name ) {
+ ret += " " + name;
+ }
+ ret += "( ";
+
+ ret = [ ret, QUnit.jsDump.parse( fn, "functionArgs" ), "){" ].join( "" );
+ return join( ret, QUnit.jsDump.parse(fn,"functionCode" ), "}" );
+ },
+ array: array,
+ nodelist: array,
+ "arguments": array,
+ object: function( map, stack ) {
+ /*jshint forin:false */
+ var ret = [ ], keys, key, val, i;
+ QUnit.jsDump.up();
+ keys = [];
+ for ( key in map ) {
+ keys.push( key );
+ }
+ keys.sort();
+ for ( i = 0; i < keys.length; i++ ) {
+ key = keys[ i ];
+ val = map[ key ];
+ ret.push( QUnit.jsDump.parse( key, "key" ) + ": " + QUnit.jsDump.parse( val, undefined, stack ) );
+ }
+ QUnit.jsDump.down();
+ return join( "{", ret, "}" );
+ },
+ node: function( node ) {
+ var len, i, val,
+ open = QUnit.jsDump.HTML ? "&lt;" : "<",
+ close = QUnit.jsDump.HTML ? "&gt;" : ">",
+ tag = node.nodeName.toLowerCase(),
+ ret = open + tag,
+ attrs = node.attributes;
+
+ if ( attrs ) {
+ for ( i = 0, len = attrs.length; i < len; i++ ) {
+ val = attrs[i].nodeValue;
+ // IE6 includes all attributes in .attributes, even ones not explicitly set.
+ // Those have values like undefined, null, 0, false, "" or "inherit".
+ if ( val && val !== "inherit" ) {
+ ret += " " + attrs[i].nodeName + "=" + QUnit.jsDump.parse( val, "attribute" );
+ }
+ }
+ }
+ ret += close;
+
+ // Show content of TextNode or CDATASection
+ if ( node.nodeType === 3 || node.nodeType === 4 ) {
+ ret += node.nodeValue;
+ }
+
+ return ret + open + "/" + tag + close;
+ },
+ // function calls it internally, it's the arguments part of the function
+ functionArgs: function( fn ) {
+ var args,
+ l = fn.length;
+
+ if ( !l ) {
+ return "";
+ }
+
+ args = new Array(l);
+ while ( l-- ) {
+ // 97 is 'a'
+ args[l] = String.fromCharCode(97+l);
+ }
+ return " " + args.join( ", " ) + " ";
+ },
+ // object calls it internally, the key part of an item in a map
+ key: quote,
+ // function calls it internally, it's the content of the function
+ functionCode: "[code]",
+ // node calls it internally, it's an html attribute value
+ attribute: quote,
+ string: quote,
+ date: quote,
+ regexp: literal,
+ number: literal,
+ "boolean": literal
+ },
+ // if true, entities are escaped ( <, >, \t, space and \n )
+ HTML: false,
+ // indentation unit
+ indentChar: " ",
+ // if true, items in a collection, are separated by a \n, else just a space.
+ multiline: true
+ };
+
+ return jsDump;
+}());
+
+/*
+ * Javascript Diff Algorithm
+ * By John Resig (http://ejohn.org/)
+ * Modified by Chu Alan "sprite"
+ *
+ * Released under the MIT license.
+ *
+ * More Info:
+ * http://ejohn.org/projects/javascript-diff-algorithm/
+ *
+ * Usage: QUnit.diff(expected, actual)
+ *
+ * QUnit.diff( "the quick brown fox jumped over", "the quick fox jumps over" ) == "the quick <del>brown </del> fox <del>jumped </del><ins>jumps </ins> over"
+ */
+QUnit.diff = (function() {
+ /*jshint eqeqeq:false, eqnull:true */
+ function diff( o, n ) {
+ var i,
+ ns = {},
+ os = {};
+
+ for ( i = 0; i < n.length; i++ ) {
+ if ( !hasOwn.call( ns, n[i] ) ) {
+ ns[ n[i] ] = {
+ rows: [],
+ o: null
+ };
+ }
+ ns[ n[i] ].rows.push( i );
+ }
+
+ for ( i = 0; i < o.length; i++ ) {
+ if ( !hasOwn.call( os, o[i] ) ) {
+ os[ o[i] ] = {
+ rows: [],
+ n: null
+ };
+ }
+ os[ o[i] ].rows.push( i );
+ }
+
+ for ( i in ns ) {
+ if ( hasOwn.call( ns, i ) ) {
+ if ( ns[i].rows.length === 1 && hasOwn.call( os, i ) && os[i].rows.length === 1 ) {
+ n[ ns[i].rows[0] ] = {
+ text: n[ ns[i].rows[0] ],
+ row: os[i].rows[0]
+ };
+ o[ os[i].rows[0] ] = {
+ text: o[ os[i].rows[0] ],
+ row: ns[i].rows[0]
+ };
+ }
+ }
+ }
+
+ for ( i = 0; i < n.length - 1; i++ ) {
+ if ( n[i].text != null && n[ i + 1 ].text == null && n[i].row + 1 < o.length && o[ n[i].row + 1 ].text == null &&
+ n[ i + 1 ] == o[ n[i].row + 1 ] ) {
+
+ n[ i + 1 ] = {
+ text: n[ i + 1 ],
+ row: n[i].row + 1
+ };
+ o[ n[i].row + 1 ] = {
+ text: o[ n[i].row + 1 ],
+ row: i + 1
+ };
+ }
+ }
+
+ for ( i = n.length - 1; i > 0; i-- ) {
+ if ( n[i].text != null && n[ i - 1 ].text == null && n[i].row > 0 && o[ n[i].row - 1 ].text == null &&
+ n[ i - 1 ] == o[ n[i].row - 1 ]) {
+
+ n[ i - 1 ] = {
+ text: n[ i - 1 ],
+ row: n[i].row - 1
+ };
+ o[ n[i].row - 1 ] = {
+ text: o[ n[i].row - 1 ],
+ row: i - 1
+ };
+ }
+ }
+
+ return {
+ o: o,
+ n: n
+ };
+ }
+
+ return function( o, n ) {
+ o = o.replace( /\s+$/, "" );
+ n = n.replace( /\s+$/, "" );
+
+ var i, pre,
+ str = "",
+ out = diff( o === "" ? [] : o.split(/\s+/), n === "" ? [] : n.split(/\s+/) ),
+ oSpace = o.match(/\s+/g),
+ nSpace = n.match(/\s+/g);
+
+ if ( oSpace == null ) {
+ oSpace = [ " " ];
+ }
+ else {
+ oSpace.push( " " );
+ }
+
+ if ( nSpace == null ) {
+ nSpace = [ " " ];
+ }
+ else {
+ nSpace.push( " " );
+ }
+
+ if ( out.n.length === 0 ) {
+ for ( i = 0; i < out.o.length; i++ ) {
+ str += "<del>" + out.o[i] + oSpace[i] + "</del>";
+ }
+ }
+ else {
+ if ( out.n[0].text == null ) {
+ for ( n = 0; n < out.o.length && out.o[n].text == null; n++ ) {
+ str += "<del>" + out.o[n] + oSpace[n] + "</del>";
+ }
+ }
+
+ for ( i = 0; i < out.n.length; i++ ) {
+ if (out.n[i].text == null) {
+ str += "<ins>" + out.n[i] + nSpace[i] + "</ins>";
+ }
+ else {
+ // `pre` initialized at top of scope
+ pre = "";
+
+ for ( n = out.n[i].row + 1; n < out.o.length && out.o[n].text == null; n++ ) {
+ pre += "<del>" + out.o[n] + oSpace[n] + "</del>";
+ }
+ str += " " + out.n[i].text + nSpace[i] + pre;
+ }
+ }
+ }
+
+ return str;
+ };
+}());
+
+// For browser, export only select globals
+if ( typeof window !== "undefined" ) {
+ extend( window, QUnit.constructor.prototype );
+ window.QUnit = QUnit;
+}
+
+// For CommonJS environments, export everything
+if ( typeof module !== "undefined" && module.exports ) {
+ module.exports = QUnit;
+}
+
+
+// Get a reference to the global object, like window in browsers
+}( (function() {
+ return this;
+})() ));
diff --git a/actionview/test/ujs/server.rb b/actionview/test/ujs/server.rb
new file mode 100644
index 0000000000..c7698c87fa
--- /dev/null
+++ b/actionview/test/ujs/server.rb
@@ -0,0 +1,105 @@
+require "rails"
+require "action_controller/railtie"
+require "action_view/railtie"
+require "blade"
+require "json"
+
+JQUERY_VERSIONS = %w[ 1.8.0 1.8.1 1.8.2 1.8.3 1.9.0 1.9.1 1.10.0 1.10.1 1.10.2 1.11.0 2.0.0 2.1.0].freeze
+
+module UJS
+ class Server < Rails::Application
+ routes.append do
+ get "/rails-ujs.js" => Blade::Assets.environment
+ get "/" => "tests#index"
+ match "/echo" => "tests#echo", via: :all
+ get "/error" => proc {|env| [403, {}, []] }
+ end
+
+ config.cache_classes = false
+ config.eager_load = false
+ config.secret_key_base = "59d7a4dbd349fa3838d79e330e39690fc22b931e7dc17d9162f03d633d526fbb92dfdb2dc9804c8be3e199631b9c1fbe43fc3e4fc75730b515851849c728d5c7"
+ config.paths["app/views"].unshift("#{Rails.root / "views"}")
+ config.public_file_server.enabled = true
+ config.logger = Logger.new(STDOUT)
+ config.log_level = :error
+ end
+end
+
+module TestsHelper
+ def jquery_link version
+ if params[:version] == version
+ "[#{version}]"
+ else
+ "<a href='/?version=#{version}&cdn=#{params[:cdn]}'>#{version}</a>".html_safe
+ end
+ end
+
+ def cdn_link cdn
+ if params[:cdn] == cdn
+ "[#{cdn}]"
+ else
+ "<a href='/?version=#{params[:version]}&cdn=#{cdn}'>#{cdn}</a>".html_safe
+ end
+ end
+
+ def jquery_src
+ if params[:version] == 'edge'
+ "/vendor/jquery.js"
+ elsif params[:cdn] && params[:cdn] == 'googleapis'
+ "https://ajax.googleapis.com/ajax/libs/jquery/#{params[:version]}/jquery.min.js"
+ else
+ "http://code.jquery.com/jquery-#{params[:version]}.js"
+ end
+ end
+
+ def test_to *names
+ names = ["/vendor/qunit.js", "settings"] + names
+ names.map { |name| script_tag name }.join("\n").html_safe
+ end
+
+ def script_tag src
+ src = "/test/#{src}.js" unless src.index('/')
+ %(<script src="#{src}" type="text/javascript"></script>).html_safe
+ end
+
+ def jquery_versions
+ JQUERY_VERSIONS
+ end
+end
+
+class TestsController < ActionController::Base
+ helper TestsHelper
+ layout "application"
+
+ def index
+ params[:version] ||= ENV['JQUERY_VERSION'] || '1.11.0'
+ params[:cdn] ||= 'jquery'
+ render :index
+ end
+
+ def echo
+ data = { :params => params.to_unsafe_h }.update(request.env)
+
+ if params[:content_type] and params[:content]
+ render inline: params[:content], content_type: params[:content_type]
+ elsif request.xhr?
+ render json: JSON.generate(data)
+ elsif params[:iframe]
+ payload = JSON.generate(data).gsub('<', '&lt;').gsub('>', '&gt;')
+ html = <<-HTML
+ <script>
+ if (window.top && window.top !== window)
+ window.top.jQuery.event.trigger('iframe:loaded', #{payload})
+ </script>
+ <p>You shouldn't be seeing this. <a href="#{request.env['HTTP_REFERER']}">Go back</a></p>
+ HTML
+
+ render html: html.html_safe
+ else
+ render text: "ERROR: #{request.path} requested without ajax", status: 404
+ end
+ end
+end
+
+Blade.initialize!
+UJS::Server.initialize!
diff --git a/actionview/test/ujs/views/layouts/application.html.erb b/actionview/test/ujs/views/layouts/application.html.erb
new file mode 100644
index 0000000000..74fa3bd06d
--- /dev/null
+++ b/actionview/test/ujs/views/layouts/application.html.erb
@@ -0,0 +1,40 @@
+<!DOCTYPE html>
+<html id="html">
+ <head>
+ <title><%= @title %></title>
+ <link href="/vendor/qunit.css" media="screen" rel="stylesheet" type="text/css" media="screen, projection" />
+ <style>
+ #jquery-cdn, #jquery-version {
+ padding: 0 2em .8em 0;
+ text-align: right;
+ font-family: sans-serif;
+ line-height: 1;
+ color: #8699A4;
+ background-color: #0d3349;
+ }
+ #jquery-cdn a, #jquery-version a {
+ color: white;
+ text-decoration: underline;
+ }
+ </style>
+
+ <%= script_tag jquery_src %>
+ <script>
+ // This is for test in override.js.
+ // Must go after jQuery is loaded, but before jquery-ujs.
+ $(document).bind('rails:attachBindings', function() {
+ $.rails.linkClickSelector += ', a[data-custom-remote-link]';
+ // Hijacks link click before ujs binds any handlers
+ // This is only used for ctrl-clicking test on remote links
+ $.rails.delegate(document, '#qunit-fixture a', 'click', function(e) {
+ e.preventDefault();
+ });
+ });
+ </script>
+ <%= script_tag "/rails-ujs.js" %>
+ </head>
+
+ <body id="body">
+ <%= yield %>
+ </body>
+</html>
diff --git a/actionview/test/ujs/views/tests/index.html.erb b/actionview/test/ujs/views/tests/index.html.erb
new file mode 100644
index 0000000000..393a5ee235
--- /dev/null
+++ b/actionview/test/ujs/views/tests/index.html.erb
@@ -0,0 +1,25 @@
+<% @title = "jquery-ujs test" %>
+
+<%= test_to 'data-confirm', 'data-remote', 'data-disable', 'data-disable-with', 'call-remote', 'call-remote-callbacks', 'data-method', 'override', 'csrf-refresh', 'csrf-token' %>
+
+<h1 id="qunit-header"><%= @title %></h1>
+<div id="jquery-cdn">
+ CDN:
+ <%= cdn_link 'jquery' %> •
+ <%= cdn_link 'googleapis' %>
+</div>
+<div id="jquery-version">
+ jQuery version:
+
+ <% jquery_versions.each do |v| %>
+ <%= ' • ' if v != jquery_versions.first %>
+ <%= jquery_link v %>
+ <% end %>
+ <%= (' • ' + jquery_link('edge')) if File.exist?(Rails.root + '/public/vendor/jquery.js') %>
+</div>
+<h2 id="qunit-banner"></h2>
+<div id="qunit-testrunner-toolbar"></div>
+<h2 id="qunit-userAgent"></h2>
+<ol id="qunit-tests"></ol>
+
+<div id="qunit-fixture"></div>
diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md
index 10f1de6706..9c53e2ecec 100644
--- a/activemodel/CHANGELOG.md
+++ b/activemodel/CHANGELOG.md
@@ -1,3 +1,9 @@
+* Allow indifferent access in `ActiveModel::Errors`.
+
+ `#include?`, `#has_key?`, `#key?`, `#delete` and `#full_messages_for`.
+
+ *Kenichi Kamiya*
+
* Removed deprecated `:tokenizer` in the length validator.
*Rafael Mendonça França*
diff --git a/activemodel/bin/test b/activemodel/bin/test
index 84a05bba08..a7beb14b27 100755
--- a/activemodel/bin/test
+++ b/activemodel/bin/test
@@ -2,5 +2,3 @@
COMPONENT_ROOT = File.expand_path("..", __dir__)
require File.expand_path("../tools/test", COMPONENT_ROOT)
-
-exit Minitest.run(ARGV)
diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb
index 14adfa081f..5ee9413cff 100644
--- a/activemodel/lib/active_model/errors.rb
+++ b/activemodel/lib/active_model/errors.rb
@@ -110,6 +110,7 @@ module ActiveModel
# person.errors.include?(:name) # => true
# person.errors.include?(:age) # => false
def include?(attribute)
+ attribute = attribute.to_sym
messages.key?(attribute) && messages[attribute].present?
end
alias :has_key? :include?
@@ -121,8 +122,9 @@ module ActiveModel
# person.errors.delete(:name) # => ["cannot be nil"]
# person.errors[:name] # => []
def delete(key)
- details.delete(key)
- messages.delete(key)
+ attribute = key.to_sym
+ details.delete(attribute)
+ messages.delete(attribute)
end
# When passed a symbol or a name of a method, returns an array of errors
@@ -342,6 +344,7 @@ module ActiveModel
# person.errors.full_messages_for(:name)
# # => ["Name is too short (minimum is 5 characters)", "Name can't be blank"]
def full_messages_for(attribute)
+ attribute = attribute.to_sym
messages[attribute].map { |message| full_message(attribute, message) }
end
diff --git a/activemodel/test/cases/errors_test.rb b/activemodel/test/cases/errors_test.rb
index 605ad64e4d..fc840bf192 100644
--- a/activemodel/test/cases/errors_test.rb
+++ b/activemodel/test/cases/errors_test.rb
@@ -30,7 +30,7 @@ class ErrorsTest < ActiveModel::TestCase
def test_delete
errors = ActiveModel::Errors.new(self)
errors[:foo] << "omg"
- errors.delete(:foo)
+ errors.delete("foo")
assert_empty errors[:foo]
end
@@ -38,6 +38,7 @@ class ErrorsTest < ActiveModel::TestCase
errors = ActiveModel::Errors.new(self)
errors[:foo] << "omg"
assert_includes errors, :foo, "errors should include :foo"
+ assert_includes errors, "foo", "errors should include 'foo' as :foo"
end
def test_dup
@@ -52,6 +53,7 @@ class ErrorsTest < ActiveModel::TestCase
errors = ActiveModel::Errors.new(self)
errors[:foo] << "omg"
assert_equal true, errors.has_key?(:foo), "errors should have key :foo"
+ assert_equal true, errors.has_key?("foo"), "errors should have key 'foo' as :foo"
end
def test_has_no_key
@@ -63,6 +65,7 @@ class ErrorsTest < ActiveModel::TestCase
errors = ActiveModel::Errors.new(self)
errors[:foo] << "omg"
assert_equal true, errors.key?(:foo), "errors should have key :foo"
+ assert_equal true, errors.key?("foo"), "errors should have key 'foo' as :foo"
end
def test_no_key
@@ -150,10 +153,11 @@ class ErrorsTest < ActiveModel::TestCase
assert_equal ["cannot be blank"], person.errors[:name]
end
- test "added? detects if a specific error was added to the object" do
+ test "added? detects indifferent if a specific error was added to the object" do
person = Person.new
person.errors.add(:name, "cannot be blank")
assert person.errors.added?(:name, "cannot be blank")
+ assert person.errors.added?("name", "cannot be blank")
end
test "added? handles symbol message" do
@@ -241,7 +245,7 @@ class ErrorsTest < ActiveModel::TestCase
assert_equal ["name cannot be blank", "name cannot be nil"], person.errors.full_messages
end
- test "full_messages_for contains all the error messages for the given attribute" do
+ test "full_messages_for contains all the error messages for the given attribute indifferent" do
person = Person.new
person.errors.add(:name, "cannot be blank")
person.errors.add(:name, "cannot be nil")
@@ -253,6 +257,7 @@ class ErrorsTest < ActiveModel::TestCase
person.errors.add(:name, "cannot be blank")
person.errors.add(:email, "cannot be blank")
assert_equal ["name cannot be blank"], person.errors.full_messages_for(:name)
+ assert_equal ["name cannot be blank"], person.errors.full_messages_for("name")
end
test "full_messages_for returns an empty list in case there are no errors for the given attribute" do
diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md
index a8bed82e19..64a0e22af2 100644
--- a/activerecord/CHANGELOG.md
+++ b/activerecord/CHANGELOG.md
@@ -1,3 +1,40 @@
+* Fix the uniqueness validation scope with a polymorphic association.
+
+ *Sergey Alekseev*
+
+* Raise ActiveRecord::RecordNotFound from collection `*_ids` setters
+ for unknown IDs with a better error message.
+
+ Changes the collection `*_ids` setters to cast provided IDs the data
+ type of the primary key set in the association, not the model
+ primary key.
+
+ *Dominic Cleal*
+
+* For PostgreSQL >= 9.4 use `pgcrypto`'s `gen_random_uuid()` instead of
+ `uuid-ossp`'s UUID generation function.
+
+ *Yuji Yaginuma*, *Yaw Boakye*
+
+* Introduce `Model#reload_<association>` to bring back the behavior
+ of `Article.category(true)` where `category` is a singular
+ association.
+
+ The force reloading of the association reader was deprecated in
+ #20888. Unfortunately the suggested alternative of
+ `article.reload.category` does not expose the same behavior.
+
+ This patch adds a reader method with the prefix `reload_` for
+ singular associations. This method has the same semantics as
+ passing true to the association reader used to have.
+
+ *Yves Senn*
+
+* Make sure eager loading `ActiveRecord::Associations` also loads
+ constants defined in `ActiveRecord::Associations::Preloader`.
+
+ *Yves Senn*
+
* Allow `ActionController::Parameters`-like objects to be passed as
values for Postgres HStore columns.
diff --git a/activerecord/bin/test b/activerecord/bin/test
index 23add35d45..3a9547e5c1 100755
--- a/activerecord/bin/test
+++ b/activerecord/bin/test
@@ -17,5 +17,3 @@ module Minitest
end
Minitest.extensions.unshift "active_record"
-
-exit Minitest.run(ARGV)
diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb
index 3c94c4bd7f..19308643f3 100644
--- a/activerecord/lib/active_record/associations.rb
+++ b/activerecord/lib/active_record/associations.rb
@@ -224,6 +224,11 @@ module ActiveRecord
autoload :AliasTracker
end
+ def self.eager_load!
+ super
+ Preloader.eager_load!
+ end
+
# Returns the association instance for the given name, instantiating it if it doesn't already exist
def association(name) #:nodoc:
association = association_instance_get(name)
diff --git a/activerecord/lib/active_record/associations/builder/singular_association.rb b/activerecord/lib/active_record/associations/builder/singular_association.rb
index bb96202a22..7732b63af6 100644
--- a/activerecord/lib/active_record/associations/builder/singular_association.rb
+++ b/activerecord/lib/active_record/associations/builder/singular_association.rb
@@ -8,7 +8,16 @@ module ActiveRecord::Associations::Builder # :nodoc:
def self.define_accessors(model, reflection)
super
- define_constructors(model.generated_association_methods, reflection.name) if reflection.constructable?
+ mixin = model.generated_association_methods
+ name = reflection.name
+
+ define_constructors(mixin, name) if reflection.constructable?
+
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
+ def reload_#{name}
+ association(:#{name}).force_reload_reader
+ end
+ CODE
end
# Defines the (build|create)_association methods for belongs_to or has_one association
diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb
index b2cf4713bb..3d23fa1e46 100644
--- a/activerecord/lib/active_record/associations/collection_association.rb
+++ b/activerecord/lib/active_record/associations/collection_association.rb
@@ -68,13 +68,17 @@ module ActiveRecord
# Implements the ids writer method, e.g. foo.item_ids= for Foo.has_many :items
def ids_writer(ids)
- pk_type = reflection.primary_key_type
+ pk_type = reflection.association_primary_key_type
ids = Array(ids).reject(&:blank?)
ids.map! { |i| pk_type.cast(i) }
records = klass.where(reflection.association_primary_key => ids).index_by do |r|
r.send(reflection.association_primary_key)
- end.values_at(*ids)
- replace(records)
+ end.values_at(*ids).compact
+ if records.size != ids.size
+ scope.raise_record_not_found_exception!(ids, records.size, ids.size, reflection.association_primary_key)
+ else
+ replace(records)
+ end
end
def reset
diff --git a/activerecord/lib/active_record/associations/singular_association.rb b/activerecord/lib/active_record/associations/singular_association.rb
index e386cc0e4c..1953cc6a72 100644
--- a/activerecord/lib/active_record/associations/singular_association.rb
+++ b/activerecord/lib/active_record/associations/singular_association.rb
@@ -30,6 +30,13 @@ module ActiveRecord
record
end
+ # Implements the reload reader method, e.g. foo.reload_bar for
+ # Foo.has_one :bar
+ def force_reload_reader
+ klass.uncached { reload }
+ target
+ end
+
private
def create_scope
diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb
index c9f907b281..5ec2fc073e 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb
@@ -445,8 +445,6 @@ module ActiveRecord
# connections in the pool within a timeout interval (default duration is
# <tt>spec.config[:checkout_timeout] * 2</tt> seconds).
def clear_reloadable_connections(raise_on_acquisition_timeout = true)
- num_new_conns_required = 0
-
with_exclusively_acquired_all_connections(raise_on_acquisition_timeout) do
synchronize do
@connections.each do |conn|
@@ -457,24 +455,9 @@ module ActiveRecord
conn.disconnect! if conn.requires_reloading?
end
@connections.delete_if(&:requires_reloading?)
-
@available.clear
-
- if @connections.size < @size
- # because of the pruning done by this method, we might be running
- # low on connections, while threads stuck in queue are helpless
- # (not being able to establish new connections for themselves),
- # see also more detailed explanation in +remove+
- num_new_conns_required = num_waiting_in_queue - @connections.size
- end
-
- @connections.each do |conn|
- @available.add conn
- end
end
end
-
- bulk_make_new_connections(num_new_conns_required) if num_new_conns_required > 0
end
# Clears the cache which maps classes and re-connects connections that
@@ -705,9 +688,26 @@ module ActiveRecord
yield
ensure
+ num_new_conns_required = 0
+
synchronize do
@threads_blocking_new_connections -= 1
+
+ if @threads_blocking_new_connections.zero?
+ @available.clear
+
+ num_new_conns_required = num_waiting_in_queue
+
+ @connections.each do |conn|
+ next if conn.in_use?
+
+ @available.add conn
+ num_new_conns_required -= 1
+ end
+ end
end
+
+ bulk_make_new_connections(num_new_conns_required) if num_new_conns_required > 0
end
# Acquire a connection by one of 1) immediately removing one
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb
index a11dbe7dce..ae1ec3e2b7 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb
@@ -11,11 +11,22 @@ module ActiveRecord
# t.timestamps
# end
#
- # By default, this will use the +uuid_generate_v4()+ function from the
- # +uuid-ossp+ extension, which MUST be enabled on your database. To enable
- # the +uuid-ossp+ extension, you can use the +enable_extension+ method in your
- # migrations. To use a UUID primary key without +uuid-ossp+ enabled, you can
- # set the +:default+ option to +nil+:
+ # By default, this will use the +gen_random_uuid()+ function from the
+ # +pgcrypto+ extension. As that extension is only available in
+ # PostgreSQL 9.4+, for earlier versions an explicit default can be set
+ # to use +uuid_generate_v4()+ from the +uuid-ossp+ extension instead:
+ #
+ # create_table :stuffs, id: false do |t|
+ # t.primary_key :id, :uuid, default: "uuid_generate_v4()"
+ # t.uuid :foo_id
+ # t.timestamps
+ # end
+ #
+ # To enable the appropriate extension, which is a requirement, use
+ # the +enable_extension+ method in your migrations.
+ #
+ # To use a UUID primary key without any of the extensions, set the
+ # +:default+ option to +nil+:
#
# create_table :stuffs, id: false do |t|
# t.primary_key :id, :uuid, default: nil
@@ -23,15 +34,15 @@ module ActiveRecord
# t.timestamps
# end
#
- # You may also pass a different UUID generation function from +uuid-ossp+
- # or another library.
+ # You may also pass a custom stored procedure that returns a UUID or use a
+ # different UUID generation function from another library.
#
# Note that setting the UUID primary key default value to +nil+ will
# require you to assure that you always provide a UUID value before saving
# a record (as primary keys cannot be +nil+). This might be done via the
# +SecureRandom.uuid+ method and a +before_save+ callback, for instance.
def primary_key(name, type = :primary_key, **options)
- options[:default] = options.fetch(:default, "uuid_generate_v4()") if type == :uuid
+ options[:default] = options.fetch(:default, "gen_random_uuid()") if type == :uuid
super
end
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
index 710b5cd887..140ad4827a 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
@@ -315,6 +315,10 @@ module ActiveRecord
postgresql_version >= 90300
end
+ def supports_pgcrypto_uuid?
+ postgresql_version >= 90400
+ end
+
def get_advisory_lock(lock_id) # :nodoc:
unless lock_id.is_a?(Integer) && lock_id.bit_length <= 63
raise(ArgumentError, "Postgres requires advisory lock ids to be a signed 64 bit integer")
diff --git a/activerecord/lib/active_record/explain.rb b/activerecord/lib/active_record/explain.rb
index 980b8e1baa..8f7ae2c33c 100644
--- a/activerecord/lib/active_record/explain.rb
+++ b/activerecord/lib/active_record/explain.rb
@@ -1,4 +1,3 @@
-require "active_support/lazy_load_hooks"
require "active_record/explain_registry"
module ActiveRecord
diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb
index 04e538baa5..ae45ac7157 100644
--- a/activerecord/lib/active_record/migration/compatibility.rb
+++ b/activerecord/lib/active_record/migration/compatibility.rb
@@ -103,6 +103,14 @@ module ActiveRecord
end
class V5_0 < V5_1
+ def create_table(table_name, options = {})
+ if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
+ if options[:id] == :uuid && !options[:default]
+ options[:default] = "uuid_generate_v4()"
+ end
+ end
+ super
+ end
end
class V4_2 < V5_0
diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb
index ef3c3bfae8..17751c9571 100644
--- a/activerecord/lib/active_record/reflection.rb
+++ b/activerecord/lib/active_record/reflection.rb
@@ -397,6 +397,10 @@ module ActiveRecord
options[:primary_key] || primary_key(klass || self.klass)
end
+ def association_primary_key_type
+ klass.type_for_attribute(association_primary_key)
+ end
+
def active_record_primary_key
@active_record_primary_key ||= options[:primary_key] || primary_key(active_record)
end
diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb
index 6f602e4a23..4e941cf2df 100644
--- a/activerecord/lib/active_record/relation.rb
+++ b/activerecord/lib/active_record/relation.rb
@@ -373,7 +373,7 @@ module ActiveRecord
stmt.set Arel.sql(@klass.send(:sanitize_sql_for_assignment, updates))
stmt.table(table)
- if joins_values.any?
+ if has_join_values?
@klass.connection.join_to_update(stmt, arel, arel_attribute(primary_key))
else
stmt.key = arel_attribute(primary_key)
@@ -522,7 +522,7 @@ module ActiveRecord
stmt = Arel::DeleteManager.new
stmt.from(table)
- if joins_values.any?
+ if has_join_values?
@klass.connection.join_to_delete(stmt, arel, arel_attribute(primary_key))
else
stmt.wheres = arel.constraints
@@ -680,6 +680,10 @@ module ActiveRecord
private
+ def has_join_values?
+ joins_values.any? || left_outer_joins_values.any?
+ end
+
def exec_queries(&block)
@records = eager_loading? ? find_with_associations.freeze : @klass.find_by_sql(arel, bound_attributes, &block).freeze
diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb
index 55ded4c6d0..93c8722aa3 100644
--- a/activerecord/lib/active_record/relation/finder_methods.rb
+++ b/activerecord/lib/active_record/relation/finder_methods.rb
@@ -345,7 +345,7 @@ module ActiveRecord
# of results obtained should be provided in the +result_size+ argument and
# the expected number of results should be provided in the +expected_size+
# argument.
- def raise_record_not_found_exception!(ids = nil, result_size = nil, expected_size = nil) # :nodoc:
+ def raise_record_not_found_exception!(ids = nil, result_size = nil, expected_size = nil, key = primary_key) # :nodoc:
conditions = arel.where_sql(@klass.arel_engine)
conditions = " [#{conditions}]" if conditions
name = @klass.name
@@ -355,10 +355,10 @@ module ActiveRecord
error << " with#{conditions}" if conditions
raise RecordNotFound.new(error, name)
elsif Array(ids).size == 1
- error = "Couldn't find #{name} with '#{primary_key}'=#{ids}#{conditions}"
- raise RecordNotFound.new(error, name, primary_key, ids)
+ error = "Couldn't find #{name} with '#{key}'=#{ids}#{conditions}"
+ raise RecordNotFound.new(error, name, key, ids)
else
- error = "Couldn't find all #{name.pluralize} with '#{primary_key}': "
+ error = "Couldn't find all #{name.pluralize} with '#{key}': "
error << "(#{ids.join(", ")})#{conditions} (found #{result_size} results, but was looking for #{expected_size})"
raise RecordNotFound.new(error, name, primary_key, ids)
diff --git a/activerecord/lib/active_record/validations/uniqueness.rb b/activerecord/lib/active_record/validations/uniqueness.rb
index bed93bfc26..512fdadacc 100644
--- a/activerecord/lib/active_record/validations/uniqueness.rb
+++ b/activerecord/lib/active_record/validations/uniqueness.rb
@@ -85,11 +85,10 @@ module ActiveRecord
def scope_relation(record, relation)
Array(options[:scope]).each do |scope_item|
- if reflection = record.class._reflect_on_association(scope_item)
- scope_value = record.send(reflection.foreign_key)
- scope_item = reflection.foreign_key
+ scope_value = if record.class._reflect_on_association(scope_item)
+ record.association(scope_item).reader
else
- scope_value = record._read_attribute(scope_item)
+ record._read_attribute(scope_item)
end
relation = relation.where(scope_item => scope_value)
end
diff --git a/activerecord/test/cases/adapters/mysql2/transaction_test.rb b/activerecord/test/cases/adapters/mysql2/transaction_test.rb
index edd5353ee3..16101e38cb 100644
--- a/activerecord/test/cases/adapters/mysql2/transaction_test.rb
+++ b/activerecord/test/cases/adapters/mysql2/transaction_test.rb
@@ -10,6 +10,8 @@ module ActiveRecord
end
setup do
+ @abort, Thread.abort_on_exception = Thread.abort_on_exception, false
+
@connection = ActiveRecord::Base.connection
@connection.clear_cache!
@@ -25,30 +27,34 @@ module ActiveRecord
teardown do
@connection.drop_table "samples", if_exists: true
+
+ Thread.abort_on_exception = @abort
end
test "raises Deadlocked when a deadlock is encountered" do
assert_raises(ActiveRecord::Deadlocked) do
+ barrier = Concurrent::CyclicBarrier.new(2)
+
s1 = Sample.create value: 1
s2 = Sample.create value: 2
thread = Thread.new do
Sample.transaction do
s1.lock!
- sleep 1
+ barrier.wait
s2.update_attributes value: 1
end
end
- sleep 0.5
-
- Sample.transaction do
- s2.lock!
- sleep 1
- s1.update_attributes value: 2
+ begin
+ Sample.transaction do
+ s2.lock!
+ barrier.wait
+ s1.update_attributes value: 2
+ end
+ ensure
+ thread.join
end
-
- thread.join
end
end
end
diff --git a/activerecord/test/cases/adapters/postgresql/transaction_test.rb b/activerecord/test/cases/adapters/postgresql/transaction_test.rb
index c450524de8..f130e344c4 100644
--- a/activerecord/test/cases/adapters/postgresql/transaction_test.rb
+++ b/activerecord/test/cases/adapters/postgresql/transaction_test.rb
@@ -11,6 +11,8 @@ module ActiveRecord
end
setup do
+ @abort, Thread.abort_on_exception = Thread.abort_on_exception, false
+
@connection = ActiveRecord::Base.connection
@connection.transaction do
@@ -25,6 +27,8 @@ module ActiveRecord
teardown do
@connection.drop_table "samples", if_exists: true
+
+ Thread.abort_on_exception = @abort
end
test "raises SerializationFailure when a serialization failure occurs" do
diff --git a/activerecord/test/cases/adapters/postgresql/uuid_test.rb b/activerecord/test/cases/adapters/postgresql/uuid_test.rb
index 9a59691737..4604c2eb3b 100644
--- a/activerecord/test/cases/adapters/postgresql/uuid_test.rb
+++ b/activerecord/test/cases/adapters/postgresql/uuid_test.rb
@@ -9,6 +9,10 @@ module PostgresqlUUIDHelper
def drop_table(name)
connection.drop_table name, if_exists: true
end
+
+ def uuid_function
+ connection.supports_pgcrypto_uuid? ? "gen_random_uuid()" : "uuid_generate_v4()"
+ end
end
class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase
@@ -21,6 +25,7 @@ class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase
setup do
enable_extension!("uuid-ossp", connection)
+ enable_extension!("pgcrypto", connection) if connection.supports_pgcrypto_uuid?
connection.create_table "uuid_data_type" do |t|
t.uuid "guid"
@@ -31,14 +36,22 @@ class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase
drop_table "uuid_data_type"
end
+ if ActiveRecord::Base.connection.supports_pgcrypto_uuid?
+ def test_uuid_column_default
+ connection.add_column :uuid_data_type, :thingy, :uuid, null: false, default: "gen_random_uuid()"
+ UUIDType.reset_column_information
+ column = UUIDType.columns_hash["thingy"]
+ assert_equal "gen_random_uuid()", column.default_function
+ end
+ end
+
def test_change_column_default
- @connection.add_column :uuid_data_type, :thingy, :uuid, null: false, default: "uuid_generate_v1()"
+ connection.add_column :uuid_data_type, :thingy, :uuid, null: false, default: "uuid_generate_v1()"
UUIDType.reset_column_information
column = UUIDType.columns_hash["thingy"]
assert_equal "uuid_generate_v1()", column.default_function
- @connection.change_column :uuid_data_type, :thingy, :uuid, null: false, default: "uuid_generate_v4()"
-
+ connection.change_column :uuid_data_type, :thingy, :uuid, null: false, default: "uuid_generate_v4()"
UUIDType.reset_column_information
column = UUIDType.columns_hash["thingy"]
assert_equal "uuid_generate_v4()", column.default_function
@@ -155,7 +168,7 @@ class PostgresqlUUIDGenerationTest < ActiveRecord::PostgreSQLTestCase
# to test dumping tables which columns have defaults with custom functions
connection.execute <<-SQL
CREATE OR REPLACE FUNCTION my_uuid_generator() RETURNS uuid
- AS $$ SELECT * FROM uuid_generate_v4() $$
+ AS $$ SELECT * FROM #{uuid_function} $$
LANGUAGE SQL VOLATILE;
SQL
@@ -164,11 +177,16 @@ class PostgresqlUUIDGenerationTest < ActiveRecord::PostgreSQLTestCase
t.string "name"
t.uuid "other_uuid_2", default: "my_uuid_generator()"
end
+
+ connection.create_table("pg_uuids_3", id: :uuid) do |t|
+ t.string "name"
+ end
end
teardown do
drop_table "pg_uuids"
drop_table "pg_uuids_2"
+ drop_table "pg_uuids_3"
connection.execute "DROP FUNCTION IF EXISTS my_uuid_generator();"
end
@@ -206,6 +224,36 @@ class PostgresqlUUIDGenerationTest < ActiveRecord::PostgreSQLTestCase
assert_match(/\bcreate_table "pg_uuids_2", id: :uuid, default: -> { "my_uuid_generator\(\)" }/, schema)
assert_match(/t\.uuid "other_uuid_2", default: -> { "my_uuid_generator\(\)" }/, schema)
end
+
+ def test_schema_dumper_for_uuid_primary_key_default
+ schema = dump_table_schema "pg_uuids_3"
+ if connection.supports_pgcrypto_uuid?
+ assert_match(/\bcreate_table "pg_uuids_3", id: :uuid, default: -> { "gen_random_uuid\(\)" }/, schema)
+ else
+ assert_match(/\bcreate_table "pg_uuids_3", id: :uuid, default: -> { "uuid_generate_v4\(\)" }/, schema)
+ end
+ end
+
+ if ActiveRecord::Base.connection.supports_pgcrypto_uuid?
+ def test_schema_dumper_for_uuid_primary_key_default_in_legacy_migration
+ @verbose_was = ActiveRecord::Migration.verbose
+ ActiveRecord::Migration.verbose = false
+
+ migration = Class.new(ActiveRecord::Migration[4.2]) do
+ def version; 101 end
+ def migrate(x)
+ create_table("pg_uuids_4", id: :uuid)
+ end
+ end.new
+ ActiveRecord::Migrator.new(:up, [migration]).migrate
+
+ schema = dump_table_schema "pg_uuids_4"
+ assert_match(/\bcreate_table "pg_uuids_4", id: :uuid, default: -> { "uuid_generate_v4\(\)" }/, schema)
+ ensure
+ drop_table "pg_uuids_4"
+ ActiveRecord::Migration.verbose = @verbose_was
+ end
+ end
end
end
diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb
index 6b7e4fee56..72f1b3b125 100644
--- a/activerecord/test/cases/associations/belongs_to_associations_test.rb
+++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb
@@ -291,6 +291,16 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase
assert client.account.new_record?
end
+ def test_reloading_the_belonging_object
+ odegy_account = accounts(:odegy_account)
+
+ assert_equal "Odegy", odegy_account.firm.name
+ Company.where(id: odegy_account.firm_id).update_all(name: "ODEGY")
+ assert_equal "Odegy", odegy_account.firm.name
+
+ assert_equal "ODEGY", odegy_account.reload_firm.name
+ end
+
def test_natural_assignment_to_nil
client = Client.find(3)
client.firm = nil
diff --git a/activerecord/test/cases/associations/has_many_through_associations_test.rb b/activerecord/test/cases/associations/has_many_through_associations_test.rb
index c2239ac03a..8defb09db7 100644
--- a/activerecord/test/cases/associations/has_many_through_associations_test.rb
+++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb
@@ -883,10 +883,25 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase
end
+ def test_collection_singular_ids_setter_with_changed_primary_key
+ company = companies(:first_firm)
+ client = companies(:first_client)
+ company.clients_using_primary_key_ids = [client.name]
+ assert_equal [client], company.clients_using_primary_key
+ end
+
def test_collection_singular_ids_setter_raises_exception_when_invalid_ids_set
company = companies(:rails_core)
ids = [Developer.first.id, -9999]
- assert_raises(ActiveRecord::AssociationTypeMismatch) { company.developer_ids = ids }
+ e = assert_raises(ActiveRecord::RecordNotFound) { company.developer_ids = ids }
+ assert_match(/Couldn't find all Developers with 'id'/, e.message)
+ end
+
+ def test_collection_singular_ids_setter_raises_exception_when_invalid_ids_set_with_changed_primary_key
+ company = companies(:first_firm)
+ ids = [Client.first.name, "unknown client"]
+ e = assert_raises(ActiveRecord::RecordNotFound) { company.clients_using_primary_key_ids = ids }
+ assert_match(/Couldn't find all Clients with 'name'/, e.message)
end
def test_build_a_model_from_hm_through_association_with_where_clause
diff --git a/activerecord/test/cases/associations/has_one_associations_test.rb b/activerecord/test/cases/associations/has_one_associations_test.rb
index 862f33a1a0..48fbc5990c 100644
--- a/activerecord/test/cases/associations/has_one_associations_test.rb
+++ b/activerecord/test/cases/associations/has_one_associations_test.rb
@@ -326,6 +326,16 @@ class HasOneAssociationsTest < ActiveRecord::TestCase
end
end
+ def test_reload_association
+ odegy = companies(:odegy)
+
+ assert_equal 53, odegy.account.credit_limit
+ Account.where(id: odegy.account.id).update_all(credit_limit: 80)
+ assert_equal 53, odegy.account.credit_limit
+
+ assert_equal 80, odegy.reload_account.credit_limit
+ end
+
def test_build
firm = Firm.new("name" => "GlobalMegaCorp")
firm.save
diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb
index b08e4f603c..1d4cd3c78b 100644
--- a/activerecord/test/cases/connection_pool_test.rb
+++ b/activerecord/test/cases/connection_pool_test.rb
@@ -455,49 +455,63 @@ module ActiveRecord
with_single_connection_pool do |pool|
[:disconnect, :disconnect!, :clear_reloadable_connections, :clear_reloadable_connections!].each do |group_action_method|
conn = pool.connection # drain the only available connection
- second_thread_done = Concurrent::CountDownLatch.new
+ second_thread_done = Concurrent::Event.new
- # create a first_thread and let it get into the FIFO queue first
- first_thread = Thread.new do
- pool.with_connection { second_thread_done.wait }
- end
-
- # wait for first_thread to get in queue
- Thread.pass until pool.num_waiting_in_queue == 1
-
- # create a different, later thread, that will attempt to do a "group action",
- # but because of the group action semantics it should be able to preempt the
- # first_thread when a connection is made available
- second_thread = Thread.new do
- pool.send(group_action_method)
- second_thread_done.count_down
- end
-
- # wait for second_thread to get in queue
- Thread.pass until pool.num_waiting_in_queue == 2
-
- # return the only available connection
- pool.checkin(conn)
-
- # if the second_thread is not able to preempt the first_thread,
- # they will temporarily (until either of them timeouts with ConnectionTimeoutError)
- # deadlock and a join(2) timeout will be reached
- failed = true unless second_thread.join(2)
-
- #--- post test clean up start
- second_thread_done.count_down if failed
-
- # after `pool.disconnect()` the first thread will be left stuck in queue, no need to wait for
- # it to timeout with ConnectionTimeoutError
- if (group_action_method == :disconnect || group_action_method == :disconnect!) && pool.num_waiting_in_queue > 0
- pool.with_connection {} # create a new connection in case there are threads still stuck in a queue
+ begin
+ # create a first_thread and let it get into the FIFO queue first
+ first_thread = Thread.new do
+ pool.with_connection { second_thread_done.wait }
+ end
+
+ # wait for first_thread to get in queue
+ Thread.pass until pool.num_waiting_in_queue == 1
+
+ # create a different, later thread, that will attempt to do a "group action",
+ # but because of the group action semantics it should be able to preempt the
+ # first_thread when a connection is made available
+ second_thread = Thread.new do
+ pool.send(group_action_method)
+ second_thread_done.set
+ end
+
+ # wait for second_thread to get in queue
+ Thread.pass until pool.num_waiting_in_queue == 2
+
+ # return the only available connection
+ pool.checkin(conn)
+
+ # if the second_thread is not able to preempt the first_thread,
+ # they will temporarily (until either of them timeouts with ConnectionTimeoutError)
+ # deadlock and a join(2) timeout will be reached
+ assert second_thread.join(2), "#{group_action_method} is not able to preempt other waiting threads"
+
+ ensure
+ # post test clean up
+ failed = !second_thread_done.set?
+
+ if failed
+ second_thread_done.set
+
+ puts
+ puts ">>> test_disconnect_and_clear_reloadable_connections_are_able_to_preempt_other_waiting_threads / #{group_action_method}"
+ p [first_thread, second_thread]
+ p pool.stat
+ p pool.connections.map(&:owner)
+
+ first_thread.join(2)
+ second_thread.join(2)
+
+ puts '---'
+ p [first_thread, second_thread]
+ p pool.stat
+ p pool.connections.map(&:owner)
+ puts '<<<'
+ puts
+ end
+
+ first_thread.join(10) || raise("first_thread got stuck")
+ second_thread.join(10) || raise("second_thread got stuck")
end
-
- first_thread.join
- second_thread.join
- #--- post test clean up end
-
- flunk "#{group_action_method} is not able to preempt other waiting threads" if failed
end
end
end
diff --git a/activerecord/test/cases/persistence_test.rb b/activerecord/test/cases/persistence_test.rb
index f667e9b055..3f1da82cb4 100644
--- a/activerecord/test/cases/persistence_test.rb
+++ b/activerecord/test/cases/persistence_test.rb
@@ -90,6 +90,14 @@ class PersistenceTest < ActiveRecord::TestCase
assert_equal count, Pet.joins(:toys).where(where_args).delete_all
end
+ def test_delete_all_with_left_joins
+ where_args = { toys: { name: "Bone" } }
+ count = Pet.left_joins(:toys).where(where_args).count
+
+ assert_equal count, 1
+ assert_equal count, Pet.left_joins(:toys).where(where_args).delete_all
+ end
+
def test_delete_all_with_joins_and_where_part_is_not_hash
where_args = ["toys.name = ?", "Bone"]
count = Pet.joins(:toys).where(where_args).count
@@ -453,6 +461,20 @@ class PersistenceTest < ActiveRecord::TestCase
assert_nil Topic.find(2).last_read
end
+ def test_update_all_with_joins
+ where_args = { toys: { name: "Bone" } }
+ count = Pet.left_joins(:toys).where(where_args).count
+
+ assert_equal count, Pet.joins(:toys).where(where_args).update_all(name: "Bob")
+ end
+
+ def test_update_all_with_left_joins
+ where_args = { toys: { name: "Bone" } }
+ count = Pet.left_joins(:toys).where(where_args).count
+
+ assert_equal count, Pet.left_joins(:toys).where(where_args).update_all(name: "Bob")
+ end
+
def test_update_all_with_non_standard_table_name
assert_equal 1, WarehouseThing.where(id: 1).update_all(["value = ?", 0])
assert_equal 0, WarehouseThing.find(1).value
diff --git a/activerecord/test/cases/test_fixtures_test.rb b/activerecord/test/cases/test_fixtures_test.rb
index 14a5faa85e..7090202a89 100644
--- a/activerecord/test/cases/test_fixtures_test.rb
+++ b/activerecord/test/cases/test_fixtures_test.rb
@@ -3,7 +3,7 @@ require "cases/helper"
class TestFixturesTest < ActiveRecord::TestCase
setup do
@klass = Class.new
- @klass.send(:include, ActiveRecord::TestFixtures)
+ @klass.include(ActiveRecord::TestFixtures)
end
def test_deprecated_use_transactional_fixtures=
diff --git a/activerecord/test/cases/validations/uniqueness_validation_test.rb b/activerecord/test/cases/validations/uniqueness_validation_test.rb
index 44b4e28777..6d22638592 100644
--- a/activerecord/test/cases/validations/uniqueness_validation_test.rb
+++ b/activerecord/test/cases/validations/uniqueness_validation_test.rb
@@ -6,6 +6,9 @@ require "models/guid"
require "models/event"
require "models/dashboard"
require "models/uuid_item"
+require "models/author"
+require "models/person"
+require "models/essay"
class Wizard < ActiveRecord::Base
self.abstract_class = true
@@ -163,6 +166,19 @@ class UniquenessValidationTest < ActiveRecord::TestCase
assert !r2.valid?, "Saving r2 first time"
end
+ def test_validate_uniqueness_with_polymorphic_object_scope
+ Essay.validates_uniqueness_of(:name, scope: :writer)
+
+ a = Author.create(name: "Sergey")
+ p = Person.create(first_name: "Sergey")
+
+ e1 = a.essays.create(name: "Essay")
+ assert e1.valid?, "Saving e1"
+
+ e2 = p.essays.create(name: "Essay")
+ assert e2.valid?, "Saving e2"
+ end
+
def test_validate_uniqueness_with_composed_attribute_scope
r1 = ReplyWithTitleObject.create "title" => "r1", "content" => "hello world"
assert r1.valid?, "Saving r1"
diff --git a/activerecord/test/schema/postgresql_specific_schema.rb b/activerecord/test/schema/postgresql_specific_schema.rb
index f00b858ea6..15ba2d67ab 100644
--- a/activerecord/test/schema/postgresql_specific_schema.rb
+++ b/activerecord/test/schema/postgresql_specific_schema.rb
@@ -1,6 +1,7 @@
ActiveRecord::Schema.define do
enable_extension!("uuid-ossp", ActiveRecord::Base.connection)
+ enable_extension!("pgcrypto", ActiveRecord::Base.connection) if ActiveRecord::Base.connection.supports_pgcrypto_uuid?
create_table :uuid_parents, id: :uuid, force: true do |t|
t.string :name
diff --git a/activesupport/bin/test b/activesupport/bin/test
index 84a05bba08..a7beb14b27 100755
--- a/activesupport/bin/test
+++ b/activesupport/bin/test
@@ -2,5 +2,3 @@
COMPONENT_ROOT = File.expand_path("..", __dir__)
require File.expand_path("../tools/test", COMPONENT_ROOT)
-
-exit Minitest.run(ARGV)
diff --git a/activesupport/lib/active_support/dependencies.rb b/activesupport/lib/active_support/dependencies.rb
index 0146401abe..e125b657f2 100644
--- a/activesupport/lib/active_support/dependencies.rb
+++ b/activesupport/lib/active_support/dependencies.rb
@@ -242,7 +242,7 @@ module ActiveSupport #:nodoc:
# resolution deterministic for constants with the same relative name in
# different namespaces whose evaluation would depend on load order
# otherwise.
- def require_dependency(file_name, message = "No such file to load -- %s")
+ def require_dependency(file_name, message = "No such file to load -- %s.rb")
file_name = file_name.to_path if file_name.respond_to?(:to_path)
unless file_name.is_a?(String)
raise ArgumentError, "the file name must either be a String or implement #to_path -- you passed #{file_name.inspect}"
diff --git a/activesupport/lib/active_support/logger.rb b/activesupport/lib/active_support/logger.rb
index 3ba6461b57..ea09d7d2df 100644
--- a/activesupport/lib/active_support/logger.rb
+++ b/activesupport/lib/active_support/logger.rb
@@ -59,14 +59,14 @@ module ActiveSupport
define_method(:silence) do |level = Logger::ERROR, &block|
if logger.respond_to?(:silence)
logger.silence(level) do
- if respond_to?(:silence)
+ if defined?(super)
super(level, &block)
else
block.call(self)
end
end
else
- if respond_to?(:silence)
+ if defined?(super)
super(level, &block)
else
block.call(self)
diff --git a/activesupport/test/broadcast_logger_test.rb b/activesupport/test/broadcast_logger_test.rb
index d003a33471..184d0ebddd 100644
--- a/activesupport/test/broadcast_logger_test.rb
+++ b/activesupport/test/broadcast_logger_test.rb
@@ -69,6 +69,20 @@ module ActiveSupport
assert_equal ::Logger::FATAL, log2.local_level
end
+ test "#silence does not break custom loggers" do
+ new_logger = FakeLogger.new
+ custom_logger = CustomLogger.new
+ custom_logger.extend(Logger.broadcast(new_logger))
+
+ custom_logger.silence do
+ custom_logger.error "from error"
+ custom_logger.unknown "from unknown"
+ end
+
+ assert_equal [[::Logger::ERROR, "from error", nil], [::Logger::UNKNOWN, "from unknown", nil]], custom_logger.adds
+ assert_equal [[::Logger::ERROR, "from error", nil], [::Logger::UNKNOWN, "from unknown", nil]], new_logger.adds
+ end
+
test "#silence silences all loggers below the default level of ERROR" do
logger.silence do
logger.debug "test"
@@ -98,9 +112,7 @@ module ActiveSupport
assert_equal [[::Logger::FATAL, "seen", nil]], log2.adds
end
- class FakeLogger
- include LoggerSilence
-
+ class CustomLogger
attr_reader :adds, :closed, :chevrons
attr_accessor :level, :progname, :formatter, :local_level
@@ -150,5 +162,9 @@ module ActiveSupport
@closed = true
end
end
+
+ class FakeLogger < CustomLogger
+ include LoggerSilence
+ end
end
end
diff --git a/activesupport/test/concern_test.rb b/activesupport/test/concern_test.rb
index 95507c815d..7a5a5414a7 100644
--- a/activesupport/test/concern_test.rb
+++ b/activesupport/test/concern_test.rb
@@ -76,7 +76,7 @@ class ConcernTest < ActiveSupport::TestCase
end
def test_class_methods_are_extended_only_on_expected_objects
- ::Object.__send__(:include, Qux)
+ ::Object.include(Qux)
Object.extend(Qux::ClassMethods)
# module needs to be created after Qux is included in Object or bug won't
# be triggered
diff --git a/guides/assets/stylesheets/responsive-tables.css b/guides/assets/stylesheets/responsive-tables.css
index f5fbcbf948..f5fbcbf948 100755..100644
--- a/guides/assets/stylesheets/responsive-tables.css
+++ b/guides/assets/stylesheets/responsive-tables.css
diff --git a/guides/source/active_record_postgresql.md b/guides/source/active_record_postgresql.md
index d7e35490ef..58af2f82b3 100644
--- a/guides/source/active_record_postgresql.md
+++ b/guides/source/active_record_postgresql.md
@@ -422,7 +422,7 @@ device = Device.create
device.id # => "814865cd-5a1d-4771-9306-4268f188fe9e"
```
-NOTE: `uuid_generate_v4()` (from `uuid-ossp`) is assumed if no `:default` option was
+NOTE: `gen_random_uuid()` (from `pgcrypto`) is assumed if no `:default` option was
passed to `create_table`.
Full Text Search
diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md
index c04d42d743..6ec5106bb3 100644
--- a/guides/source/getting_started.md
+++ b/guides/source/getting_started.md
@@ -1157,7 +1157,7 @@ it look as follows:
```html+erb
<h1>Edit article</h1>
-<%= form_for :article, url: article_path(@article), method: :patch do |f| %>
+<%= form_for(@article) do |f| %>
<% if @article.errors.any? %>
<div id="error_explanation">
@@ -1195,14 +1195,15 @@ it look as follows:
This time we point the form to the `update` action, which is not defined yet
but will be very soon.
-The `method: :patch` option tells Rails that we want this form to be submitted
+Passing the article object to the method, will automagically create url for submitting the edited article form.
+This option tells Rails that we want this form to be submitted
via the `PATCH` HTTP method which is the HTTP method you're expected to use to
**update** resources according to the REST protocol.
The first parameter of `form_for` can be an object, say, `@article` which would
cause the helper to fill in the form with the fields of the object. Passing in a
symbol (`:article`) with the same name as the instance variable (`@article`)
-also automagically leads to the same behavior. This is what is happening here.
+also automagically leads to the same behavior.
More details can be found in [form_for documentation]
(http://api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-form_for).
diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md
index 7db7e2e34d..5e5583734a 100644
--- a/railties/CHANGELOG.md
+++ b/railties/CHANGELOG.md
@@ -1,3 +1,14 @@
+* Removed jquery-rails from default stack, instead rails-ujs is
+ included as default UJS adapter.
+
+ *Guillermo Iguaran*
+
+* The config file `secrets.yml` is now loaded in with all keys as symbols.
+ This allows secrets files to contain more complex information without all
+ child keys being strings while parent keys are symbols.
+
+ *Isaac Sloan*
+
* Add `:skip_sprockets` to `Rails::PluginBuilder::PASSTHROUGH_OPTIONS`
*Tsukuru Tanimichi*
diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb
index 3b94ae4f82..f96432c89f 100644
--- a/railties/lib/rails/application.rb
+++ b/railties/lib/rails/application.rb
@@ -394,8 +394,8 @@ module Rails
shared_secrets = all_secrets["shared"]
env_secrets = all_secrets[Rails.env]
- secrets.merge!(shared_secrets.symbolize_keys) if shared_secrets
- secrets.merge!(env_secrets.symbolize_keys) if env_secrets
+ secrets.merge!(shared_secrets.deep_symbolize_keys) if shared_secrets
+ secrets.merge!(env_secrets.deep_symbolize_keys) if env_secrets
end
# Fallback to config.secret_key_base if secrets.secret_key_base isn't set
diff --git a/railties/lib/rails/generators/app_base.rb b/railties/lib/rails/generators/app_base.rb
index 83e9c30548..2951d6c83d 100644
--- a/railties/lib/rails/generators/app_base.rb
+++ b/railties/lib/rails/generators/app_base.rb
@@ -30,7 +30,7 @@ module Rails
class_option :database, type: :string, aliases: "-d", default: "sqlite3",
desc: "Preconfigure for selected database (options: #{DATABASES.join('/')})"
- class_option :javascript, type: :string, aliases: "-j", default: "jquery",
+ class_option :javascript, type: :string, aliases: "-j",
desc: "Preconfigure for selected JavaScript library"
class_option :skip_gemfile, type: :boolean, default: false,
@@ -328,8 +328,13 @@ module Rails
gems = [javascript_runtime_gemfile_entry]
gems << coffee_gemfile_entry unless options[:skip_coffee]
- gems << GemfileEntry.version("#{options[:javascript]}-rails", nil,
- "Use #{options[:javascript]} as the JavaScript library")
+ if options[:javascript]
+ gems << GemfileEntry.version("#{options[:javascript]}-rails", nil,
+ "Use #{options[:javascript]} as the JavaScript library")
+ end
+
+ gems << GemfileEntry.github("rails-ujs", "rails/rails-ujs", nil,
+ "Unobstrusive JavaScript adapter for Rails")
unless options[:skip_turbolinks]
gems << GemfileEntry.version("turbolinks", "~> 5",
diff --git a/railties/lib/rails/generators/rails/app/templates/app/assets/javascripts/application.js.tt b/railties/lib/rails/generators/rails/app/templates/app/assets/javascripts/application.js.tt
index 5d633724d5..8db5b7e075 100644
--- a/railties/lib/rails/generators/rails/app/templates/app/assets/javascripts/application.js.tt
+++ b/railties/lib/rails/generators/rails/app/templates/app/assets/javascripts/application.js.tt
@@ -11,8 +11,10 @@
// about supported directives.
//
<% unless options[:skip_javascript] -%>
+<% if options[:javascript] -%>
//= require <%= options[:javascript] %>
-//= require <%= options[:javascript] %>_ujs
+<% end -%>
+//= require rails-ujs
<% unless options[:skip_turbolinks] -%>
//= require turbolinks
<% end -%>
diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb
index be84cd5027..c409f1ea79 100644
--- a/railties/test/application/configuration_test.rb
+++ b/railties/test/application/configuration_test.rb
@@ -636,6 +636,20 @@ module ApplicationTests
end
end
+ test "that nested keys are symbolized the same as parents for hashes more than one level deep" do
+ app_file "config/secrets.yml", <<-YAML
+ development:
+ smtp_settings:
+ address: "smtp.example.com"
+ user_name: "postmaster@example.com"
+ password: "697361616320736c6f616e2028656c6f7265737429"
+ YAML
+
+ app "development"
+
+ assert_equal "697361616320736c6f616e2028656c6f7265737429", app.secrets.smtp_settings[:password]
+ end
+
test "protect from forgery is the default in a new app" do
make_basic_app
@@ -1518,5 +1532,24 @@ module ApplicationTests
assert_equal :default, Rails.configuration.debug_exception_response_format
end
+
+ test "controller force_ssl declaration can be used even if session_store is disabled" do
+ make_basic_app do |application|
+ application.config.session_store :disabled
+ end
+
+ class ::OmgController < ActionController::Base
+ force_ssl
+
+ def index
+ render plain: "Yay! You're on Rails!"
+ end
+ end
+
+ get "/"
+
+ assert_equal 301, last_response.status
+ assert_equal "https://example.org/", last_response.location
+ end
end
end
diff --git a/railties/test/backtrace_cleaner_test.rb b/railties/test/backtrace_cleaner_test.rb
index 17201d6f77..f71e56f323 100644
--- a/railties/test/backtrace_cleaner_test.rb
+++ b/railties/test/backtrace_cleaner_test.rb
@@ -15,7 +15,7 @@ class BacktraceCleanerTest < ActiveSupport::TestCase
test "should format installed gems not in Gem.default_dir correctly" do
target_dir = Gem.path.detect { |p| p != Gem.default_dir }
# skip this test if default_dir is the only directory on Gem.path
- if @target_dir
+ if target_dir
backtrace = [ "#{target_dir}/gems/nosuchgem-1.2.3/lib/foo.rb" ]
result = @cleaner.clean(backtrace, :all)
assert_equal "nosuchgem (1.2.3) lib/foo.rb", result[0]
diff --git a/railties/test/code_statistics_calculator_test.rb b/railties/test/code_statistics_calculator_test.rb
index 8a2f0294d0..1bd4225f34 100644
--- a/railties/test/code_statistics_calculator_test.rb
+++ b/railties/test/code_statistics_calculator_test.rb
@@ -24,7 +24,7 @@ class CodeStatisticsCalculatorTest < ActiveSupport::TestCase
end
end
- test "count number of methods in MiniTest file" do
+ test "count number of methods in Minitest file" do
code = <<-RUBY
class FooTest < ActionController::TestCase
test 'expectation' do
diff --git a/railties/test/generators/api_app_generator_test.rb b/railties/test/generators/api_app_generator_test.rb
index bbb814ef4e..cefad48f52 100644
--- a/railties/test/generators/api_app_generator_test.rb
+++ b/railties/test/generators/api_app_generator_test.rb
@@ -35,7 +35,7 @@ class ApiAppGeneratorTest < Rails::Generators::TestCase
assert_file "Gemfile" do |content|
assert_no_match(/gem 'coffee-rails'/, content)
- assert_no_match(/gem 'jquery-rails'/, content)
+ assert_no_match(/gem 'rails-ujs'/, content)
assert_no_match(/gem 'sass-rails'/, content)
assert_no_match(/gem 'web-console'/, content)
assert_match(/# gem 'jbuilder'/, content)
diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb
index 3ec99193e3..2d01da7f46 100644
--- a/railties/test/generators/app_generator_test.rb
+++ b/railties/test/generators/app_generator_test.rb
@@ -405,7 +405,6 @@ class AppGeneratorTest < Rails::Generators::TestCase
assert_match(/#\s+require\s+["']sprockets\/railtie["']/, content)
end
assert_file "Gemfile" do |content|
- assert_no_match(/jquery-rails/, content)
assert_no_match(/sass-rails/, content)
assert_no_match(/uglifier/, content)
assert_no_match(/coffee-rails/, content)
@@ -448,22 +447,20 @@ class AppGeneratorTest < Rails::Generators::TestCase
end
end
- def test_jquery_is_the_default_javascript_library
+ def test_rails_ujs_is_the_default_ujs_library
run_generator
assert_file "app/assets/javascripts/application.js" do |contents|
- assert_match %r{^//= require jquery}, contents
- assert_match %r{^//= require jquery_ujs}, contents
+ assert_match %r{^//= require rails-ujs}, contents
end
- assert_gem "jquery-rails"
+ assert_gem "rails-ujs"
end
- def test_other_javascript_libraries
- run_generator [destination_root, "-j", "prototype"]
+ def test_inclusion_of_javascript_libraries_if_required
+ run_generator [destination_root, "-j", "jquery"]
assert_file "app/assets/javascripts/application.js" do |contents|
- assert_match %r{^//= require prototype}, contents
- assert_match %r{^//= require prototype_ujs}, contents
+ assert_match %r{^//= require jquery}, contents
end
- assert_gem "prototype-rails"
+ assert_gem "jquery-rails"
end
def test_javascript_is_skipped_if_required
@@ -479,8 +476,8 @@ class AppGeneratorTest < Rails::Generators::TestCase
assert_file "Gemfile" do |content|
assert_no_match(/coffee-rails/, content)
- assert_no_match(/jquery-rails/, content)
assert_no_match(/uglifier/, content)
+ assert_no_match(/rails-ujs/, content)
end
assert_file "config/environments/production.rb" do |content|
@@ -493,7 +490,6 @@ class AppGeneratorTest < Rails::Generators::TestCase
assert_file "Gemfile" do |content|
assert_no_match(/coffee-rails/, content)
- assert_match(/jquery-rails/, content)
assert_match(/uglifier/, content)
end
end
diff --git a/railties/test/generators/plugin_generator_test.rb b/railties/test/generators/plugin_generator_test.rb
index 0fdc30ac43..a0018dc782 100644
--- a/railties/test/generators/plugin_generator_test.rb
+++ b/railties/test/generators/plugin_generator_test.rb
@@ -507,7 +507,6 @@ class PluginGeneratorTest < Rails::Generators::TestCase
assert_no_match("gemspec", contents)
assert_match(/gem 'rails'/, contents)
assert_match_sqlite3(contents)
- assert_no_match(/# gem "jquery-rails"/, contents)
end
end
diff --git a/railties/test/isolation/abstract_unit.rb b/railties/test/isolation/abstract_unit.rb
index aa0a06faf1..1902eac862 100644
--- a/railties/test/isolation/abstract_unit.rb
+++ b/railties/test/isolation/abstract_unit.rb
@@ -162,7 +162,6 @@ module TestHelpers
require "rails"
require "action_controller/railtie"
require "action_view/railtie"
- require "action_dispatch/middleware/flash"
@app = Class.new(Rails::Application)
@app.config.eager_load = false
diff --git a/tools/test.rb b/tools/test.rb
index 7819c13ee2..824ee57c96 100644
--- a/tools/test.rb
+++ b/tools/test.rb
@@ -13,3 +13,5 @@ module Rails
end
Rails::TestUnitReporter.executable = "bin/test"
+Minitest.run_via[:rails] = true
+require "active_support/testing/autorun"