diff options
211 files changed, 10237 insertions, 528 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 @@ -38,6 +38,8 @@ gem "rb-inotify", github: "matthewd/rb-inotify", branch: "close-handling", requi # Explicitly avoid 1.x that doesn't support Ruby 2.4+ gem "json", ">= 2.0.0" +gem "rubocop", require: false + group :doc do gem "sdoc", "1.0.0.beta2" gem "redcarpet", "~> 3.2.3", platforms: :ruby diff --git a/Gemfile.lock b/Gemfile.lock index bec01cda4a..eb14330c2b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,9 +118,11 @@ 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) backburner (1.3.1) beaneater (~> 1.0) concurrent-ruby (~> 1.0.1) @@ -130,9 +132,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) @@ -143,8 +145,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 @@ -181,13 +183,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) @@ -195,7 +197,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) @@ -246,10 +248,14 @@ GEM mini_portile2 (~> 2.1.0) nokogiri (1.6.8.1-x86-mingw32) mini_portile2 (~> 2.1.0) + parser (2.3.2.0) + ast (~> 2.2) pg (0.19.0) pg (0.19.0-x64-mingw32) 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 @@ -271,6 +277,7 @@ GEM nokogiri (~> 1.6.0) rails-html-sanitizer (1.0.3) loofah (~> 2.0) + rainbow (2.1.0) rake (11.3.0) rb-fsevent (0.9.7) rdoc (5.0.0.beta2) @@ -283,6 +290,13 @@ GEM redis (~> 3.3) resque (~> 1.26) rufus-scheduler (~> 3.2) + rubocop (0.45.0) + parser (>= 2.3.1.1, < 3.0) + powerpack (~> 0.1) + rainbow (>= 1.99.1, < 3.0) + ruby-progressbar (~> 1.7) + unicode-display_width (~> 1.0, >= 1.0.1) + ruby-progressbar (1.8.1) ruby_dep (1.4.0) rubyzip (1.2.0) rufus-scheduler (3.2.2) @@ -294,7 +308,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) @@ -346,6 +360,7 @@ GEM tzinfo (>= 1.0.0) uglifier (3.0.2) execjs (>= 0.3.0, < 3) + unicode-display_width (1.1.1) useragent (0.16.8) vegas (0.1.11) rack (>= 1.0.0) @@ -403,6 +418,7 @@ DEPENDENCIES redis resque! resque-scheduler + rubocop sass! sass-rails sdoc (= 1.0.0.beta2) 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/lib/action_cable/connection/base.rb b/actioncable/lib/action_cable/connection/base.rb index dfee123ea2..e1da126d64 100644 --- a/actioncable/lib/action_cable/connection/base.rb +++ b/actioncable/lib/action_cable/connection/base.rb @@ -24,11 +24,8 @@ module ActionCable # # protected # def find_verified_user - # if current_user = User.find_by_identity cookies.signed[:identity_id] - # current_user - # else + # User.find_by_identity(cookies.signed[:identity_id]) || # reject_unauthorized_connection - # end # end # end # end diff --git a/actioncable/lib/action_cable/subscription_adapter/evented_redis.rb b/actioncable/lib/action_cable/subscription_adapter/evented_redis.rb index bcd46d2a0e..c3018c5281 100644 --- a/actioncable/lib/action_cable/subscription_adapter/evented_redis.rb +++ b/actioncable/lib/action_cable/subscription_adapter/evented_redis.rb @@ -68,10 +68,10 @@ module ActionCable end def ensure_reactor_running - return if EventMachine.reactor_running? + return if EventMachine.reactor_running? && EventMachine.reactor_thread @@mutex.synchronize do Thread.new { EventMachine.run } unless EventMachine.reactor_running? - Thread.pass until EventMachine.reactor_running? + Thread.pass until EventMachine.reactor_running? && EventMachine.reactor_thread end end end 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/actioncable/test/subscription_adapter/evented_redis_test.rb b/actioncable/test/subscription_adapter/evented_redis_test.rb index f316bc46ef..2401950aa7 100644 --- a/actioncable/test/subscription_adapter/evented_redis_test.rb +++ b/actioncable/test/subscription_adapter/evented_redis_test.rb @@ -23,6 +23,32 @@ class EventedRedisAdapterTest < ActionCable::TestCase $VERBOSE = @previous_verbose end + def test_slow_eventmachine + require "eventmachine" + require "thread" + + lock = Mutex.new + + EventMachine.singleton_class.class_eval do + alias_method :delayed_initialize_event_machine, :initialize_event_machine + define_method(:initialize_event_machine) do + lock.synchronize do + sleep 0.5 + delayed_initialize_event_machine + end + end + end + + test_basic_broadcast + ensure + lock.synchronize do + EventMachine.singleton_class.class_eval do + alias_method :initialize_event_machine, :delayed_initialize_event_machine + remove_method :delayed_initialize_event_machine + end + end + end + def cable_config { adapter: "evented_redis", url: "redis://127.0.0.1:6379/12" } end 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_controller/metal/rendering.rb b/actionpack/lib/action_controller/metal/rendering.rb index e971917ca2..56cfb4fbba 100644 --- a/actionpack/lib/action_controller/metal/rendering.rb +++ b/actionpack/lib/action_controller/metal/rendering.rb @@ -67,7 +67,7 @@ module ActionController end def _set_rendered_content_type(format) - unless response.content_type + if format && !response.content_type self.content_type = format.to_s end end diff --git a/actionpack/lib/action_controller/metal/strong_parameters.rb b/actionpack/lib/action_controller/metal/strong_parameters.rb index e14da22e01..acfeca1fcb 100644 --- a/actionpack/lib/action_controller/metal/strong_parameters.rb +++ b/actionpack/lib/action_controller/metal/strong_parameters.rb @@ -71,8 +71,8 @@ module ActionController # * +permit_all_parameters+ - If it's +true+, all the parameters will be # permitted by default. The default is +false+. # * +action_on_unpermitted_parameters+ - Allow to control the behavior when parameters - # that are not explicitly permitted are found. The values can be <tt>:log</tt> to - # write a message on the logger or <tt>:raise</tt> to raise + # that are not explicitly permitted are found. The values can be +false+ to just filter them + # out, <tt>:log</tt> to additionally write a message on the logger, or <tt>:raise</tt> to raise # ActionController::UnpermittedParameters exception. The default value is <tt>:log</tt> # in test and development environments, +false+ otherwise. # diff --git a/actionpack/lib/action_dispatch/http/response.rb b/actionpack/lib/action_dispatch/http/response.rb index 357ca56036..f71c6afd6c 100644 --- a/actionpack/lib/action_dispatch/http/response.rb +++ b/actionpack/lib/action_dispatch/http/response.rb @@ -408,7 +408,7 @@ module ActionDispatch # :nodoc: def parse_content_type(content_type) if content_type type, charset = content_type.split(/;\s*charset=/) - type = nil if type.empty? + type = nil if type && type.empty? ContentTypeHeader.new(type, charset) else NullContentTypeHeader diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb index a1c2a8858a..021ffec862 100644 --- a/actionpack/lib/action_dispatch/testing/integration.rb +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -69,7 +69,7 @@ module ActionDispatch DEFAULT_HOST = "www.example.com" include Minitest::Assertions - include RequestHelpers, Assertions + include TestProcess, RequestHelpers, Assertions %w( status status_message headers body redirect? ).each do |method| delegate method, to: :response, allow_nil: true @@ -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 }, @@ -598,7 +598,7 @@ module ActionDispatch # Consult the Rails Testing Guide for more. class IntegrationTest < ActiveSupport::TestCase - include TestProcess + include TestProcess::FixtureFile module UrlOptions extend ActiveSupport::Concern 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/lib/action_dispatch/testing/test_process.rb b/actionpack/lib/action_dispatch/testing/test_process.rb index 8b03b776fa..0282eb15c3 100644 --- a/actionpack/lib/action_dispatch/testing/test_process.rb +++ b/actionpack/lib/action_dispatch/testing/test_process.rb @@ -3,6 +3,26 @@ require "action_dispatch/middleware/flash" module ActionDispatch module TestProcess + module FixtureFile + # Shortcut for <tt>Rack::Test::UploadedFile.new(File.join(ActionDispatch::IntegrationTest.fixture_path, path), type)</tt>: + # + # post :change_avatar, avatar: fixture_file_upload('files/spongebob.png', 'image/png') + # + # To upload binary files on Windows, pass <tt>:binary</tt> as the last parameter. + # This will not affect other platforms: + # + # post :change_avatar, avatar: fixture_file_upload('files/spongebob.png', 'image/png', :binary) + def fixture_file_upload(path, mime_type = nil, binary = false) + if self.class.respond_to?(:fixture_path) && self.class.fixture_path && + !File.exist?(path) + path = File.join(self.class.fixture_path, path) + end + Rack::Test::UploadedFile.new(path, mime_type, binary) + end + end + + include FixtureFile + def assigns(key = nil) raise NoMethodError, "assigns has been extracted to a gem. To continue using it, @@ -24,21 +44,5 @@ module ActionDispatch def redirect_to_url @response.redirect_url end - - # Shortcut for <tt>Rack::Test::UploadedFile.new(File.join(ActionDispatch::IntegrationTest.fixture_path, path), type)</tt>: - # - # post :change_avatar, avatar: fixture_file_upload('files/spongebob.png', 'image/png') - # - # To upload binary files on Windows, pass <tt>:binary</tt> as the last parameter. - # This will not affect other platforms: - # - # post :change_avatar, avatar: fixture_file_upload('files/spongebob.png', 'image/png', :binary) - def fixture_file_upload(path, mime_type = nil, binary = false) - if self.class.respond_to?(:fixture_path) && self.class.fixture_path && - !File.exist?(path) - path = File.join(self.class.fixture_path, path) - end - Rack::Test::UploadedFile.new(path, mime_type, binary) - end 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/actionpack/test/dispatch/response_test.rb b/actionpack/test/dispatch/response_test.rb index 400af42bac..2df70704a1 100644 --- a/actionpack/test/dispatch/response_test.rb +++ b/actionpack/test/dispatch/response_test.rb @@ -110,6 +110,11 @@ class ResponseTest < ActiveSupport::TestCase assert_equal "application/aaron", @response.content_type end + def test_empty_content_type_returns_nil + @response.headers['Content-Type'] = "" + assert_equal nil, @response.content_type + end + test "simple output" do @response.body = "Hello, World!" diff --git a/actionpack/test/dispatch/routing/ipv6_redirect_test.rb b/actionpack/test/dispatch/routing/ipv6_redirect_test.rb index 4987ed84e4..179aee9ba7 100644 --- a/actionpack/test/dispatch/routing/ipv6_redirect_test.rb +++ b/actionpack/test/dispatch/routing/ipv6_redirect_test.rb @@ -7,7 +7,7 @@ class IPv6IntegrationTest < ActionDispatch::IntegrationTest class ::BadRouteRequestController < ActionController::Base include Routes.url_helpers def index - render text: foo_path + render plain: foo_path end def foo diff --git a/actionpack/test/dispatch/static_test.rb b/actionpack/test/dispatch/static_test.rb index cdb905f298..3facbf59c2 100644 --- a/actionpack/test/dispatch/static_test.rb +++ b/actionpack/test/dispatch/static_test.rb @@ -204,7 +204,7 @@ module StaticTests end # Windows doesn't allow \ / : * ? " < > | in filenames - unless RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ + unless Gem.win_platform? def test_serves_static_file_with_colon with_static_file "/foo/foo:bar.html" do |file| assert_html file, get("/foo/foo%3Abar.html") 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..8da2e4ae1d 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,3 +1,41 @@ +* Use `ActionView::Resolver.caching?` (`config.action_view.cache_template_loading`) + to enable template recompilation. + + Before it was enabled by `consider_all_requests_local`, which caused + recompilation in tests. + + *Max Melentiev* + +* 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/helpers/url_helper.rb b/actionview/lib/action_view/helpers/url_helper.rb index 1277126995..22cc4b2920 100644 --- a/actionview/lib/action_view/helpers/url_helper.rb +++ b/actionview/lib/action_view/helpers/url_helper.rb @@ -105,10 +105,9 @@ module ActionView # driver to prompt with the question specified (in this case, the # resulting text would be <tt>question?</tt>. If the user accepts, the # link is processed normally, otherwise no action is taken. - # * <tt>:disable_with</tt> - Value of this parameter will be - # used as the value for a disabled version of the submit - # button when the form is submitted. This feature is provided - # by the unobtrusive JavaScript driver. + # * <tt>:disable_with</tt> - Value of this parameter will be used as the + # name for a disabled version of the link. This feature is provided by + # the unobtrusive JavaScript driver. # # ==== Examples # Because it relies on +url_for+, +link_to+ supports both older-style controller/action/id arguments diff --git a/actionview/lib/action_view/railtie.rb b/actionview/lib/action_view/railtie.rb index 68205cb720..d344d98f4b 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 @@ -39,7 +39,7 @@ module ActionView initializer "action_view.per_request_digest_cache" do |app| ActiveSupport.on_load(:action_view) do - if app.config.consider_all_requests_local + unless ActionView::Resolver.caching? app.executor.to_run ActionView::Digestor::PerExecutionDigestCacheExpiry end end diff --git a/actionview/lib/action_view/template.rb b/actionview/lib/action_view/template.rb index 2dcd6324db..0afdcd1def 100644 --- a/actionview/lib/action_view/template.rb +++ b/actionview/lib/action_view/template.rb @@ -326,7 +326,7 @@ module ActionView def locals_code #:nodoc: # Only locals with valid variable names get set directly. Others will # still be available in local_assigns. - locals = @locals.to_set - Module::DELEGATION_RESERVED_METHOD_NAMES + locals = @locals - Module::RUBY_RESERVED_KEYWORDS locals = locals.grep(/\A(?![A-Z0-9])(?:[[:alnum:]_]|[^\0-\177])+\z/) # Double assign to suppress the dreaded 'assigned but unused variable' warning diff --git a/actionview/lib/action_view/template/text.rb b/actionview/lib/action_view/template/text.rb index e8d4e18f04..380528d6ef 100644 --- a/actionview/lib/action_view/template/text.rb +++ b/actionview/lib/action_view/template/text.rb @@ -4,10 +4,9 @@ module ActionView #:nodoc: class Text #:nodoc: attr_accessor :type - def initialize(string, type = nil) + def initialize(string) @string = string.to_s - @type = Types[type] || type if type - @type ||= Types[:text] + @type = Types[:text] end def identifier @@ -25,7 +24,7 @@ module ActionView #:nodoc: end def formats - [@type.respond_to?(:ref) ? @type.ref : @type.to_s] + [@type.ref] end end end 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/actionpack/controller/layout_test.rb b/actionview/test/actionpack/controller/layout_test.rb index a342b22161..b79835ff34 100644 --- a/actionview/test/actionpack/controller/layout_test.rb +++ b/actionview/test/actionpack/controller/layout_test.rb @@ -252,7 +252,7 @@ class LayoutStatusIsRenderedTest < ActionController::TestCase end end -unless /mswin|mingw/.match?(RbConfig::CONFIG["host_os"]) +unless Gem.win_platform? class LayoutSymlinkedTest < LayoutTest layout "symlinked/symlinked_layout" end diff --git a/actionview/test/fixtures/test/test_template_with_delegation_reserved_keywords.erb b/actionview/test/fixtures/test/test_template_with_delegation_reserved_keywords.erb new file mode 100644 index 0000000000..edfe52e422 --- /dev/null +++ b/actionview/test/fixtures/test/test_template_with_delegation_reserved_keywords.erb @@ -0,0 +1 @@ +<%= _ %> <%= arg %> <%= args %> <%= block %>
\ No newline at end of file diff --git a/actionview/test/template/compiled_templates_test.rb b/actionview/test/template/compiled_templates_test.rb index 3ecac46d34..40ac867b38 100644 --- a/actionview/test/template/compiled_templates_test.rb +++ b/actionview/test/template/compiled_templates_test.rb @@ -24,6 +24,16 @@ class CompiledTemplatesTest < ActiveSupport::TestCase assert_equal locals.inspect, render(file: "test/render_file_inspect_local_assigns", locals: locals) end + def test_template_with_delegation_reserved_keywords + locals = { + _: "one", + arg: "two", + args: "three", + block: "four", + } + assert_equal "one two three four", render(file: "test/test_template_with_delegation_reserved_keywords", locals: locals) + end + def test_template_with_unicode_identifier assert_equal "🎂", render(file: "test/render_file_unicode_local", locals: { 🎃: "🎂" }) end 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="✓" />} + 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="✓" />} + 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/template/text_test.rb b/actionview/test/template/text_test.rb index 6510688f97..ee526dc367 100644 --- a/actionview/test/template/text_test.rb +++ b/actionview/test/template/text_test.rb @@ -1,17 +1,7 @@ require "abstract_unit" class TextTest < ActiveSupport::TestCase - test "formats returns symbol for recognized MIME type" do - assert_equal [:text], ActionView::Template::Text.new("", :text).formats - end - - test "formats returns string for recognized MIME type when MIME does not have symbol" do - foo = Mime::Type.lookup("foo") - assert_nil foo.to_sym - assert_equal ["foo"], ActionView::Template::Text.new("", foo).formats - end - - test "formats returns string for unknown MIME type" do - assert_equal ["foo"], ActionView::Template::Text.new("", "foo").formats + test "formats always return :text" do + assert_equal [:text], ActionView::Template::Text.new("").formats 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/> "; + } + }, + + // 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 "'"; + case "\"": + return """; + case "<": + return "<"; + case ">": + return ">"; + case "&": + return "&"; + } + }); +} + +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 ? " " : " "; + }, + // 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, " " ); + } + 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 ? "<" : "<", + close = QUnit.jsDump.HTML ? ">" : ">", + 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..cc02cd8419 --- /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('<', '<').gsub('>', '>') + 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 plain: "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/activejob/test/support/integration/dummy_app_template.rb b/activejob/test/support/integration/dummy_app_template.rb index 62f6fa13f6..29a5691f30 100644 --- a/activejob/test/support/integration/dummy_app_template.rb +++ b/activejob/test/support/integration/dummy_app_template.rb @@ -18,12 +18,13 @@ class TestJob < ActiveJob::Base queue_as :integration_tests def perform(x) - File.open(Rails.root.join("tmp/\#{x}"), "wb+") do |f| + File.open(Rails.root.join("tmp/\#{x}.new"), "wb+") do |f| f.write Marshal.dump({ "locale" => I18n.locale.to_s || "en", "executed_at" => Time.now.to_r }) end + File.rename(Rails.root.join("tmp/\#{x}.new"), Rails.root.join("tmp/\#{x}")) end end CODE diff --git a/activejob/test/support/integration/helper.rb b/activejob/test/support/integration/helper.rb index 1aaee2c809..626b932cce 100644 --- a/activejob/test/support/integration/helper.rb +++ b/activejob/test/support/integration/helper.rb @@ -5,6 +5,7 @@ ActiveJob::Base.queue_name_prefix = nil require "rails/generators/rails/app/app_generator" +require "tmpdir" dummy_app_path = Dir.mktmpdir + "/dummy" dummy_app_template = File.expand_path("../dummy_app_template.rb", __FILE__) args = Rails::Generators::ARGVScrubber.new(["new", dummy_app_path, "--skip-gemfile", "--skip-bundle", diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 10f1de6706..853a1e7d9d 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,3 +1,13 @@ +* Moved DecimalWithoutScale, Text, and UnsignedInteger from Active Model to Active Record + + *Iain Beeston* + +* 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..9df4ca51fe 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 @@ -416,16 +419,23 @@ module ActiveModel I18n.translate(key, options) end - def marshal_dump + def marshal_dump # :nodoc: [@base, without_default_proc(@messages), without_default_proc(@details)] end - def marshal_load(array) + def marshal_load(array) # :nodoc: @base, @messages, @details = array apply_default_array(@messages) apply_default_array(@details) end + def init_with(coder) # :nodoc: + coder.map.each { |k, v| instance_variable_set(:"@#{k}", v) } + @details ||= {} + apply_default_array(@messages) + apply_default_array(@details) + end + private def normalize_message(attribute, message, options) case message diff --git a/activemodel/lib/active_model/model.rb b/activemodel/lib/active_model/model.rb index e683050787..945a5402a3 100644 --- a/activemodel/lib/active_model/model.rb +++ b/activemodel/lib/active_model/model.rb @@ -2,10 +2,10 @@ module ActiveModel # == Active \Model \Basic \Model # # Includes the required interface for an object to interact with - # <tt>ActionPack</tt>, using different <tt>ActiveModel</tt> modules. + # Action Pack and Action View, using different Active Model modules. # It includes model name introspections, conversions, translations and # validations. Besides that, it allows you to initialize the object with a - # hash of attributes, pretty much like <tt>ActiveRecord</tt> does. + # hash of attributes, pretty much like Active Record does. # # A minimal implementation could be: # diff --git a/activemodel/lib/active_model/type.rb b/activemodel/lib/active_model/type.rb index 0d3349e236..b8e6d2376b 100644 --- a/activemodel/lib/active_model/type.rb +++ b/activemodel/lib/active_model/type.rb @@ -7,14 +7,11 @@ require "active_model/type/boolean" require "active_model/type/date" require "active_model/type/date_time" require "active_model/type/decimal" -require "active_model/type/decimal_without_scale" require "active_model/type/float" require "active_model/type/immutable_string" require "active_model/type/integer" require "active_model/type/string" -require "active_model/type/text" require "active_model/type/time" -require "active_model/type/unsigned_integer" require "active_model/type/registry" @@ -53,7 +50,6 @@ module ActiveModel register(:immutable_string, Type::ImmutableString) register(:integer, Type::Integer) register(:string, Type::String) - register(:text, Type::Text) register(:time, Type::Time) end end diff --git a/activemodel/lib/active_model/type/decimal_without_scale.rb b/activemodel/lib/active_model/type/decimal_without_scale.rb deleted file mode 100644 index 985e1038ed..0000000000 --- a/activemodel/lib/active_model/type/decimal_without_scale.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "active_model/type/big_integer" - -module ActiveModel - module Type - class DecimalWithoutScale < BigInteger # :nodoc: - def type - :decimal - end - end - end -end diff --git a/activemodel/lib/active_model/type/helpers/time_value.rb b/activemodel/lib/active_model/type/helpers/time_value.rb index ad78fd49ec..721f9543ed 100644 --- a/activemodel/lib/active_model/type/helpers/time_value.rb +++ b/activemodel/lib/active_model/type/helpers/time_value.rb @@ -34,7 +34,7 @@ module ActiveModel return value unless precision && value.respond_to?(:usec) number_of_insignificant_digits = 6 - precision round_power = 10**number_of_insignificant_digits - value.change(usec: value.usec / round_power * round_power) + value.change(usec: value.usec - value.usec % round_power) end def type_cast_for_schema(value) diff --git a/activemodel/lib/active_model/type/text.rb b/activemodel/lib/active_model/type/text.rb deleted file mode 100644 index 7c0d647706..0000000000 --- a/activemodel/lib/active_model/type/text.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "active_model/type/string" - -module ActiveModel - module Type - class Text < String # :nodoc: - def type - :text - end - end - end -end diff --git a/activemodel/test/cases/errors_test.rb b/activemodel/test/cases/errors_test.rb index 605ad64e4d..e6ba06301d 100644 --- a/activemodel/test/cases/errors_test.rb +++ b/activemodel/test/cases/errors_test.rb @@ -1,4 +1,5 @@ require "cases/helper" +require "yaml" class ErrorsTest < ActiveModel::TestCase class Person @@ -30,7 +31,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 +39,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 +54,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 +66,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 +154,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 +246,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 +258,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 @@ -360,4 +366,24 @@ class ErrorsTest < ActiveModel::TestCase assert_equal errors.messages, serialized.messages assert_equal errors.details, serialized.details end + + test "errors are backward compatible with the Rails 4.2 format" do + yaml = <<-CODE.strip_heredoc + --- !ruby/object:ActiveModel::Errors + base: &1 !ruby/object:ErrorsTest::Person + errors: !ruby/object:ActiveModel::Errors + base: *1 + messages: {} + messages: {} + CODE + + errors = YAML.load(yaml) + errors.add(:name, :invalid) + assert_equal({ name: ["is invalid"] }, errors.messages) + assert_equal({ name: [{ error: :invalid }] }, errors.details) + + errors.clear + assert_equal({}, errors.messages) + assert_equal({}, errors.details) + end end diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index a8bed82e19..6e2ece80f9 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,96 @@ +* Support index length and order options using both string and symbol + column names. + + Fixes #27243. + + *Ryuta Kamizono* + +* Raise `ActiveRecord::RangeError` when values that executed are out of range. + + *Ryuta Kamizono* + +* Raise `ActiveRecord::NotNullViolation` when a record cannot be inserted + or updated because it would violate a not null constraint. + + *Ryuta Kamizono* + +* Emulate db trigger behaviour for after_commit :destroy, :update. + + Race conditions can occur when an ActiveRecord is destroyed + twice or destroyed and updated. The callbacks should only be + triggered once, similar to a SQL database trigger. + + *Stefan Budeanu* + +* Moved `DecimalWithoutScale`, `Text`, and `UnsignedInteger` from Active Model to Active Record. + + *Iain Beeston* + +* Fix `write_attribute` method to check whether an attribute is aliased or not, and + use the aliased attribute name if needed. + + *Prathamesh Sonpatki* + +* Fix `read_attribute` method to check whether an attribute is aliased or not, and + use the aliased attribute name if needed. + + Fixes #26417. + + *Prathamesh Sonpatki* + +* PostgreSQL & MySQL: Use big integer as primary key type for new tables. + + *Jon McCartie*, *Pavel Pravosud* + +* Change the type argument of `ActiveRecord::Base#attribute` to be optional. + The default is now `ActiveRecord::Type::Value.new`, which provides no type + casting behavior. + + *Sean Griffin* + +* Fix that unsigned with zerofill is treated as signed. + + Fixes #27125. + + *Ryuta Kamizono* + +* 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. @@ -5,7 +98,7 @@ *Jon Moss* -* Added `stat` method to `ActiveRecord::ConnectionAdapters::ConnectionPool` +* Added `stat` method to `ActiveRecord::ConnectionAdapters::ConnectionPool`. Example: @@ -52,7 +145,7 @@ * Fixed: Optimistic locking does not work well with `null` in the database. - Fixes #26024 + Fixes #26024. *bogdanvlviv* @@ -61,7 +154,7 @@ *Edho Arief* -* Serialize JSON attribute value `nil` as SQL `NULL`, not JSON `null` +* Serialize JSON attribute value `nil` as SQL `NULL`, not JSON `null`. *Trung Duc Tran* @@ -165,7 +258,7 @@ successfully rolled back when the column was given and invalid column type. - Fixes #26087 + Fixes #26087. *Travis O'Neill* @@ -195,7 +288,7 @@ *Takeshi Akima* * Virtual attributes will no longer raise when read on models loaded from the - database + database. *Sean Griffin* 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..46923f690a 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 + klass.all.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..ee7b7c8bea 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 @@ -51,6 +58,8 @@ module ActiveRecord sc.execute(binds, klass, conn) do |record| set_inverse_instance record end.first + rescue ::RangeError + nil end def replace(record) diff --git a/activerecord/lib/active_record/attribute_decorators.rb b/activerecord/lib/active_record/attribute_decorators.rb index 340dfe11cf..c39e9ce4c5 100644 --- a/activerecord/lib/active_record/attribute_decorators.rb +++ b/activerecord/lib/active_record/attribute_decorators.rb @@ -8,12 +8,34 @@ module ActiveRecord end module ClassMethods # :nodoc: + # This method is an internal API used to create class macros such as + # +serialize+, and features like time zone aware attributes. + # + # Used to wrap the type of an attribute in a new type. + # When the schema for a model is loaded, attributes with the same name as + # +column_name+ will have their type yielded to the given block. The + # return value of that block will be used instead. + # + # Subsequent calls where +column_name+ and +decorator_name+ are the same + # will override the previous decorator, not decorate twice. This can be + # used to create idempotent class macros like +serialize+ def decorate_attribute_type(column_name, decorator_name, &block) matcher = ->(name, _) { name == column_name.to_s } key = "_#{column_name}_#{decorator_name}" decorate_matching_attribute_types(matcher, key, &block) end + # This method is an internal API used to create higher level features like + # time zone aware attributes. + # + # When the schema for a model is loaded, +matcher+ will be called for each + # attribute with its name and type. If the matcher returns a truthy value, + # the type will then be yielded to the given block, and the return value + # of that block will replace the type. + # + # Subsequent calls to this method with the same value for +decorator_name+ + # will replace the previous decorator, not decorate twice. This can be + # used to ensure that class macros are idempotent. def decorate_matching_attribute_types(matcher, decorator_name, &block) reload_schema_from_cache decorator_name = decorator_name.to_s diff --git a/activerecord/lib/active_record/attribute_methods/dirty.rb b/activerecord/lib/active_record/attribute_methods/dirty.rb index b22190455a..e20b65e43c 100644 --- a/activerecord/lib/active_record/attribute_methods/dirty.rb +++ b/activerecord/lib/active_record/attribute_methods/dirty.rb @@ -100,14 +100,14 @@ module ActiveRecord if defined?(@cached_changed_attributes) @cached_changed_attributes else - emit_warning_if_needed("changed_attributes", "attributes_in_database") + emit_warning_if_needed("changed_attributes", "saved_changes.transform_values(&:first)") super.reverse_merge(mutation_tracker.changed_values).freeze end end def changes cache_changed_attributes do - emit_warning_if_needed("changes", "changes_to_save") + emit_warning_if_needed("changes", "saved_changes") super end end @@ -212,22 +212,22 @@ module ActiveRecord end def attribute_was(*) - emit_warning_if_needed("attribute_was", "attribute_in_database") + emit_warning_if_needed("attribute_was", "attribute_before_last_save") super end def attribute_change(*) - emit_warning_if_needed("attribute_change", "attribute_change_to_be_saved") + emit_warning_if_needed("attribute_change", "saved_change_to_attribute") super end def attribute_changed?(*) - emit_warning_if_needed("attribute_changed?", "will_save_change_to_attribute?") + emit_warning_if_needed("attribute_changed?", "saved_change_to_attribute?") super end def changed(*) - emit_warning_if_needed("changed", "changed_attribute_names_to_save") + emit_warning_if_needed("changed", "saved_changes.keys") super end diff --git a/activerecord/lib/active_record/attribute_methods/read.rb b/activerecord/lib/active_record/attribute_methods/read.rb index 30f7750884..5448ebc165 100644 --- a/activerecord/lib/active_record/attribute_methods/read.rb +++ b/activerecord/lib/active_record/attribute_methods/read.rb @@ -48,7 +48,12 @@ module ActiveRecord # it has been typecast (for example, "2004-12-12" in a date column is cast # to a date object, like Date.new(2004, 12, 12)). def read_attribute(attr_name, &block) - name = attr_name.to_s + name = if self.class.attribute_alias?(attr_name) + self.class.attribute_alias(attr_name).to_s + else + attr_name.to_s + end + name = self.class.primary_key if name == "id".freeze _read_attribute(name, &block) end diff --git a/activerecord/lib/active_record/attribute_methods/write.rb b/activerecord/lib/active_record/attribute_methods/write.rb index f65c297e01..0022d526a4 100644 --- a/activerecord/lib/active_record/attribute_methods/write.rb +++ b/activerecord/lib/active_record/attribute_methods/write.rb @@ -29,7 +29,13 @@ module ActiveRecord # specified +value+. Empty strings for Integer and Float columns are # turned into +nil+. def write_attribute(attr_name, value) - write_attribute_with_type_cast(attr_name, value, true) + name = if self.class.attribute_alias?(attr_name) + self.class.attribute_alias(attr_name).to_s + else + attr_name.to_s + end + + write_attribute_with_type_cast(name, value, true) end def raw_write_attribute(attr_name, value) # :nodoc: diff --git a/activerecord/lib/active_record/attributes.rb b/activerecord/lib/active_record/attributes.rb index dcbfca1c04..75f5ba3a96 100644 --- a/activerecord/lib/active_record/attributes.rb +++ b/activerecord/lib/active_record/attributes.rb @@ -191,7 +191,7 @@ module ActiveRecord # tracking is performed. The methods +changed?+ and +changed_in_place?+ # will be called from ActiveModel::Dirty. See the documentation for those # methods in ActiveModel::Type::Value for more details. - def attribute(name, cast_type, **options) + def attribute(name, cast_type = Type::Value.new, **options) name = name.to_s reload_schema_from_cache diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb index b343332bae..9d0b501862 100644 --- a/activerecord/lib/active_record/autosave_association.rb +++ b/activerecord/lib/active_record/autosave_association.rb @@ -383,6 +383,9 @@ module ActiveRecord if association = association_instance_get(reflection.name) autosave = reflection.options[:autosave] + # reconstruct the scope now that we know the owner's id + association.reset_scope if association.respond_to?(:reset_scope) + if records = associated_records_to_validate_or_save(association, @new_record_before_save, autosave) if autosave records_to_destroy = records.select(&:marked_for_destruction?) @@ -408,9 +411,6 @@ module ActiveRecord raise ActiveRecord::Rollback unless saved end end - - # reconstruct the scope now that we know the owner's id - association.reset_scope if association.respond_to?(:reset_scope) end end 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/abstract/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb index 83d1d7cd01..f783b1941b 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -71,7 +71,7 @@ module ActiveRecord polymorphic: false, index: true, foreign_key: false, - type: :integer, + type: :bigint, **options ) @name = name diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_dumper.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_dumper.rb index dabccc00bb..b912d24626 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_dumper.rb @@ -56,7 +56,7 @@ module ActiveRecord private def default_primary_key?(column) - schema_type(column) == :integer + schema_type(column) == :bigint end def schema_type(column) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb index 151629b02a..5623257fe8 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -996,15 +996,13 @@ module ActiveRecord def insert_versions_sql(versions) # :nodoc: sm_table = ActiveRecord::Migrator.schema_migrations_table_name - if supports_multi_insert? + if versions.is_a?(Array) sql = "INSERT INTO #{sm_table} (version) VALUES\n" sql << versions.map { |v| "('#{v}')" }.join(",\n") sql << ";\n\n" sql else - versions.map { |version| - "INSERT INTO #{sm_table} (version) VALUES ('#{version}');" - }.join "\n\n" + "INSERT INTO #{sm_table} (version) VALUES ('#{versions}');" end end @@ -1042,7 +1040,13 @@ module ActiveRecord if (duplicate = inserting.detect { |v| inserting.count(v) > 1 }) raise "Duplicate migration #{duplicate}. Please renumber your migrations to resolve the conflict." end - execute insert_versions_sql(inserting) + if supports_multi_insert? + execute insert_versions_sql(inserting) + else + inserting.each do |v| + execute insert_versions_sql(v) + end + end end end @@ -1171,6 +1175,7 @@ module ActiveRecord if order = options[:order] case order when Hash + order = order.symbolize_keys quoted_columns.each { |name, column| column << " #{order[name].upcase}" if order[name].present? } when String quoted_columns.each { |name, column| column << " #{order.upcase}" if order.present? } diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb index cbbba5b1a5..6985d2c1b2 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -39,7 +39,7 @@ module ActiveRecord self.emulate_booleans = true NATIVE_DATABASE_TYPES = { - primary_key: "int auto_increment PRIMARY KEY", + primary_key: "bigint auto_increment PRIMARY KEY", string: { name: "varchar", limit: 255 }, text: { name: "text", limit: 65535 }, integer: { name: "int", limit: 4 }, @@ -693,7 +693,7 @@ module ActiveRecord def register_integer_type(mapping, key, options) # :nodoc: mapping.register_type(key) do |sql_type| - if /\bunsigned\z/.match?(sql_type) + if /\bunsigned\b/.match?(sql_type) Type::UnsignedInteger.new(options) else Type::Integer.new(options) @@ -717,6 +717,7 @@ module ActiveRecord if length = options[:length] case length when Hash + length = length.symbolize_keys quoted_columns.each { |name, column| column << "(#{length[name]})" if length[name].present? } when Integer quoted_columns.each { |name, column| column << "(#{length})" } @@ -733,9 +734,14 @@ module ActiveRecord # See https://dev.mysql.com/doc/refman/5.7/en/error-messages-server.html ER_DUP_ENTRY = 1062 + ER_NOT_NULL_VIOLATION = 1048 + ER_DO_NOT_HAVE_DEFAULT = 1364 ER_NO_REFERENCED_ROW_2 = 1452 ER_DATA_TOO_LONG = 1406 + ER_OUT_OF_RANGE = 1264 ER_LOCK_DEADLOCK = 1213 + ER_CANNOT_ADD_FOREIGN = 1215 + ER_CANNOT_CREATE_TABLE = 1005 def translate_exception(exception, message) case error_number(exception) @@ -743,8 +749,20 @@ module ActiveRecord RecordNotUnique.new(message) when ER_NO_REFERENCED_ROW_2 InvalidForeignKey.new(message) + when ER_CANNOT_ADD_FOREIGN + mismatched_foreign_key(message) + when ER_CANNOT_CREATE_TABLE + if message.include?("errno: 150") + mismatched_foreign_key(message) + else + super + end when ER_DATA_TOO_LONG ValueTooLong.new(message) + when ER_OUT_OF_RANGE + RangeError.new(message) + when ER_NOT_NULL_VIOLATION, ER_DO_NOT_HAVE_DEFAULT + NotNullViolation.new(message) when ER_LOCK_DEADLOCK Deadlocked.new(message) else @@ -769,6 +787,10 @@ module ActiveRecord options[:null] = column.null end + unless options.key?(:comment) + options[:comment] = column.comment + end + td = create_table_definition(table_name) cd = td.new_column_definition(column.name, type, options) schema_creation.accept(ChangeColumnDefinition.new(cd, column.name)) @@ -910,6 +932,18 @@ module ActiveRecord MySQL::TableDefinition.new(*args) end + def mismatched_foreign_key(message) + parts = message.scan(/`(\w+)`[ $)]/).flatten + MismatchedForeignKey.new( + self, + message: message, + table: parts[0], + foreign_key: parts[1], + target_table: parts[2], + primary_key: parts[3], + ) + end + def extract_schema_qualified_name(string) # :nodoc: schema, name = string.to_s.scan(/[^`.\s]+|`[^`]*`/) schema, name = @config[:database], schema unless name diff --git a/activerecord/lib/active_record/connection_adapters/mysql/column.rb b/activerecord/lib/active_record/connection_adapters/mysql/column.rb index f82c556a6f..c66d543752 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/column.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/column.rb @@ -5,7 +5,8 @@ module ActiveRecord delegate :extra, to: :sql_type_metadata, allow_nil: true def unsigned? - /\bunsigned\z/.match?(sql_type) + # enum and set types do not allow being defined as unsigned. + !/\A(?:enum|set)\b/.match?(sql_type) && /\bunsigned\b/.match?(sql_type) end def case_sensitive? diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb index ce773ed75b..0cf40de70f 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb @@ -3,7 +3,10 @@ module ActiveRecord module MySQL module ColumnMethods def primary_key(name, type = :primary_key, **options) - options[:auto_increment] = true if type == :bigint && !options.key?(:default) + if type == :primary_key && !options.key?(:default) + options[:auto_increment] = true + options[:limit] = 8 + end super end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb index 9b02d8a34b..2065816501 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb @@ -3,11 +3,9 @@ module ActiveRecord module MySQL module ColumnDumper def column_spec_for_primary_key(column) - if column.bigint? - spec = { id: :bigint.inspect } - spec[:default] = schema_default(column) || "nil" unless column.auto_increment? - else - spec = super + spec = super + if column.type == :integer && !column.auto_increment? + spec[:default] = schema_default(column) || "nil" end spec[:unsigned] = "true" if column.unsigned? spec 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..4afb4733eb 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,24 @@ 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 + if type == :uuid + options[:default] = options.fetch(:default, "gen_random_uuid()") + elsif options.delete(:auto_increment) == true && %i(integer bigint).include?(type) + type = if type == :bigint || options[:limit] == 8 + :bigserial + else + :serial + end + end + super end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb index c20baf655c..7808d37deb 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb @@ -25,7 +25,7 @@ module ActiveRecord private def default_primary_key?(column) - schema_type(column) == :serial + schema_type(column) == :bigserial end def schema_type(column) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 710b5cd887..33cdcf9a76 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -70,7 +70,7 @@ module ActiveRecord ADAPTER_NAME = "PostgreSQL".freeze NATIVE_DATABASE_TYPES = { - primary_key: "serial primary key", + primary_key: "bigserial primary key", string: { name: "character varying" }, text: { name: "text" }, integer: { name: "integer" }, @@ -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") @@ -404,6 +408,8 @@ module ActiveRecord # See http://www.postgresql.org/docs/current/static/errcodes-appendix.html VALUE_LIMIT_VIOLATION = "22001" + NUMERIC_VALUE_OUT_OF_RANGE = "22003" + NOT_NULL_VIOLATION = "23502" FOREIGN_KEY_VIOLATION = "23503" UNIQUE_VIOLATION = "23505" SERIALIZATION_FAILURE = "40001" @@ -419,6 +425,10 @@ module ActiveRecord InvalidForeignKey.new(message) when VALUE_LIMIT_VIOLATION ValueTooLong.new(message) + when NUMERIC_VALUE_OUT_OF_RANGE + RangeError.new(message) + when NOT_NULL_VIOLATION + NotNullViolation.new(message) when SERIALIZATION_FAILURE SerializationFailure.new(message) when DEADLOCK_DETECTED @@ -798,7 +808,6 @@ module ActiveRecord map[Integer] = PG::TextEncoder::Integer.new map[TrueClass] = PG::TextEncoder::Boolean.new map[FalseClass] = PG::TextEncoder::Boolean.new - map[Float] = PG::TextEncoder::Float.new @connection.type_map_for_queries = map end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb new file mode 100644 index 0000000000..d0b38dff4c --- /dev/null +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb @@ -0,0 +1,23 @@ +module ActiveRecord + module ConnectionAdapters + module SQLite3 + module ColumnMethods + def primary_key(name, type = :primary_key, **options) + if options.delete(:auto_increment) == true && %i(integer bigint).include?(type) + type = :primary_key + end + + super + end + end + + class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition + include ColumnMethods + end + + class Table < ActiveRecord::ConnectionAdapters::Table + include ColumnMethods + end + end + end +end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_dumper.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_dumper.rb new file mode 100644 index 0000000000..c027fef83c --- /dev/null +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_dumper.rb @@ -0,0 +1,13 @@ +module ActiveRecord + module ConnectionAdapters + module SQLite3 + module ColumnDumper + private + + def default_primary_key?(column) + schema_type(column) == :integer + end + end + end + end +end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index 0493ab4e4b..a7c4a2cd86 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -3,6 +3,8 @@ require "active_record/connection_adapters/statement_pool" require "active_record/connection_adapters/sqlite3/explain_pretty_printer" require "active_record/connection_adapters/sqlite3/quoting" require "active_record/connection_adapters/sqlite3/schema_creation" +require "active_record/connection_adapters/sqlite3/schema_definitions" +require "active_record/connection_adapters/sqlite3/schema_dumper" gem "sqlite3", "~> 1.3.6" require "sqlite3" @@ -52,6 +54,7 @@ module ActiveRecord ADAPTER_NAME = "SQLite".freeze include SQLite3::Quoting + include SQLite3::ColumnDumper NATIVE_DATABASE_TYPES = { primary_key: "INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL", @@ -75,6 +78,10 @@ module ActiveRecord end end + def update_table_definition(table_name, base) # :nodoc: + SQLite3::Table.new(table_name, base) + end + def schema_creation # :nodoc: SQLite3::SchemaCreation.new self end @@ -523,6 +530,8 @@ module ActiveRecord # column *column_name* is not unique when /column(s)? .* (is|are) not unique/, /UNIQUE constraint failed: .*/ RecordNotUnique.new(message) + when /.* may not be NULL/, /NOT NULL constraint failed: .*/ + NotNullViolation.new(message) else super end @@ -569,6 +578,10 @@ module ActiveRecord basic_structure.to_hash end end + + def create_table_definition(*args) + SQLite3::TableDefinition.new(*args) + end end end end diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb index 1fbe374ade..878d87638d 100644 --- a/activerecord/lib/active_record/core.rb +++ b/activerecord/lib/active_record/core.rb @@ -194,7 +194,7 @@ module ActiveRecord name, primary_key, id) end record - rescue RangeError + rescue ::RangeError raise RecordNotFound.new("Couldn't find #{name} with an out of range value for '#{primary_key}'", name, primary_key) end @@ -223,7 +223,7 @@ module ActiveRecord statement.execute(hash.values, self, connection).first rescue TypeError raise ActiveRecord::StatementInvalid - rescue RangeError + rescue ::RangeError nil end end diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index 6464d40c94..c812a05101 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -123,10 +123,46 @@ module ActiveRecord class InvalidForeignKey < WrappedDatabaseException end + # Raised when a foreign key constraint cannot be added because the column type does not match the referenced column type. + class MismatchedForeignKey < StatementInvalid + def initialize(adapter = nil, message: nil, table: nil, foreign_key: nil, target_table: nil, primary_key: nil) + @adapter = adapter + if table + msg = <<-EOM.strip_heredoc + Column `#{foreign_key}` on table `#{table}` has a type of `#{column_type(table, foreign_key)}`. + This does not match column `#{primary_key}` on `#{target_table}`, which has type `#{column_type(target_table, primary_key)}`. + To resolve this issue, change the type of the `#{foreign_key}` column on `#{table}` to be :integer. (For example `t.integer #{foreign_key}`). + EOM + else + msg = <<-EOM + There is a mismatch between the foreign key and primary key column types. + Verify that the foreign key column type and the primary key of the associated table match types. + EOM + end + if message + msg << "\nOriginal message: #{message}" + end + super(msg) + end + + private + def column_type(table, column) + @adapter.columns(table).detect { |c| c.name == column }.sql_type + end + end + + # Raised when a record cannot be inserted or updated because it would violate a not null constraint. + class NotNullViolation < StatementInvalid + end + # Raised when a record cannot be inserted or updated because a value too long for a column type. class ValueTooLong < StatementInvalid end + # Raised when values that executed are out of range. + class RangeError < StatementInvalid + end + # Raised when number of bind variables in statement given to +:condition+ key # (for example, when using {ActiveRecord::Base.find}[rdoc-ref:FinderMethods#find] method) # does not match number of expected values supplied. 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..9c357e1604 100644 --- a/activerecord/lib/active_record/migration/compatibility.rb +++ b/activerecord/lib/active_record/migration/compatibility.rb @@ -103,6 +103,23 @@ module ActiveRecord end class V5_0 < V5_1 + def create_table(table_name, options = {}) + if adapter_name == "PostgreSQL" + if options[:id] == :uuid && !options[:default] + options[:default] = "uuid_generate_v4()" + end + end + + # Since 5.1 Postgres adapter uses bigserial type for primary + # keys by default and MySQL uses bigint. This compat layer makes old migrations utilize + # serial/int type instead -- the way it used to work before 5.1. + if options[:id].blank? + options[:id] = :integer + options[:auto_increment] = true + end + + super + end end class V4_2 < V5_0 diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index 8e13ee3564..60d8e95b21 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -181,7 +181,11 @@ module ActiveRecord _raise_readonly_record_error if readonly? destroy_associations self.class.connection.add_transaction_record(self) - destroy_row if persisted? + @_trigger_destroy_callback = if persisted? + destroy_row > 0 + else + true + end @destroyed = true freeze end @@ -519,6 +523,7 @@ module ActiveRecord raise ActiveRecord::StaleObjectError.new(self, "touch") end + @_trigger_update_callback = result result else true @@ -550,10 +555,13 @@ module ActiveRecord def _update_record(attribute_names = self.attribute_names) attributes_values = arel_attributes_with_values_for_update(attribute_names) if attributes_values.empty? - 0 + rows_affected = 0 + @_trigger_update_callback = true else - self.class.unscoped._update_record attributes_values, id, id_in_database + rows_affected = self.class.unscoped._update_record attributes_values, id, id_in_database + @_trigger_update_callback = rows_affected > 0 end + rows_affected end # Creates a record with values matching those of the instance attributes diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index ef3c3bfae8..e1a3c59f08 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 @@ -846,6 +850,10 @@ module ActiveRecord actual_source_reflection.options[:primary_key] || primary_key(klass || self.klass) end + def association_primary_key_type + klass.type_for_attribute(association_primary_key) + end + # Gets an array of possible <tt>:through</tt> source reflection names in both singular and plural form. # # class Post < ActiveRecord::Base 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..5e456452e9 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -76,7 +76,7 @@ module ActiveRecord # Post.find_by "published_at < ?", 2.weeks.ago def find_by(arg, *args) where(arg, *args).take - rescue RangeError + rescue ::RangeError nil end @@ -84,7 +84,7 @@ module ActiveRecord # an ActiveRecord::RecordNotFound error. def find_by!(arg, *args) where(arg, *args).take! - rescue RangeError + rescue ::RangeError raise RecordNotFound.new("Couldn't find #{@klass.name} with an out of range value", @klass.name) end @@ -333,7 +333,7 @@ module ActiveRecord end connection.select_value(relation, "#{name} Exists", relation.bound_attributes) ? true : false - rescue RangeError + rescue ::RangeError false end @@ -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) @@ -458,7 +458,7 @@ module ActiveRecord else find_some(ids) end - rescue RangeError + rescue ::RangeError raise RecordNotFound, "Couldn't find #{@klass.name} with an out of range ID" end diff --git a/activerecord/lib/active_record/transactions.rb b/activerecord/lib/active_record/transactions.rb index af3fc88282..ce939c8b97 100644 --- a/activerecord/lib/active_record/transactions.rb +++ b/activerecord/lib/active_record/transactions.rb @@ -461,9 +461,10 @@ module ActiveRecord when :create transaction_record_state(:new_record) when :destroy - destroyed? + defined?(@_trigger_destroy_callback) && @_trigger_destroy_callback when :update - !(transaction_record_state(:new_record) || destroyed?) + !(transaction_record_state(:new_record) || destroyed?) && + (defined?(@_trigger_update_callback) && @_trigger_update_callback) end end end diff --git a/activerecord/lib/active_record/type.rb b/activerecord/lib/active_record/type.rb index 0b48d2186a..4f632660a8 100644 --- a/activerecord/lib/active_record/type.rb +++ b/activerecord/lib/active_record/type.rb @@ -5,7 +5,10 @@ require "active_record/type/internal/timezone" require "active_record/type/date" require "active_record/type/date_time" +require "active_record/type/decimal_without_scale" require "active_record/type/time" +require "active_record/type/text" +require "active_record/type/unsigned_integer" require "active_record/type/serialized" require "active_record/type/adapter_specific_registry" @@ -53,12 +56,9 @@ module ActiveRecord Binary = ActiveModel::Type::Binary Boolean = ActiveModel::Type::Boolean Decimal = ActiveModel::Type::Decimal - DecimalWithoutScale = ActiveModel::Type::DecimalWithoutScale Float = ActiveModel::Type::Float Integer = ActiveModel::Type::Integer String = ActiveModel::Type::String - Text = ActiveModel::Type::Text - UnsignedInteger = ActiveModel::Type::UnsignedInteger Value = ActiveModel::Type::Value register(:big_integer, Type::BigInteger, override: false) diff --git a/activerecord/lib/active_record/type/decimal_without_scale.rb b/activerecord/lib/active_record/type/decimal_without_scale.rb new file mode 100644 index 0000000000..7ce33e9cd3 --- /dev/null +++ b/activerecord/lib/active_record/type/decimal_without_scale.rb @@ -0,0 +1,9 @@ +module ActiveRecord + module Type + class DecimalWithoutScale < ActiveModel::Type::BigInteger # :nodoc: + def type + :decimal + end + end + end +end diff --git a/activerecord/lib/active_record/type/text.rb b/activerecord/lib/active_record/type/text.rb new file mode 100644 index 0000000000..cb1949700a --- /dev/null +++ b/activerecord/lib/active_record/type/text.rb @@ -0,0 +1,9 @@ +module ActiveRecord + module Type + class Text < ActiveModel::Type::String # :nodoc: + def type + :text + end + end + end +end diff --git a/activemodel/lib/active_model/type/unsigned_integer.rb b/activerecord/lib/active_record/type/unsigned_integer.rb index 288fa23efe..9ae0109f9f 100644 --- a/activemodel/lib/active_model/type/unsigned_integer.rb +++ b/activerecord/lib/active_record/type/unsigned_integer.rb @@ -1,6 +1,6 @@ -module ActiveModel +module ActiveRecord module Type - class UnsignedInteger < Integer # :nodoc: + class UnsignedInteger < ActiveModel::Type::Integer # :nodoc: private def max_value 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/adapter_test.rb b/activerecord/test/cases/adapter_test.rb index 8bcecf2ed3..8de69869a4 100644 --- a/activerecord/test/cases/adapter_test.rb +++ b/activerecord/test/cases/adapter_test.rb @@ -182,6 +182,14 @@ module ActiveRecord assert_not_nil error.cause end + def test_not_null_violations_are_translated_to_specific_exception + error = assert_raises(ActiveRecord::NotNullViolation) do + Post.create + end + + assert_not_nil error.cause + end + unless current_adapter?(:SQLite3Adapter) def test_foreign_key_violations_are_translated_to_specific_exception error = assert_raises(ActiveRecord::InvalidForeignKey) do @@ -218,6 +226,14 @@ module ActiveRecord assert_not_nil error.cause end + + def test_numeric_value_out_of_ranges_are_translated_to_specific_exception + error = assert_raises(ActiveRecord::RangeError) do + Book.connection.create("INSERT INTO books(author_id) VALUES (2147483648)") + end + + assert_not_nil error.cause + end end def test_disable_referential_integrity diff --git a/activerecord/test/cases/adapters/mysql2/active_schema_test.rb b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb index a70eb5a094..2a528b2cb1 100644 --- a/activerecord/test/cases/adapters/mysql2/active_schema_test.rb +++ b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb @@ -28,12 +28,15 @@ class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`(15)) " assert_equal expected, add_index(:people, [:last_name, :first_name], length: 15) + assert_equal expected, add_index(:people, ["last_name", "first_name"], length: 15) expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`) " assert_equal expected, add_index(:people, [:last_name, :first_name], length: { last_name: 15 }) + assert_equal expected, add_index(:people, ["last_name", "first_name"], length: { last_name: 15 }) expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`(10)) " assert_equal expected, add_index(:people, [:last_name, :first_name], length: { last_name: 15, first_name: 10 }) + assert_equal expected, add_index(:people, ["last_name", :first_name], length: { last_name: 15, "first_name" => 10 }) %w(SPATIAL FULLTEXT UNIQUE).each do |type| expected = "CREATE #{type} INDEX `index_people_on_last_name` ON `people` (`last_name`) " diff --git a/activerecord/test/cases/adapters/mysql2/legacy_migration_test.rb b/activerecord/test/cases/adapters/mysql2/legacy_migration_test.rb new file mode 100644 index 0000000000..5d3125c2be --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/legacy_migration_test.rb @@ -0,0 +1,60 @@ +require "cases/helper" + +class MysqlLegacyMigrationTest < ActiveRecord::Mysql2TestCase + self.use_transactional_tests = false + + class GenerateTableWithoutBigint < ActiveRecord::Migration[5.0] + def change + create_table :legacy_integer_pk do |table| + table.string :foo + end + + create_table :override_pk, id: :bigint do |table| + table.string :bar + end + end + end + + def setup + super + @connection = ActiveRecord::Base.connection + + @migration_verbose_old = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + + migrations = [GenerateTableWithoutBigint.new(nil, 1)] + + ActiveRecord::Migrator.new(:up, migrations).migrate + end + + def teardown + ActiveRecord::Migration.verbose = @migration_verbose_old + @connection.drop_table("legacy_integer_pk") + @connection.drop_table("override_pk") + ActiveRecord::SchemaMigration.delete_all rescue nil + super + end + + def test_create_table_uses_integer_as_pkey_by_default + col = column(:legacy_integer_pk, :id) + assert_equal "int(11)", sql_type_for(col) + assert col.auto_increment? + end + + def test_create_tables_respects_pk_column_type_override + col = column(:override_pk, :id) + assert_equal "bigint(20)", sql_type_for(col) + end + + private + + def column(table_name, column_name) + ActiveRecord::Base.connection + .columns(table_name.to_s) + .detect { |c| c.name == column_name.to_s } + end + + def sql_type_for(col) + col && col.sql_type + end +end diff --git a/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb index 69336eb906..aab3dcb724 100644 --- a/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb +++ b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb @@ -65,6 +65,19 @@ class Mysql2AdapterTest < ActiveRecord::Mysql2TestCase @conn.columns_for_distinct("posts.id", [order]) end + def test_errors_for_bigint_fks_on_integer_pk_table + # table old_cars has primary key of integer + + error = assert_raises(ActiveRecord::MismatchedForeignKey) do + @conn.add_reference :engines, :old_car + @conn.add_foreign_key :engines, :old_cars + end + + assert_match "Column `old_car_id` on table `engines` has a type of `bigint(20)`", error.message + assert_not_nil error.cause + @conn.exec_query("ALTER TABLE engines DROP COLUMN old_car_id") + end + private def with_example_table(definition = "id int auto_increment primary key, number int, data varchar(255)", &block) 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/mysql2/unsigned_type_test.rb b/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb index 452f8d5ae8..268800d538 100644 --- a/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb +++ b/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb @@ -34,10 +34,10 @@ class Mysql2UnsignedTypeTest < ActiveRecord::Mysql2TestCase assert_raise(ActiveModel::RangeError) do UnsignedType.create(unsigned_bigint: -10) end - assert_raise(ActiveRecord::StatementInvalid) do + assert_raise(ActiveRecord::RangeError) do UnsignedType.create(unsigned_float: -10.0) end - assert_raise(ActiveRecord::StatementInvalid) do + assert_raise(ActiveRecord::RangeError) do UnsignedType.create(unsigned_decimal: -10.0) end end @@ -48,6 +48,7 @@ class Mysql2UnsignedTypeTest < ActiveRecord::Mysql2TestCase t.unsigned_bigint :unsigned_bigint_t t.unsigned_float :unsigned_float_t t.unsigned_decimal :unsigned_decimal_t, precision: 10, scale: 2 + t.column :unsigned_zerofill, "int unsigned zerofill" end @connection.columns("unsigned_types").select { |c| /^unsigned_/.match?(c.name) }.each do |column| diff --git a/activerecord/test/cases/adapters/postgresql/active_schema_test.rb b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb index d3c65f3d94..b787de8453 100644 --- a/activerecord/test/cases/adapters/postgresql/active_schema_test.rb +++ b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb @@ -39,6 +39,10 @@ class PostgresqlActiveSchemaTest < ActiveRecord::PostgreSQLTestCase expected = %(CREATE INDEX CONCURRENTLY "index_people_on_last_name" ON "people" ("last_name")) assert_equal expected, add_index(:people, :last_name, algorithm: :concurrently) + expected = %(CREATE INDEX "index_people_on_last_name_and_first_name" ON "people" ("last_name" DESC, "first_name" ASC)) + assert_equal expected, add_index(:people, [:last_name, :first_name], order: { last_name: :desc, first_name: :asc }) + assert_equal expected, add_index(:people, ["last_name", :first_name], order: { last_name: :desc, "first_name" => :asc }) + %w(gin gist hash btree).each do |type| expected = %(CREATE INDEX "index_people_on_last_name" ON "people" USING #{type} ("last_name")) assert_equal expected, add_index(:people, :last_name, using: type) diff --git a/activerecord/test/cases/adapters/postgresql/legacy_migration_test.rb b/activerecord/test/cases/adapters/postgresql/legacy_migration_test.rb new file mode 100644 index 0000000000..082fe95053 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/legacy_migration_test.rb @@ -0,0 +1,54 @@ +require "cases/helper" + +class PostgresqlLegacyMigrationTest < ActiveRecord::PostgreSQLTestCase + class GenerateTableWithoutBigserial < ActiveRecord::Migration[5.0] + def change + create_table :legacy_integer_pk do |table| + table.string :foo + end + + create_table :override_pk, id: :bigint do |table| + table.string :bar + end + end + end + + def setup + super + + @migration_verbose_old = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + + migrations = [GenerateTableWithoutBigserial.new(nil, 1)] + ActiveRecord::Migrator.new(:up, migrations).migrate + end + + def teardown + ActiveRecord::Migration.verbose = @migration_verbose_old + + super + end + + def test_create_table_uses_serial_as_pkey_by_default + col = column(:legacy_integer_pk, :id) + assert_equal "integer", sql_type_for(col) + assert col.serial? + end + + def test_create_tables_respects_pk_column_type_override + col = column(:override_pk, :id) + assert_equal "bigint", sql_type_for(col) + end + + private + + def column(table_name, column_name) + ActiveRecord::Base.connection. + columns(table_name.to_s). + detect { |c| c.name == column_name.to_s } + end + + def sql_type_for(col) + col && col.sql_type + 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/adapters/sqlite3/legacy_migration_test.rb b/activerecord/test/cases/adapters/sqlite3/legacy_migration_test.rb new file mode 100644 index 0000000000..fcca8d66b5 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/legacy_migration_test.rb @@ -0,0 +1,59 @@ +require "cases/helper" + +class SqliteLegacyMigrationTest < ActiveRecord::SQLite3TestCase + self.use_transactional_tests = false + + class GenerateTableWithoutBigint < ActiveRecord::Migration[5.0] + def change + create_table :legacy_integer_pk do |table| + table.string :foo + end + + create_table :override_pk, id: :bigint do |table| + table.string :bar + end + end + end + + def setup + super + @connection = ActiveRecord::Base.connection + + @migration_verbose_old = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + + migrations = [GenerateTableWithoutBigint.new(nil, 1)] + + ActiveRecord::Migrator.new(:up, migrations).migrate + end + + def teardown + ActiveRecord::Migration.verbose = @migration_verbose_old + @connection.drop_table("legacy_integer_pk") + @connection.drop_table("override_pk") + ActiveRecord::SchemaMigration.delete_all rescue nil + super + end + + def test_create_table_uses_integer_as_pkey_by_default + col = column(:legacy_integer_pk, :id) + assert_equal "INTEGER", sql_type_for(col) + assert primary_key?(:legacy_integer_pk, "id"), "id is not primary key" + end + + private + + def column(table_name, column_name) + ActiveRecord::Base.connection + .columns(table_name.to_s) + .detect { |c| c.name == column_name.to_s } + end + + def sql_type_for(col) + col && col.sql_type + end + + def primary_key?(table_name, column) + ActiveRecord::Base.connection.execute("PRAGMA table_info(#{table_name})").find { |col| col["name"] == column }["pk"] == 1 + 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..81a2a161f2 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 @@ -1062,6 +1072,20 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase assert_equal 1, parent.reload.children_count end + def test_belongs_to_with_out_of_range_value_assigning + model = Class.new(Comment) do + def self.name; "Temp"; end + validates :post, presence: true + end + + comment = model.new + comment.post_id = 9223372036854775808 # out of range in the bigint + + assert_nil comment.post + assert_not comment.valid? + assert_equal [{ error: :blank }], comment.errors.details[:post] + end + def test_polymorphic_with_custom_primary_key toy = Toy.create! sponsor = Sponsor.create!(sponsorable: toy) 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..83b1f8d4d6 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,32 @@ 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_collection_singular_ids_through_setter_raises_exception_when_invalid_ids_set + author = authors(:david) + ids = [categories(:general).name, "Unknown"] + e = assert_raises(ActiveRecord::RecordNotFound) { author.essay_category_ids = ids } + assert_equal "Couldn't find all Categories with 'name': (General, Unknown) (found 1 results, but was looking for 2)", 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/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index ba6877a6a6..4ac604a164 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -319,6 +319,13 @@ class AttributeMethodsTest < ActiveRecord::TestCase assert_equal "Still another topic: part 4", topic.title end + test "write_attribute can write aliased attributes as well" do + topic = Topic.new(title: "Don't change the topic") + topic.write_attribute :heading, "New topic" + + assert_equal "New topic", topic.title + end + test "read_attribute" do topic = Topic.new topic.title = "Don't change the topic" @@ -329,6 +336,16 @@ class AttributeMethodsTest < ActiveRecord::TestCase assert_equal "Don't change the topic", topic[:title] end + test "read_attribute can read aliased attributes as well" do + topic = Topic.new(title: "Don't change the topic") + + assert_equal "Don't change the topic", topic.read_attribute("heading") + assert_equal "Don't change the topic", topic["heading"] + + assert_equal "Don't change the topic", topic.read_attribute(:heading) + assert_equal "Don't change the topic", topic[:heading] + end + test "read_attribute raises ActiveModel::MissingAttributeError when the attribute does not exist" do computer = Computer.select("id").first assert_raises(ActiveModel::MissingAttributeError) { computer[:developer] } diff --git a/activerecord/test/cases/attributes_test.rb b/activerecord/test/cases/attributes_test.rb index f4620ae2da..3705a6be89 100644 --- a/activerecord/test/cases/attributes_test.rb +++ b/activerecord/test/cases/attributes_test.rb @@ -255,5 +255,13 @@ module ActiveRecord assert_includes inspection, "non_existent_decimal" end + + test "attributes do not require a type" do + klass = Class.new(OverloadedType) do + attribute :no_type + end + assert_equal 1, klass.new(no_type: 1).no_type + assert_equal "foo", klass.new(no_type: "foo").no_type + end end end diff --git a/activerecord/test/cases/autosave_association_test.rb b/activerecord/test/cases/autosave_association_test.rb index a3f82ed49d..77ee3ca2d7 100644 --- a/activerecord/test/cases/autosave_association_test.rb +++ b/activerecord/test/cases/autosave_association_test.rb @@ -1699,3 +1699,27 @@ class TestAutosaveAssociationWithTouch < ActiveRecord::TestCase assert_nothing_raised { invoice.line_items.create(amount: 10) } end end + +class TestAutosaveAssociationOnAHasManyAssociationWithInverse < ActiveRecord::TestCase + class Post < ActiveRecord::Base + has_many :comments, inverse_of: :post + end + + class Comment < ActiveRecord::Base + belongs_to :post, inverse_of: :comments + + attr_accessor :post_comments_count + after_save do + self.post_comments_count = post.comments.count + end + end + + def test_after_save_callback_with_autosave + post = Post.new(title: "Test", body: "...") + comment = post.comments.build(body: "...") + post.save! + + assert_equal 1, post.comments.count + assert_equal 1, comment.post_comments_count + end +end diff --git a/activerecord/test/cases/comment_test.rb b/activerecord/test/cases/comment_test.rb index 262ad319be..a625299e8d 100644 --- a/activerecord/test/cases/comment_test.rb +++ b/activerecord/test/cases/comment_test.rb @@ -2,7 +2,6 @@ require "cases/helper" require "support/schema_dumping_helper" if ActiveRecord::Base.connection.supports_comments? - class CommentTest < ActiveRecord::TestCase include SchemaDumpingHelper @@ -102,6 +101,7 @@ if ActiveRecord::Base.connection.supports_comments? # Do all the stuff from other tests @connection.add_column :commenteds, :rating, :integer, comment: "I am running out of imagination" @connection.change_column :commenteds, :content, :string, comment: "Whoa, content describes itself!" + @connection.change_column :commenteds, :content, :string @connection.change_column :commenteds, :obvious, :string, comment: nil @connection.add_index :commenteds, :obvious, name: "idx_obvious", comment: "We need to see obvious comments" @@ -135,5 +135,4 @@ if ActiveRecord::Base.connection.supports_comments? assert_no_match %r[t\.string\s+"absent_comment", comment:\n], output end end - end 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/defaults_test.rb b/activerecord/test/cases/defaults_test.rb index fcaff38f82..6532efcf22 100644 --- a/activerecord/test/cases/defaults_test.rb +++ b/activerecord/test/cases/defaults_test.rb @@ -169,7 +169,7 @@ if current_adapter?(:Mysql2Adapter) assert_nil record.non_null_text assert_nil record.non_null_blob - assert_raises(ActiveRecord::StatementInvalid) { klass.create } + assert_raises(ActiveRecord::NotNullViolation) { klass.create } end end end diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index 7eaf31aa24..f8724b0993 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -983,7 +983,6 @@ class FinderTest < ActiveRecord::TestCase assert_equal devs[2], Developer.offset(2).first assert_equal devs[-3], Developer.offset(2).last - assert_equal devs[-3], Developer.offset(2).last assert_equal devs[-3], Developer.offset(2).order("id DESC").first end diff --git a/activerecord/test/cases/migration/change_schema_test.rb b/activerecord/test/cases/migration/change_schema_test.rb index bdb90eaa74..03f9c4a9ed 100644 --- a/activerecord/test/cases/migration/change_schema_test.rb +++ b/activerecord/test/cases/migration/change_schema_test.rb @@ -43,7 +43,7 @@ module ActiveRecord t.column :foo, :string, null: false end - assert_raises(ActiveRecord::StatementInvalid) do + assert_raises(ActiveRecord::NotNullViolation) do connection.execute "insert into testings (foo) values (NULL)" end end @@ -233,7 +233,7 @@ module ActiveRecord end connection.add_column :testings, :bar, :string, null: false - assert_raise(ActiveRecord::StatementInvalid) do + assert_raise(ActiveRecord::NotNullViolation) do connection.execute "insert into testings (foo, bar) values ('hello', NULL)" end end @@ -244,12 +244,16 @@ module ActiveRecord t.column :foo, :string end - con = connection - connection.execute "insert into testings (#{con.quote_column_name('id')}, #{con.quote_column_name('foo')}) values (1, 'hello')" - assert_nothing_raised { connection.add_column :testings, :bar, :string, null: false, default: "default" } + quoted_id = connection.quote_column_name("id") + quoted_foo = connection.quote_column_name("foo") + quoted_bar = connection.quote_column_name("bar") + connection.execute("insert into testings (#{quoted_id}, #{quoted_foo}) values (1, 'hello')") + assert_nothing_raised do + connection.add_column :testings, :bar, :string, null: false, default: "default" + end - assert_raises(ActiveRecord::StatementInvalid) do - connection.execute "insert into testings (#{con.quote_column_name('id')}, #{con.quote_column_name('foo')}, #{con.quote_column_name('bar')}) values (2, 'hello', NULL)" + assert_raises(ActiveRecord::NotNullViolation) do + connection.execute("insert into testings (#{quoted_id}, #{quoted_foo}, #{quoted_bar}) values (2, 'hello', NULL)") end end diff --git a/activerecord/test/cases/migration/change_table_test.rb b/activerecord/test/cases/migration/change_table_test.rb index ec817a579b..8a4242cf1d 100644 --- a/activerecord/test/cases/migration/change_table_test.rb +++ b/activerecord/test/cases/migration/change_table_test.rb @@ -101,7 +101,12 @@ module ActiveRecord def test_primary_key_creates_primary_key_column with_change_table do |t| - @connection.expect :add_column, nil, [:delete_me, :id, :primary_key, primary_key: true, first: true] + if current_adapter?(:Mysql2Adapter) + @connection.expect :add_column, nil, [:delete_me, :id, :primary_key, { first: true, auto_increment: true, limit: 8, primary_key: true }] + else + @connection.expect :add_column, nil, [:delete_me, :id, :primary_key, primary_key: true, first: true] + end + t.primary_key :id, first: true end end diff --git a/activerecord/test/cases/migration/foreign_key_test.rb b/activerecord/test/cases/migration/foreign_key_test.rb index cab2069754..1921a4d7c2 100644 --- a/activerecord/test/cases/migration/foreign_key_test.rb +++ b/activerecord/test/cases/migration/foreign_key_test.rb @@ -76,7 +76,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end def test_add_foreign_key_with_non_standard_primary_key - with_example_table @connection, "space_shuttles", "pk integer PRIMARY KEY" do + with_example_table @connection, "space_shuttles", "pk BIGINT PRIMARY KEY" do @connection.add_foreign_key(:astronauts, :space_shuttles, column: "rocket_id", primary_key: "pk", name: "custom_pk") @@ -229,7 +229,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? create_table("cities") { |t| } create_table("houses") do |t| - t.column :city_id, :integer + t.column :city_id, :bigint end add_foreign_key :houses, :cities, column: "city_id" @@ -261,7 +261,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? create_table(:schools) create_table(:classes) do |t| - t.column :school_id, :integer + t.column :school_id, :bigint end add_foreign_key :classes, :schools end diff --git a/activerecord/test/cases/migration/references_foreign_key_test.rb b/activerecord/test/cases/migration/references_foreign_key_test.rb index 528811db49..4957ab8b3d 100644 --- a/activerecord/test/cases/migration/references_foreign_key_test.rb +++ b/activerecord/test/cases/migration/references_foreign_key_test.rb @@ -42,7 +42,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? test "options hash can be passed" do @connection.change_table :testing_parents do |t| - t.integer :other_id + t.bigint :other_id t.index :other_id, unique: true end @connection.create_table :testings do |t| @@ -92,7 +92,7 @@ if ActiveRecord::Base.connection.supports_foreign_keys? test "foreign keys accept options when changing the table" do @connection.change_table :testing_parents do |t| - t.integer :other_id + t.bigint :other_id t.index :other_id, unique: true end @connection.create_table :testings @@ -177,8 +177,8 @@ if ActiveRecord::Base.connection.supports_foreign_keys? test "multiple foreign keys can be added to the same table" do @connection.create_table :testings do |t| - t.integer :col_1 - t.integer :col_2 + t.bigint :col_1 + t.bigint :col_2 t.foreign_key :testing_parents, column: :col_1 t.foreign_key :testing_parents, column: :col_2 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/primary_keys_test.rb b/activerecord/test/cases/primary_keys_test.rb index eaaf50d14f..b434f4a6b6 100644 --- a/activerecord/test/cases/primary_keys_test.rb +++ b/activerecord/test/cases/primary_keys_test.rb @@ -216,6 +216,43 @@ class PrimaryKeyWithNoConnectionTest < ActiveRecord::TestCase end end +class PrimaryKeyWithAutoIncrementTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class AutoIncrement < ActiveRecord::Base + end + + def setup + @connection = ActiveRecord::Base.connection + end + + def teardown + @connection.drop_table(:auto_increments, if_exists: true) + end + + def test_primary_key_with_auto_increment + @connection.create_table(:auto_increments, id: :integer, auto_increment: true, force: true) + assert_auto_incremented + end + + def test_primary_key_with_auto_increment_and_bigint + @connection.create_table(:auto_increments, id: :bigint, auto_increment: true, force: true) + assert_auto_incremented + end + + private + def assert_auto_incremented + record1 = AutoIncrement.create! + assert_not_nil record1.id + + record1.destroy + + record2 = AutoIncrement.create! + assert_not_nil record2.id + assert_operator record2.id, :>, record1.id + end +end + class PrimaryKeyAnyTypeTest < ActiveRecord::TestCase include SchemaDumpingHelper @@ -289,85 +326,69 @@ class CompositePrimaryKeyTest < ActiveRecord::TestCase end if current_adapter?(:Mysql2Adapter) - class PrimaryKeyBigintNilDefaultTest < ActiveRecord::TestCase + class PrimaryKeyIntegerNilDefaultTest < ActiveRecord::TestCase include SchemaDumpingHelper self.use_transactional_tests = false def setup @connection = ActiveRecord::Base.connection - @connection.create_table(:bigint_defaults, id: :bigint, default: nil, force: true) + @connection.create_table(:int_defaults, id: :integer, default: nil, force: true) end def teardown - @connection.drop_table :bigint_defaults, if_exists: true + @connection.drop_table :int_defaults, if_exists: true end - test "primary key with bigint allows default override via nil" do - column = @connection.columns(:bigint_defaults).find { |c| c.name == "id" } - assert column.bigint? + test "primary key with integer allows default override via nil" do + column = @connection.columns(:int_defaults).find { |c| c.name == "id" } + assert_equal :integer, column.type assert_not column.auto_increment? end - test "schema dump primary key with bigint default nil" do - schema = dump_table_schema "bigint_defaults" - assert_match %r{create_table "bigint_defaults", id: :bigint, default: nil}, schema + test "schema dump primary key with int default nil" do + schema = dump_table_schema "int_defaults" + assert_match %r{create_table "int_defaults", id: :integer, default: nil}, schema end end end -if current_adapter?(:PostgreSQLAdapter, :Mysql2Adapter) - class PrimaryKeyBigSerialTest < ActiveRecord::TestCase - include SchemaDumpingHelper +class PrimaryKeyIntegerTest < ActiveRecord::TestCase + include SchemaDumpingHelper - self.use_transactional_tests = false + self.use_transactional_tests = false - class Widget < ActiveRecord::Base - end + class Widget < ActiveRecord::Base + end - setup do - @connection = ActiveRecord::Base.connection - if current_adapter?(:PostgreSQLAdapter) - @connection.create_table(:widgets, id: :bigserial, force: true) - else - @connection.create_table(:widgets, id: :bigint, force: true) - end - end + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table(:widgets, force: true) + end - teardown do - @connection.drop_table :widgets, if_exists: true - Widget.reset_column_information - end + teardown do + @connection.drop_table :widgets, if_exists: true + Widget.reset_column_information + end - test "primary key column type with bigserial" do - column_type = Widget.type_for_attribute(Widget.primary_key) - assert_equal :integer, column_type.type - assert_equal 8, column_type.limit + if current_adapter?(:PostgreSQLAdapter, :Mysql2Adapter) + test "schema dump primary key with bigserial" do + schema = dump_table_schema "widgets" + assert_match %r{create_table "widgets", force: :cascade}, schema end + end - test "primary key with bigserial are automatically numbered" do - widget = Widget.create! - assert_not_nil widget.id - end + test "primary key column type" do + column_type = Widget.type_for_attribute(Widget.primary_key) + assert_equal :integer, column_type.type - test "schema dump primary key with bigserial" do - schema = dump_table_schema "widgets" - if current_adapter?(:PostgreSQLAdapter) - assert_match %r{create_table "widgets", id: :bigserial, force: :cascade}, schema - else - assert_match %r{create_table "widgets", id: :bigint, force: :cascade}, schema - end + if current_adapter?(:PostgreSQLAdapter, :Mysql2Adapter) + assert_equal 8, column_type.limit end if current_adapter?(:Mysql2Adapter) - test "primary key column type with options" do - @connection.create_table(:widgets, id: :primary_key, limit: 8, unsigned: true, force: true) - column = @connection.columns(:widgets).find { |c| c.name == "id" } - assert column.auto_increment? - assert_equal :integer, column.type - assert_equal 8, column.limit - assert column.unsigned? - end + column = @connection.columns(:widgets).find { |c| c.name == "id" } + assert column.auto_increment? end end end diff --git a/activerecord/test/cases/schema_dumper_test.rb b/activerecord/test/cases/schema_dumper_test.rb index 007f976f2e..97096e31e7 100644 --- a/activerecord/test/cases/schema_dumper_test.rb +++ b/activerecord/test/cases/schema_dumper_test.rb @@ -346,7 +346,7 @@ class SchemaDumperTest < ActiveRecord::TestCase create_table("dogs") do |t| t.column :name, :string - t.column :owner_id, :integer + t.column :owner_id, :bigint t.index [:name] t.foreign_key :dog_owners, column: "owner_id" if supports_foreign_keys? end 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/transaction_callbacks_test.rb b/activerecord/test/cases/transaction_callbacks_test.rb index dba100f5c9..391bbe8877 100644 --- a/activerecord/test/cases/transaction_callbacks_test.rb +++ b/activerecord/test/cases/transaction_callbacks_test.rb @@ -449,6 +449,51 @@ class CallbacksOnMultipleActionsTest < ActiveRecord::TestCase end end +class CallbacksOnDestroyUpdateActionRaceTest < ActiveRecord::TestCase + class TopicWithHistory < ActiveRecord::Base + self.table_name = :topics + + def self.clear_history + @@history = [] + end + + def self.history + @@history ||= [] + end + end + + class TopicWithCallbacksOnDestroy < TopicWithHistory + after_commit(on: :destroy) { |record| record.class.history << :destroy } + end + + class TopicWithCallbacksOnUpdate < TopicWithHistory + after_commit(on: :update) { |record| record.class.history << :update } + end + + def test_trigger_once_on_multiple_deletions + TopicWithCallbacksOnDestroy.clear_history + topic = TopicWithCallbacksOnDestroy.new + topic.save + topic_clone = TopicWithCallbacksOnDestroy.find(topic.id) + topic.destroy + topic_clone.destroy + + assert_equal [:destroy], TopicWithCallbacksOnDestroy.history + end + + def test_trigger_on_update_where_row_was_deleted + TopicWithCallbacksOnUpdate.clear_history + topic = TopicWithCallbacksOnUpdate.new + topic.save + topic_clone = TopicWithCallbacksOnUpdate.find(topic.id) + topic.destroy + topic_clone.author_name = "Test Author" + topic_clone.save + + assert_equal [], TopicWithCallbacksOnUpdate.history + end +end + class TransactionEnrollmentCallbacksTest < ActiveRecord::TestCase class TopicWithoutTransactionalEnrollmentCallbacks < ActiveRecord::Base self.table_name = :topics diff --git a/activemodel/test/cases/type/unsigned_integer_test.rb b/activerecord/test/cases/type/unsigned_integer_test.rb index 026cb08a06..1cd4dbc2c5 100644 --- a/activemodel/test/cases/type/unsigned_integer_test.rb +++ b/activerecord/test/cases/type/unsigned_integer_test.rb @@ -1,9 +1,8 @@ require "cases/helper" -require "active_model/type" -module ActiveModel +module ActiveRecord module Type - class UnsignedIntegerTest < ActiveModel::TestCase + class UnsignedIntegerTest < ActiveRecord::TestCase test "unsigned int max value is in range" do assert_equal(4294967295, UnsignedInteger.new.serialize(4294967295)) end 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/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index d889f46031..658591b6ec 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -54,7 +54,7 @@ ActiveRecord::Schema.define do create_table :authors, force: true do |t| t.string :name, null: false - t.integer :author_address_id + t.bigint :author_address_id t.integer :author_address_extra_id t.string :organization_id t.string :owned_essay_id @@ -126,6 +126,9 @@ ActiveRecord::Schema.define do t.timestamps null: false end + create_table :old_cars, id: :integer, force: true do |t| + end + create_table :carriers, force: true create_table :categories, force: true do |t| @@ -303,7 +306,7 @@ ActiveRecord::Schema.define do end create_table :engines, force: true do |t| - t.integer :car_id + t.bigint :car_id end create_table :entrants, force: true do |t| @@ -1004,7 +1007,7 @@ ActiveRecord::Schema.define do if supports_foreign_keys? # fk_test_has_fk should be before fk_test_has_pk create_table :fk_test_has_fk, force: true do |t| - t.integer :fk_id, null: false + t.bigint :fk_id, null: false end create_table :fk_test_has_pk, force: true, primary_key: "pk_id" do |t| diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 10095ee1bd..2a5c8deb11 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,9 @@ +* Change return value of `NilClass#duplicable?`, `FalseClass#duplicable?`, + `TrueClass#duplicable?`, `Symbol#duplicable?` and `Numeric#duplicable?` + to true with Ruby 2.4+. These classes can dup with Ruby 2.4+. + + *Yuji Yaginuma* + * Remove deprecated class `ActiveSupport::Concurrency::Latch` *Andrew White* 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/core_ext/class/subclasses.rb b/activesupport/lib/active_support/core_ext/class/subclasses.rb index 10a7c787f6..62397d9508 100644 --- a/activesupport/lib/active_support/core_ext/class/subclasses.rb +++ b/activesupport/lib/active_support/core_ext/class/subclasses.rb @@ -22,6 +22,7 @@ class Class def descendants descendants = [] ObjectSpace.each_object(singleton_class) do |k| + next if k.singleton_class? descendants.unshift k unless k == self end descendants diff --git a/activesupport/lib/active_support/core_ext/module/delegation.rb b/activesupport/lib/active_support/core_ext/module/delegation.rb index f5f4ba61b7..19f692e943 100644 --- a/activesupport/lib/active_support/core_ext/module/delegation.rb +++ b/activesupport/lib/active_support/core_ext/module/delegation.rb @@ -6,11 +6,12 @@ class Module # option is not used. class DelegationError < NoMethodError; end + RUBY_RESERVED_KEYWORDS = %w(alias and BEGIN begin break case class def defined? do + else elsif END end ensure false for if in module next nil not or redo rescue retry + return self super then true undef unless until when while yield) + DELEGATION_RESERVED_KEYWORDS = %w(_ arg args block) DELEGATION_RESERVED_METHOD_NAMES = Set.new( - %w(_ arg args alias and BEGIN begin block break case class def defined? do - else elsif END end ensure false for if in module next nil not or redo - rescue retry return self super then true undef unless until when while - yield) + RUBY_RESERVED_KEYWORDS + DELEGATION_RESERVED_KEYWORDS ).freeze # Provides a +delegate+ class method to easily expose contained objects' diff --git a/activesupport/lib/active_support/core_ext/object/duplicable.rb b/activesupport/lib/active_support/core_ext/object/duplicable.rb index aa2282cb7e..ebe31b38ca 100644 --- a/activesupport/lib/active_support/core_ext/object/duplicable.rb +++ b/activesupport/lib/active_support/core_ext/object/duplicable.rb @@ -1,7 +1,7 @@ #-- -# Most objects are cloneable, but not all. For example you can't dup +nil+: +# Most objects are cloneable, but not all. For example you can't dup methods: # -# nil.dup # => TypeError: can't dup NilClass +# method(:puts).dup # => TypeError: allocator undefined for Method # # Classes may signal their instances are not duplicable removing +dup+/+clone+ # or raising exceptions from them. So, to dup an arbitrary object you normally @@ -19,7 +19,7 @@ class Object # Can you safely dup this object? # - # False for +nil+, +false+, +true+, symbol, number, method objects; + # False for method objects; # true otherwise. def duplicable? true @@ -27,52 +27,77 @@ class Object end class NilClass - # +nil+ is not duplicable: - # - # nil.duplicable? # => false - # nil.dup # => TypeError: can't dup NilClass - def duplicable? - false + begin + nil.dup + rescue TypeError + + # +nil+ is not duplicable: + # + # nil.duplicable? # => false + # nil.dup # => TypeError: can't dup NilClass + def duplicable? + false + end end end class FalseClass - # +false+ is not duplicable: - # - # false.duplicable? # => false - # false.dup # => TypeError: can't dup FalseClass - def duplicable? - false + begin + false.dup + rescue TypeError + + # +false+ is not duplicable: + # + # false.duplicable? # => false + # false.dup # => TypeError: can't dup FalseClass + def duplicable? + false + end end end class TrueClass - # +true+ is not duplicable: - # - # true.duplicable? # => false - # true.dup # => TypeError: can't dup TrueClass - def duplicable? - false + begin + true.dup + rescue TypeError + + # +true+ is not duplicable: + # + # true.duplicable? # => false + # true.dup # => TypeError: can't dup TrueClass + def duplicable? + false + end end end class Symbol - # Symbols are not duplicable: - # - # :my_symbol.duplicable? # => false - # :my_symbol.dup # => TypeError: can't dup Symbol - def duplicable? - false + begin + :symbol.dup + rescue TypeError + + # Symbols are not duplicable: + # + # :my_symbol.duplicable? # => false + # :my_symbol.dup # => TypeError: can't dup Symbol + def duplicable? + false + end end end class Numeric - # Numbers are not duplicable: - # - # 3.duplicable? # => false - # 3.dup # => TypeError: can't dup Integer - def duplicable? - false + begin + 1.dup + rescue TypeError + + # Numbers are not duplicable: + # + # 3.duplicable? # => false + # 3.dup # => TypeError: can't dup Integer + def duplicable? + false + end end end 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/duration.rb b/activesupport/lib/active_support/duration.rb index 82322291d0..ad2e139ea6 100644 --- a/activesupport/lib/active_support/duration.rb +++ b/activesupport/lib/active_support/duration.rb @@ -15,16 +15,22 @@ module ActiveSupport autoload :ISO8601Serializer, "active_support/duration/iso8601_serializer" def initialize(value, parts) #:nodoc: - @value, @parts = value, parts + @value, @parts = value, parts.to_h + @parts.default = 0 end # Adds another Duration or a Numeric to this Duration. Numeric values # are treated as seconds. def +(other) if Duration === other - Duration.new(value + other.value, @parts + other.parts) + parts = @parts.dup + other.parts.each do |(key, value)| + parts[key] += value + end + Duration.new(value + other.value, parts) else - Duration.new(value + other, @parts + [[:seconds, other]]) + seconds = @parts[:seconds] + other + Duration.new(value + other, @parts.merge(seconds: seconds)) end end 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/lib/active_support/notifications.rb b/activesupport/lib/active_support/notifications.rb index bae5f067ae..2df819e554 100644 --- a/activesupport/lib/active_support/notifications.rb +++ b/activesupport/lib/active_support/notifications.rb @@ -13,7 +13,7 @@ module ActiveSupport # To instrument an event you just need to do: # # ActiveSupport::Notifications.instrument('render', extra: :information) do - # render text: 'Foo' + # render plain: 'Foo' # end # # That first executes the block and then notifies all subscribers once done. @@ -48,7 +48,7 @@ module ActiveSupport # The block is saved and will be called whenever someone instruments "render": # # ActiveSupport::Notifications.instrument('render', extra: :information) do - # render text: 'Foo' + # render plain: 'Foo' # end # # event = events.first diff --git a/activesupport/lib/active_support/subscriber.rb b/activesupport/lib/active_support/subscriber.rb index c018d3e245..2bde575698 100644 --- a/activesupport/lib/active_support/subscriber.rb +++ b/activesupport/lib/active_support/subscriber.rb @@ -1,4 +1,5 @@ require "active_support/per_thread_registry" +require "active_support/notifications" module ActiveSupport # ActiveSupport::Subscriber is an object set to consume diff --git a/activesupport/lib/active_support/testing/isolation.rb b/activesupport/lib/active_support/testing/isolation.rb index 404efe50bf..14edd13a8f 100644 --- a/activesupport/lib/active_support/testing/isolation.rb +++ b/activesupport/lib/active_support/testing/isolation.rb @@ -2,7 +2,6 @@ module ActiveSupport module Testing module Isolation require "thread" - require "shellwords" def self.included(klass) #:nodoc: klass.class_eval do @@ -79,13 +78,15 @@ module ActiveSupport "ISOLATION_OUTPUT" => tmpfile.path } - load_paths = $-I.map { |p| "-I\"#{File.expand_path(p)}\"" }.join(" ") - orig_args = ORIG_ARGV.join(" ") - test_opts = "-n#{self.class.name}##{Shellwords.escape(self.name)}" - command = "#{Gem.ruby} #{load_paths} #{$0} '#{orig_args}' #{test_opts}" + test_opts = "-n#{self.class.name}##{self.name}" - # IO.popen lets us pass env in a cross-platform way - child = IO.popen(env, command) + load_path_args = [] + $-I.each do |p| + load_path_args << "-I" + load_path_args << File.expand_path(p) + end + + child = IO.popen([env, Gem.ruby, *load_path_args, $0, *ORIG_ARGV, test_opts]) begin Process.wait(child.pid) diff --git a/activesupport/lib/active_support/xml_mini.rb b/activesupport/lib/active_support/xml_mini.rb index e16581d697..e3203ef076 100644 --- a/activesupport/lib/active_support/xml_mini.rb +++ b/activesupport/lib/active_support/xml_mini.rb @@ -68,7 +68,17 @@ module ActiveSupport "datetime" => Proc.new { |time| Time.xmlschema(time).utc rescue ::DateTime.parse(time).utc }, "integer" => Proc.new { |integer| integer.to_i }, "float" => Proc.new { |float| float.to_f }, - "decimal" => Proc.new { |number| BigDecimal(number) }, + "decimal" => Proc.new do |number| + if String === number + begin + BigDecimal(number) + rescue ArgumentError + BigDecimal('0') + end + else + BigDecimal(number) + end + end, "boolean" => Proc.new { |boolean| %w(1 true).include?(boolean.to_s.strip) }, "string" => Proc.new { |string| string.to_s }, "yaml" => Proc.new { |yaml| YAML::load(yaml) rescue yaml }, 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/activesupport/test/core_ext/class_test.rb b/activesupport/test/core_ext/class_test.rb index a9c44907cc..a7905196ae 100644 --- a/activesupport/test/core_ext/class_test.rb +++ b/activesupport/test/core_ext/class_test.rb @@ -25,4 +25,14 @@ class ClassTest < ActiveSupport::TestCase assert_equal [Baz], Bar.subclasses assert_equal [], Baz.subclasses end + + def test_descendants_excludes_singleton_classes + klass = Parent.new.singleton_class + refute Parent.descendants.include?(klass), "descendants should not include singleton classes" + end + + def test_subclasses_excludes_singleton_classes + klass = Parent.new.singleton_class + refute Parent.subclasses.include?(klass), "subclasses should not include singleton classes" + end end diff --git a/activesupport/test/core_ext/duration_test.rb b/activesupport/test/core_ext/duration_test.rb index b7b4a9dd00..26a9ea4aee 100644 --- a/activesupport/test/core_ext/duration_test.rb +++ b/activesupport/test/core_ext/duration_test.rb @@ -345,6 +345,17 @@ class DurationTest < ActiveSupport::TestCase end end + def test_adding_durations_do_not_hold_prior_states + time = Time.parse("Nov 29, 2016") + # If the implementation adds and subtracts 3 months, the + # resulting date would have been in February so the day will + # change to the 29th. + d1 = 3.months - 3.months + d2 = 2.months - 2.months + + assert_equal time + d1, time + d2 + end + private def eastern_time_zone if Gem.win_platform? diff --git a/activesupport/test/core_ext/object/duplicable_test.rb b/activesupport/test/core_ext/object/duplicable_test.rb index 677e32db1d..c2a1e68f57 100644 --- a/activesupport/test/core_ext/object/duplicable_test.rb +++ b/activesupport/test/core_ext/object/duplicable_test.rb @@ -4,9 +4,13 @@ require "active_support/core_ext/object/duplicable" require "active_support/core_ext/numeric/time" class DuplicableTest < ActiveSupport::TestCase - RAISE_DUP = [nil, false, true, :symbol, 1, 2.3, method(:puts)] - ALLOW_DUP = ["1", Object.new, /foo/, [], {}, Time.now, Class.new, Module.new] - ALLOW_DUP << BigDecimal.new("4.56") + if RUBY_VERSION >= "2.4.0" + RAISE_DUP = [method(:puts)] + ALLOW_DUP = ["1", Object.new, /foo/, [], {}, Time.now, Class.new, Module.new, BigDecimal.new("4.56"), nil, false, true, :symbol, 1, 2.3] + else + RAISE_DUP = [nil, false, true, :symbol, 1, 2.3, method(:puts)] + ALLOW_DUP = ["1", Object.new, /foo/, [], {}, Time.now, Class.new, Module.new, BigDecimal.new("4.56")] + end def test_duplicable rubinius_skip "* Method#dup is allowed at the moment on Rubinius\n" \ 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/5_0_release_notes.md b/guides/source/5_0_release_notes.md index a98f7be067..39753cbd6f 100644 --- a/guides/source/5_0_release_notes.md +++ b/guides/source/5_0_release_notes.md @@ -585,7 +585,7 @@ Please refer to the [Changelog][active-record] for detailed changes. gem. ([Pull Request](https://github.com/rails/rails/pull/21161)) * Removed support for the legacy `mysql` database adapter from core. Most users should - be able to use `mysql2`. It will be converted to a separate gem when when we find someone + be able to use `mysql2`. It will be converted to a separate gem when we find someone to maintain it. ([Pull Request 1](https://github.com/rails/rails/pull/22642), [Pull Request 2](https://github.com/rails/rails/pull/22715)) diff --git a/guides/source/action_mailer_basics.md b/guides/source/action_mailer_basics.md index 34847832fd..0825d54cb7 100644 --- a/guides/source/action_mailer_basics.md +++ b/guides/source/action_mailer_basics.md @@ -400,7 +400,7 @@ class UserMailer < ApplicationMailer mail(to: @user.email, subject: 'Welcome to My Awesome Site') do |format| format.html { render 'another_template' } - format.text { render text: 'Render text' } + format.text { render plain: 'Render text' } end end end 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/form_helpers.md b/guides/source/form_helpers.md index 048fe190e8..8ad76ad01e 100644 --- a/guides/source/form_helpers.md +++ b/guides/source/form_helpers.md @@ -438,8 +438,6 @@ output: Whenever Rails sees that the internal value of an option being generated matches this value, it will add the `selected` attribute to that option. -TIP: The second argument to `options_for_select` must be exactly equal to the desired internal value. In particular if the value is the integer `2` you cannot pass `"2"` to `options_for_select` - you must pass `2`. Be aware of values extracted from the `params` hash as they are all strings. - WARNING: When `:include_blank` or `:prompt` are not present, `:include_blank` is forced true if the select attribute `required` is true, display `size` is one and `multiple` is not true. You can add arbitrary attributes to the options using hashes: 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/guides/source/testing.md b/guides/source/testing.md index bc1f78fb2a..6f783089a9 100644 --- a/guides/source/testing.md +++ b/guides/source/testing.md @@ -322,7 +322,6 @@ specify to make your test failure messages clearer. | `assert_not_operator( obj1, operator, [obj2], [msg] )` | Ensures that `obj1.operator(obj2)` is false.| | `assert_predicate ( obj, predicate, [msg] )` | Ensures that `obj.predicate` is true, e.g. `assert_predicate str, :empty?`| | `assert_not_predicate ( obj, predicate, [msg] )` | Ensures that `obj.predicate` is false, e.g. `assert_not_predicate str, :empty?`| -| `assert_send( array, [msg] )` | Ensures that executing the method listed in `array[1]` on the object in `array[0]` with the parameters of `array[2 and up]` is true, e.g. assert_send [@user, :full_name, 'Sam Smith']. This one is weird eh?| | `flunk( [msg] )` | Ensures failure. This is useful to explicitly mark a test that isn't finished yet.| The above are a subset of assertions that minitest supports. For an exhaustive & @@ -800,6 +799,13 @@ end Now you can try running all the tests and they should pass. +NOTE: If you followed the steps in the Basic Authentication section, you'll need to add the following to the `setup` block to get all the tests passing: + +```ruby +request.headers['Authorization'] = ActionController::HttpAuthentication::Basic. + encode_credentials('dhh', 'secret') +``` + ### Available Request Types for Functional Tests If you're familiar with the HTTP protocol, you'll know that `get` is a type of request. There are 6 request types supported in Rails functional tests: diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 7db7e2e34d..8a1a440fca 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,30 @@ +* Add Webpack support in new apps via the --webpack option, which will delegate to the rails/webpacker gem. + + To generate a new app that has Webpack dependencies configured and binstubs for webpack and webpack-watcher: + + rails new myapp --webpack + + To generate a new app that has Webpack + React configured and an example intalled: + + rails new myapp --webpack=react + + *DHH* + +* Add Yarn support in new apps with a yarn binstub and vendor/package.json. Skippable via --skip-yarn option. + + *Liceth Ovalles*, *Guillermo Iguaran*, *DHH* + +* Removed jquery-rails from default stack, instead rails-ujs that is shipped + with Action View 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/Rakefile b/railties/Rakefile index 202644fb26..680ed03f75 100644 --- a/railties/Rakefile +++ b/railties/Rakefile @@ -18,6 +18,7 @@ namespace :test do "lib", "#{File.dirname(__FILE__)}/../activesupport/lib", "#{File.dirname(__FILE__)}/../actionpack/lib", + "#{File.dirname(__FILE__)}/../actionview/lib", "#{File.dirname(__FILE__)}/../activemodel/lib" ] ruby "-w", "-I#{dash_i.join ':'}", file 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/commands/runner/USAGE b/railties/lib/rails/commands/runner/USAGE index dc47a35ff3..b2a6e8493d 100644 --- a/railties/lib/rails/commands/runner/USAGE +++ b/railties/lib/rails/commands/runner/USAGE @@ -8,7 +8,7 @@ Run the Ruby file located at `path/to/filename.rb` after loading the app: <%= executable %> path/to/filename.rb -<% if RbConfig::CONFIG['host_os'] !~ /mswin|mingw/ %> +<% unless Gem.win_platform? %> You can also use the runner command as a shebang line for your executables: #!/usr/bin/env <%= File.expand_path(executable) %> diff --git a/railties/lib/rails/generators/actions.rb b/railties/lib/rails/generators/actions.rb index 5075eb1328..8a60560f86 100644 --- a/railties/lib/rails/generators/actions.rb +++ b/railties/lib/rails/generators/actions.rb @@ -279,13 +279,13 @@ module Rails def execute_command(executor, command, options = {}) log executor, command env = options[:env] || ENV["RAILS_ENV"] || "development" - sudo = options[:sudo] && RbConfig::CONFIG["host_os"] !~ /mswin|mingw/ ? "sudo " : "" + sudo = options[:sudo] && !Gem.win_platform? ? "sudo " : "" in_root { run("#{sudo}#{extify(executor)} #{command} RAILS_ENV=#{env}", verbose: false) } end # Add an extension to the given name based on the platform. def extify(name) - if RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ + if Gem.win_platform? "#{name}.bat" else name diff --git a/railties/lib/rails/generators/app_base.rb b/railties/lib/rails/generators/app_base.rb index 83e9c30548..15cc070e35 100644 --- a/railties/lib/rails/generators/app_base.rb +++ b/railties/lib/rails/generators/app_base.rb @@ -30,9 +30,15 @@ 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 :webpack, type: :string, default: nil, + desc: "Preconfigure for app-like JavaScript with Webpack" + + class_option :skip_yarn, type: :boolean, default: false, + desc: "Don't use Yarn for managing JavaScript dependencies" + class_option :skip_gemfile, type: :boolean, default: false, desc: "Don't create a Gemfile" @@ -125,6 +131,7 @@ module Rails database_gemfile_entry, webserver_gemfile_entry, assets_gemfile_entry, + webpacker_gemfile_entry, javascript_gemfile_entry, jbuilder_gemfile_entry, psych_gemfile_entry, @@ -312,6 +319,13 @@ module Rails gems end + def webpacker_gemfile_entry + return [] unless options[:webpack] + + comment = "Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker" + GemfileEntry.github "webpacker", "rails/webpacker", nil, comment + end + def jbuilder_gemfile_entry comment = "Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder" GemfileEntry.new "jbuilder", "~> 2.5", comment, {}, options[:api] @@ -328,8 +342,10 @@ 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 unless options[:skip_turbolinks] gems << GemfileEntry.version("turbolinks", "~> 5", @@ -409,6 +425,13 @@ module Rails bundle_command("install") if bundle_install? end + def run_webpack + if !(webpack = options[:webpack]).nil? + rails_command "webpacker:install" + rails_command "webpacker:install:#{webpack}" unless webpack == "webpack" + end + end + def generate_spring_binstubs if bundle_install? && spring_install? bundle_command("exec spring binstub --all") diff --git a/railties/lib/rails/generators/rails/app/app_generator.rb b/railties/lib/rails/generators/rails/app/app_generator.rb index d6ffa2d89d..19d3ba2f0f 100644 --- a/railties/lib/rails/generators/rails/app/app_generator.rb +++ b/railties/lib/rails/generators/rails/app/app_generator.rb @@ -151,19 +151,12 @@ module Rails end def vendor - vendor_javascripts - vendor_stylesheets - end + empty_directory_with_keep_file "vendor" - def vendor_javascripts - unless options[:skip_javascript] - empty_directory_with_keep_file "vendor/assets/javascripts" + unless options[:skip_yarn] + template "package.json", "vendor/package.json" end end - - def vendor_stylesheets - empty_directory_with_keep_file "vendor/assets/stylesheets" - end end module Generators @@ -193,9 +186,11 @@ module Rails raise Error, "Invalid value for --database option. Supported for preconfiguration are: #{DATABASES.join(", ")}." end - # Force sprockets to be skipped when generating API only apps. + # Force sprockets and yarn to be skipped when generating API only apps. # Can't modify options hash as it's frozen by default. - self.options = options.merge(skip_sprockets: true, skip_javascript: true).freeze if options[:api] + if options[:api] + self.options = options.merge(skip_sprockets: true, skip_javascript: true, skip_yarn: true).freeze + end end public_task :set_default_accessors! @@ -205,8 +200,8 @@ module Rails build(:readme) build(:rakefile) build(:configru) - build(:gitignore) unless options[:skip_git] - build(:gemfile) unless options[:skip_gemfile] + build(:gitignore) unless options[:skip_git] + build(:gemfile) unless options[:skip_gemfile] end def create_app_files @@ -267,6 +262,10 @@ module Rails def create_vendor_files build(:vendor) + + if options[:skip_yarn] + remove_file "vendor/package.json" + end end def delete_app_assets_if_api_option @@ -274,7 +273,6 @@ module Rails remove_dir "app/assets" remove_dir "lib/assets" remove_dir "tmp/cache/assets" - remove_dir "vendor/assets" end end @@ -350,12 +348,16 @@ module Rails end end + def delete_bin_yarn_if_api_option + remove_file "bin/yarn" if options[:api] + end + def finish_template build(:leftovers) end public_task :apply_rails_template, :run_bundle - public_task :generate_spring_binstubs + public_task :run_webpack, :generate_spring_binstubs def run_after_bundle_callbacks @after_bundle_callbacks.each(&:call) @@ -423,7 +425,7 @@ module Rails "/opt/local/var/run/mysql4/mysqld.sock", # mac + darwinports + mysql4 "/opt/local/var/run/mysql5/mysqld.sock", # mac + darwinports + mysql5 "/opt/lampp/var/mysql/mysql.sock" # xampp for linux - ].find { |f| File.exist?(f) } unless RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ + ].find { |f| File.exist?(f) } unless Gem.win_platform? end def get_builder_class 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..25870f19c8 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 @@ -1,8 +1,8 @@ // This is a manifest file that'll be compiled into application.js, which will include all the files // listed below. // -// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, -// or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. +// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, or any plugin's +// vendor/assets/javascripts directory can be referenced here using a relative path. // // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the // compiled file. JavaScript code in this file should be added after the last require_* statement. @@ -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/lib/rails/generators/rails/app/templates/app/assets/stylesheets/application.css b/railties/lib/rails/generators/rails/app/templates/app/assets/stylesheets/application.css index 0ebd7fe829..865300bef9 100644 --- a/railties/lib/rails/generators/rails/app/templates/app/assets/stylesheets/application.css +++ b/railties/lib/rails/generators/rails/app/templates/app/assets/stylesheets/application.css @@ -2,8 +2,8 @@ * This is a manifest file that'll be compiled into application.css, which will include all the files * listed below. * - * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, - * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's + * vendor/assets/stylesheets directory can be referenced here using a relative path. * * You're free to add application-wide styles to this file and they'll appear at the bottom of the * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS diff --git a/railties/lib/rails/generators/rails/app/templates/bin/setup.tt b/railties/lib/rails/generators/rails/app/templates/bin/setup.tt index 8635e97b76..c6607dbb2b 100644 --- a/railties/lib/rails/generators/rails/app/templates/bin/setup.tt +++ b/railties/lib/rails/generators/rails/app/templates/bin/setup.tt @@ -16,8 +16,12 @@ chdir APP_ROOT do puts '== Installing dependencies ==' system! 'gem install bundler --conservative' system('bundle check') || system!('bundle install') -<% unless options.skip_active_record -%> +<% unless options[:skip_yarn] %> + # Install JavaScript dependencies if using Yarn + # system('bin/yarn') +<% end %> +<% unless options.skip_active_record -%> # puts "\n== Copying sample files ==" # unless File.exist?('config/database.yml') # cp 'config/database.yml.sample', 'config/database.yml' diff --git a/railties/lib/rails/generators/rails/app/templates/bin/update.tt b/railties/lib/rails/generators/rails/app/templates/bin/update.tt index d385b363c6..d23af018c7 100644 --- a/railties/lib/rails/generators/rails/app/templates/bin/update.tt +++ b/railties/lib/rails/generators/rails/app/templates/bin/update.tt @@ -17,7 +17,6 @@ chdir APP_ROOT do system! 'gem install bundler --conservative' system('bundle check') || system!('bundle install') <% unless options.skip_active_record -%> - puts "\n== Updating database ==" system! 'bin/rails db:migrate' <% end -%> diff --git a/railties/lib/rails/generators/rails/app/templates/bin/yarn b/railties/lib/rails/generators/rails/app/templates/bin/yarn new file mode 100644 index 0000000000..872438cecb --- /dev/null +++ b/railties/lib/rails/generators/rails/app/templates/bin/yarn @@ -0,0 +1,9 @@ +VENDOR_PATH = File.expand_path('../vendor', __dir__) +Dir.chdir(VENDOR_PATH) do + begin + exec "yarnpkg #{ARGV.join(" ")}" + rescue Errno::ENOENT + puts "Yarn executable was not detected in the system." + puts "Download Yarn at https://yarnpkg.com/en/docs/install" + end +end diff --git a/railties/lib/rails/generators/rails/app/templates/config/initializers/assets.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/initializers/assets.rb.tt index 2318cf59ff..f5d03fb117 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/initializers/assets.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/initializers/assets.rb.tt @@ -3,8 +3,12 @@ # Version of your assets, change this if you want to expire all your assets. Rails.application.config.assets.version = '1.0' -# Add additional assets to the asset load path +# Add additional assets to the asset load path. # Rails.application.config.assets.paths << Emoji.images_path +<%- unless options[:skip_yarn] -%> +# Add Yarn node_modules folder to the asset load path. +Rails.application.config.assets.paths << Rails.root.join('vendor/node_modules') +<%- end -%> # Precompile additional assets. # application.js, application.css, and all non-JS/CSS in the app/assets diff --git a/railties/lib/rails/generators/rails/app/templates/config/puma.rb b/railties/lib/rails/generators/rails/app/templates/config/puma.rb index 7ee948002e..1e19380dcb 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/puma.rb +++ b/railties/lib/rails/generators/rails/app/templates/config/puma.rb @@ -1,13 +1,13 @@ # Puma can serve each request in a thread from an internal thread pool. -# The `threads` method setting takes two numbers a minimum and maximum. +# The `threads` method setting takes two numbers: a minimum and maximum. # Any libraries that use thread pools should be configured to match # the maximum value specified for Puma. Default is set to 5 threads for minimum -# and maximum, this matches the default thread size of Active Record. +# and maximum; this matches the default thread size of Active Record. # threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } threads threads_count, threads_count -# Specifies the `port` that Puma will listen on to receive requests, default is 3000. +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. # port ENV.fetch("PORT") { 3000 } @@ -42,9 +42,9 @@ environment ENV.fetch("RAILS_ENV") { "development" } # The code in the `on_worker_boot` will be called if you are using # clustered mode by specifying a number of `workers`. After each worker -# process is booted this block will be run, if you are using `preload_app!` -# option you will want to use this block to reconnect to any threads -# or connections that may have been created at application boot, Ruby +# process is booted, this block will be run. If you are using the `preload_app!` +# option, you will want to use this block to reconnect to any threads +# or connections that may have been created at application boot, as Ruby # cannot share connections between processes. # # on_worker_boot do diff --git a/railties/lib/rails/generators/rails/app/templates/gitignore b/railties/lib/rails/generators/rails/app/templates/gitignore index 0e66cc4237..709b341387 100644 --- a/railties/lib/rails/generators/rails/app/templates/gitignore +++ b/railties/lib/rails/generators/rails/app/templates/gitignore @@ -21,5 +21,8 @@ !/tmp/.keep <% end -%> -# Ignore Byebug command history file. +<% unless options[:skip_yarn] -%> +/vendor/node_modules + +<% end -%> .byebug_history diff --git a/railties/lib/rails/generators/rails/app/templates/package.json b/railties/lib/rails/generators/rails/app/templates/package.json new file mode 100644 index 0000000000..46db57dcbe --- /dev/null +++ b/railties/lib/rails/generators/rails/app/templates/package.json @@ -0,0 +1,5 @@ +{ + "name": "<%= app_name %>", + "private": true, + "dependencies": {} +} diff --git a/railties/lib/rails/generators/rails/controller/controller_generator.rb b/railties/lib/rails/generators/rails/controller/controller_generator.rb index ced3c85c00..01214dc919 100644 --- a/railties/lib/rails/generators/rails/controller/controller_generator.rb +++ b/railties/lib/rails/generators/rails/controller/controller_generator.rb @@ -16,7 +16,7 @@ module Rails unless options[:skip_routes] actions.reverse_each do |action| # route prepends two spaces onto the front of the string that is passed, this corrects that. - route generate_routing_code(action)[2..-1] + route generate_routing_code(action) end end end @@ -40,7 +40,7 @@ module Rails # namespace :bar do namespace_ladder = regular_class_path.each_with_index.map do |ns, i| indent(" namespace :#{ns} do\n", i * 2) - end.join + end.join[2..-1] # Create route # get 'baz/index' @@ -54,7 +54,7 @@ module Rails end.join # Combine the 3 parts to generate complete route entry - namespace_ladder + route + end_ladder + "#{namespace_ladder}#{route}#{end_ladder}" end end end diff --git a/railties/lib/rails/paths.rb b/railties/lib/rails/paths.rb index 1c1810dde6..10925de8b2 100644 --- a/railties/lib/rails/paths.rb +++ b/railties/lib/rails/paths.rb @@ -45,7 +45,6 @@ module Rails attr_accessor :path def initialize(path) - @current = nil @path = path @root = {} end diff --git a/railties/lib/rails/source_annotation_extractor.rb b/railties/lib/rails/source_annotation_extractor.rb index 967e969f81..3a48c4c496 100644 --- a/railties/lib/rails/source_annotation_extractor.rb +++ b/railties/lib/rails/source_annotation_extractor.rb @@ -116,7 +116,7 @@ class SourceAnnotationExtractor # Otherwise it returns an empty hash. def extract_annotations_from(file, pattern) lineno = 0 - result = File.readlines(file).inject([]) do |list, line| + result = File.readlines(file, encoding: Encoding::BINARY).inject([]) do |list, line| lineno += 1 next list unless line =~ pattern list << Annotation.new(lineno, $1, $2) diff --git a/railties/lib/rails/tasks/statistics.rake b/railties/lib/rails/tasks/statistics.rake index a6cdd1e99c..8265aef10b 100644 --- a/railties/lib/rails/tasks/statistics.rake +++ b/railties/lib/rails/tasks/statistics.rake @@ -8,7 +8,7 @@ STATS_DIRECTORIES = [ %w(Models app/models), %w(Mailers app/mailers), %w(Channels app/channels), - %w(Javascripts app/assets/javascripts), + %w(JavaScripts app/assets/javascripts), %w(Libraries lib/), %w(Tasks lib/tasks), %w(APIs app/apis), 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..c54d9cc599 100644 --- a/railties/test/generators/api_app_generator_test.rb +++ b/railties/test/generators/api_app_generator_test.rb @@ -35,7 +35,6 @@ 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 'sass-rails'/, content) assert_no_match(/gem 'web-console'/, content) assert_match(/# gem 'jbuilder'/, content) @@ -106,10 +105,10 @@ class ApiAppGeneratorTest < Rails::Generators::TestCase %w(app/assets app/helpers app/views/layouts/application.html.erb + bin/yarn config/initializers/assets.rb config/initializers/cookies_serializer.rb lib/assets - vendor/assets test/helpers tmp/cache/assets public/404.html @@ -117,6 +116,8 @@ class ApiAppGeneratorTest < Rails::Generators::TestCase public/500.html public/apple-touch-icon-precomposed.png public/apple-touch-icon.png - public/favicon.ico) + public/favicon.icon + vendor/package.json + ) end end diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index 3ec99193e3..9285a9f091 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -43,9 +43,6 @@ DEFAULT_APP_FILES = %w( test/mailers test/integration vendor - vendor/assets - vendor/assets/stylesheets - vendor/assets/javascripts tmp tmp/cache tmp/cache/assets @@ -405,7 +402,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,29 +444,25 @@ 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" 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 run_generator [destination_root, "--skip-javascript"] assert_no_file "app/assets/javascripts" - assert_no_file "vendor/assets/javascripts" assert_file "app/views/layouts/application.html.erb" do |contents| assert_match(/stylesheet_link_tag\s+'application', media: 'all' %>/, contents) @@ -479,7 +471,6 @@ 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) end @@ -493,11 +484,25 @@ 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 + def test_generator_for_yarn + run_generator([destination_root]) + assert_file "vendor/package.json", /dependencies/ + assert_file "config/initializers/assets.rb", /node_modules/ + end + + def test_generator_for_yarn_skipped + run_generator([destination_root, "--skip-yarn"]) + assert_no_file "vendor/package.json" + + assert_file "config/initializers/assets.rb" do |content| + assert_no_match(/node_modules/, content) + end + end + def test_inclusion_of_jbuilder run_generator assert_gem "jbuilder" @@ -736,7 +741,6 @@ class AppGeneratorTest < Rails::Generators::TestCase test/helpers test/integration tmp - vendor/assets/stylesheets ) folders_with_keep.each do |folder| assert_file("#{folder}/.keep") 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" |