diff options
Diffstat (limited to 'actionview')
535 files changed, 58320 insertions, 0 deletions
diff --git a/actionview/.gitignore b/actionview/.gitignore new file mode 100644 index 0000000000..246aabbb7f --- /dev/null +++ b/actionview/.gitignore @@ -0,0 +1,5 @@ +/lib/assets/compiled/ +/log/ +/test/fixtures/public/absolute/ +/test/ujs/log/ +/tmp/ diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md new file mode 100644 index 0000000000..df4036a5a7 --- /dev/null +++ b/actionview/CHANGELOG.md @@ -0,0 +1,183 @@ +* Fix UJS permanently showing disabled text in a[data-remote][data-disable-with] elements within forms. + Fixes #33889 + + *Wolfgang Hobmaier* + + +* Prevent non-primary mouse keys from triggering Rails UJS click handlers. + Firefox fires click events even if the click was triggered by non-primary mouse keys such as right- or scroll-wheel-clicks. + For example, right-clicking a link such as the one described below (with an underlying ajax request registered on click) should not cause that request to occur. + + ``` + <%= link_to 'Remote', remote_path, class: 'remote', remote: true, data: { type: :json } %> + ``` + + Fixes #34541 + + *Wolfgang Hobmaier* + + +* Prevent `ActionView::TextHelper#word_wrap` from unexpectedly stripping white space from the _left_ side of lines. + + For example, given input like this: + + ``` + This is a paragraph with an initial indent, + followed by additional lines that are not indented, + and finally terminated with a blockquote: + "A pithy saying" + ``` + + Calling `word_wrap` should not trim the indents on the first and last lines. + + Fixes #34487 + + *Lyle Mullican* + + +* Add allocations to template rendering instrumentation. + + Adds the allocations for template and partial rendering to the server output on render. + + ``` + Rendered posts/_form.html.erb (Duration: 7.1ms | Allocations: 6004) + Rendered posts/new.html.erb within layouts/application (Duration: 8.3ms | Allocations: 6654) + Completed 200 OK in 858ms (Views: 848.4ms | ActiveRecord: 0.4ms | Allocations: 1539564) + ``` + + *Eileen M. Uchitelle*, *Aaron Patterson* + +* Respect the `only_path` option passed to `url_for` when the options are passed in as an array + + Fixes #33237. + + *Joel Ambass* + +* Deprecate calling private model methods from view helpers. + + For example, in methods like `options_from_collection_for_select` + and `collection_select` it is possible to call private methods from + the objects used. + + Fixes #33546. + + *Ana María Martínez Gómez* + +* Fix issue with `button_to`'s `to_form_params` + + `button_to` was throwing exception when invoked with `params` hash that + contains symbol and string keys. The reason for the exception was that + `to_form_params` was comparing the given symbol and string keys. + + The issue is fixed by turning all keys to strings inside + `to_form_params` before comparing them. + + *Georgi Georgiev* + +* Mark arrays of translations as trusted safe by using the `_html` suffix. + + Example: + + en: + foo_html: + - "One" + - "<strong>Two</strong>" + - "Three 👋 🙂" + + *Juan Broullon* + +* Add `year_format` option to date_select tag. This option makes it possible to customize year + names. Lambda should be passed to use this option. + + Example: + + date_select('user_birthday', '', start_year: 1998, end_year: 2000, year_format: ->year { "Heisei #{year - 1988}" }) + + The HTML produced: + + <select id="user_birthday__1i" name="user_birthday[(1i)]"> + <option value="1998">Heisei 10</option> + <option value="1999">Heisei 11</option> + <option value="2000">Heisei 12</option> + </select> + /* The rest is omitted */ + + *Koki Ryu* + +* Fix JavaScript views rendering does not work with Firefox when using + Content Security Policy. + + Fixes #32577. + + *Yuji Yaginuma* + +* Add the `nonce: true` option for `javascript_include_tag` helper to + support automatic nonce generation for Content Security Policy. + Works the same way as `javascript_tag nonce: true` does. + + *Yaroslav Markin* + +* Remove `ActionView::Helpers::RecordTagHelper`. + + *Yoshiyuki Hirano* + +* Disable `ActionView::Template` finalizers in test environment. + + Template finalization can be expensive in large view test suites. + Add a configuration option, + `action_view.finalize_compiled_template_methods`, and turn it off in + the test environment. + + *Simon Coffey* + +* Extract the `confirm` call in its own, overridable method in `rails_ujs`. + + Example: + + Rails.confirm = function(message, element) { + return (my_bootstrap_modal_confirm(message)); + } + + *Mathieu Mahé* + +* Enable select tag helper to mark `prompt` option as `selected` and/or `disabled` for `required` + field. + + Example: + + select :post, + :category, + ["lifestyle", "programming", "spiritual"], + { selected: "", disabled: "", prompt: "Choose one" }, + { required: true } + + Placeholder option would be selected and disabled. + + The HTML produced: + + <select required="required" name="post[category]" id="post_category"> + <option disabled="disabled" selected="selected" value="">Choose one</option> + <option value="lifestyle">lifestyle</option> + <option value="programming">programming</option> + <option value="spiritual">spiritual</option></select> + + *Sergey Prikhodko* + +* Don't enforce UTF-8 by default. + + With the disabling of TLS 1.0 by most major websites, continuing to run + IE8 or lower becomes increasingly difficult so default to not enforcing + UTF-8 encoding as it's not relevant to other browsers. + + *Andrew White* + +* Change translation key of `submit_tag` from `module_name_class_name` to `module_name/class_name`. + + *Rui Onodera* + +* Rails 6 requires Ruby 2.5.0 or newer. + + *Jeremy Daer*, *Kasper Timm Hansen* + + +Please check [5-2-stable](https://github.com/rails/rails/blob/5-2-stable/actionview/CHANGELOG.md) for previous changes. diff --git a/actionview/MIT-LICENSE b/actionview/MIT-LICENSE new file mode 100644 index 0000000000..1cb3add0fc --- /dev/null +++ b/actionview/MIT-LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2004-2018 David Heinemeier Hansson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/actionview/README.rdoc b/actionview/README.rdoc new file mode 100644 index 0000000000..03a0723564 --- /dev/null +++ b/actionview/README.rdoc @@ -0,0 +1,38 @@ += Action View + +Action View is a framework for handling view template lookup and rendering, and provides +view helpers that assist when building HTML forms, Atom feeds and more. +Template formats that Action View handles are ERB (embedded Ruby, typically +used to inline short Ruby snippets inside HTML), and XML Builder. + +== Download and installation + +The latest version of Action View can be installed with RubyGems: + + $ gem install actionview + +Source code can be downloaded as part of the Rails project on GitHub: + +* https://github.com/rails/rails/tree/master/actionview + + +== License + +Action View is released under the MIT license: + +* https://opensource.org/licenses/MIT + + +== Support + +API documentation is at + +* http://api.rubyonrails.org + +Bug reports for the Ruby on Rails project can be filed here: + +* https://github.com/rails/rails/issues + +Feature requests should be discussed on the rails-core mailing list here: + +* https://groups.google.com/forum/?fromgroups#!forum/rubyonrails-core diff --git a/actionview/RUNNING_UJS_TESTS.rdoc b/actionview/RUNNING_UJS_TESTS.rdoc new file mode 100644 index 0000000000..e30c2aee55 --- /dev/null +++ b/actionview/RUNNING_UJS_TESTS.rdoc @@ -0,0 +1,8 @@ +== Running UJS tests + +Ensure that you can build the project by running: + rake ujs:server + +Then run the web tests by visiting the following URL in your browser: + + http://localhost:4567 diff --git a/actionview/RUNNING_UNIT_TESTS.rdoc b/actionview/RUNNING_UNIT_TESTS.rdoc new file mode 100644 index 0000000000..4442dbdb9e --- /dev/null +++ b/actionview/RUNNING_UNIT_TESTS.rdoc @@ -0,0 +1,26 @@ +== Running with Rake + +The easiest way to run the unit tests is through Rake. The default task runs +the entire test suite for all classes. For more information, checkout the +full array of rake tasks with <tt>rake -T</tt> + +Rake can be found at https://ruby.github.io/rake/. + +== Running by hand + +Run a single test suite: + + rake test TEST=path/to/test.rb + +which can be further narrowed down to one test: + + rake test TEST=path/to/test.rb TESTOPTS="--name=test_something" + +== Dependency on Active Record and database setup + +Test cases in the +test/activerecord/+ directory depend on having +activerecord+ and +sqlite3+ installed. If Active Record is not in +actionview/../activerecord+ directory, or the +sqlite3+ Ruby gem is not installed, + these tests are skipped. +Other tests are runnable from a fresh copy of actionview without any configuration. + diff --git a/actionview/Rakefile b/actionview/Rakefile new file mode 100644 index 0000000000..7851a2b6bf --- /dev/null +++ b/actionview/Rakefile @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "rake/testtask" +require "fileutils" +require "open3" + +desc "Default Task" +task default: :test + +task package: %w( assets:compile assets:verify ) + +# Run the unit tests + +desc "Run all unit tests" +task test: ["test:template", "test:integration:action_pack", "test:integration:active_record"] + +namespace :test do + task :isolated do + Dir.glob("test/{actionpack,activerecord,template}/**/*_test.rb").all? do |file| + sh(Gem.ruby, "-w", "-Ilib:test", file) + end || raise("Failures") + end + + Rake::TestTask.new(:template) do |t| + t.libs << "test" + t.test_files = Dir.glob("test/template/**/*_test.rb") + t.warning = true + t.verbose = true + t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) + end + + desc "Run tests for rails-ujs" + task :ujs do + begin + Dir.mkdir("log") + pid = spawn("bundle exec rackup test/ujs/config.ru -p 4567 -s puma > log/test.log 2>&1", pgroup: true) + + start_time = Time.now + + loop do + break if system("lsof -i :4567", 1 => File::NULL) + + if Time.now - start_time > 5 + puts "Timed out after 5 seconds" + exit 1 + end + end + + system("npm run lint && bundle exec ruby ../ci/qunit-selenium-runner.rb http://localhost:4567/") + status = $?.exitstatus + ensure + Process.kill("KILL", -pid) if pid + FileUtils.rm_rf("log") + end + + exit status + end + + namespace :integration do + # Active Record Integration Tests + Rake::TestTask.new(:active_record) do |t| + t.libs << "test" + t.test_files = Dir.glob("test/activerecord/*_test.rb") + t.warning = true + t.verbose = true + t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) + end + + # Action Pack Integration Tests + Rake::TestTask.new(:action_pack) do |t| + t.libs << "test" + t.test_files = Dir.glob("test/actionpack/**/*_test.rb") + t.warning = true + t.verbose = true + t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) + end + 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" + require "sprockets" + require "sprockets/export" + Blade.build + end + + desc "Verify compiled Action View assets" + task :verify do + file = "lib/assets/compiled/rails-ujs.js" + pathname = Pathname.new("#{__dir__}/#{file}") + + print "[verify] #{file} exists " + if pathname.exist? + puts "[OK]" + else + $stderr.puts "[FAIL]" + fail + end + + print "[verify] #{file} is a UMD module " + if /module\.exports.*define\.amd/m.match?(pathname.read) + puts "[OK]" + else + $stderr.puts "[FAIL]" + fail + end + + print "[verify] #{__dir__} can be required as a module " + js = <<-JS + window = { Event: class {} } + class Element {} + require('#{__dir__}') + JS + _, stderr, status = Open3.capture3("node", "--print", js) + if status.success? + puts "[OK]" + else + $stderr.puts "[FAIL]\n#{stderr}" + fail + end + end +end + +task :lines do + load File.expand_path("../tools/line_statistics", __dir__) + files = FileList["lib/**/*.rb"] + CodeTools::LineStatistics.new(files).print_loc +end diff --git a/actionview/actionview.gemspec b/actionview/actionview.gemspec new file mode 100644 index 0000000000..d8bd233ceb --- /dev/null +++ b/actionview/actionview.gemspec @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +version = File.read(File.expand_path("../RAILS_VERSION", __dir__)).strip + +Gem::Specification.new do |s| + s.platform = Gem::Platform::RUBY + s.name = "actionview" + s.version = version + s.summary = "Rendering framework putting the V in MVC (part of Rails)." + s.description = "Simple, battle-tested conventions and helpers for building web pages." + + s.required_ruby_version = ">= 2.5.0" + + s.license = "MIT" + + s.author = "David Heinemeier Hansson" + s.email = "david@loudthinking.com" + s.homepage = "http://rubyonrails.org" + + s.files = Dir["CHANGELOG.md", "README.rdoc", "MIT-LICENSE", "lib/**/*"] + s.require_path = "lib" + s.requirements << "none" + + s.metadata = { + "source_code_uri" => "https://github.com/rails/rails/tree/v#{version}/actionview", + "changelog_uri" => "https://github.com/rails/rails/blob/v#{version}/actionview/CHANGELOG.md" + } + + # NOTE: Please read our dependency guidelines before updating versions: + # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves + + s.add_dependency "activesupport", version + + s.add_dependency "builder", "~> 3.1" + s.add_dependency "erubi", "~> 1.4" + s.add_dependency "rails-html-sanitizer", "~> 1.0", ">= 1.0.3" + s.add_dependency "rails-dom-testing", "~> 2.0" + + s.add_development_dependency "actionpack", version + s.add_development_dependency "activemodel", version +end diff --git a/actionview/app/assets/javascripts/MIT-LICENSE b/actionview/app/assets/javascripts/MIT-LICENSE new file mode 100644 index 0000000000..28e1b12496 --- /dev/null +++ b/actionview/app/assets/javascripts/MIT-LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2007-2018 Rails Core team + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/actionview/app/assets/javascripts/README.md b/actionview/app/assets/javascripts/README.md new file mode 100644 index 0000000000..b74fa1afad --- /dev/null +++ b/actionview/app/assets/javascripts/README.md @@ -0,0 +1,57 @@ +# Ruby on Rails unobtrusive scripting adapter + +This unobtrusive scripting support file is developed for the Ruby on Rails framework, but is not strictly tied to any specific backend. You can drop this into any application to: + +- force confirmation dialogs for various actions; +- make non-GET requests from hyperlinks; +- make forms or hyperlinks submit data asynchronously with Ajax; +- have submit buttons become automatically disabled on form submit to prevent double-clicking. + +These features are achieved by adding certain [`data` attributes][data] to your HTML markup. In Rails, they are added by the framework's template helpers. + +## Optional prerequisites + +Note that the `data` attributes this library adds are a feature of HTML5. If you're not targeting HTML5, these attributes may make your HTML to fail [validation][validator]. However, this shouldn't create any issues for web browsers or other user agents. + +## Installation + +### NPM + + npm install rails-ujs --save + +### Yarn + + yarn add rails-ujs + +Ensure that `.yarnclean` does not include `assets` if you use [yarn autoclean](https://yarnpkg.com/lang/en/docs/cli/autoclean/). + +## Usage + +### Asset pipeline + +In a conventional Rails application that uses the asset pipeline, require `rails-ujs` in your `application.js` manifest: + +```javascript +//= require rails-ujs +``` + +### ES2015+ + +If you're using the Webpacker gem or some other JavaScript bundler, add the following to your main JS file: + +```javascript +import Rails from 'rails-ujs'; +Rails.start() +``` + +## How to run tests + +Run `bundle exec rake ujs:server` first, and then run the web tests by visiting http://localhost:4567 in your browser. + +## License + +rails-ujs is released under the [MIT License](MIT-LICENSE). + +[data]: https://www.w3.org/TR/html5/dom.html#embedding-custom-non-visible-data-with-the-data-attributes "Embedding custom non-visible data with the data-* attributes" +[validator]: http://validator.w3.org/ +[csrf]: http://api.rubyonrails.org/classes/ActionController/RequestForgeryProtection.html diff --git a/actionview/app/assets/javascripts/rails-ujs.coffee b/actionview/app/assets/javascripts/rails-ujs.coffee new file mode 100644 index 0000000000..bd6e9bb881 --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs.coffee @@ -0,0 +1,39 @@ +#= require ./rails-ujs/BANNER +#= export Rails +#= require_self +#= require_tree ./rails-ujs/utils +#= require_tree ./rails-ujs/features +#= require ./rails-ujs/start + +@Rails = + # Link elements bound by rails-ujs + linkClickSelector: 'a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]' + + # Button elements bound by rails-ujs + buttonClickSelector: + selector: 'button[data-remote]:not([form]), button[data-confirm]:not([form])' + exclude: 'form button' + + # Select elements bound by rails-ujs + inputChangeSelector: 'select[data-remote], input[data-remote], textarea[data-remote]' + + # Form elements bound by rails-ujs + formSubmitSelector: 'form' + + # Form input elements bound by rails-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 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/rails-ujs/BANNER.js b/actionview/app/assets/javascripts/rails-ujs/BANNER.js new file mode 100644 index 0000000000..47ecd66003 --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/BANNER.js @@ -0,0 +1,5 @@ +/* +Unobtrusive JavaScript +https://github.com/rails/rails/blob/master/actionview/app/assets/javascripts +Released under the MIT license + */ diff --git a/actionview/app/assets/javascripts/rails-ujs/features/confirm.coffee b/actionview/app/assets/javascripts/rails-ujs/features/confirm.coffee new file mode 100644 index 0000000000..0738ffcdc9 --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/features/confirm.coffee @@ -0,0 +1,30 @@ +#= require_tree ../utils + +{ fire, stopEverything } = Rails + +Rails.handleConfirm = (e) -> + stopEverything(e) unless allowAction(this) + +# Default confirm dialog, may be overridden with custom confirm dialog in Rails.confirm +Rails.confirm = (message, element) -> + confirm(message) + +# 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 = Rails.confirm(message, element) + callback = fire(element, 'confirm:complete', [answer]) + + answer and callback diff --git a/actionview/app/assets/javascripts/rails-ujs/features/disable.coffee b/actionview/app/assets/javascripts/rails-ujs/features/disable.coffee new file mode 100644 index 0000000000..4cfaead078 --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/features/disable.coffee @@ -0,0 +1,93 @@ +#= require_tree ../utils + +{ matches, getData, setData, stopEverything, formElements } = Rails + +Rails.handleDisabledElement = (e) -> + element = this + stopEverything(e) if element.disabled + +# Unified function to enable an element (link, button and form) +Rails.enableElement = (e) -> + if e instanceof Event + return if isXhrRedirect(e) + element = e.target + else + element = 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) -> + return if getData(element, 'ujs:disabled') + 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) -> + return if getData(element, 'ujs:disabled') + 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) + +isXhrRedirect = (event) -> + xhr = event.detail?[0] + xhr?.getResponseHeader("X-Xhr-Redirect")? diff --git a/actionview/app/assets/javascripts/rails-ujs/features/method.coffee b/actionview/app/assets/javascripts/rails-ujs/features/method.coffee new file mode 100644 index 0000000000..d04d9414dd --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/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/rails-ujs/features/remote.coffee b/actionview/app/assets/javascripts/rails-ujs/features/remote.coffee new file mode 100644 index 0000000000..a5b61220bb --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/features/remote.coffee @@ -0,0 +1,93 @@ +#= require_tree ../utils + +{ + matches, getData, setData + fire, stopEverything + ajax, isCrossDomain + 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') + return false + 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) + +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.preventInsignificantClick = (e) -> + link = this + method = (link.getAttribute('data-method') or 'GET').toUpperCase() + data = link.getAttribute('data-params') + metaClick = e.metaKey or e.ctrlKey + insignificantMetaClick = metaClick and method is 'GET' and not data + primaryMouseKey = e.button is 0 + e.stopImmediatePropagation() if not primaryMouseKey or insignificantMetaClick + diff --git a/actionview/app/assets/javascripts/rails-ujs/start.coffee b/actionview/app/assets/javascripts/rails-ujs/start.coffee new file mode 100644 index 0000000000..5c1214df59 --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/start.coffee @@ -0,0 +1,73 @@ +{ + fire, delegate + getData, $ + refreshCSRFTokens, CSRFProtection + enableElement, disableElement, handleDisabledElement + handleConfirm, preventInsignificantClick + handleRemote, formSubmitButtonClick, + handleMethod +} = Rails + +# For backward compatibility +if jQuery? and jQuery.ajax? + throw new Error('If you load both jquery_ujs and rails-ujs, use rails-ujs only.') if 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 + # rails-ujs twice by detecting and raising an error when it happens. + throw new Error('rails-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', preventInsignificantClick + delegate document, Rails.linkClickSelector, 'click', handleDisabledElement + delegate document, Rails.linkClickSelector, 'click', handleConfirm + delegate document, Rails.linkClickSelector, 'click', disableElement + delegate document, Rails.linkClickSelector, 'click', handleRemote + delegate document, Rails.linkClickSelector, 'click', handleMethod + + delegate document, Rails.buttonClickSelector, 'click', preventInsignificantClick + delegate document, Rails.buttonClickSelector, 'click', handleDisabledElement + delegate document, Rails.buttonClickSelector, 'click', handleConfirm + delegate document, Rails.buttonClickSelector, 'click', disableElement + delegate document, Rails.buttonClickSelector, 'click', handleRemote + + delegate document, Rails.inputChangeSelector, 'change', handleDisabledElement + delegate document, Rails.inputChangeSelector, 'change', handleConfirm + delegate document, Rails.inputChangeSelector, 'change', handleRemote + + delegate document, Rails.formSubmitSelector, 'submit', handleDisabledElement + delegate document, Rails.formSubmitSelector, 'submit', handleConfirm + 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', preventInsignificantClick + delegate document, Rails.formInputClickSelector, 'click', handleDisabledElement + 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/rails-ujs/utils/ajax.coffee b/actionview/app/assets/javascripts/rails-ujs/utils/ajax.coffee new file mode 100644 index 0000000000..019bda635a --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/utils/ajax.coffee @@ -0,0 +1,97 @@ +#= require ./csp +#= require ./csrf +#= require ./event + +{ cspNonce, 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.responseText, 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) + + if options.beforeSend? && !options.beforeSend(xhr, options) + return false + + if xhr.readyState is XMLHttpRequest.OPENED + xhr.send(options.data) + +prepareOptions = (options) -> + options.url = options.url or location.href + 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(/\b(?:java|ecma)script\b/) + script = document.createElement('script') + script.setAttribute('nonce', cspNonce()) + script.text = response + document.head.appendChild(script).parentNode.removeChild(script) + else if type.match(/\bxml\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/rails-ujs/utils/csp.coffee b/actionview/app/assets/javascripts/rails-ujs/utils/csp.coffee new file mode 100644 index 0000000000..8d2d6ce447 --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/utils/csp.coffee @@ -0,0 +1,4 @@ +# Content-Security-Policy nonce for inline scripts +cspNonce = Rails.cspNonce = -> + meta = document.querySelector('meta[name=csp-nonce]') + meta and meta.content diff --git a/actionview/app/assets/javascripts/rails-ujs/utils/csrf.coffee b/actionview/app/assets/javascripts/rails-ujs/utils/csrf.coffee new file mode 100644 index 0000000000..4eb5ebb414 --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/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/rails-ujs/utils/dom.coffee b/actionview/app/assets/javascripts/rails-ujs/utils/dom.coffee new file mode 100644 index 0000000000..3d3c5bb330 --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/utils/dom.coffee @@ -0,0 +1,35 @@ +m = Element.prototype.matches or + Element.prototype.matchesSelector or + Element.prototype.mozMatchesSelector or + Element.prototype.msMatchesSelector or + Element.prototype.oMatchesSelector or + Element.prototype.webkitMatchesSelector + +# Checks if the given native dom element matches the selector +# element:: +# native DOM element +# selector:: +# css selector string or +# a javascript object with `selector` and `exclude` properties +# Examples: "form", { selector: "form", exclude: "form[data-remote='true']"} +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/rails-ujs/utils/event.coffee b/actionview/app/assets/javascripts/rails-ujs/utils/event.coffee new file mode 100644 index 0000000000..a7eee52060 --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/utils/event.coffee @@ -0,0 +1,68 @@ +#= 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 isnt 'function' + CustomEvent = (event, params) -> + evt = document.createEvent('CustomEvent') + evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail) + evt + + CustomEvent.prototype = window.Event.prototype + + # Fix setting `defaultPrevented` when `preventDefault()` is called + # http://stackoverflow.com/questions/23349191/event-preventdefault-is-not-working-in-ie-11-for-custom-events + { preventDefault } = CustomEvent.prototype + CustomEvent.prototype.preventDefault = -> + result = preventDefault.call(this) + if @cancelable and not @defaultPrevented + Object.defineProperty(this, 'defaultPrevented', get: -> true) + result + +# Triggers a custom event on an element and returns false if the event result is false +# obj:: +# a native DOM element +# name:: +# string that corrspends to the event you want to trigger +# e.g. 'click', 'submit' +# data:: +# data you want to pass when you dispatch an event +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() + +# Delegates events +# to a specified parent `element`, which fires event `handler` +# for the specified `selector` when an event of `eventType` is triggered +# element:: +# parent element that will listen for events e.g. document +# selector:: +# css selector; or an object that has `selector` and `exclude` properties (see: Rails.matches) +# eventType:: +# string representing the event e.g. 'submit', 'click' +# handler:: +# the event handler to be called +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/rails-ujs/utils/form.coffee b/actionview/app/assets/javascripts/rails-ujs/utils/form.coffee new file mode 100644 index 0000000000..736cab08db --- /dev/null +++ b/actionview/app/assets/javascripts/rails-ujs/utils/form.coffee @@ -0,0 +1,36 @@ +#= 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 if !input.name || input.disabled + if matches(input, 'select') + toArray(input.options).forEach (option) -> + params.push(name: input.name, value: option.value) if option.selected + else if input.checked or ['radio', 'checkbox', 'submit'].indexOf(input.type) == -1 + 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)) diff --git a/actionview/bin/test b/actionview/bin/test new file mode 100755 index 0000000000..c53377cc97 --- /dev/null +++ b/actionview/bin/test @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +COMPONENT_ROOT = File.expand_path("..", __dir__) +require_relative "../../tools/test" 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/coffeelint.json b/actionview/coffeelint.json new file mode 100644 index 0000000000..cf8bf2171b --- /dev/null +++ b/actionview/coffeelint.json @@ -0,0 +1,135 @@ +{ + "arrow_spacing": { + "level": "warn" + }, + "braces_spacing": { + "level": "warn", + "spaces": 1, + "empty_object_spaces": 0 + }, + "camel_case_classes": { + "level": "error" + }, + "coffeescript_error": { + "level": "error" + }, + "colon_assignment_spacing": { + "level": "warn", + "spacing": { + "left": 0, + "right": 1 + } + }, + "cyclomatic_complexity": { + "level": "warn", + "value": 10 + }, + "duplicate_key": { + "level": "error" + }, + "empty_constructor_needs_parens": { + "level": "warn" + }, + "ensure_comprehensions": { + "level": "warn" + }, + "eol_last": { + "level": "warn" + }, + "indentation": { + "value": 2, + "level": "error" + }, + "line_endings": { + "level": "warn", + "value": "unix" + }, + "max_line_length": { + "value": 80, + "level": "ignore", + "limitComments": true + }, + "missing_fat_arrows": { + "level": "ignore" + }, + "newlines_after_classes": { + "value": 3, + "level": "warn" + }, + "no_backticks": { + "level": "error" + }, + "no_debugger": { + "level": "warn", + "console": false + }, + "no_empty_functions": { + "level": "warn" + }, + "no_empty_param_list": { + "level": "warn" + }, + "no_implicit_braces": { + "level": "ignore", + "strict": true + }, + "no_implicit_parens": { + "level": "ignore", + "strict": true + }, + "no_interpolation_in_single_quotes": { + "level": "warn" + }, + "no_nested_string_interpolation": { + "level": "warn" + }, + "no_plusplus": { + "level": "warn" + }, + "no_private_function_fat_arrows": { + "level": "warn" + }, + "no_stand_alone_at": { + "level": "warn" + }, + "no_tabs": { + "level": "error" + }, + "no_this": { + "level": "warn" + }, + "no_throwing_strings": { + "level": "error" + }, + "no_trailing_semicolons": { + "level": "error" + }, + "no_trailing_whitespace": { + "level": "error", + "allowed_in_comments": false, + "allowed_in_empty_lines": true + }, + "no_unnecessary_double_quotes": { + "level": "warn" + }, + "no_unnecessary_fat_arrows": { + "level": "warn" + }, + "non_empty_constructor_needs_parens": { + "level": "warn" + }, + "prefer_english_operator": { + "level": "ignore", + "doubleNotLevel": "warn" + }, + "space_operators": { + "level": "warn" + }, + "spacing_after_comma": { + "level": "warn" + }, + "transform_messes_up_line_numbers": { + "level": "warn" + } +} + diff --git a/actionview/lib/action_view.rb b/actionview/lib/action_view.rb new file mode 100644 index 0000000000..c1eeda75f5 --- /dev/null +++ b/actionview/lib/action_view.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +#-- +# Copyright (c) 2004-2018 David Heinemeier Hansson +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +#++ + +require "active_support" +require "active_support/rails" +require "action_view/version" + +module ActionView + extend ActiveSupport::Autoload + + ENCODING_FLAG = '#.*coding[:=]\s*(\S+)[ \t]*' + + eager_autoload do + autoload :Base + autoload :Context + autoload :CompiledTemplates, "action_view/context" + autoload :Digestor + autoload :Helpers + autoload :LookupContext + autoload :Layouts + autoload :PathSet + autoload :RecordIdentifier + autoload :Rendering + autoload :RoutingUrlFor + autoload :Template + autoload :ViewPaths + + autoload_under "renderer" do + autoload :Renderer + autoload :AbstractRenderer + autoload :PartialRenderer + autoload :TemplateRenderer + autoload :StreamingTemplateRenderer + end + + autoload_at "action_view/template/resolver" do + autoload :Resolver + autoload :PathResolver + autoload :OptimizedFileSystemResolver + autoload :FallbackFileSystemResolver + end + + autoload_at "action_view/buffers" do + autoload :OutputBuffer + autoload :StreamingBuffer + end + + autoload_at "action_view/flows" do + autoload :OutputFlow + autoload :StreamingFlow + end + + autoload_at "action_view/template/error" do + autoload :MissingTemplate + autoload :ActionViewError + autoload :EncodingError + autoload :TemplateError + autoload :WrongEncodingError + end + end + + autoload :TestCase + + def self.eager_load! + super + ActionView::Helpers.eager_load! + ActionView::Template.eager_load! + end +end + +require "active_support/core_ext/string/output_safety" + +ActiveSupport.on_load(:i18n) do + I18n.load_path << File.expand_path("action_view/locale/en.yml", __dir__) +end diff --git a/actionview/lib/action_view/base.rb b/actionview/lib/action_view/base.rb new file mode 100644 index 0000000000..d41fe2a608 --- /dev/null +++ b/actionview/lib/action_view/base.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attr_internal" +require "active_support/core_ext/module/attribute_accessors" +require "active_support/ordered_options" +require "action_view/log_subscriber" +require "action_view/helpers" +require "action_view/context" +require "action_view/template" +require "action_view/lookup_context" + +module ActionView #:nodoc: + # = Action View Base + # + # Action View templates can be written in several ways. + # If the template file has a <tt>.erb</tt> extension, then it uses the erubi[https://rubygems.org/gems/erubi] + # template system which can embed Ruby into an HTML document. + # If the template file has a <tt>.builder</tt> extension, then Jim Weirich's Builder::XmlMarkup library is used. + # + # == ERB + # + # You trigger ERB by using embeddings such as <tt><% %></tt>, <tt><% -%></tt>, and <tt><%= %></tt>. The <tt><%= %></tt> tag set is used when you want output. Consider the + # following loop for names: + # + # <b>Names of all the people</b> + # <% @people.each do |person| %> + # Name: <%= person.name %><br/> + # <% end %> + # + # The loop is setup in regular embedding tags <tt><% %></tt>, and the name is written using the output embedding tag <tt><%= %></tt>. Note that this + # is not just a usage suggestion. Regular output functions like print or puts won't work with ERB templates. So this would be wrong: + # + # <%# WRONG %> + # Hi, Mr. <% puts "Frodo" %> + # + # If you absolutely must write from within a function use +concat+. + # + # When on a line that only contains whitespaces except for the tag, <tt><% %></tt> suppresses leading and trailing whitespace, + # including the trailing newline. <tt><% %></tt> and <tt><%- -%></tt> are the same. + # Note however that <tt><%= %></tt> and <tt><%= -%></tt> are different: only the latter removes trailing whitespaces. + # + # === Using sub templates + # + # Using sub templates allows you to sidestep tedious replication and extract common display structures in shared templates. The + # classic example is the use of a header and footer (even though the Action Pack-way would be to use Layouts): + # + # <%= render "shared/header" %> + # Something really specific and terrific + # <%= render "shared/footer" %> + # + # As you see, we use the output embeddings for the render methods. The render call itself will just return a string holding the + # result of the rendering. The output embedding writes it to the current template. + # + # But you don't have to restrict yourself to static includes. Templates can share variables amongst themselves by using instance + # variables defined using the regular embedding tags. Like this: + # + # <% @page_title = "A Wonderful Hello" %> + # <%= render "shared/header" %> + # + # Now the header can pick up on the <tt>@page_title</tt> variable and use it for outputting a title tag: + # + # <title><%= @page_title %></title> + # + # === Passing local variables to sub templates + # + # You can pass local variables to sub templates by using a hash with the variable names as keys and the objects as values: + # + # <%= render "shared/header", { headline: "Welcome", person: person } %> + # + # These can now be accessed in <tt>shared/header</tt> with: + # + # Headline: <%= headline %> + # First name: <%= person.first_name %> + # + # The local variables passed to sub templates can be accessed as a hash using the <tt>local_assigns</tt> hash. This lets you access the + # variables as: + # + # Headline: <%= local_assigns[:headline] %> + # + # This is useful in cases where you aren't sure if the local variable has been assigned. Alternatively, you could also use + # <tt>defined? headline</tt> to first check if the variable has been assigned before using it. + # + # === Template caching + # + # By default, Rails will compile each template to a method in order to render it. When you alter a template, + # Rails will check the file's modification time and recompile it in development mode. + # + # == Builder + # + # Builder templates are a more programmatic alternative to ERB. They are especially useful for generating XML content. An XmlMarkup object + # named +xml+ is automatically made available to templates with a <tt>.builder</tt> extension. + # + # Here are some basic examples: + # + # xml.em("emphasized") # => <em>emphasized</em> + # xml.em { xml.b("emph & bold") } # => <em><b>emph & bold</b></em> + # xml.a("A Link", "href" => "http://onestepback.org") # => <a href="http://onestepback.org">A Link</a> + # xml.target("name" => "compile", "option" => "fast") # => <target option="fast" name="compile"\> + # # NOTE: order of attributes is not specified. + # + # Any method with a block will be treated as an XML markup tag with nested markup in the block. For example, the following: + # + # xml.div do + # xml.h1(@person.name) + # xml.p(@person.bio) + # end + # + # would produce something like: + # + # <div> + # <h1>David Heinemeier Hansson</h1> + # <p>A product of Danish Design during the Winter of '79...</p> + # </div> + # + # Here is a full-length RSS example actually used on Basecamp: + # + # xml.rss("version" => "2.0", "xmlns:dc" => "http://purl.org/dc/elements/1.1/") do + # xml.channel do + # xml.title(@feed_title) + # xml.link(@url) + # xml.description "Basecamp: Recent items" + # xml.language "en-us" + # xml.ttl "40" + # + # @recent_items.each do |item| + # xml.item do + # xml.title(item_title(item)) + # xml.description(item_description(item)) if item_description(item) + # xml.pubDate(item_pubDate(item)) + # xml.guid(@person.firm.account.url + @recent_items.url(item)) + # xml.link(@person.firm.account.url + @recent_items.url(item)) + # + # xml.tag!("dc:creator", item.author_name) if item_has_creator?(item) + # end + # end + # end + # end + # + # For more information on Builder please consult the {source + # code}[https://github.com/jimweirich/builder]. + class Base + include Helpers, ::ERB::Util, Context + + # Specify the proc used to decorate input tags that refer to attributes with errors. + cattr_accessor :field_error_proc, default: Proc.new { |html_tag, instance| "<div class=\"field_with_errors\">#{html_tag}</div>".html_safe } + + # How to complete the streaming when an exception occurs. + # This is our best guess: first try to close the attribute, then the tag. + cattr_accessor :streaming_completion_on_exception, default: %("><script>window.location = "/500.html"</script></html>) + + # Specify whether rendering within namespaced controllers should prefix + # the partial paths for ActiveModel objects with the namespace. + # (e.g., an Admin::PostsController would render @post using /admin/posts/_post.erb) + cattr_accessor :prefix_partial_path_with_controller_namespace, default: true + + # Specify default_formats that can be rendered. + cattr_accessor :default_formats + + # Specify whether an error should be raised for missing translations + cattr_accessor :raise_on_missing_translations, default: false + + # Specify whether submit_tag should automatically disable on click + cattr_accessor :automatically_disable_submit_tag, default: true + + class_attribute :_routes + class_attribute :logger + + class << self + delegate :erb_trim_mode=, to: "ActionView::Template::Handlers::ERB" + + def cache_template_loading + ActionView::Resolver.caching? + end + + def cache_template_loading=(value) + ActionView::Resolver.caching = value + end + + def xss_safe? #:nodoc: + true + end + end + + attr_accessor :view_renderer + attr_internal :config, :assigns + + delegate :lookup_context, to: :view_renderer + delegate :formats, :formats=, :locale, :locale=, :view_paths, :view_paths=, to: :lookup_context + + def assign(new_assigns) # :nodoc: + @_assigns = new_assigns.each { |key, value| instance_variable_set("@#{key}", value) } + end + + def initialize(context = nil, assigns = {}, controller = nil, formats = nil) #:nodoc: + @_config = ActiveSupport::InheritableOptions.new + + if context.is_a?(ActionView::Renderer) + @view_renderer = context + else + lookup_context = context.is_a?(ActionView::LookupContext) ? + context : ActionView::LookupContext.new(context) + lookup_context.formats = formats if formats + lookup_context.prefixes = controller._prefixes if controller + @view_renderer = ActionView::Renderer.new(lookup_context) + end + + @cache_hit = {} + assign(assigns) + assign_controller(controller) + _prepare_context + end + + ActiveSupport.run_load_hooks(:action_view, self) + end +end diff --git a/actionview/lib/action_view/buffers.rb b/actionview/lib/action_view/buffers.rb new file mode 100644 index 0000000000..18eaee5d79 --- /dev/null +++ b/actionview/lib/action_view/buffers.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/output_safety" + +module ActionView + # Used as a buffer for views + # + # The main difference between this and ActiveSupport::SafeBuffer + # is for the methods `<<` and `safe_expr_append=` the inputs are + # checked for nil before they are assigned and `to_s` is called on + # the input. For example: + # + # obuf = ActionView::OutputBuffer.new "hello" + # obuf << 5 + # puts obuf # => "hello5" + # + # sbuf = ActiveSupport::SafeBuffer.new "hello" + # sbuf << 5 + # puts sbuf # => "hello\u0005" + # + class OutputBuffer < ActiveSupport::SafeBuffer #:nodoc: + def initialize(*) + super + encode! + end + + def <<(value) + return self if value.nil? + super(value.to_s) + end + alias :append= :<< + + def safe_expr_append=(val) + return self if val.nil? + safe_concat val.to_s + end + + alias :safe_append= :safe_concat + end + + class StreamingBuffer #:nodoc: + def initialize(block) + @block = block + end + + def <<(value) + value = value.to_s + value = ERB::Util.h(value) unless value.html_safe? + @block.call(value) + end + alias :concat :<< + alias :append= :<< + + def safe_concat(value) + @block.call(value.to_s) + end + alias :safe_append= :safe_concat + + def html_safe? + true + end + + def html_safe + self + end + end +end diff --git a/actionview/lib/action_view/context.rb b/actionview/lib/action_view/context.rb new file mode 100644 index 0000000000..3c605c3ee3 --- /dev/null +++ b/actionview/lib/action_view/context.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module ActionView + module CompiledTemplates #:nodoc: + # holds compiled template code + end + + # = Action View Context + # + # Action View contexts are supplied to Action Controller to render a template. + # The default Action View context is ActionView::Base. + # + # In order to work with Action Controller, a Context must just include this + # module. The initialization of the variables used by the context + # (@output_buffer, @view_flow, and @virtual_path) is responsibility of the + # object that includes this module (although you can call _prepare_context + # defined below). + module Context + include CompiledTemplates + attr_accessor :output_buffer, :view_flow + + # Prepares the context by setting the appropriate instance variables. + def _prepare_context + @view_flow = OutputFlow.new + @output_buffer = nil + @virtual_path = nil + end + + # Encapsulates the interaction with the view flow so it + # returns the correct buffer on +yield+. This is usually + # overwritten by helpers to add more behavior. + def _layout_for(name = nil) + name ||= :layout + view_flow.get(name).html_safe + end + end +end diff --git a/actionview/lib/action_view/dependency_tracker.rb b/actionview/lib/action_view/dependency_tracker.rb new file mode 100644 index 0000000000..182f6e2eef --- /dev/null +++ b/actionview/lib/action_view/dependency_tracker.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "concurrent/map" +require "action_view/path_set" + +module ActionView + class DependencyTracker # :nodoc: + @trackers = Concurrent::Map.new + + def self.find_dependencies(name, template, view_paths = nil) + tracker = @trackers[template.handler] + return [] unless tracker + + tracker.call(name, template, view_paths) + end + + def self.register_tracker(extension, tracker) + handler = Template.handler_for_extension(extension) + if tracker.respond_to?(:supports_view_paths?) + @trackers[handler] = tracker + else + @trackers[handler] = lambda { |name, template, _| + tracker.call(name, template) + } + end + end + + def self.remove_tracker(handler) + @trackers.delete(handler) + end + + class ERBTracker # :nodoc: + EXPLICIT_DEPENDENCY = /# Template Dependency: (\S+)/ + + # A valid ruby identifier - suitable for class, method and specially variable names + IDENTIFIER = / + [[:alpha:]_] # at least one uppercase letter, lowercase letter or underscore + [[:word:]]* # followed by optional letters, numbers or underscores + /x + + # Any kind of variable name. e.g. @instance, @@class, $global or local. + # Possibly following a method call chain + VARIABLE_OR_METHOD_CHAIN = / + (?:\$|@{1,2})? # optional global, instance or class variable indicator + (?:#{IDENTIFIER}\.)* # followed by an optional chain of zero-argument method calls + (?<dynamic>#{IDENTIFIER}) # and a final valid identifier, captured as DYNAMIC + /x + + # A simple string literal. e.g. "School's out!" + STRING = / + (?<quote>['"]) # an opening quote + (?<static>.*?) # with anything inside, captured as STATIC + \k<quote> # and a matching closing quote + /x + + # Part of any hash containing the :partial key + PARTIAL_HASH_KEY = / + (?:\bpartial:|:partial\s*=>) # partial key in either old or new style hash syntax + \s* # followed by optional spaces + /x + + # Part of any hash containing the :layout key + LAYOUT_HASH_KEY = / + (?:\blayout:|:layout\s*=>) # layout key in either old or new style hash syntax + \s* # followed by optional spaces + /x + + # Matches: + # partial: "comments/comment", collection: @all_comments => "comments/comment" + # (object: @single_comment, partial: "comments/comment") => "comments/comment" + # + # "comments/comments" + # 'comments/comments' + # ('comments/comments') + # + # (@topic) => "topics/topic" + # topics => "topics/topic" + # (message.topics) => "topics/topic" + RENDER_ARGUMENTS = /\A + (?:\s*\(?\s*) # optional opening paren surrounded by spaces + (?:.*?#{PARTIAL_HASH_KEY}|#{LAYOUT_HASH_KEY})? # optional hash, up to the partial or layout key declaration + (?:#{STRING}|#{VARIABLE_OR_METHOD_CHAIN}) # finally, the dependency name of interest + /xm + + LAYOUT_DEPENDENCY = /\A + (?:\s*\(?\s*) # optional opening paren surrounded by spaces + (?:.*?#{LAYOUT_HASH_KEY}) # check if the line has layout key declaration + (?:#{STRING}|#{VARIABLE_OR_METHOD_CHAIN}) # finally, the dependency name of interest + /xm + + def self.supports_view_paths? # :nodoc: + true + end + + def self.call(name, template, view_paths = nil) + new(name, template, view_paths).dependencies + end + + def initialize(name, template, view_paths = nil) + @name, @template, @view_paths = name, template, view_paths + end + + def dependencies + render_dependencies + explicit_dependencies + end + + attr_reader :name, :template + private :name, :template + + private + def source + template.source + end + + def directory + name.split("/")[0..-2].join("/") + end + + def render_dependencies + render_dependencies = [] + render_calls = source.split(/\brender\b/).drop(1) + + render_calls.each do |arguments| + add_dependencies(render_dependencies, arguments, LAYOUT_DEPENDENCY) + add_dependencies(render_dependencies, arguments, RENDER_ARGUMENTS) + end + + render_dependencies.uniq + end + + def add_dependencies(render_dependencies, arguments, pattern) + arguments.scan(pattern) do + add_dynamic_dependency(render_dependencies, Regexp.last_match[:dynamic]) + add_static_dependency(render_dependencies, Regexp.last_match[:static]) + end + end + + def add_dynamic_dependency(dependencies, dependency) + if dependency + dependencies << "#{dependency.pluralize}/#{dependency.singularize}" + end + end + + def add_static_dependency(dependencies, dependency) + if dependency + if dependency.include?("/") + dependencies << dependency + else + dependencies << "#{directory}/#{dependency}" + end + end + end + + def resolve_directories(wildcard_dependencies) + return [] unless @view_paths + + wildcard_dependencies.flat_map { |query, templates| + @view_paths.find_all_with_query(query).map do |template| + "#{File.dirname(query)}/#{File.basename(template).split('.').first}" + end + }.sort + end + + def explicit_dependencies + dependencies = source.scan(EXPLICIT_DEPENDENCY).flatten.uniq + + wildcards, explicits = dependencies.partition { |dependency| dependency[-1] == "*" } + + (explicits + resolve_directories(wildcards)).uniq + end + end + + register_tracker :erb, ERBTracker + end +end diff --git a/actionview/lib/action_view/digestor.rb b/actionview/lib/action_view/digestor.rb new file mode 100644 index 0000000000..6d2e471a44 --- /dev/null +++ b/actionview/lib/action_view/digestor.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "action_view/dependency_tracker" + +module ActionView + class Digestor + @@digest_mutex = Mutex.new + + module PerExecutionDigestCacheExpiry + def self.before(target) + ActionView::LookupContext::DetailsKey.clear + end + end + + class << self + # Supported options: + # + # * <tt>name</tt> - Template name + # * <tt>finder</tt> - An instance of <tt>ActionView::LookupContext</tt> + # * <tt>dependencies</tt> - An array of dependent views + def digest(name:, finder:, dependencies: nil) + if dependencies.nil? || dependencies.empty? + cache_key = "#{name}.#{finder.rendered_format}" + else + cache_key = [ name, finder.rendered_format, dependencies ].flatten.compact.join(".") + end + + # this is a correctly done double-checked locking idiom + # (Concurrent::Map's lookups have volatile semantics) + finder.digest_cache[cache_key] || @@digest_mutex.synchronize do + finder.digest_cache.fetch(cache_key) do # re-check under lock + partial = name.include?("/_") + root = tree(name, finder, partial) + dependencies.each do |injected_dep| + root.children << Injected.new(injected_dep, nil, nil) + end if dependencies + finder.digest_cache[cache_key] = root.digest(finder) + end + end + end + + def logger + ActionView::Base.logger || NullLogger + end + + # Create a dependency tree for template named +name+. + def tree(name, finder, partial = false, seen = {}) + logical_name = name.gsub(%r|/_|, "/") + + if template = find_template(finder, logical_name, [], partial, []) + finder.rendered_format ||= template.formats.first + + if node = seen[template.identifier] # handle cycles in the tree + node + else + node = seen[template.identifier] = Node.create(name, logical_name, template, partial) + + deps = DependencyTracker.find_dependencies(name, template, finder.view_paths) + deps.uniq { |n| n.gsub(%r|/_|, "/") }.each do |dep_file| + node.children << tree(dep_file, finder, true, seen) + end + node + end + else + unless name.include?("#") # Dynamic template partial names can never be tracked + logger.error " Couldn't find template for digesting: #{name}" + end + + seen[name] ||= Missing.new(name, logical_name, nil) + end + end + + private + def find_template(finder, name, prefixes, partial, keys) + finder.disable_cache do + format = finder.rendered_format + result = finder.find_all(name, prefixes, partial, keys, formats: [format]).first if format + result || finder.find_all(name, prefixes, partial, keys).first + end + end + end + + class Node + attr_reader :name, :logical_name, :template, :children + + def self.create(name, logical_name, template, partial) + klass = partial ? Partial : Node + klass.new(name, logical_name, template, []) + end + + def initialize(name, logical_name, template, children = []) + @name = name + @logical_name = logical_name + @template = template + @children = children + end + + def digest(finder, stack = []) + ActiveSupport::Digest.hexdigest("#{template.source}-#{dependency_digest(finder, stack)}") + end + + def dependency_digest(finder, stack) + children.map do |node| + if stack.include?(node) + false + else + finder.digest_cache[node.name] ||= begin + stack.push node + node.digest(finder, stack).tap { stack.pop } + end + end + end.join("-") + end + + def to_dep_map + children.any? ? { name => children.map(&:to_dep_map) } : name + end + end + + class Partial < Node; end + + class Missing < Node + def digest(finder, _ = []) "" end + end + + class Injected < Node + def digest(finder, _ = []) name end + end + + class NullLogger + def self.debug(_); end + def self.error(_); end + end + end +end diff --git a/actionview/lib/action_view/flows.rb b/actionview/lib/action_view/flows.rb new file mode 100644 index 0000000000..ff44fa6619 --- /dev/null +++ b/actionview/lib/action_view/flows.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/output_safety" + +module ActionView + class OutputFlow #:nodoc: + attr_reader :content + + def initialize + @content = Hash.new { |h, k| h[k] = ActiveSupport::SafeBuffer.new } + end + + # Called by _layout_for to read stored values. + def get(key) + @content[key] + end + + # Called by each renderer object to set the layout contents. + def set(key, value) + @content[key] = ActiveSupport::SafeBuffer.new(value) + end + + # Called by content_for + def append(key, value) + @content[key] << value + end + alias_method :append!, :append + end + + class StreamingFlow < OutputFlow #:nodoc: + def initialize(view, fiber) + @view = view + @parent = nil + @child = view.output_buffer + @content = view.view_flow.content + @fiber = fiber + @root = Fiber.current.object_id + end + + # Try to get stored content. If the content + # is not available and we're inside the layout fiber, + # then it will begin waiting for the given key and yield. + def get(key) + return super if @content.key?(key) + + if inside_fiber? + view = @view + + begin + @waiting_for = key + view.output_buffer, @parent = @child, view.output_buffer + Fiber.yield + ensure + @waiting_for = nil + view.output_buffer, @child = @parent, view.output_buffer + end + end + + super + end + + # Appends the contents for the given key. This is called + # by providing and resuming back to the fiber, + # if that's the key it's waiting for. + def append!(key, value) + super + @fiber.resume if @waiting_for == key + end + + private + + def inside_fiber? + Fiber.current.object_id != @root + end + end +end diff --git a/actionview/lib/action_view/gem_version.rb b/actionview/lib/action_view/gem_version.rb new file mode 100644 index 0000000000..77ae444a58 --- /dev/null +++ b/actionview/lib/action_view/gem_version.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ActionView + # Returns the version of the currently loaded Action View as a <tt>Gem::Version</tt> + def self.gem_version + Gem::Version.new VERSION::STRING + end + + module VERSION + MAJOR = 6 + MINOR = 0 + TINY = 0 + PRE = "alpha" + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + end +end diff --git a/actionview/lib/action_view/helpers.rb b/actionview/lib/action_view/helpers.rb new file mode 100644 index 0000000000..0d77f74171 --- /dev/null +++ b/actionview/lib/action_view/helpers.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "active_support/benchmarkable" + +module ActionView #:nodoc: + module Helpers #:nodoc: + extend ActiveSupport::Autoload + + autoload :ActiveModelHelper + autoload :AssetTagHelper + autoload :AssetUrlHelper + autoload :AtomFeedHelper + autoload :CacheHelper + autoload :CaptureHelper + autoload :ControllerHelper + autoload :CspHelper + autoload :CsrfHelper + autoload :DateHelper + autoload :DebugHelper + autoload :FormHelper + autoload :FormOptionsHelper + autoload :FormTagHelper + autoload :JavaScriptHelper, "action_view/helpers/javascript_helper" + autoload :NumberHelper + autoload :OutputSafetyHelper + autoload :RenderingHelper + autoload :SanitizeHelper + autoload :TagHelper + autoload :TextHelper + autoload :TranslationHelper + autoload :UrlHelper + autoload :Tags + + def self.eager_load! + super + Tags.eager_load! + end + + extend ActiveSupport::Concern + + include ActiveSupport::Benchmarkable + include ActiveModelHelper + include AssetTagHelper + include AssetUrlHelper + include AtomFeedHelper + include CacheHelper + include CaptureHelper + include ControllerHelper + include CspHelper + include CsrfHelper + include DateHelper + include DebugHelper + include FormHelper + include FormOptionsHelper + include FormTagHelper + include JavaScriptHelper + include NumberHelper + include OutputSafetyHelper + include RenderingHelper + include SanitizeHelper + include TagHelper + include TextHelper + include TranslationHelper + include UrlHelper + end +end diff --git a/actionview/lib/action_view/helpers/active_model_helper.rb b/actionview/lib/action_view/helpers/active_model_helper.rb new file mode 100644 index 0000000000..e41a95d2ce --- /dev/null +++ b/actionview/lib/action_view/helpers/active_model_helper.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attribute_accessors" +require "active_support/core_ext/enumerable" + +module ActionView + # = Active Model Helpers + module Helpers #:nodoc: + module ActiveModelHelper + end + + module ActiveModelInstanceTag + def object + @active_model_object ||= begin + object = super + object.respond_to?(:to_model) ? object.to_model : object + end + end + + def content_tag(type, options, *) + select_markup_helper?(type) ? super : error_wrapping(super) + end + + def tag(type, options, *) + tag_generate_errors?(options) ? error_wrapping(super) : super + end + + def error_wrapping(html_tag) + if object_has_errors? + Base.field_error_proc.call(html_tag, self) + else + html_tag + end + end + + def error_message + object.errors[@method_name] + end + + private + + def object_has_errors? + object.respond_to?(:errors) && object.errors.respond_to?(:[]) && error_message.present? + end + + def select_markup_helper?(type) + ["optgroup", "option"].include?(type) + end + + def tag_generate_errors?(options) + options["type"] != "hidden" + end + end + end +end diff --git a/actionview/lib/action_view/helpers/asset_tag_helper.rb b/actionview/lib/action_view/helpers/asset_tag_helper.rb new file mode 100644 index 0000000000..3d7c8dae75 --- /dev/null +++ b/actionview/lib/action_view/helpers/asset_tag_helper.rb @@ -0,0 +1,511 @@ +# frozen_string_literal: true + +require "active_support/core_ext/array/extract_options" +require "active_support/core_ext/hash/keys" +require "active_support/core_ext/object/inclusion" +require "active_support/core_ext/object/try" +require "action_view/helpers/asset_url_helper" +require "action_view/helpers/tag_helper" + +module ActionView + # = Action View Asset Tag Helpers + module Helpers #:nodoc: + # This module provides methods for generating HTML that links views to assets such + # as images, JavaScripts, stylesheets, and feeds. These methods do not verify + # the assets exist before linking to them: + # + # image_tag("rails.png") + # # => <img src="/assets/rails.png" /> + # stylesheet_link_tag("application") + # # => <link href="/assets/application.css?body=1" media="screen" rel="stylesheet" /> + module AssetTagHelper + extend ActiveSupport::Concern + + include AssetUrlHelper + include TagHelper + + # Returns an HTML script tag for each of the +sources+ provided. + # + # Sources may be paths to JavaScript files. Relative paths are assumed to be relative + # to <tt>assets/javascripts</tt>, full paths are assumed to be relative to the document + # root. Relative paths are idiomatic, use absolute paths only when needed. + # + # When passing paths, the ".js" extension is optional. If you do not want ".js" + # appended to the path <tt>extname: false</tt> can be set on the options. + # + # You can modify the HTML attributes of the script tag by passing a hash as the + # last argument. + # + # When the Asset Pipeline is enabled, you can pass the name of your manifest as + # source, and include other JavaScript or CoffeeScript files inside the manifest. + # + # If the server supports Early Hints header links for these assets will be + # automatically pushed. + # + # ==== Options + # + # When the last parameter is a hash you can add HTML attributes using that + # parameter. The following options are supported: + # + # * <tt>:extname</tt> - Append an extension to the generated URL unless the extension + # already exists. This only applies for relative URLs. + # * <tt>:protocol</tt> - Sets the protocol of the generated URL. This option only + # applies when a relative URL and +host+ options are provided. + # * <tt>:host</tt> - When a relative URL is provided the host is added to the + # that path. + # * <tt>:skip_pipeline</tt> - This option is used to bypass the asset pipeline + # when it is set to true. + # * <tt>:nonce</tt> - When set to true, adds an automatic nonce value if + # you have Content Security Policy enabled. + # + # ==== Examples + # + # javascript_include_tag "xmlhr" + # # => <script src="/assets/xmlhr.debug-1284139606.js"></script> + # + # javascript_include_tag "xmlhr", host: "localhost", protocol: "https" + # # => <script src="https://localhost/assets/xmlhr.debug-1284139606.js"></script> + # + # javascript_include_tag "template.jst", extname: false + # # => <script src="/assets/template.debug-1284139606.jst"></script> + # + # javascript_include_tag "xmlhr.js" + # # => <script src="/assets/xmlhr.debug-1284139606.js"></script> + # + # javascript_include_tag "common.javascript", "/elsewhere/cools" + # # => <script src="/assets/common.javascript.debug-1284139606.js"></script> + # # <script src="/elsewhere/cools.debug-1284139606.js"></script> + # + # javascript_include_tag "http://www.example.com/xmlhr" + # # => <script src="http://www.example.com/xmlhr"></script> + # + # javascript_include_tag "http://www.example.com/xmlhr.js" + # # => <script src="http://www.example.com/xmlhr.js"></script> + # + # javascript_include_tag "http://www.example.com/xmlhr.js", nonce: true + # # => <script src="http://www.example.com/xmlhr.js" nonce="..."></script> + def javascript_include_tag(*sources) + options = sources.extract_options!.stringify_keys + path_options = options.extract!("protocol", "extname", "host", "skip_pipeline").symbolize_keys + early_hints_links = [] + + sources_tags = sources.uniq.map { |source| + href = path_to_javascript(source, path_options) + early_hints_links << "<#{href}>; rel=preload; as=script" + tag_options = { + "src" => href + }.merge!(options) + if tag_options["nonce"] == true + tag_options["nonce"] = content_security_policy_nonce + end + content_tag("script", "", tag_options) + }.join("\n").html_safe + + request.send_early_hints("Link" => early_hints_links.join("\n")) if respond_to?(:request) && request + + sources_tags + end + + # Returns a stylesheet link tag for the sources specified as arguments. If + # you don't specify an extension, <tt>.css</tt> will be appended automatically. + # You can modify the link attributes by passing a hash as the last argument. + # For historical reasons, the 'media' attribute will always be present and defaults + # to "screen", so you must explicitly set it to "all" for the stylesheet(s) to + # apply to all media types. + # + # If the server supports Early Hints header links for these assets will be + # automatically pushed. + # + # stylesheet_link_tag "style" + # # => <link href="/assets/style.css" media="screen" rel="stylesheet" /> + # + # stylesheet_link_tag "style.css" + # # => <link href="/assets/style.css" media="screen" rel="stylesheet" /> + # + # stylesheet_link_tag "http://www.example.com/style.css" + # # => <link href="http://www.example.com/style.css" media="screen" rel="stylesheet" /> + # + # stylesheet_link_tag "style", media: "all" + # # => <link href="/assets/style.css" media="all" rel="stylesheet" /> + # + # stylesheet_link_tag "style", media: "print" + # # => <link href="/assets/style.css" media="print" rel="stylesheet" /> + # + # stylesheet_link_tag "random.styles", "/css/stylish" + # # => <link href="/assets/random.styles" media="screen" rel="stylesheet" /> + # # <link href="/css/stylish.css" media="screen" rel="stylesheet" /> + def stylesheet_link_tag(*sources) + options = sources.extract_options!.stringify_keys + path_options = options.extract!("protocol", "host", "skip_pipeline").symbolize_keys + early_hints_links = [] + + sources_tags = sources.uniq.map { |source| + href = path_to_stylesheet(source, path_options) + early_hints_links << "<#{href}>; rel=preload; as=style" + tag_options = { + "rel" => "stylesheet", + "media" => "screen", + "href" => href + }.merge!(options) + tag(:link, tag_options) + }.join("\n").html_safe + + request.send_early_hints("Link" => early_hints_links.join("\n")) if respond_to?(:request) && request + + sources_tags + end + + # Returns a link tag that browsers and feed readers can use to auto-detect + # an RSS, Atom, or JSON feed. The +type+ can be <tt>:rss</tt> (default), + # <tt>:atom</tt>, or <tt>:json</tt>. Control the link options in url_for format + # using the +url_options+. You can modify the LINK tag itself in +tag_options+. + # + # ==== Options + # + # * <tt>:rel</tt> - Specify the relation of this link, defaults to "alternate" + # * <tt>:type</tt> - Override the auto-generated mime type + # * <tt>:title</tt> - Specify the title of the link, defaults to the +type+ + # + # ==== Examples + # + # auto_discovery_link_tag + # # => <link rel="alternate" type="application/rss+xml" title="RSS" href="http://www.currenthost.com/controller/action" /> + # auto_discovery_link_tag(:atom) + # # => <link rel="alternate" type="application/atom+xml" title="ATOM" href="http://www.currenthost.com/controller/action" /> + # auto_discovery_link_tag(:json) + # # => <link rel="alternate" type="application/json" title="JSON" href="http://www.currenthost.com/controller/action" /> + # auto_discovery_link_tag(:rss, {action: "feed"}) + # # => <link rel="alternate" type="application/rss+xml" title="RSS" href="http://www.currenthost.com/controller/feed" /> + # auto_discovery_link_tag(:rss, {action: "feed"}, {title: "My RSS"}) + # # => <link rel="alternate" type="application/rss+xml" title="My RSS" href="http://www.currenthost.com/controller/feed" /> + # auto_discovery_link_tag(:rss, {controller: "news", action: "feed"}) + # # => <link rel="alternate" type="application/rss+xml" title="RSS" href="http://www.currenthost.com/news/feed" /> + # auto_discovery_link_tag(:rss, "http://www.example.com/feed.rss", {title: "Example RSS"}) + # # => <link rel="alternate" type="application/rss+xml" title="Example RSS" href="http://www.example.com/feed.rss" /> + def auto_discovery_link_tag(type = :rss, url_options = {}, tag_options = {}) + if !(type == :rss || type == :atom || type == :json) && tag_options[:type].blank? + raise ArgumentError.new("You should pass :type tag_option key explicitly, because you have passed #{type} type other than :rss, :atom, or :json.") + end + + tag( + "link", + "rel" => tag_options[:rel] || "alternate", + "type" => tag_options[:type] || Template::Types[type].to_s, + "title" => tag_options[:title] || type.to_s.upcase, + "href" => url_options.is_a?(Hash) ? url_for(url_options.merge(only_path: false)) : url_options + ) + end + + # Returns a link tag for a favicon managed by the asset pipeline. + # + # If a page has no link like the one generated by this helper, browsers + # ask for <tt>/favicon.ico</tt> automatically, and cache the file if the + # request succeeds. If the favicon changes it is hard to get it updated. + # + # To have better control applications may let the asset pipeline manage + # their favicon storing the file under <tt>app/assets/images</tt>, and + # using this helper to generate its corresponding link tag. + # + # The helper gets the name of the favicon file as first argument, which + # defaults to "favicon.ico", and also supports +:rel+ and +:type+ options + # to override their defaults, "shortcut icon" and "image/x-icon" + # respectively: + # + # favicon_link_tag + # # => <link href="/assets/favicon.ico" rel="shortcut icon" type="image/x-icon" /> + # + # favicon_link_tag 'myicon.ico' + # # => <link href="/assets/myicon.ico" rel="shortcut icon" type="image/x-icon" /> + # + # Mobile Safari looks for a different link tag, pointing to an image that + # will be used if you add the page to the home screen of an iOS device. + # The following call would generate such a tag: + # + # favicon_link_tag 'mb-icon.png', rel: 'apple-touch-icon', type: 'image/png' + # # => <link href="/assets/mb-icon.png" rel="apple-touch-icon" type="image/png" /> + def favicon_link_tag(source = "favicon.ico", options = {}) + tag("link", { + rel: "shortcut icon", + type: "image/x-icon", + href: path_to_image(source, skip_pipeline: options.delete(:skip_pipeline)) + }.merge!(options.symbolize_keys)) + end + + # Returns a link tag that browsers can use to preload the +source+. + # The +source+ can be the path of a resource managed by asset pipeline, + # a full path, or an URI. + # + # ==== Options + # + # * <tt>:type</tt> - Override the auto-generated mime type, defaults to the mime type for +source+ extension. + # * <tt>:as</tt> - Override the auto-generated value for as attribute, calculated using +source+ extension and mime type. + # * <tt>:crossorigin</tt> - Specify the crossorigin attribute, required to load cross-origin resources. + # * <tt>:nopush</tt> - Specify if the use of server push is not desired for the resource. Defaults to +false+. + # + # ==== Examples + # + # preload_link_tag("custom_theme.css") + # # => <link rel="preload" href="/assets/custom_theme.css" as="style" type="text/css" /> + # + # preload_link_tag("/videos/video.webm") + # # => <link rel="preload" href="/videos/video.mp4" as="video" type="video/webm" /> + # + # preload_link_tag(post_path(format: :json), as: "fetch") + # # => <link rel="preload" href="/posts.json" as="fetch" type="application/json" /> + # + # preload_link_tag("worker.js", as: "worker") + # # => <link rel="preload" href="/assets/worker.js" as="worker" type="text/javascript" /> + # + # preload_link_tag("//example.com/font.woff2") + # # => <link rel="preload" href="//example.com/font.woff2" as="font" type="font/woff2" crossorigin="anonymous"/> + # + # preload_link_tag("//example.com/font.woff2", crossorigin: "use-credentials") + # # => <link rel="preload" href="//example.com/font.woff2" as="font" type="font/woff2" crossorigin="use-credentials" /> + # + # preload_link_tag("/media/audio.ogg", nopush: true) + # # => <link rel="preload" href="/media/audio.ogg" as="audio" type="audio/ogg" /> + # + def preload_link_tag(source, options = {}) + href = asset_path(source, skip_pipeline: options.delete(:skip_pipeline)) + extname = File.extname(source).downcase.delete(".") + mime_type = options.delete(:type) || Template::Types[extname].try(:to_s) + as_type = options.delete(:as) || resolve_link_as(extname, mime_type) + crossorigin = options.delete(:crossorigin) + crossorigin = "anonymous" if crossorigin == true || (crossorigin.blank? && as_type == "font") + nopush = options.delete(:nopush) || false + + link_tag = tag.link({ + rel: "preload", + href: href, + as: as_type, + type: mime_type, + crossorigin: crossorigin + }.merge!(options.symbolize_keys)) + + early_hints_link = "<#{href}>; rel=preload; as=#{as_type}" + early_hints_link += "; type=#{mime_type}" if mime_type + early_hints_link += "; crossorigin=#{crossorigin}" if crossorigin + early_hints_link += "; nopush" if nopush + + request.send_early_hints("Link" => early_hints_link) if respond_to?(:request) && request + + link_tag + end + + # Returns an HTML image tag for the +source+. The +source+ can be a full + # path, a file, or an Active Storage attachment. + # + # ==== Options + # + # You can add HTML attributes using the +options+. The +options+ supports + # additional keys for convenience and conformance: + # + # * <tt>:size</tt> - Supplied as "{Width}x{Height}" or "{Number}", so "30x45" becomes + # width="30" and height="45", and "50" becomes width="50" and height="50". + # <tt>:size</tt> will be ignored if the value is not in the correct format. + # * <tt>:srcset</tt> - If supplied as a hash or array of <tt>[source, descriptor]</tt> + # pairs, each image path will be expanded before the list is formatted as a string. + # + # ==== Examples + # + # Assets (images that are part of your app): + # + # image_tag("icon") + # # => <img src="/assets/icon" /> + # image_tag("icon.png") + # # => <img src="/assets/icon.png" /> + # image_tag("icon.png", size: "16x10", alt: "Edit Entry") + # # => <img src="/assets/icon.png" width="16" height="10" alt="Edit Entry" /> + # image_tag("/icons/icon.gif", size: "16") + # # => <img src="/icons/icon.gif" width="16" height="16" /> + # image_tag("/icons/icon.gif", height: '32', width: '32') + # # => <img height="32" src="/icons/icon.gif" width="32" /> + # image_tag("/icons/icon.gif", class: "menu_icon") + # # => <img class="menu_icon" src="/icons/icon.gif" /> + # image_tag("/icons/icon.gif", data: { title: 'Rails Application' }) + # # => <img data-title="Rails Application" src="/icons/icon.gif" /> + # image_tag("icon.png", srcset: { "icon_2x.png" => "2x", "icon_4x.png" => "4x" }) + # # => <img src="/assets/icon.png" srcset="/assets/icon_2x.png 2x, /assets/icon_4x.png 4x"> + # image_tag("pic.jpg", srcset: [["pic_1024.jpg", "1024w"], ["pic_1980.jpg", "1980w"]], sizes: "100vw") + # # => <img src="/assets/pic.jpg" srcset="/assets/pic_1024.jpg 1024w, /assets/pic_1980.jpg 1980w" sizes="100vw"> + # + # Active Storage (images that are uploaded by the users of your app): + # + # image_tag(user.avatar) + # # => <img src="/rails/active_storage/blobs/.../tiger.jpg" /> + # image_tag(user.avatar.variant(resize_to_fit: [100, 100])) + # # => <img src="/rails/active_storage/variants/.../tiger.jpg" /> + # image_tag(user.avatar.variant(resize_to_fit: [100, 100]), size: '100') + # # => <img width="100" height="100" src="/rails/active_storage/variants/.../tiger.jpg" /> + def image_tag(source, options = {}) + options = options.symbolize_keys + check_for_image_tag_errors(options) + skip_pipeline = options.delete(:skip_pipeline) + + options[:src] = resolve_image_source(source, skip_pipeline) + + if options[:srcset] && !options[:srcset].is_a?(String) + options[:srcset] = options[:srcset].map do |src_path, size| + src_path = path_to_image(src_path, skip_pipeline: skip_pipeline) + "#{src_path} #{size}" + end.join(", ") + end + + options[:width], options[:height] = extract_dimensions(options.delete(:size)) if options[:size] + tag("img", options) + end + + # Returns a string suitable for an HTML image tag alt attribute. + # The +src+ argument is meant to be an image file path. + # The method removes the basename of the file path and the digest, + # if any. It also removes hyphens and underscores from file names and + # replaces them with spaces, returning a space-separated, titleized + # string. + # + # ==== Examples + # + # image_alt('rails.png') + # # => Rails + # + # image_alt('hyphenated-file-name.png') + # # => Hyphenated file name + # + # image_alt('underscored_file_name.png') + # # => Underscored file name + def image_alt(src) + ActiveSupport::Deprecation.warn("image_alt is deprecated and will be removed from Rails 6.0. You must explicitly set alt text on images.") + + File.basename(src, ".*").sub(/-[[:xdigit:]]{32,64}\z/, "").tr("-_", " ").capitalize + end + + # Returns an HTML video tag for the +sources+. If +sources+ is a string, + # a single video tag will be returned. If +sources+ is an array, a video + # tag with nested source tags for each source will be returned. The + # +sources+ can be full paths or files that exist in your public videos + # directory. + # + # ==== Options + # + # When the last parameter is a hash you can add HTML attributes using that + # parameter. The following options are supported: + # + # * <tt>:poster</tt> - Set an image (like a screenshot) to be shown + # before the video loads. The path is calculated like the +src+ of +image_tag+. + # * <tt>:size</tt> - Supplied as "{Width}x{Height}" or "{Number}", so "30x45" becomes + # width="30" and height="45", and "50" becomes width="50" and height="50". + # <tt>:size</tt> will be ignored if the value is not in the correct format. + # * <tt>:poster_skip_pipeline</tt> will bypass the asset pipeline when using + # the <tt>:poster</tt> option instead using an asset in the public folder. + # + # ==== Examples + # + # video_tag("trailer") + # # => <video src="/videos/trailer"></video> + # video_tag("trailer.ogg") + # # => <video src="/videos/trailer.ogg"></video> + # video_tag("trailer.ogg", controls: true, preload: 'none') + # # => <video preload="none" controls="controls" src="/videos/trailer.ogg"></video> + # video_tag("trailer.m4v", size: "16x10", poster: "screenshot.png") + # # => <video src="/videos/trailer.m4v" width="16" height="10" poster="/assets/screenshot.png"></video> + # video_tag("trailer.m4v", size: "16x10", poster: "screenshot.png", poster_skip_pipeline: true) + # # => <video src="/videos/trailer.m4v" width="16" height="10" poster="screenshot.png"></video> + # video_tag("/trailers/hd.avi", size: "16x16") + # # => <video src="/trailers/hd.avi" width="16" height="16"></video> + # video_tag("/trailers/hd.avi", size: "16") + # # => <video height="16" src="/trailers/hd.avi" width="16"></video> + # video_tag("/trailers/hd.avi", height: '32', width: '32') + # # => <video height="32" src="/trailers/hd.avi" width="32"></video> + # video_tag("trailer.ogg", "trailer.flv") + # # => <video><source src="/videos/trailer.ogg" /><source src="/videos/trailer.flv" /></video> + # video_tag(["trailer.ogg", "trailer.flv"]) + # # => <video><source src="/videos/trailer.ogg" /><source src="/videos/trailer.flv" /></video> + # video_tag(["trailer.ogg", "trailer.flv"], size: "160x120") + # # => <video height="120" width="160"><source src="/videos/trailer.ogg" /><source src="/videos/trailer.flv" /></video> + def video_tag(*sources) + options = sources.extract_options!.symbolize_keys + public_poster_folder = options.delete(:poster_skip_pipeline) + sources << options + multiple_sources_tag_builder("video", sources) do |tag_options| + tag_options[:poster] = path_to_image(tag_options[:poster], skip_pipeline: public_poster_folder) if tag_options[:poster] + tag_options[:width], tag_options[:height] = extract_dimensions(tag_options.delete(:size)) if tag_options[:size] + end + end + + # Returns an HTML audio tag for the +sources+. If +sources+ is a string, + # a single audio tag will be returned. If +sources+ is an array, an audio + # tag with nested source tags for each source will be returned. The + # +sources+ can be full paths or files that exist in your public audios + # directory. + # + # When the last parameter is a hash you can add HTML attributes using that + # parameter. + # + # audio_tag("sound") + # # => <audio src="/audios/sound"></audio> + # audio_tag("sound.wav") + # # => <audio src="/audios/sound.wav"></audio> + # audio_tag("sound.wav", autoplay: true, controls: true) + # # => <audio autoplay="autoplay" controls="controls" src="/audios/sound.wav"></audio> + # audio_tag("sound.wav", "sound.mid") + # # => <audio><source src="/audios/sound.wav" /><source src="/audios/sound.mid" /></audio> + def audio_tag(*sources) + multiple_sources_tag_builder("audio", sources) + end + + private + def multiple_sources_tag_builder(type, sources) + options = sources.extract_options!.symbolize_keys + skip_pipeline = options.delete(:skip_pipeline) + sources.flatten! + + yield options if block_given? + + if sources.size > 1 + content_tag(type, options) do + safe_join sources.map { |source| tag("source", src: send("path_to_#{type}", source, skip_pipeline: skip_pipeline)) } + end + else + options[:src] = send("path_to_#{type}", sources.first, skip_pipeline: skip_pipeline) + content_tag(type, nil, options) + end + end + + def resolve_image_source(source, skip_pipeline) + if source.is_a?(Symbol) || source.is_a?(String) + path_to_image(source, skip_pipeline: skip_pipeline) + else + polymorphic_url(source) + end + rescue NoMethodError => e + raise ArgumentError, "Can't resolve image into URL: #{e}" + end + + def extract_dimensions(size) + size = size.to_s + if /\A\d+x\d+\z/.match?(size) + size.split("x") + elsif /\A\d+\z/.match?(size) + [size, size] + end + end + + def check_for_image_tag_errors(options) + if options[:size] && (options[:height] || options[:width]) + raise ArgumentError, "Cannot pass a :size option with a :height or :width option" + end + end + + def resolve_link_as(extname, mime_type) + if extname == "js" + "script" + elsif extname == "css" + "style" + elsif extname == "vtt" + "track" + elsif (type = mime_type.to_s.split("/")[0]) && type.in?(%w(audio video font)) + type + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/asset_url_helper.rb b/actionview/lib/action_view/helpers/asset_url_helper.rb new file mode 100644 index 0000000000..cc62783d60 --- /dev/null +++ b/actionview/lib/action_view/helpers/asset_url_helper.rb @@ -0,0 +1,470 @@ +# frozen_string_literal: true + +require "zlib" + +module ActionView + # = Action View Asset URL Helpers + module Helpers #:nodoc: + # This module provides methods for generating asset paths and + # URLs. + # + # image_path("rails.png") + # # => "/assets/rails.png" + # + # image_url("rails.png") + # # => "http://www.example.com/assets/rails.png" + # + # === Using asset hosts + # + # By default, Rails links to these assets on the current host in the public + # folder, but you can direct Rails to link to assets from a dedicated asset + # server by setting <tt>ActionController::Base.asset_host</tt> in the application + # configuration, typically in <tt>config/environments/production.rb</tt>. + # For example, you'd define <tt>assets.example.com</tt> to be your asset + # host this way, inside the <tt>configure</tt> block of your environment-specific + # configuration files or <tt>config/application.rb</tt>: + # + # config.action_controller.asset_host = "assets.example.com" + # + # Helpers take that into account: + # + # image_tag("rails.png") + # # => <img src="http://assets.example.com/assets/rails.png" /> + # stylesheet_link_tag("application") + # # => <link href="http://assets.example.com/assets/application.css" media="screen" rel="stylesheet" /> + # + # Browsers open a limited number of simultaneous connections to a single + # host. The exact number varies by browser and version. This limit may cause + # some asset downloads to wait for previous assets to finish before they can + # begin. You can use the <tt>%d</tt> wildcard in the +asset_host+ to + # distribute the requests over four hosts. For example, + # <tt>assets%d.example.com</tt> will spread the asset requests over + # "assets0.example.com", ..., "assets3.example.com". + # + # image_tag("rails.png") + # # => <img src="http://assets0.example.com/assets/rails.png" /> + # stylesheet_link_tag("application") + # # => <link href="http://assets2.example.com/assets/application.css" media="screen" rel="stylesheet" /> + # + # This may improve the asset loading performance of your application. + # It is also possible the combination of additional connection overhead + # (DNS, SSL) and the overall browser connection limits may result in this + # solution being slower. You should be sure to measure your actual + # performance across targeted browsers both before and after this change. + # + # To implement the corresponding hosts you can either setup four actual + # hosts or use wildcard DNS to CNAME the wildcard to a single asset host. + # You can read more about setting up your DNS CNAME records from your ISP. + # + # Note: This is purely a browser performance optimization and is not meant + # for server load balancing. See https://www.die.net/musings/page_load_time/ + # for background and https://www.browserscope.org/?category=network for + # connection limit data. + # + # Alternatively, you can exert more control over the asset host by setting + # +asset_host+ to a proc like this: + # + # ActionController::Base.asset_host = Proc.new { |source| + # "http://assets#{Digest::MD5.hexdigest(source).to_i(16) % 2 + 1}.example.com" + # } + # image_tag("rails.png") + # # => <img src="http://assets1.example.com/assets/rails.png" /> + # stylesheet_link_tag("application") + # # => <link href="http://assets2.example.com/assets/application.css" media="screen" rel="stylesheet" /> + # + # The example above generates "http://assets1.example.com" and + # "http://assets2.example.com". This option is useful for example if + # you need fewer/more than four hosts, custom host names, etc. + # + # As you see the proc takes a +source+ parameter. That's a string with the + # absolute path of the asset, for example "/assets/rails.png". + # + # ActionController::Base.asset_host = Proc.new { |source| + # if source.ends_with?('.css') + # "http://stylesheets.example.com" + # else + # "http://assets.example.com" + # end + # } + # image_tag("rails.png") + # # => <img src="http://assets.example.com/assets/rails.png" /> + # stylesheet_link_tag("application") + # # => <link href="http://stylesheets.example.com/assets/application.css" media="screen" rel="stylesheet" /> + # + # Alternatively you may ask for a second parameter +request+. That one is + # particularly useful for serving assets from an SSL-protected page. The + # example proc below disables asset hosting for HTTPS connections, while + # still sending assets for plain HTTP requests from asset hosts. If you don't + # have SSL certificates for each of the asset hosts this technique allows you + # to avoid warnings in the client about mixed media. + # Note that the +request+ parameter might not be supplied, e.g. when the assets + # are precompiled with the command `rails assets:precompile`. Make sure to use a + # +Proc+ instead of a lambda, since a +Proc+ allows missing parameters and sets them + # to +nil+. + # + # config.action_controller.asset_host = Proc.new { |source, request| + # if request && request.ssl? + # "#{request.protocol}#{request.host_with_port}" + # else + # "#{request.protocol}assets.example.com" + # end + # } + # + # You can also implement a custom asset host object that responds to +call+ + # and takes either one or two parameters just like the proc. + # + # config.action_controller.asset_host = AssetHostingWithMinimumSsl.new( + # "http://asset%d.example.com", "https://asset1.example.com" + # ) + # + module AssetUrlHelper + URI_REGEXP = %r{^[-a-z]+://|^(?:cid|data):|^//}i + + # This is the entry point for all assets. + # When using the asset pipeline (i.e. sprockets and sprockets-rails), the + # behavior is "enhanced". You can bypass the asset pipeline by passing in + # <tt>skip_pipeline: true</tt> to the options. + # + # All other asset *_path helpers delegate through this method. + # + # === With the asset pipeline + # + # All options passed to +asset_path+ will be passed to +compute_asset_path+ + # which is implemented by sprockets-rails. + # + # asset_path("application.js") # => "/assets/application-60aa4fdc5cea14baf5400fba1abf4f2a46a5166bad4772b1effe341570f07de9.js" + # + # === Without the asset pipeline (<tt>skip_pipeline: true</tt>) + # + # Accepts a <tt>type</tt> option that can specify the asset's extension. No error + # checking is done to verify the source passed into +asset_path+ is valid + # and that the file exists on disk. + # + # asset_path("application.js", skip_pipeline: true) # => "application.js" + # asset_path("filedoesnotexist.png", skip_pipeline: true) # => "filedoesnotexist.png" + # asset_path("application", type: :javascript, skip_pipeline: true) # => "/javascripts/application.js" + # asset_path("application", type: :stylesheet, skip_pipeline: true) # => "/stylesheets/application.css" + # + # === Options applying to all assets + # + # Below lists scenarios that apply to +asset_path+ whether or not you're + # using the asset pipeline. + # + # - All fully qualified URLs are returned immediately. This bypasses the + # asset pipeline and all other behavior described. + # + # asset_path("http://www.example.com/js/xmlhr.js") # => "http://www.example.com/js/xmlhr.js" + # + # - All assets that begin with a forward slash are assumed to be full + # URLs and will not be expanded. This will bypass the asset pipeline. + # + # asset_path("/foo.png") # => "/foo.png" + # + # - All blank strings will be returned immediately. This bypasses the + # asset pipeline and all other behavior described. + # + # asset_path("") # => "" + # + # - If <tt>config.relative_url_root</tt> is specified, all assets will have that + # root prepended. + # + # Rails.application.config.relative_url_root = "bar" + # asset_path("foo.js", skip_pipeline: true) # => "bar/foo.js" + # + # - A different asset host can be specified via <tt>config.action_controller.asset_host</tt> + # this is commonly used in conjunction with a CDN. + # + # Rails.application.config.action_controller.asset_host = "assets.example.com" + # asset_path("foo.js", skip_pipeline: true) # => "http://assets.example.com/foo.js" + # + # - An extension name can be specified manually with <tt>extname</tt>. + # + # asset_path("foo", skip_pipeline: true, extname: ".js") # => "/foo.js" + # asset_path("foo.css", skip_pipeline: true, extname: ".js") # => "/foo.css.js" + def asset_path(source, options = {}) + raise ArgumentError, "nil is not a valid asset source" if source.nil? + + source = source.to_s + return "" if source.blank? + return source if URI_REGEXP.match?(source) + + tail, source = source[/([\?#].+)$/], source.sub(/([\?#].+)$/, "") + + if extname = compute_asset_extname(source, options) + source = "#{source}#{extname}" + end + + if source[0] != ?/ + if options[:skip_pipeline] + source = public_compute_asset_path(source, options) + else + source = compute_asset_path(source, options) + end + end + + relative_url_root = defined?(config.relative_url_root) && config.relative_url_root + if relative_url_root + source = File.join(relative_url_root, source) unless source.starts_with?("#{relative_url_root}/") + end + + if host = compute_asset_host(source, options) + source = File.join(host, source) + end + + "#{source}#{tail}" + end + alias_method :path_to_asset, :asset_path # aliased to avoid conflicts with an asset_path named route + + # Computes the full URL to an asset in the public directory. This + # will use +asset_path+ internally, so most of their behaviors + # will be the same. If :host options is set, it overwrites global + # +config.action_controller.asset_host+ setting. + # + # All other options provided are forwarded to +asset_path+ call. + # + # asset_url "application.js" # => http://example.com/assets/application.js + # asset_url "application.js", host: "http://cdn.example.com" # => http://cdn.example.com/assets/application.js + # + def asset_url(source, options = {}) + path_to_asset(source, options.merge(protocol: :request)) + end + alias_method :url_to_asset, :asset_url # aliased to avoid conflicts with an asset_url named route + + ASSET_EXTENSIONS = { + javascript: ".js", + stylesheet: ".css" + } + + # Compute extname to append to asset path. Returns +nil+ if + # nothing should be added. + def compute_asset_extname(source, options = {}) + return if options[:extname] == false + extname = options[:extname] || ASSET_EXTENSIONS[options[:type]] + if extname && File.extname(source) != extname + extname + else + nil + end + end + + # Maps asset types to public directory. + ASSET_PUBLIC_DIRECTORIES = { + audio: "/audios", + font: "/fonts", + image: "/images", + javascript: "/javascripts", + stylesheet: "/stylesheets", + video: "/videos" + } + + # Computes asset path to public directory. Plugins and + # extensions can override this method to point to custom assets + # or generate digested paths or query strings. + def compute_asset_path(source, options = {}) + dir = ASSET_PUBLIC_DIRECTORIES[options[:type]] || "" + File.join(dir, source) + end + alias :public_compute_asset_path :compute_asset_path + + # Pick an asset host for this source. Returns +nil+ if no host is set, + # the host if no wildcard is set, the host interpolated with the + # numbers 0-3 if it contains <tt>%d</tt> (the number is the source hash mod 4), + # or the value returned from invoking call on an object responding to call + # (proc or otherwise). + def compute_asset_host(source = "", options = {}) + request = self.request if respond_to?(:request) + host = options[:host] + host ||= config.asset_host if defined? config.asset_host + + if host + if host.respond_to?(:call) + arity = host.respond_to?(:arity) ? host.arity : host.method(:call).arity + args = [source] + args << request if request && (arity > 1 || arity < 0) + host = host.call(*args) + elsif host.include?("%d") + host = host % (Zlib.crc32(source) % 4) + end + end + + host ||= request.base_url if request && options[:protocol] == :request + return unless host + + if URI_REGEXP.match?(host) + host + else + protocol = options[:protocol] || config.default_asset_host_protocol || (request ? :request : :relative) + case protocol + when :relative + "//#{host}" + when :request + "#{request.protocol}#{host}" + else + "#{protocol}://#{host}" + end + end + end + + # Computes the path to a JavaScript asset in the public javascripts directory. + # If the +source+ filename has no extension, .js will be appended (except for explicit URIs) + # Full paths from the document root will be passed through. + # Used internally by +javascript_include_tag+ to build the script path. + # + # javascript_path "xmlhr" # => /assets/xmlhr.js + # javascript_path "dir/xmlhr.js" # => /assets/dir/xmlhr.js + # javascript_path "/dir/xmlhr" # => /dir/xmlhr.js + # javascript_path "http://www.example.com/js/xmlhr" # => http://www.example.com/js/xmlhr + # javascript_path "http://www.example.com/js/xmlhr.js" # => http://www.example.com/js/xmlhr.js + def javascript_path(source, options = {}) + path_to_asset(source, { type: :javascript }.merge!(options)) + end + alias_method :path_to_javascript, :javascript_path # aliased to avoid conflicts with a javascript_path named route + + # Computes the full URL to a JavaScript asset in the public javascripts directory. + # This will use +javascript_path+ internally, so most of their behaviors will be the same. + # Since +javascript_url+ is based on +asset_url+ method you can set :host options. If :host + # options is set, it overwrites global +config.action_controller.asset_host+ setting. + # + # javascript_url "js/xmlhr.js", host: "http://stage.example.com" # => http://stage.example.com/assets/js/xmlhr.js + # + def javascript_url(source, options = {}) + url_to_asset(source, { type: :javascript }.merge!(options)) + end + alias_method :url_to_javascript, :javascript_url # aliased to avoid conflicts with a javascript_url named route + + # Computes the path to a stylesheet asset in the public stylesheets directory. + # If the +source+ filename has no extension, .css will be appended (except for explicit URIs). + # Full paths from the document root will be passed through. + # Used internally by +stylesheet_link_tag+ to build the stylesheet path. + # + # stylesheet_path "style" # => /assets/style.css + # stylesheet_path "dir/style.css" # => /assets/dir/style.css + # stylesheet_path "/dir/style.css" # => /dir/style.css + # stylesheet_path "http://www.example.com/css/style" # => http://www.example.com/css/style + # stylesheet_path "http://www.example.com/css/style.css" # => http://www.example.com/css/style.css + def stylesheet_path(source, options = {}) + path_to_asset(source, { type: :stylesheet }.merge!(options)) + end + alias_method :path_to_stylesheet, :stylesheet_path # aliased to avoid conflicts with a stylesheet_path named route + + # Computes the full URL to a stylesheet asset in the public stylesheets directory. + # This will use +stylesheet_path+ internally, so most of their behaviors will be the same. + # Since +stylesheet_url+ is based on +asset_url+ method you can set :host options. If :host + # options is set, it overwrites global +config.action_controller.asset_host+ setting. + # + # stylesheet_url "css/style.css", host: "http://stage.example.com" # => http://stage.example.com/assets/css/style.css + # + def stylesheet_url(source, options = {}) + url_to_asset(source, { type: :stylesheet }.merge!(options)) + end + alias_method :url_to_stylesheet, :stylesheet_url # aliased to avoid conflicts with a stylesheet_url named route + + # Computes the path to an image asset. + # Full paths from the document root will be passed through. + # Used internally by +image_tag+ to build the image path: + # + # image_path("edit") # => "/assets/edit" + # image_path("edit.png") # => "/assets/edit.png" + # image_path("icons/edit.png") # => "/assets/icons/edit.png" + # image_path("/icons/edit.png") # => "/icons/edit.png" + # image_path("http://www.example.com/img/edit.png") # => "http://www.example.com/img/edit.png" + # + # If you have images as application resources this method may conflict with their named routes. + # The alias +path_to_image+ is provided to avoid that. Rails uses the alias internally, and + # plugin authors are encouraged to do so. + def image_path(source, options = {}) + path_to_asset(source, { type: :image }.merge!(options)) + end + alias_method :path_to_image, :image_path # aliased to avoid conflicts with an image_path named route + + # Computes the full URL to an image asset. + # This will use +image_path+ internally, so most of their behaviors will be the same. + # Since +image_url+ is based on +asset_url+ method you can set :host options. If :host + # options is set, it overwrites global +config.action_controller.asset_host+ setting. + # + # image_url "edit.png", host: "http://stage.example.com" # => http://stage.example.com/assets/edit.png + # + def image_url(source, options = {}) + url_to_asset(source, { type: :image }.merge!(options)) + end + alias_method :url_to_image, :image_url # aliased to avoid conflicts with an image_url named route + + # Computes the path to a video asset in the public videos directory. + # Full paths from the document root will be passed through. + # Used internally by +video_tag+ to build the video path. + # + # video_path("hd") # => /videos/hd + # video_path("hd.avi") # => /videos/hd.avi + # video_path("trailers/hd.avi") # => /videos/trailers/hd.avi + # video_path("/trailers/hd.avi") # => /trailers/hd.avi + # video_path("http://www.example.com/vid/hd.avi") # => http://www.example.com/vid/hd.avi + def video_path(source, options = {}) + path_to_asset(source, { type: :video }.merge!(options)) + end + alias_method :path_to_video, :video_path # aliased to avoid conflicts with a video_path named route + + # Computes the full URL to a video asset in the public videos directory. + # This will use +video_path+ internally, so most of their behaviors will be the same. + # Since +video_url+ is based on +asset_url+ method you can set :host options. If :host + # options is set, it overwrites global +config.action_controller.asset_host+ setting. + # + # video_url "hd.avi", host: "http://stage.example.com" # => http://stage.example.com/videos/hd.avi + # + def video_url(source, options = {}) + url_to_asset(source, { type: :video }.merge!(options)) + end + alias_method :url_to_video, :video_url # aliased to avoid conflicts with a video_url named route + + # Computes the path to an audio asset in the public audios directory. + # Full paths from the document root will be passed through. + # Used internally by +audio_tag+ to build the audio path. + # + # audio_path("horse") # => /audios/horse + # audio_path("horse.wav") # => /audios/horse.wav + # audio_path("sounds/horse.wav") # => /audios/sounds/horse.wav + # audio_path("/sounds/horse.wav") # => /sounds/horse.wav + # audio_path("http://www.example.com/sounds/horse.wav") # => http://www.example.com/sounds/horse.wav + def audio_path(source, options = {}) + path_to_asset(source, { type: :audio }.merge!(options)) + end + alias_method :path_to_audio, :audio_path # aliased to avoid conflicts with an audio_path named route + + # Computes the full URL to an audio asset in the public audios directory. + # This will use +audio_path+ internally, so most of their behaviors will be the same. + # Since +audio_url+ is based on +asset_url+ method you can set :host options. If :host + # options is set, it overwrites global +config.action_controller.asset_host+ setting. + # + # audio_url "horse.wav", host: "http://stage.example.com" # => http://stage.example.com/audios/horse.wav + # + def audio_url(source, options = {}) + url_to_asset(source, { type: :audio }.merge!(options)) + end + alias_method :url_to_audio, :audio_url # aliased to avoid conflicts with an audio_url named route + + # Computes the path to a font asset. + # Full paths from the document root will be passed through. + # + # font_path("font") # => /fonts/font + # font_path("font.ttf") # => /fonts/font.ttf + # font_path("dir/font.ttf") # => /fonts/dir/font.ttf + # font_path("/dir/font.ttf") # => /dir/font.ttf + # font_path("http://www.example.com/dir/font.ttf") # => http://www.example.com/dir/font.ttf + def font_path(source, options = {}) + path_to_asset(source, { type: :font }.merge!(options)) + end + alias_method :path_to_font, :font_path # aliased to avoid conflicts with a font_path named route + + # Computes the full URL to a font asset. + # This will use +font_path+ internally, so most of their behaviors will be the same. + # Since +font_url+ is based on +asset_url+ method you can set :host options. If :host + # options is set, it overwrites global +config.action_controller.asset_host+ setting. + # + # font_url "font.ttf", host: "http://stage.example.com" # => http://stage.example.com/fonts/font.ttf + # + def font_url(source, options = {}) + url_to_asset(source, { type: :font }.merge!(options)) + end + alias_method :url_to_font, :font_url # aliased to avoid conflicts with a font_url named route + end + end +end diff --git a/actionview/lib/action_view/helpers/atom_feed_helper.rb b/actionview/lib/action_view/helpers/atom_feed_helper.rb new file mode 100644 index 0000000000..e6b9878271 --- /dev/null +++ b/actionview/lib/action_view/helpers/atom_feed_helper.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "set" + +module ActionView + # = Action View Atom Feed Helpers + module Helpers #:nodoc: + module AtomFeedHelper + # Adds easy defaults to writing Atom feeds with the Builder template engine (this does not work on ERB or any other + # template languages). + # + # Full usage example: + # + # config/routes.rb: + # Rails.application.routes.draw do + # resources :posts + # root to: "posts#index" + # end + # + # app/controllers/posts_controller.rb: + # class PostsController < ApplicationController + # # GET /posts.html + # # GET /posts.atom + # def index + # @posts = Post.all + # + # respond_to do |format| + # format.html + # format.atom + # end + # end + # end + # + # app/views/posts/index.atom.builder: + # atom_feed do |feed| + # feed.title("My great blog!") + # feed.updated(@posts[0].created_at) if @posts.length > 0 + # + # @posts.each do |post| + # feed.entry(post) do |entry| + # entry.title(post.title) + # entry.content(post.body, type: 'html') + # + # entry.author do |author| + # author.name("DHH") + # end + # end + # end + # end + # + # The options for atom_feed are: + # + # * <tt>:language</tt>: Defaults to "en-US". + # * <tt>:root_url</tt>: The HTML alternative that this feed is doubling for. Defaults to / on the current host. + # * <tt>:url</tt>: The URL for this feed. Defaults to the current URL. + # * <tt>:id</tt>: The id for this feed. Defaults to "tag:localhost,2005:/posts", in this case. + # * <tt>:schema_date</tt>: The date at which the tag scheme for the feed was first used. A good default is the year you + # created the feed. See http://feedvalidator.org/docs/error/InvalidTAG.html for more information. If not specified, + # 2005 is used (as an "I don't care" value). + # * <tt>:instruct</tt>: Hash of XML processing instructions in the form {target => {attribute => value, }} or {target => [{attribute => value, }, ]} + # + # Other namespaces can be added to the root element: + # + # app/views/posts/index.atom.builder: + # atom_feed({'xmlns:app' => 'http://www.w3.org/2007/app', + # 'xmlns:openSearch' => 'http://a9.com/-/spec/opensearch/1.1/'}) do |feed| + # feed.title("My great blog!") + # feed.updated((@posts.first.created_at)) + # feed.tag!('openSearch:totalResults', 10) + # + # @posts.each do |post| + # feed.entry(post) do |entry| + # entry.title(post.title) + # entry.content(post.body, type: 'html') + # entry.tag!('app:edited', Time.now) + # + # entry.author do |author| + # author.name("DHH") + # end + # end + # end + # end + # + # The Atom spec defines five elements (content rights title subtitle + # summary) which may directly contain xhtml content if type: 'xhtml' + # is specified as an attribute. If so, this helper will take care of + # the enclosing div and xhtml namespace declaration. Example usage: + # + # entry.summary type: 'xhtml' do |xhtml| + # xhtml.p pluralize(order.line_items.count, "line item") + # xhtml.p "Shipped to #{order.address}" + # xhtml.p "Paid by #{order.pay_type}" + # end + # + # + # <tt>atom_feed</tt> yields an +AtomFeedBuilder+ instance. Nested elements yield + # an +AtomBuilder+ instance. + def atom_feed(options = {}, &block) + if options[:schema_date] + options[:schema_date] = options[:schema_date].strftime("%Y-%m-%d") if options[:schema_date].respond_to?(:strftime) + else + options[:schema_date] = "2005" # The Atom spec copyright date + end + + xml = options.delete(:xml) || eval("xml", block.binding) + xml.instruct! + if options[:instruct] + options[:instruct].each do |target, attrs| + if attrs.respond_to?(:keys) + xml.instruct!(target, attrs) + elsif attrs.respond_to?(:each) + attrs.each { |attr_group| xml.instruct!(target, attr_group) } + end + end + end + + feed_opts = { "xml:lang" => options[:language] || "en-US", "xmlns" => "http://www.w3.org/2005/Atom" } + feed_opts.merge!(options).reject! { |k, v| !k.to_s.match(/^xml/) } + + xml.feed(feed_opts) do + xml.id(options[:id] || "tag:#{request.host},#{options[:schema_date]}:#{request.fullpath.split(".")[0]}") + xml.link(rel: "alternate", type: "text/html", href: options[:root_url] || (request.protocol + request.host_with_port)) + xml.link(rel: "self", type: "application/atom+xml", href: options[:url] || request.url) + + yield AtomFeedBuilder.new(xml, self, options) + end + end + + class AtomBuilder #:nodoc: + XHTML_TAG_NAMES = %w(content rights title subtitle summary).to_set + + def initialize(xml) + @xml = xml + end + + private + # Delegate to xml builder, first wrapping the element in an xhtml + # namespaced div element if the method and arguments indicate + # that an xhtml_block? is desired. + def method_missing(method, *arguments, &block) + if xhtml_block?(method, arguments) + @xml.__send__(method, *arguments) do + @xml.div(xmlns: "http://www.w3.org/1999/xhtml") do |xhtml| + block.call(xhtml) + end + end + else + @xml.__send__(method, *arguments, &block) + end + end + + # True if the method name matches one of the five elements defined + # in the Atom spec as potentially containing XHTML content and + # if type: 'xhtml' is, in fact, specified. + def xhtml_block?(method, arguments) + if XHTML_TAG_NAMES.include?(method.to_s) + last = arguments.last + last.is_a?(Hash) && last[:type].to_s == "xhtml" + end + end + end + + class AtomFeedBuilder < AtomBuilder #:nodoc: + def initialize(xml, view, feed_options = {}) + @xml, @view, @feed_options = xml, view, feed_options + end + + # Accepts a Date or Time object and inserts it in the proper format. If +nil+ is passed, current time in UTC is used. + def updated(date_or_time = nil) + @xml.updated((date_or_time || Time.now.utc).xmlschema) + end + + # Creates an entry tag for a specific record and prefills the id using class and id. + # + # Options: + # + # * <tt>:published</tt>: Time first published. Defaults to the created_at attribute on the record if one such exists. + # * <tt>:updated</tt>: Time of update. Defaults to the updated_at attribute on the record if one such exists. + # * <tt>:url</tt>: The URL for this entry or +false+ or +nil+ for not having a link tag. Defaults to the +polymorphic_url+ for the record. + # * <tt>:id</tt>: The ID for this entry. Defaults to "tag:#{@view.request.host},#{@feed_options[:schema_date]}:#{record.class}/#{record.id}" + # * <tt>:type</tt>: The TYPE for this entry. Defaults to "text/html". + def entry(record, options = {}) + @xml.entry do + @xml.id(options[:id] || "tag:#{@view.request.host},#{@feed_options[:schema_date]}:#{record.class}/#{record.id}") + + if options[:published] || (record.respond_to?(:created_at) && record.created_at) + @xml.published((options[:published] || record.created_at).xmlschema) + end + + if options[:updated] || (record.respond_to?(:updated_at) && record.updated_at) + @xml.updated((options[:updated] || record.updated_at).xmlschema) + end + + type = options.fetch(:type, "text/html") + + url = options.fetch(:url) { @view.polymorphic_url(record) } + @xml.link(rel: "alternate", type: type, href: url) if url + + yield AtomBuilder.new(@xml) + end + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/cache_helper.rb b/actionview/lib/action_view/helpers/cache_helper.rb new file mode 100644 index 0000000000..b1a14250c3 --- /dev/null +++ b/actionview/lib/action_view/helpers/cache_helper.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +module ActionView + # = Action View Cache Helper + module Helpers #:nodoc: + module CacheHelper + # This helper exposes a method for caching fragments of a view + # rather than an entire action or page. This technique is useful + # caching pieces like menus, lists of new topics, static HTML + # fragments, and so on. This method takes a block that contains + # the content you wish to cache. + # + # The best way to use this is by doing recyclable key-based cache expiration + # on top of a cache store like Memcached or Redis that'll automatically + # kick out old entries. + # + # When using this method, you list the cache dependency as the name of the cache, like so: + # + # <% cache project do %> + # <b>All the topics on this project</b> + # <%= render project.topics %> + # <% end %> + # + # This approach will assume that when a new topic is added, you'll touch + # the project. The cache key generated from this call will be something like: + # + # views/template/action.html.erb:7a1156131a6928cb0026877f8b749ac9/projects/123 + # ^template path ^template tree digest ^class ^id + # + # This cache key is stable, but it's combined with a cache version derived from the project + # record. When the project updated_at is touched, the #cache_version changes, even + # if the key stays stable. This means that unlike a traditional key-based cache expiration + # approach, you won't be generating cache trash, unused keys, simply because the dependent + # record is updated. + # + # If your template cache depends on multiple sources (try to avoid this to keep things simple), + # you can name all these dependencies as part of an array: + # + # <% cache [ project, current_user ] do %> + # <b>All the topics on this project</b> + # <%= render project.topics %> + # <% end %> + # + # This will include both records as part of the cache key and updating either of them will + # expire the cache. + # + # ==== \Template digest + # + # The template digest that's added to the cache key is computed by taking an MD5 of the + # contents of the entire template file. This ensures that your caches will automatically + # expire when you change the template file. + # + # Note that the MD5 is taken of the entire template file, not just what's within the + # cache do/end call. So it's possible that changing something outside of that call will + # still expire the cache. + # + # Additionally, the digestor will automatically look through your template file for + # explicit and implicit dependencies, and include those as part of the digest. + # + # The digestor can be bypassed by passing skip_digest: true as an option to the cache call: + # + # <% cache project, skip_digest: true do %> + # <b>All the topics on this project</b> + # <%= render project.topics %> + # <% end %> + # + # ==== Implicit dependencies + # + # Most template dependencies can be derived from calls to render in the template itself. + # Here are some examples of render calls that Cache Digests knows how to decode: + # + # render partial: "comments/comment", collection: commentable.comments + # render "comments/comments" + # render 'comments/comments' + # render('comments/comments') + # + # render "header" translates to render("comments/header") + # + # render(@topic) translates to render("topics/topic") + # render(topics) translates to render("topics/topic") + # render(message.topics) translates to render("topics/topic") + # + # It's not possible to derive all render calls like that, though. + # Here are a few examples of things that can't be derived: + # + # render group_of_attachments + # render @project.documents.where(published: true).order('created_at') + # + # You will have to rewrite those to the explicit form: + # + # render partial: 'attachments/attachment', collection: group_of_attachments + # render partial: 'documents/document', collection: @project.documents.where(published: true).order('created_at') + # + # === Explicit dependencies + # + # Sometimes you'll have template dependencies that can't be derived at all. This is typically + # the case when you have template rendering that happens in helpers. Here's an example: + # + # <%= render_sortable_todolists @project.todolists %> + # + # You'll need to use a special comment format to call those out: + # + # <%# Template Dependency: todolists/todolist %> + # <%= render_sortable_todolists @project.todolists %> + # + # In some cases, like a single table inheritance setup, you might have + # a bunch of explicit dependencies. Instead of writing every template out, + # you can use a wildcard to match any template in a directory: + # + # <%# Template Dependency: events/* %> + # <%= render_categorizable_events @person.events %> + # + # This marks every template in the directory as a dependency. To find those + # templates, the wildcard path must be absolutely defined from <tt>app/views</tt> or paths + # otherwise added with +prepend_view_path+ or +append_view_path+. + # This way the wildcard for <tt>app/views/recordings/events</tt> would be <tt>recordings/events/*</tt> etc. + # + # The pattern used to match explicit dependencies is <tt>/# Template Dependency: (\S+)/</tt>, + # so it's important that you type it out just so. + # You can only declare one template dependency per line. + # + # === External dependencies + # + # If you use a helper method, for example, inside a cached block and + # you then update that helper, you'll have to bump the cache as well. + # It doesn't really matter how you do it, but the MD5 of the template file + # must change. One recommendation is to simply be explicit in a comment, like: + # + # <%# Helper Dependency Updated: May 6, 2012 at 6pm %> + # <%= some_helper_method(person) %> + # + # Now all you have to do is change that timestamp when the helper method changes. + # + # === Collection Caching + # + # When rendering a collection of objects that each use the same partial, a <tt>:cached</tt> + # option can be passed. + # + # For collections rendered such: + # + # <%= render partial: 'projects/project', collection: @projects, cached: true %> + # + # The <tt>cached: true</tt> will make Action View's rendering read several templates + # from cache at once instead of one call per template. + # + # Templates in the collection not already cached are written to cache. + # + # Works great alongside individual template fragment caching. + # For instance if the template the collection renders is cached like: + # + # # projects/_project.html.erb + # <% cache project do %> + # <%# ... %> + # <% end %> + # + # Any collection renders will find those cached templates when attempting + # to read multiple templates at once. + # + # If your collection cache depends on multiple sources (try to avoid this to keep things simple), + # you can name all these dependencies as part of a block that returns an array: + # + # <%= render partial: 'projects/project', collection: @projects, cached: -> project { [ project, current_user ] } %> + # + # This will include both records as part of the cache key and updating either of them will + # expire the cache. + def cache(name = {}, options = {}, &block) + if controller.respond_to?(:perform_caching) && controller.perform_caching + name_options = options.slice(:skip_digest, :virtual_path) + safe_concat(fragment_for(cache_fragment_name(name, name_options), options, &block)) + else + yield + end + + nil + end + + # Cache fragments of a view if +condition+ is true + # + # <% cache_if admin?, project do %> + # <b>All the topics on this project</b> + # <%= render project.topics %> + # <% end %> + def cache_if(condition, name = {}, options = {}, &block) + if condition + cache(name, options, &block) + else + yield + end + + nil + end + + # Cache fragments of a view unless +condition+ is true + # + # <% cache_unless admin?, project do %> + # <b>All the topics on this project</b> + # <%= render project.topics %> + # <% end %> + def cache_unless(condition, name = {}, options = {}, &block) + cache_if !condition, name, options, &block + end + + # This helper returns the name of a cache key for a given fragment cache + # call. By supplying <tt>skip_digest: true</tt> to cache, the digestion of cache + # fragments can be manually bypassed. This is useful when cache fragments + # cannot be manually expired unless you know the exact key which is the + # case when using memcached. + # + # The digest will be generated using +virtual_path:+ if it is provided. + # + def cache_fragment_name(name = {}, skip_digest: nil, virtual_path: nil, digest_path: nil) + if skip_digest + name + else + fragment_name_with_digest(name, virtual_path, digest_path) + end + end + + def digest_path_from_virtual(virtual_path) # :nodoc: + digest = Digestor.digest(name: virtual_path, finder: lookup_context, dependencies: view_cache_dependencies) + + if digest.present? + "#{virtual_path}:#{digest}" + else + virtual_path + end + end + + private + + def fragment_name_with_digest(name, virtual_path, digest_path) + virtual_path ||= @virtual_path + + if virtual_path || digest_path + name = controller.url_for(name).split("://").last if name.is_a?(Hash) + + digest_path ||= digest_path_from_virtual(virtual_path) + + [ digest_path, name ] + else + name + end + end + + def fragment_for(name = {}, options = nil, &block) + if content = read_fragment_for(name, options) + @view_renderer.cache_hits[@virtual_path] = :hit if defined?(@view_renderer) + content + else + @view_renderer.cache_hits[@virtual_path] = :miss if defined?(@view_renderer) + write_fragment_for(name, options, &block) + end + end + + def read_fragment_for(name, options) + controller.read_fragment(name, options) + end + + def write_fragment_for(name, options) + pos = output_buffer.length + yield + output_safe = output_buffer.html_safe? + fragment = output_buffer.slice!(pos..-1) + if output_safe + self.output_buffer = output_buffer.class.new(output_buffer) + end + controller.write_fragment(name, fragment, options) + end + end + end +end diff --git a/actionview/lib/action_view/helpers/capture_helper.rb b/actionview/lib/action_view/helpers/capture_helper.rb new file mode 100644 index 0000000000..c87c212cc7 --- /dev/null +++ b/actionview/lib/action_view/helpers/capture_helper.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/output_safety" + +module ActionView + # = Action View Capture Helper + module Helpers #:nodoc: + # CaptureHelper exposes methods to let you extract generated markup which + # can be used in other parts of a template or layout file. + # + # It provides a method to capture blocks into variables through capture and + # a way to capture a block of markup for use in a layout through {content_for}[rdoc-ref:ActionView::Helpers::CaptureHelper#content_for]. + module CaptureHelper + # The capture method extracts part of a template as a String object. + # You can then use this object anywhere in your templates, layout, or helpers. + # + # The capture method can be used in ERB templates... + # + # <% @greeting = capture do %> + # Welcome to my shiny new web page! The date and time is + # <%= Time.now %> + # <% end %> + # + # ...and Builder (RXML) templates. + # + # @timestamp = capture do + # "The current timestamp is #{Time.now}." + # end + # + # You can then use that variable anywhere else. For example: + # + # <html> + # <head><title><%= @greeting %></title></head> + # <body> + # <b><%= @greeting %></b> + # </body> + # </html> + # + # The return of capture is the string generated by the block. For Example: + # + # @greeting # => "Welcome to my shiny new web page! The date and time is 2018-09-06 11:09:16 -0500" + # + def capture(*args) + value = nil + buffer = with_output_buffer { value = yield(*args) } + if (string = buffer.presence || value) && string.is_a?(String) + ERB::Util.html_escape string + end + end + + # Calling <tt>content_for</tt> stores a block of markup in an identifier for later use. + # In order to access this stored content in other templates, helper modules + # or the layout, you would pass the identifier as an argument to <tt>content_for</tt>. + # + # Note: <tt>yield</tt> can still be used to retrieve the stored content, but calling + # <tt>yield</tt> doesn't work in helper modules, while <tt>content_for</tt> does. + # + # <% content_for :not_authorized do %> + # alert('You are not authorized to do that!') + # <% end %> + # + # You can then use <tt>content_for :not_authorized</tt> anywhere in your templates. + # + # <%= content_for :not_authorized if current_user.nil? %> + # + # This is equivalent to: + # + # <%= yield :not_authorized if current_user.nil? %> + # + # <tt>content_for</tt>, however, can also be used in helper modules. + # + # module StorageHelper + # def stored_content + # content_for(:storage) || "Your storage is empty" + # end + # end + # + # This helper works just like normal helpers. + # + # <%= stored_content %> + # + # You can also use the <tt>yield</tt> syntax alongside an existing call to + # <tt>yield</tt> in a layout. For example: + # + # <%# This is the layout %> + # <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + # <head> + # <title>My Website</title> + # <%= yield :script %> + # </head> + # <body> + # <%= yield %> + # </body> + # </html> + # + # And now, we'll create a view that has a <tt>content_for</tt> call that + # creates the <tt>script</tt> identifier. + # + # <%# This is our view %> + # Please login! + # + # <% content_for :script do %> + # <script>alert('You are not authorized to view this page!')</script> + # <% end %> + # + # Then, in another view, you could to do something like this: + # + # <%= link_to 'Logout', action: 'logout', remote: true %> + # + # <% content_for :script do %> + # <%= javascript_include_tag :defaults %> + # <% end %> + # + # That will place +script+ tags for your default set of JavaScript files on the page; + # this technique is useful if you'll only be using these scripts in a few views. + # + # Note that <tt>content_for</tt> concatenates (default) the blocks it is given for a particular + # identifier in order. For example: + # + # <% content_for :navigation do %> + # <li><%= link_to 'Home', action: 'index' %></li> + # <% end %> + # + # And in another place: + # + # <% content_for :navigation do %> + # <li><%= link_to 'Login', action: 'login' %></li> + # <% end %> + # + # Then, in another template or layout, this code would render both links in order: + # + # <ul><%= content_for :navigation %></ul> + # + # If the flush parameter is +true+ <tt>content_for</tt> replaces the blocks it is given. For example: + # + # <% content_for :navigation do %> + # <li><%= link_to 'Home', action: 'index' %></li> + # <% end %> + # + # <%# Add some other content, or use a different template: %> + # + # <% content_for :navigation, flush: true do %> + # <li><%= link_to 'Login', action: 'login' %></li> + # <% end %> + # + # Then, in another template or layout, this code would render only the last link: + # + # <ul><%= content_for :navigation %></ul> + # + # Lastly, simple content can be passed as a parameter: + # + # <% content_for :script, javascript_include_tag(:defaults) %> + # + # WARNING: <tt>content_for</tt> is ignored in caches. So you shouldn't use it for elements that will be fragment cached. + def content_for(name, content = nil, options = {}, &block) + if content || block_given? + if block_given? + options = content if content + content = capture(&block) + end + if content + options[:flush] ? @view_flow.set(name, content) : @view_flow.append(name, content) + end + nil + else + @view_flow.get(name).presence + end + end + + # The same as +content_for+ but when used with streaming flushes + # straight back to the layout. In other words, if you want to + # concatenate several times to the same buffer when rendering a given + # template, you should use +content_for+, if not, use +provide+ to tell + # the layout to stop looking for more contents. + def provide(name, content = nil, &block) + content = capture(&block) if block_given? + result = @view_flow.append!(name, content) if content + result unless content + end + + # <tt>content_for?</tt> checks whether any content has been captured yet using <tt>content_for</tt>. + # Useful to render parts of your layout differently based on what is in your views. + # + # <%# This is the layout %> + # <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + # <head> + # <title>My Website</title> + # <%= yield :script %> + # </head> + # <body class="<%= content_for?(:right_col) ? 'two-column' : 'one-column' %>"> + # <%= yield %> + # <%= yield :right_col %> + # </body> + # </html> + def content_for?(name) + @view_flow.get(name).present? + end + + # Use an alternate output buffer for the duration of the block. + # Defaults to a new empty string. + def with_output_buffer(buf = nil) #:nodoc: + unless buf + buf = ActionView::OutputBuffer.new + if output_buffer && output_buffer.respond_to?(:encoding) + buf.force_encoding(output_buffer.encoding) + end + end + self.output_buffer, old_buffer = buf, output_buffer + yield + output_buffer + ensure + self.output_buffer = old_buffer + end + end + end +end diff --git a/actionview/lib/action_view/helpers/controller_helper.rb b/actionview/lib/action_view/helpers/controller_helper.rb new file mode 100644 index 0000000000..79cf86c7d1 --- /dev/null +++ b/actionview/lib/action_view/helpers/controller_helper.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attr_internal" + +module ActionView + module Helpers #:nodoc: + # This module keeps all methods and behavior in ActionView + # that simply delegates to the controller. + module ControllerHelper #:nodoc: + attr_internal :controller, :request + + CONTROLLER_DELEGATES = [:request_forgery_protection_token, :params, + :session, :cookies, :response, :headers, :flash, :action_name, + :controller_name, :controller_path] + + delegate(*CONTROLLER_DELEGATES, to: :controller) + + def assign_controller(controller) + if @_controller = controller + @_request = controller.request if controller.respond_to?(:request) + @_config = controller.config.inheritable_copy if controller.respond_to?(:config) + @_default_form_builder = controller.default_form_builder if controller.respond_to?(:default_form_builder) + end + end + + def logger + controller.logger if controller.respond_to?(:logger) + end + + def respond_to?(method_name, include_private = false) + return controller.respond_to?(method_name) if CONTROLLER_DELEGATES.include?(method_name.to_sym) + super + end + end + end +end diff --git a/actionview/lib/action_view/helpers/csp_helper.rb b/actionview/lib/action_view/helpers/csp_helper.rb new file mode 100644 index 0000000000..e2e065c218 --- /dev/null +++ b/actionview/lib/action_view/helpers/csp_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ActionView + # = Action View CSP Helper + module Helpers #:nodoc: + module CspHelper + # Returns a meta tag "csp-nonce" with the per-session nonce value + # for allowing inline <script> tags. + # + # <head> + # <%= csp_meta_tag %> + # </head> + # + # This is used by the Rails UJS helper to create dynamically + # loaded inline <script> elements. + # + def csp_meta_tag + if content_security_policy? + tag("meta", name: "csp-nonce", content: content_security_policy_nonce) + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/csrf_helper.rb b/actionview/lib/action_view/helpers/csrf_helper.rb new file mode 100644 index 0000000000..69c59844a6 --- /dev/null +++ b/actionview/lib/action_view/helpers/csrf_helper.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ActionView + # = Action View CSRF Helper + module Helpers #:nodoc: + module CsrfHelper + # Returns meta tags "csrf-param" and "csrf-token" with the name of the cross-site + # request forgery protection parameter and token, respectively. + # + # <head> + # <%= csrf_meta_tags %> + # </head> + # + # These are used to generate the dynamic forms that implement non-remote links with + # <tt>:method</tt>. + # + # You don't need to use these tags for regular forms as they generate their own hidden fields. + # + # For AJAX requests other than GETs, extract the "csrf-token" from the meta-tag and send as the + # "X-CSRF-Token" HTTP header. If you are using rails-ujs this happens automatically. + # + def csrf_meta_tags + if protect_against_forgery? + [ + tag("meta", name: "csrf-param", content: request_forgery_protection_token), + tag("meta", name: "csrf-token", content: form_authenticity_token) + ].join("\n").html_safe + end + end + + # For backwards compatibility. + alias csrf_meta_tag csrf_meta_tags + end + end +end diff --git a/actionview/lib/action_view/helpers/date_helper.rb b/actionview/lib/action_view/helpers/date_helper.rb new file mode 100644 index 0000000000..9d5e5eaba3 --- /dev/null +++ b/actionview/lib/action_view/helpers/date_helper.rb @@ -0,0 +1,1200 @@ +# frozen_string_literal: true + +require "date" +require "action_view/helpers/tag_helper" +require "active_support/core_ext/array/extract_options" +require "active_support/core_ext/date/conversions" +require "active_support/core_ext/hash/slice" +require "active_support/core_ext/object/acts_like" +require "active_support/core_ext/object/with_options" + +module ActionView + module Helpers #:nodoc: + # = Action View Date Helpers + # + # The Date Helper primarily creates select/option tags for different kinds of dates and times or date and time + # elements. All of the select-type methods share a number of common options that are as follows: + # + # * <tt>:prefix</tt> - overwrites the default prefix of "date" used for the select names. So specifying "birthday" + # would give \birthday[month] instead of \date[month] if passed to the <tt>select_month</tt> method. + # * <tt>:include_blank</tt> - set to true if it should be possible to set an empty date. + # * <tt>:discard_type</tt> - set to true if you want to discard the type part of the select name. If set to true, + # the <tt>select_month</tt> method would use simply "date" (which can be overwritten using <tt>:prefix</tt>) instead + # of \date[month]. + module DateHelper + MINUTES_IN_YEAR = 525600 + MINUTES_IN_QUARTER_YEAR = 131400 + MINUTES_IN_THREE_QUARTERS_YEAR = 394200 + + # Reports the approximate distance in time between two Time, Date or DateTime objects or integers as seconds. + # Pass <tt>include_seconds: true</tt> if you want more detailed approximations when distance < 1 min, 29 secs. + # Distances are reported based on the following table: + # + # 0 <-> 29 secs # => less than a minute + # 30 secs <-> 1 min, 29 secs # => 1 minute + # 1 min, 30 secs <-> 44 mins, 29 secs # => [2..44] minutes + # 44 mins, 30 secs <-> 89 mins, 29 secs # => about 1 hour + # 89 mins, 30 secs <-> 23 hrs, 59 mins, 29 secs # => about [2..24] hours + # 23 hrs, 59 mins, 30 secs <-> 41 hrs, 59 mins, 29 secs # => 1 day + # 41 hrs, 59 mins, 30 secs <-> 29 days, 23 hrs, 59 mins, 29 secs # => [2..29] days + # 29 days, 23 hrs, 59 mins, 30 secs <-> 44 days, 23 hrs, 59 mins, 29 secs # => about 1 month + # 44 days, 23 hrs, 59 mins, 30 secs <-> 59 days, 23 hrs, 59 mins, 29 secs # => about 2 months + # 59 days, 23 hrs, 59 mins, 30 secs <-> 1 yr minus 1 sec # => [2..12] months + # 1 yr <-> 1 yr, 3 months # => about 1 year + # 1 yr, 3 months <-> 1 yr, 9 months # => over 1 year + # 1 yr, 9 months <-> 2 yr minus 1 sec # => almost 2 years + # 2 yrs <-> max time or date # => (same rules as 1 yr) + # + # With <tt>include_seconds: true</tt> and the difference < 1 minute 29 seconds: + # 0-4 secs # => less than 5 seconds + # 5-9 secs # => less than 10 seconds + # 10-19 secs # => less than 20 seconds + # 20-39 secs # => half a minute + # 40-59 secs # => less than a minute + # 60-89 secs # => 1 minute + # + # from_time = Time.now + # distance_of_time_in_words(from_time, from_time + 50.minutes) # => about 1 hour + # distance_of_time_in_words(from_time, 50.minutes.from_now) # => about 1 hour + # distance_of_time_in_words(from_time, from_time + 15.seconds) # => less than a minute + # distance_of_time_in_words(from_time, from_time + 15.seconds, include_seconds: true) # => less than 20 seconds + # distance_of_time_in_words(from_time, 3.years.from_now) # => about 3 years + # distance_of_time_in_words(from_time, from_time + 60.hours) # => 3 days + # distance_of_time_in_words(from_time, from_time + 45.seconds, include_seconds: true) # => less than a minute + # distance_of_time_in_words(from_time, from_time - 45.seconds, include_seconds: true) # => less than a minute + # distance_of_time_in_words(from_time, 76.seconds.from_now) # => 1 minute + # distance_of_time_in_words(from_time, from_time + 1.year + 3.days) # => about 1 year + # distance_of_time_in_words(from_time, from_time + 3.years + 6.months) # => over 3 years + # distance_of_time_in_words(from_time, from_time + 4.years + 9.days + 30.minutes + 5.seconds) # => about 4 years + # + # to_time = Time.now + 6.years + 19.days + # distance_of_time_in_words(from_time, to_time, include_seconds: true) # => about 6 years + # distance_of_time_in_words(to_time, from_time, include_seconds: true) # => about 6 years + # distance_of_time_in_words(Time.now, Time.now) # => less than a minute + # + # With the <tt>scope</tt> option, you can define a custom scope for Rails + # to look up the translation. + # + # For example you can define the following in your locale (e.g. en.yml). + # + # datetime: + # distance_in_words: + # short: + # about_x_hours: + # one: 'an hour' + # other: '%{count} hours' + # + # See https://github.com/svenfuchs/rails-i18n/blob/master/rails/locale/en.yml + # for more examples. + # + # Which will then result in the following: + # + # from_time = Time.now + # distance_of_time_in_words(from_time, from_time + 50.minutes, scope: 'datetime.distance_in_words.short') # => "an hour" + # distance_of_time_in_words(from_time, from_time + 3.hours, scope: 'datetime.distance_in_words.short') # => "3 hours" + def distance_of_time_in_words(from_time, to_time = 0, options = {}) + options = { + scope: :'datetime.distance_in_words' + }.merge!(options) + + from_time = normalize_distance_of_time_argument_to_time(from_time) + to_time = normalize_distance_of_time_argument_to_time(to_time) + from_time, to_time = to_time, from_time if from_time > to_time + distance_in_minutes = ((to_time - from_time) / 60.0).round + distance_in_seconds = (to_time - from_time).round + + I18n.with_options locale: options[:locale], scope: options[:scope] do |locale| + case distance_in_minutes + when 0..1 + return distance_in_minutes == 0 ? + locale.t(:less_than_x_minutes, count: 1) : + locale.t(:x_minutes, count: distance_in_minutes) unless options[:include_seconds] + + case distance_in_seconds + when 0..4 then locale.t :less_than_x_seconds, count: 5 + when 5..9 then locale.t :less_than_x_seconds, count: 10 + when 10..19 then locale.t :less_than_x_seconds, count: 20 + when 20..39 then locale.t :half_a_minute + when 40..59 then locale.t :less_than_x_minutes, count: 1 + else locale.t :x_minutes, count: 1 + end + + when 2...45 then locale.t :x_minutes, count: distance_in_minutes + when 45...90 then locale.t :about_x_hours, count: 1 + # 90 mins up to 24 hours + when 90...1440 then locale.t :about_x_hours, count: (distance_in_minutes.to_f / 60.0).round + # 24 hours up to 42 hours + when 1440...2520 then locale.t :x_days, count: 1 + # 42 hours up to 30 days + when 2520...43200 then locale.t :x_days, count: (distance_in_minutes.to_f / 1440.0).round + # 30 days up to 60 days + when 43200...86400 then locale.t :about_x_months, count: (distance_in_minutes.to_f / 43200.0).round + # 60 days up to 365 days + when 86400...525600 then locale.t :x_months, count: (distance_in_minutes.to_f / 43200.0).round + else + from_year = from_time.year + from_year += 1 if from_time.month >= 3 + to_year = to_time.year + to_year -= 1 if to_time.month < 3 + leap_years = (from_year > to_year) ? 0 : (from_year..to_year).count { |x| Date.leap?(x) } + minute_offset_for_leap_year = leap_years * 1440 + # Discount the leap year days when calculating year distance. + # e.g. if there are 20 leap year days between 2 dates having the same day + # and month then the based on 365 days calculation + # the distance in years will come out to over 80 years when in written + # English it would read better as about 80 years. + minutes_with_offset = distance_in_minutes - minute_offset_for_leap_year + remainder = (minutes_with_offset % MINUTES_IN_YEAR) + distance_in_years = (minutes_with_offset.div MINUTES_IN_YEAR) + if remainder < MINUTES_IN_QUARTER_YEAR + locale.t(:about_x_years, count: distance_in_years) + elsif remainder < MINUTES_IN_THREE_QUARTERS_YEAR + locale.t(:over_x_years, count: distance_in_years) + else + locale.t(:almost_x_years, count: distance_in_years + 1) + end + end + end + end + + # Like <tt>distance_of_time_in_words</tt>, but where <tt>to_time</tt> is fixed to <tt>Time.now</tt>. + # + # time_ago_in_words(3.minutes.from_now) # => 3 minutes + # time_ago_in_words(3.minutes.ago) # => 3 minutes + # time_ago_in_words(Time.now - 15.hours) # => about 15 hours + # time_ago_in_words(Time.now) # => less than a minute + # time_ago_in_words(Time.now, include_seconds: true) # => less than 5 seconds + # + # from_time = Time.now - 3.days - 14.minutes - 25.seconds + # time_ago_in_words(from_time) # => 3 days + # + # from_time = (3.days + 14.minutes + 25.seconds).ago + # time_ago_in_words(from_time) # => 3 days + # + # Note that you cannot pass a <tt>Numeric</tt> value to <tt>time_ago_in_words</tt>. + # + def time_ago_in_words(from_time, options = {}) + distance_of_time_in_words(from_time, Time.now, options) + end + + alias_method :distance_of_time_in_words_to_now, :time_ago_in_words + + # Returns a set of select tags (one for year, month, and day) pre-selected for accessing a specified date-based + # attribute (identified by +method+) on an object assigned to the template (identified by +object+). + # + # ==== Options + # * <tt>:use_month_numbers</tt> - Set to true if you want to use month numbers rather than month names (e.g. + # "2" instead of "February"). + # * <tt>:use_two_digit_numbers</tt> - Set to true if you want to display two digit month and day numbers (e.g. + # "02" instead of "February" and "08" instead of "8"). + # * <tt>:use_short_month</tt> - Set to true if you want to use abbreviated month names instead of full + # month names (e.g. "Feb" instead of "February"). + # * <tt>:add_month_numbers</tt> - Set to true if you want to use both month numbers and month names (e.g. + # "2 - February" instead of "February"). + # * <tt>:use_month_names</tt> - Set to an array with 12 month names if you want to customize month names. + # Note: You can also use Rails' i18n functionality for this. + # * <tt>:month_format_string</tt> - Set to a format string. The string gets passed keys +:number+ (integer) + # and +:name+ (string). A format string would be something like "%{name} (%<number>02d)" for example. + # See <tt>Kernel.sprintf</tt> for documentation on format sequences. + # * <tt>:date_separator</tt> - Specifies a string to separate the date fields. Default is "" (i.e. nothing). + # * <tt>:time_separator</tt> - Specifies a string to separate the time fields. Default is "" (i.e. nothing). + # * <tt>:datetime_separator</tt>- Specifies a string to separate the date and time fields. Default is "" (i.e. nothing). + # * <tt>:start_year</tt> - Set the start year for the year select. Default is <tt>Date.today.year - 5</tt> if + # you are creating new record. While editing existing record, <tt>:start_year</tt> defaults to + # the current selected year minus 5. + # * <tt>:end_year</tt> - Set the end year for the year select. Default is <tt>Date.today.year + 5</tt> if + # you are creating new record. While editing existing record, <tt>:end_year</tt> defaults to + # the current selected year plus 5. + # * <tt>:year_format</tt> - Set format of years for year select. Lambda should be passed. + # * <tt>:discard_day</tt> - Set to true if you don't want to show a day select. This includes the day + # as a hidden field instead of showing a select field. Also note that this implicitly sets the day to be the + # first of the given month in order to not create invalid dates like 31 February. + # * <tt>:discard_month</tt> - Set to true if you don't want to show a month select. This includes the month + # as a hidden field instead of showing a select field. Also note that this implicitly sets :discard_day to true. + # * <tt>:discard_year</tt> - Set to true if you don't want to show a year select. This includes the year + # as a hidden field instead of showing a select field. + # * <tt>:order</tt> - Set to an array containing <tt>:day</tt>, <tt>:month</tt> and <tt>:year</tt> to + # customize the order in which the select fields are shown. If you leave out any of the symbols, the respective + # select will not be shown (like when you set <tt>discard_xxx: true</tt>. Defaults to the order defined in + # the respective locale (e.g. [:year, :month, :day] in the en locale that ships with Rails). + # * <tt>:include_blank</tt> - Include a blank option in every select field so it's possible to set empty + # dates. + # * <tt>:default</tt> - Set a default date if the affected date isn't set or is +nil+. + # * <tt>:selected</tt> - Set a date that overrides the actual value. + # * <tt>:disabled</tt> - Set to true if you want show the select fields as disabled. + # * <tt>:prompt</tt> - Set to true (for a generic prompt), a prompt string or a hash of prompt strings + # for <tt>:year</tt>, <tt>:month</tt>, <tt>:day</tt>, <tt>:hour</tt>, <tt>:minute</tt> and <tt>:second</tt>. + # Setting this option prepends a select option with a generic prompt (Day, Month, Year, Hour, Minute, Seconds) + # or the given prompt string. + # * <tt>:with_css_classes</tt> - Set to true or a hash of strings. Use true if you want to assign generic styles for + # select tags. This automatically set classes 'year', 'month', 'day', 'hour', 'minute' and 'second'. A hash of + # strings for <tt>:year</tt>, <tt>:month</tt>, <tt>:day</tt>, <tt>:hour</tt>, <tt>:minute</tt>, <tt>:second</tt> + # will extend the select type with the given value. Use +html_options+ to modify every select tag in the set. + # * <tt>:use_hidden</tt> - Set to true if you only want to generate hidden input tags. + # + # If anything is passed in the +html_options+ hash it will be applied to every select tag in the set. + # + # NOTE: Discarded selects will default to 1. So if no month select is available, January will be assumed. + # + # # Generates a date select that when POSTed is stored in the article variable, in the written_on attribute. + # date_select("article", "written_on") + # + # # Generates a date select that when POSTed is stored in the article variable, in the written_on attribute, + # # with the year in the year drop down box starting at 1995. + # date_select("article", "written_on", start_year: 1995) + # + # # Generates a date select that when POSTed is stored in the article variable, in the written_on attribute, + # # with the year in the year drop down box starting at 1995, numbers used for months instead of words, + # # and without a day select box. + # date_select("article", "written_on", start_year: 1995, use_month_numbers: true, + # discard_day: true, include_blank: true) + # + # # Generates a date select that when POSTed is stored in the article variable, in the written_on attribute, + # # with two digit numbers used for months and days. + # date_select("article", "written_on", use_two_digit_numbers: true) + # + # # Generates a date select that when POSTed is stored in the article variable, in the written_on attribute + # # with the fields ordered as day, month, year rather than month, day, year. + # date_select("article", "written_on", order: [:day, :month, :year]) + # + # # Generates a date select that when POSTed is stored in the user variable, in the birthday attribute + # # lacking a year field. + # date_select("user", "birthday", order: [:month, :day]) + # + # # Generates a date select that when POSTed is stored in the article variable, in the written_on attribute + # # which is initially set to the date 3 days from the current date + # date_select("article", "written_on", default: 3.days.from_now) + # + # # Generates a date select that when POSTed is stored in the article variable, in the written_on attribute + # # which is set in the form with today's date, regardless of the value in the Active Record object. + # date_select("article", "written_on", selected: Date.today) + # + # # Generates a date select that when POSTed is stored in the credit_card variable, in the bill_due attribute + # # that will have a default day of 20. + # date_select("credit_card", "bill_due", default: { day: 20 }) + # + # # Generates a date select with custom prompts. + # date_select("article", "written_on", prompt: { day: 'Select day', month: 'Select month', year: 'Select year' }) + # + # # Generates a date select with custom year format. + # date_select("article", "written_on", year_format: ->(year) { "Heisei #{year - 1988}" }) + # + # The selects are prepared for multi-parameter assignment to an Active Record object. + # + # Note: If the day is not included as an option but the month is, the day will be set to the 1st to ensure that + # all month choices are valid. + def date_select(object_name, method, options = {}, html_options = {}) + Tags::DateSelect.new(object_name, method, self, options, html_options).render + end + + # Returns a set of select tags (one for hour, minute and optionally second) pre-selected for accessing a + # specified time-based attribute (identified by +method+) on an object assigned to the template (identified by + # +object+). You can include the seconds with <tt>:include_seconds</tt>. You can get hours in the AM/PM format + # with <tt>:ampm</tt> option. + # + # This method will also generate 3 input hidden tags, for the actual year, month and day unless the option + # <tt>:ignore_date</tt> is set to +true+. If you set the <tt>:ignore_date</tt> to +true+, you must have a + # +date_select+ on the same method within the form otherwise an exception will be raised. + # + # If anything is passed in the html_options hash it will be applied to every select tag in the set. + # + # # Creates a time select tag that, when POSTed, will be stored in the article variable in the sunrise attribute. + # time_select("article", "sunrise") + # + # # Creates a time select tag with a seconds field that, when POSTed, will be stored in the article variables in + # # the sunrise attribute. + # time_select("article", "start_time", include_seconds: true) + # + # # You can set the <tt>:minute_step</tt> to 15 which will give you: 00, 15, 30, and 45. + # time_select 'game', 'game_time', { minute_step: 15 } + # + # # Creates a time select tag with a custom prompt. Use <tt>prompt: true</tt> for generic prompts. + # time_select("article", "written_on", prompt: { hour: 'Choose hour', minute: 'Choose minute', second: 'Choose seconds' }) + # time_select("article", "written_on", prompt: { hour: true }) # generic prompt for hours + # time_select("article", "written_on", prompt: true) # generic prompts for all + # + # # You can set :ampm option to true which will show the hours as: 12 PM, 01 AM .. 11 PM. + # time_select 'game', 'game_time', { ampm: true } + # + # The selects are prepared for multi-parameter assignment to an Active Record object. + # + # Note: If the day is not included as an option but the month is, the day will be set to the 1st to ensure that + # all month choices are valid. + def time_select(object_name, method, options = {}, html_options = {}) + Tags::TimeSelect.new(object_name, method, self, options, html_options).render + end + + # Returns a set of select tags (one for year, month, day, hour, and minute) pre-selected for accessing a + # specified datetime-based attribute (identified by +method+) on an object assigned to the template (identified + # by +object+). + # + # If anything is passed in the html_options hash it will be applied to every select tag in the set. + # + # # Generates a datetime select that, when POSTed, will be stored in the article variable in the written_on + # # attribute. + # datetime_select("article", "written_on") + # + # # Generates a datetime select with a year select that starts at 1995 that, when POSTed, will be stored in the + # # article variable in the written_on attribute. + # datetime_select("article", "written_on", start_year: 1995) + # + # # Generates a datetime select with a default value of 3 days from the current time that, when POSTed, will + # # be stored in the trip variable in the departing attribute. + # datetime_select("trip", "departing", default: 3.days.from_now) + # + # # Generate a datetime select with hours in the AM/PM format + # datetime_select("article", "written_on", ampm: true) + # + # # Generates a datetime select that discards the type that, when POSTed, will be stored in the article variable + # # as the written_on attribute. + # datetime_select("article", "written_on", discard_type: true) + # + # # Generates a datetime select with a custom prompt. Use <tt>prompt: true</tt> for generic prompts. + # datetime_select("article", "written_on", prompt: { day: 'Choose day', month: 'Choose month', year: 'Choose year' }) + # datetime_select("article", "written_on", prompt: { hour: true }) # generic prompt for hours + # datetime_select("article", "written_on", prompt: true) # generic prompts for all + # + # The selects are prepared for multi-parameter assignment to an Active Record object. + def datetime_select(object_name, method, options = {}, html_options = {}) + Tags::DatetimeSelect.new(object_name, method, self, options, html_options).render + end + + # Returns a set of HTML select-tags (one for year, month, day, hour, minute, and second) pre-selected with the + # +datetime+. It's also possible to explicitly set the order of the tags using the <tt>:order</tt> option with + # an array of symbols <tt>:year</tt>, <tt>:month</tt> and <tt>:day</tt> in the desired order. If you do not + # supply a Symbol, it will be appended onto the <tt>:order</tt> passed in. You can also add + # <tt>:date_separator</tt>, <tt>:datetime_separator</tt> and <tt>:time_separator</tt> keys to the +options+ to + # control visual display of the elements. + # + # If anything is passed in the html_options hash it will be applied to every select tag in the set. + # + # my_date_time = Time.now + 4.days + # + # # Generates a datetime select that defaults to the datetime in my_date_time (four days after today). + # select_datetime(my_date_time) + # + # # Generates a datetime select that defaults to today (no specified datetime) + # select_datetime() + # + # # Generates a datetime select that defaults to the datetime in my_date_time (four days after today) + # # with the fields ordered year, month, day rather than month, day, year. + # select_datetime(my_date_time, order: [:year, :month, :day]) + # + # # Generates a datetime select that defaults to the datetime in my_date_time (four days after today) + # # with a '/' between each date field. + # select_datetime(my_date_time, date_separator: '/') + # + # # Generates a datetime select that defaults to the datetime in my_date_time (four days after today) + # # with a date fields separated by '/', time fields separated by '' and the date and time fields + # # separated by a comma (','). + # select_datetime(my_date_time, date_separator: '/', time_separator: '', datetime_separator: ',') + # + # # Generates a datetime select that discards the type of the field and defaults to the datetime in + # # my_date_time (four days after today) + # select_datetime(my_date_time, discard_type: true) + # + # # Generate a datetime field with hours in the AM/PM format + # select_datetime(my_date_time, ampm: true) + # + # # Generates a datetime select that defaults to the datetime in my_date_time (four days after today) + # # prefixed with 'payday' rather than 'date' + # select_datetime(my_date_time, prefix: 'payday') + # + # # Generates a datetime select with a custom prompt. Use <tt>prompt: true</tt> for generic prompts. + # select_datetime(my_date_time, prompt: { day: 'Choose day', month: 'Choose month', year: 'Choose year' }) + # select_datetime(my_date_time, prompt: { hour: true }) # generic prompt for hours + # select_datetime(my_date_time, prompt: true) # generic prompts for all + def select_datetime(datetime = Time.current, options = {}, html_options = {}) + DateTimeSelector.new(datetime, options, html_options).select_datetime + end + + # Returns a set of HTML select-tags (one for year, month, and day) pre-selected with the +date+. + # It's possible to explicitly set the order of the tags using the <tt>:order</tt> option with an array of + # symbols <tt>:year</tt>, <tt>:month</tt> and <tt>:day</tt> in the desired order. + # If the array passed to the <tt>:order</tt> option does not contain all the three symbols, all tags will be hidden. + # + # If anything is passed in the html_options hash it will be applied to every select tag in the set. + # + # my_date = Time.now + 6.days + # + # # Generates a date select that defaults to the date in my_date (six days after today). + # select_date(my_date) + # + # # Generates a date select that defaults to today (no specified date). + # select_date() + # + # # Generates a date select that defaults to the date in my_date (six days after today) + # # with the fields ordered year, month, day rather than month, day, year. + # select_date(my_date, order: [:year, :month, :day]) + # + # # Generates a date select that discards the type of the field and defaults to the date in + # # my_date (six days after today). + # select_date(my_date, discard_type: true) + # + # # Generates a date select that defaults to the date in my_date, + # # which has fields separated by '/'. + # select_date(my_date, date_separator: '/') + # + # # Generates a date select that defaults to the datetime in my_date (six days after today) + # # prefixed with 'payday' rather than 'date'. + # select_date(my_date, prefix: 'payday') + # + # # Generates a date select with a custom prompt. Use <tt>prompt: true</tt> for generic prompts. + # select_date(my_date, prompt: { day: 'Choose day', month: 'Choose month', year: 'Choose year' }) + # select_date(my_date, prompt: { hour: true }) # generic prompt for hours + # select_date(my_date, prompt: true) # generic prompts for all + def select_date(date = Date.current, options = {}, html_options = {}) + DateTimeSelector.new(date, options, html_options).select_date + end + + # Returns a set of HTML select-tags (one for hour and minute). + # You can set <tt>:time_separator</tt> key to format the output, and + # the <tt>:include_seconds</tt> option to include an input for seconds. + # + # If anything is passed in the html_options hash it will be applied to every select tag in the set. + # + # my_time = Time.now + 5.days + 7.hours + 3.minutes + 14.seconds + # + # # Generates a time select that defaults to the time in my_time. + # select_time(my_time) + # + # # Generates a time select that defaults to the current time (no specified time). + # select_time() + # + # # Generates a time select that defaults to the time in my_time, + # # which has fields separated by ':'. + # select_time(my_time, time_separator: ':') + # + # # Generates a time select that defaults to the time in my_time, + # # that also includes an input for seconds. + # select_time(my_time, include_seconds: true) + # + # # Generates a time select that defaults to the time in my_time, that has fields + # # separated by ':' and includes an input for seconds. + # select_time(my_time, time_separator: ':', include_seconds: true) + # + # # Generate a time select field with hours in the AM/PM format + # select_time(my_time, ampm: true) + # + # # Generates a time select field with hours that range from 2 to 14 + # select_time(my_time, start_hour: 2, end_hour: 14) + # + # # Generates a time select with a custom prompt. Use <tt>:prompt</tt> to true for generic prompts. + # select_time(my_time, prompt: { day: 'Choose day', month: 'Choose month', year: 'Choose year' }) + # select_time(my_time, prompt: { hour: true }) # generic prompt for hours + # select_time(my_time, prompt: true) # generic prompts for all + def select_time(datetime = Time.current, options = {}, html_options = {}) + DateTimeSelector.new(datetime, options, html_options).select_time + end + + # Returns a select tag with options for each of the seconds 0 through 59 with the current second selected. + # The <tt>datetime</tt> can be either a +Time+ or +DateTime+ object or an integer. + # Override the field name using the <tt>:field_name</tt> option, 'second' by default. + # + # my_time = Time.now + 16.seconds + # + # # Generates a select field for seconds that defaults to the seconds for the time in my_time. + # select_second(my_time) + # + # # Generates a select field for seconds that defaults to the number given. + # select_second(33) + # + # # Generates a select field for seconds that defaults to the seconds for the time in my_time + # # that is named 'interval' rather than 'second'. + # select_second(my_time, field_name: 'interval') + # + # # Generates a select field for seconds with a custom prompt. Use <tt>prompt: true</tt> for a + # # generic prompt. + # select_second(14, prompt: 'Choose seconds') + def select_second(datetime, options = {}, html_options = {}) + DateTimeSelector.new(datetime, options, html_options).select_second + end + + # Returns a select tag with options for each of the minutes 0 through 59 with the current minute selected. + # Also can return a select tag with options by <tt>minute_step</tt> from 0 through 59 with the 00 minute + # selected. The <tt>datetime</tt> can be either a +Time+ or +DateTime+ object or an integer. + # Override the field name using the <tt>:field_name</tt> option, 'minute' by default. + # + # my_time = Time.now + 10.minutes + # + # # Generates a select field for minutes that defaults to the minutes for the time in my_time. + # select_minute(my_time) + # + # # Generates a select field for minutes that defaults to the number given. + # select_minute(14) + # + # # Generates a select field for minutes that defaults to the minutes for the time in my_time + # # that is named 'moment' rather than 'minute'. + # select_minute(my_time, field_name: 'moment') + # + # # Generates a select field for minutes with a custom prompt. Use <tt>prompt: true</tt> for a + # # generic prompt. + # select_minute(14, prompt: 'Choose minutes') + def select_minute(datetime, options = {}, html_options = {}) + DateTimeSelector.new(datetime, options, html_options).select_minute + end + + # Returns a select tag with options for each of the hours 0 through 23 with the current hour selected. + # The <tt>datetime</tt> can be either a +Time+ or +DateTime+ object or an integer. + # Override the field name using the <tt>:field_name</tt> option, 'hour' by default. + # + # my_time = Time.now + 6.hours + # + # # Generates a select field for hours that defaults to the hour for the time in my_time. + # select_hour(my_time) + # + # # Generates a select field for hours that defaults to the number given. + # select_hour(13) + # + # # Generates a select field for hours that defaults to the hour for the time in my_time + # # that is named 'stride' rather than 'hour'. + # select_hour(my_time, field_name: 'stride') + # + # # Generates a select field for hours with a custom prompt. Use <tt>prompt: true</tt> for a + # # generic prompt. + # select_hour(13, prompt: 'Choose hour') + # + # # Generate a select field for hours in the AM/PM format + # select_hour(my_time, ampm: true) + # + # # Generates a select field that includes options for hours from 2 to 14. + # select_hour(my_time, start_hour: 2, end_hour: 14) + def select_hour(datetime, options = {}, html_options = {}) + DateTimeSelector.new(datetime, options, html_options).select_hour + end + + # Returns a select tag with options for each of the days 1 through 31 with the current day selected. + # The <tt>date</tt> can also be substituted for a day number. + # If you want to display days with a leading zero set the <tt>:use_two_digit_numbers</tt> key in +options+ to true. + # Override the field name using the <tt>:field_name</tt> option, 'day' by default. + # + # my_date = Time.now + 2.days + # + # # Generates a select field for days that defaults to the day for the date in my_date. + # select_day(my_date) + # + # # Generates a select field for days that defaults to the number given. + # select_day(5) + # + # # Generates a select field for days that defaults to the number given, but displays it with two digits. + # select_day(5, use_two_digit_numbers: true) + # + # # Generates a select field for days that defaults to the day for the date in my_date + # # that is named 'due' rather than 'day'. + # select_day(my_date, field_name: 'due') + # + # # Generates a select field for days with a custom prompt. Use <tt>prompt: true</tt> for a + # # generic prompt. + # select_day(5, prompt: 'Choose day') + def select_day(date, options = {}, html_options = {}) + DateTimeSelector.new(date, options, html_options).select_day + end + + # Returns a select tag with options for each of the months January through December with the current month + # selected. The month names are presented as keys (what's shown to the user) and the month numbers (1-12) are + # used as values (what's submitted to the server). It's also possible to use month numbers for the presentation + # instead of names -- set the <tt>:use_month_numbers</tt> key in +options+ to true for this to happen. If you + # want both numbers and names, set the <tt>:add_month_numbers</tt> key in +options+ to true. If you would prefer + # to show month names as abbreviations, set the <tt>:use_short_month</tt> key in +options+ to true. If you want + # to use your own month names, set the <tt>:use_month_names</tt> key in +options+ to an array of 12 month names. + # If you want to display months with a leading zero set the <tt>:use_two_digit_numbers</tt> key in +options+ to true. + # Override the field name using the <tt>:field_name</tt> option, 'month' by default. + # + # # Generates a select field for months that defaults to the current month that + # # will use keys like "January", "March". + # select_month(Date.today) + # + # # Generates a select field for months that defaults to the current month that + # # is named "start" rather than "month". + # select_month(Date.today, field_name: 'start') + # + # # Generates a select field for months that defaults to the current month that + # # will use keys like "1", "3". + # select_month(Date.today, use_month_numbers: true) + # + # # Generates a select field for months that defaults to the current month that + # # will use keys like "1 - January", "3 - March". + # select_month(Date.today, add_month_numbers: true) + # + # # Generates a select field for months that defaults to the current month that + # # will use keys like "Jan", "Mar". + # select_month(Date.today, use_short_month: true) + # + # # Generates a select field for months that defaults to the current month that + # # will use keys like "Januar", "Marts." + # select_month(Date.today, use_month_names: %w(Januar Februar Marts ...)) + # + # # Generates a select field for months that defaults to the current month that + # # will use keys with two digit numbers like "01", "03". + # select_month(Date.today, use_two_digit_numbers: true) + # + # # Generates a select field for months with a custom prompt. Use <tt>prompt: true</tt> for a + # # generic prompt. + # select_month(14, prompt: 'Choose month') + def select_month(date, options = {}, html_options = {}) + DateTimeSelector.new(date, options, html_options).select_month + end + + # Returns a select tag with options for each of the five years on each side of the current, which is selected. + # The five year radius can be changed using the <tt>:start_year</tt> and <tt>:end_year</tt> keys in the + # +options+. Both ascending and descending year lists are supported by making <tt>:start_year</tt> less than or + # greater than <tt>:end_year</tt>. The <tt>date</tt> can also be substituted for a year given as a number. + # Override the field name using the <tt>:field_name</tt> option, 'year' by default. + # + # # Generates a select field for years that defaults to the current year that + # # has ascending year values. + # select_year(Date.today, start_year: 1992, end_year: 2007) + # + # # Generates a select field for years that defaults to the current year that + # # is named 'birth' rather than 'year'. + # select_year(Date.today, field_name: 'birth') + # + # # Generates a select field for years that defaults to the current year that + # # has descending year values. + # select_year(Date.today, start_year: 2005, end_year: 1900) + # + # # Generates a select field for years that defaults to the year 2006 that + # # has ascending year values. + # select_year(2006, start_year: 2000, end_year: 2010) + # + # # Generates a select field for years with a custom prompt. Use <tt>prompt: true</tt> for a + # # generic prompt. + # select_year(14, prompt: 'Choose year') + def select_year(date, options = {}, html_options = {}) + DateTimeSelector.new(date, options, html_options).select_year + end + + # Returns an HTML time tag for the given date or time. + # + # time_tag Date.today # => + # <time datetime="2010-11-04">November 04, 2010</time> + # time_tag Time.now # => + # <time datetime="2010-11-04T17:55:45+01:00">November 04, 2010 17:55</time> + # time_tag Date.yesterday, 'Yesterday' # => + # <time datetime="2010-11-03">Yesterday</time> + # time_tag Date.today, datetime: Date.today.strftime('%G-W%V') # => + # <time datetime="2010-W44">November 04, 2010</time> + # + # <%= time_tag Time.now do %> + # <span>Right now</span> + # <% end %> + # # => <time datetime="2010-11-04T17:55:45+01:00"><span>Right now</span></time> + def time_tag(date_or_time, *args, &block) + options = args.extract_options! + format = options.delete(:format) || :long + content = args.first || I18n.l(date_or_time, format: format) + + content_tag("time", content, options.reverse_merge(datetime: date_or_time.iso8601), &block) + end + + private + + def normalize_distance_of_time_argument_to_time(value) + if value.is_a?(Numeric) + Time.at(value) + elsif value.respond_to?(:to_time) + value.to_time + else + raise ArgumentError, "#{value.inspect} can't be converted to a Time value" + end + end + end + + class DateTimeSelector #:nodoc: + include ActionView::Helpers::TagHelper + + DEFAULT_PREFIX = "date" + POSITION = { + year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6 + }.freeze + + AMPM_TRANSLATION = Hash[ + [[0, "12 AM"], [1, "01 AM"], [2, "02 AM"], [3, "03 AM"], + [4, "04 AM"], [5, "05 AM"], [6, "06 AM"], [7, "07 AM"], + [8, "08 AM"], [9, "09 AM"], [10, "10 AM"], [11, "11 AM"], + [12, "12 PM"], [13, "01 PM"], [14, "02 PM"], [15, "03 PM"], + [16, "04 PM"], [17, "05 PM"], [18, "06 PM"], [19, "07 PM"], + [20, "08 PM"], [21, "09 PM"], [22, "10 PM"], [23, "11 PM"]] + ].freeze + + def initialize(datetime, options = {}, html_options = {}) + @options = options.dup + @html_options = html_options.dup + @datetime = datetime + @options[:datetime_separator] ||= " — " + @options[:time_separator] ||= " : " + end + + def select_datetime + order = date_order.dup + order -= [:hour, :minute, :second] + @options[:discard_year] ||= true unless order.include?(:year) + @options[:discard_month] ||= true unless order.include?(:month) + @options[:discard_day] ||= true if @options[:discard_month] || !order.include?(:day) + @options[:discard_minute] ||= true if @options[:discard_hour] + @options[:discard_second] ||= true unless @options[:include_seconds] && !@options[:discard_minute] + + set_day_if_discarded + + if @options[:tag] && @options[:ignore_date] + select_time + else + [:day, :month, :year].each { |o| order.unshift(o) unless order.include?(o) } + order += [:hour, :minute, :second] unless @options[:discard_hour] + + build_selects_from_types(order) + end + end + + def select_date + order = date_order.dup + + @options[:discard_hour] = true + @options[:discard_minute] = true + @options[:discard_second] = true + + @options[:discard_year] ||= true unless order.include?(:year) + @options[:discard_month] ||= true unless order.include?(:month) + @options[:discard_day] ||= true if @options[:discard_month] || !order.include?(:day) + + set_day_if_discarded + + [:day, :month, :year].each { |o| order.unshift(o) unless order.include?(o) } + + build_selects_from_types(order) + end + + def select_time + order = [] + + @options[:discard_month] = true + @options[:discard_year] = true + @options[:discard_day] = true + @options[:discard_second] ||= true unless @options[:include_seconds] + + order += [:year, :month, :day] unless @options[:ignore_date] + + order += [:hour, :minute] + order << :second if @options[:include_seconds] + + build_selects_from_types(order) + end + + def select_second + if @options[:use_hidden] || @options[:discard_second] + build_hidden(:second, sec) if @options[:include_seconds] + else + build_options_and_select(:second, sec) + end + end + + def select_minute + if @options[:use_hidden] || @options[:discard_minute] + build_hidden(:minute, min) + else + build_options_and_select(:minute, min, step: @options[:minute_step]) + end + end + + def select_hour + if @options[:use_hidden] || @options[:discard_hour] + build_hidden(:hour, hour) + else + options = {} + options[:ampm] = @options[:ampm] || false + options[:start] = @options[:start_hour] || 0 + options[:end] = @options[:end_hour] || 23 + build_options_and_select(:hour, hour, options) + end + end + + def select_day + if @options[:use_hidden] || @options[:discard_day] + build_hidden(:day, day || 1) + else + build_options_and_select(:day, day, start: 1, end: 31, leading_zeros: false, use_two_digit_numbers: @options[:use_two_digit_numbers]) + end + end + + def select_month + if @options[:use_hidden] || @options[:discard_month] + build_hidden(:month, month || 1) + else + month_options = [] + 1.upto(12) do |month_number| + options = { value: month_number } + options[:selected] = "selected" if month == month_number + month_options << content_tag("option", month_name(month_number), options) + "\n" + end + build_select(:month, month_options.join) + end + end + + def select_year + if !@datetime || @datetime == 0 + val = "1" + middle_year = Date.today.year + else + val = middle_year = year + end + + if @options[:use_hidden] || @options[:discard_year] + build_hidden(:year, val) + else + options = {} + options[:start] = @options[:start_year] || middle_year - 5 + options[:end] = @options[:end_year] || middle_year + 5 + options[:step] = options[:start] < options[:end] ? 1 : -1 + options[:leading_zeros] = false + options[:max_years_allowed] = @options[:max_years_allowed] || 1000 + + if (options[:end] - options[:start]).abs > options[:max_years_allowed] + raise ArgumentError, "There are too many years options to be built. Are you sure you haven't mistyped something? You can provide the :max_years_allowed parameter." + end + + build_select(:year, build_year_options(val, options)) + end + end + + private + %w( sec min hour day month year ).each do |method| + define_method(method) do + case @datetime + when Hash then @datetime[method.to_sym] + when Numeric then @datetime + when nil then nil + else @datetime.send(method) + end + end + end + + # If the day is hidden, the day should be set to the 1st so all month and year choices are + # valid. Otherwise, February 31st or February 29th, 2011 can be selected, which are invalid. + def set_day_if_discarded + if @datetime && @options[:discard_day] + @datetime = @datetime.change(day: 1) + end + end + + # Returns translated month names, but also ensures that a custom month + # name array has a leading +nil+ element. + def month_names + @month_names ||= begin + month_names = @options[:use_month_names] || translated_month_names + month_names.unshift(nil) if month_names.size < 13 + month_names + end + end + + # Returns translated month names. + # => [nil, "January", "February", "March", + # "April", "May", "June", "July", + # "August", "September", "October", + # "November", "December"] + # + # If <tt>:use_short_month</tt> option is set + # => [nil, "Jan", "Feb", "Mar", "Apr", "May", "Jun", + # "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + def translated_month_names + key = @options[:use_short_month] ? :'date.abbr_month_names' : :'date.month_names' + I18n.translate(key, locale: @options[:locale]) + end + + # Looks up month names by number (1-based): + # + # month_name(1) # => "January" + # + # If the <tt>:use_month_numbers</tt> option is passed: + # + # month_name(1) # => 1 + # + # If the <tt>:use_two_month_numbers</tt> option is passed: + # + # month_name(1) # => '01' + # + # If the <tt>:add_month_numbers</tt> option is passed: + # + # month_name(1) # => "1 - January" + # + # If the <tt>:month_format_string</tt> option is passed: + # + # month_name(1) # => "January (01)" + # + # depending on the format string. + def month_name(number) + if @options[:use_month_numbers] + number + elsif @options[:use_two_digit_numbers] + "%02d" % number + elsif @options[:add_month_numbers] + "#{number} - #{month_names[number]}" + elsif format_string = @options[:month_format_string] + format_string % { number: number, name: month_names[number] } + else + month_names[number] + end + end + + # Looks up year names by number. + # + # year_name(1998) # => 1998 + # + # If the <tt>:year_format</tt> option is passed: + # + # year_name(1998) # => "Heisei 10" + def year_name(number) + if year_format_lambda = @options[:year_format] + year_format_lambda.call(number) + else + number + end + end + + def date_order + @date_order ||= @options[:order] || translated_date_order + end + + def translated_date_order + date_order = I18n.translate(:'date.order', locale: @options[:locale], default: []) + date_order = date_order.map(&:to_sym) + + forbidden_elements = date_order - [:year, :month, :day] + if forbidden_elements.any? + raise StandardError, + "#{@options[:locale]}.date.order only accepts :year, :month and :day" + end + + date_order + end + + # Build full select tag from date type and options. + def build_options_and_select(type, selected, options = {}) + build_select(type, build_options(selected, options)) + end + + # Build select option HTML from date value and options. + # build_options(15, start: 1, end: 31) + # => "<option value="1">1</option> + # <option value="2">2</option> + # <option value="3">3</option>..." + # + # If <tt>use_two_digit_numbers: true</tt> option is passed + # build_options(15, start: 1, end: 31, use_two_digit_numbers: true) + # => "<option value="1">01</option> + # <option value="2">02</option> + # <option value="3">03</option>..." + # + # If <tt>:step</tt> options is passed + # build_options(15, start: 1, end: 31, step: 2) + # => "<option value="1">1</option> + # <option value="3">3</option> + # <option value="5">5</option>..." + def build_options(selected, options = {}) + options = { + leading_zeros: true, ampm: false, use_two_digit_numbers: false + }.merge!(options) + + start = options.delete(:start) || 0 + stop = options.delete(:end) || 59 + step = options.delete(:step) || 1 + leading_zeros = options.delete(:leading_zeros) + + select_options = [] + start.step(stop, step) do |i| + value = leading_zeros ? sprintf("%02d", i) : i + tag_options = { value: value } + tag_options[:selected] = "selected" if selected == i + text = options[:use_two_digit_numbers] ? sprintf("%02d", i) : value + text = options[:ampm] ? AMPM_TRANSLATION[i] : text + select_options << content_tag("option", text, tag_options) + end + + (select_options.join("\n") + "\n").html_safe + end + + # Build select option HTML for year. + # If <tt>year_format</tt> option is not passed + # build_year_options(1998, start: 1998, end: 2000) + # => "<option value="1998" selected="selected">1998</option> + # <option value="1999">1999</option> + # <option value="2000">2000</option>" + # + # If <tt>year_format</tt> option is passed + # build_year_options(1998, start: 1998, end: 2000, year_format: ->year { "Heisei #{ year - 1988 }" }) + # => "<option value="1998" selected="selected">Heisei 10</option> + # <option value="1999">Heisei 11</option> + # <option value="2000">Heisei 12</option>" + def build_year_options(selected, options = {}) + start = options.delete(:start) + stop = options.delete(:end) + step = options.delete(:step) + + select_options = [] + start.step(stop, step) do |value| + tag_options = { value: value } + tag_options[:selected] = "selected" if selected == value + text = year_name(value) + select_options << content_tag("option", text, tag_options) + end + + (select_options.join("\n") + "\n").html_safe + end + + # Builds select tag from date type and HTML select options. + # build_select(:month, "<option value="1">January</option>...") + # => "<select id="post_written_on_2i" name="post[written_on(2i)]"> + # <option value="1">January</option>... + # </select>" + def build_select(type, select_options_as_html) + select_options = { + id: input_id_from_type(type), + name: input_name_from_type(type) + }.merge!(@html_options) + select_options[:disabled] = "disabled" if @options[:disabled] + select_options[:class] = css_class_attribute(type, select_options[:class], @options[:with_css_classes]) if @options[:with_css_classes] + + select_html = +"\n" + select_html << content_tag("option", "", value: "") + "\n" if @options[:include_blank] + select_html << prompt_option_tag(type, @options[:prompt]) + "\n" if @options[:prompt] + select_html << select_options_as_html + + (content_tag("select", select_html.html_safe, select_options) + "\n").html_safe + end + + # Builds the css class value for the select element + # css_class_attribute(:year, 'date optional', { year: 'my-year' }) + # => "date optional my-year" + def css_class_attribute(type, html_options_class, options) # :nodoc: + css_class = \ + case options + when Hash + options[type.to_sym] + else + type + end + + [html_options_class, css_class].compact.join(" ") + end + + # Builds a prompt option tag with supplied options or from default options. + # prompt_option_tag(:month, prompt: 'Select month') + # => "<option value="">Select month</option>" + def prompt_option_tag(type, options) + prompt = \ + case options + when Hash + default_options = { year: false, month: false, day: false, hour: false, minute: false, second: false } + default_options.merge!(options)[type.to_sym] + when String + options + else + I18n.translate(:"datetime.prompts.#{type}", locale: @options[:locale]) + end + + prompt ? content_tag("option", prompt, value: "") : "" + end + + # Builds hidden input tag for date part and value. + # build_hidden(:year, 2008) + # => "<input id="post_written_on_1i" name="post[written_on(1i)]" type="hidden" value="2008" />" + def build_hidden(type, value) + select_options = { + type: "hidden", + id: input_id_from_type(type), + name: input_name_from_type(type), + value: value + }.merge!(@html_options.slice(:disabled)) + select_options[:disabled] = "disabled" if @options[:disabled] + + tag(:input, select_options) + "\n".html_safe + end + + # Returns the name attribute for the input tag. + # => post[written_on(1i)] + def input_name_from_type(type) + prefix = @options[:prefix] || ActionView::Helpers::DateTimeSelector::DEFAULT_PREFIX + prefix += "[#{@options[:index]}]" if @options.has_key?(:index) + + field_name = @options[:field_name] || type.to_s + if @options[:include_position] + field_name += "(#{ActionView::Helpers::DateTimeSelector::POSITION[type]}i)" + end + + @options[:discard_type] ? prefix : "#{prefix}[#{field_name}]" + end + + # Returns the id attribute for the input tag. + # => "post_written_on_1i" + def input_id_from_type(type) + id = input_name_from_type(type).gsub(/([\[\(])|(\]\[)/, "_").gsub(/[\]\)]/, "") + id = @options[:namespace] + "_" + id if @options[:namespace] + + id + end + + # Given an ordering of datetime components, create the selection HTML + # and join them with their appropriate separators. + def build_selects_from_types(order) + select = +"" + first_visible = order.find { |type| !@options[:"discard_#{type}"] } + order.reverse_each do |type| + separator = separator(type) unless type == first_visible # don't add before first visible field + select.insert(0, separator.to_s + send("select_#{type}").to_s) + end + select.html_safe + end + + # Returns the separator for a given datetime component. + def separator(type) + return "" if @options[:use_hidden] + + case type + when :year, :month, :day + @options[:"discard_#{type}"] ? "" : @options[:date_separator] + when :hour + (@options[:discard_year] && @options[:discard_day]) ? "" : @options[:datetime_separator] + when :minute, :second + @options[:"discard_#{type}"] ? "" : @options[:time_separator] + end + end + end + + class FormBuilder + # Wraps ActionView::Helpers::DateHelper#date_select for form builders: + # + # <%= form_for @person do |f| %> + # <%= f.date_select :birth_date %> + # <%= f.submit %> + # <% end %> + # + # Please refer to the documentation of the base helper for details. + def date_select(method, options = {}, html_options = {}) + @template.date_select(@object_name, method, objectify_options(options), html_options) + end + + # Wraps ActionView::Helpers::DateHelper#time_select for form builders: + # + # <%= form_for @race do |f| %> + # <%= f.time_select :average_lap %> + # <%= f.submit %> + # <% end %> + # + # Please refer to the documentation of the base helper for details. + def time_select(method, options = {}, html_options = {}) + @template.time_select(@object_name, method, objectify_options(options), html_options) + end + + # Wraps ActionView::Helpers::DateHelper#datetime_select for form builders: + # + # <%= form_for @person do |f| %> + # <%= f.datetime_select :last_request_at %> + # <%= f.submit %> + # <% end %> + # + # Please refer to the documentation of the base helper for details. + def datetime_select(method, options = {}, html_options = {}) + @template.datetime_select(@object_name, method, objectify_options(options), html_options) + end + end + end +end diff --git a/actionview/lib/action_view/helpers/debug_helper.rb b/actionview/lib/action_view/helpers/debug_helper.rb new file mode 100644 index 0000000000..88ceba414b --- /dev/null +++ b/actionview/lib/action_view/helpers/debug_helper.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ActionView + # = Action View Debug Helper + # + # Provides a set of methods for making it easier to debug Rails objects. + module Helpers #:nodoc: + module DebugHelper + include TagHelper + + # Returns a YAML representation of +object+ wrapped with <pre> and </pre>. + # If the object cannot be converted to YAML using +to_yaml+, +inspect+ will be called instead. + # Useful for inspecting an object at the time of rendering. + # + # @user = User.new({ username: 'testing', password: 'xyz', age: 42}) + # debug(@user) + # # => + # <pre class='debug_dump'>--- !ruby/object:User + # attributes: + # updated_at: + # username: testing + # age: 42 + # password: xyz + # created_at: + # </pre> + def debug(object) + Marshal.dump(object) + object = ERB::Util.html_escape(object.to_yaml) + content_tag(:pre, object, class: "debug_dump") + rescue # errors from Marshal or YAML + # Object couldn't be dumped, perhaps because of singleton methods -- this is the fallback + content_tag(:code, object.inspect, class: "debug_dump") + end + end + end +end diff --git a/actionview/lib/action_view/helpers/form_helper.rb b/actionview/lib/action_view/helpers/form_helper.rb new file mode 100644 index 0000000000..c2caa77afb --- /dev/null +++ b/actionview/lib/action_view/helpers/form_helper.rb @@ -0,0 +1,2348 @@ +# frozen_string_literal: true + +require "cgi" +require "action_view/helpers/date_helper" +require "action_view/helpers/tag_helper" +require "action_view/helpers/form_tag_helper" +require "action_view/helpers/active_model_helper" +require "action_view/model_naming" +require "action_view/record_identifier" +require "active_support/core_ext/module/attribute_accessors" +require "active_support/core_ext/hash/slice" +require "active_support/core_ext/string/output_safety" +require "active_support/core_ext/string/inflections" + +module ActionView + # = Action View Form Helpers + module Helpers #:nodoc: + # Form helpers are designed to make working with resources much easier + # compared to using vanilla HTML. + # + # Typically, a form designed to create or update a resource reflects the + # identity of the resource in several ways: (i) the URL that the form is + # sent to (the form element's +action+ attribute) should result in a request + # being routed to the appropriate controller action (with the appropriate <tt>:id</tt> + # parameter in the case of an existing resource), (ii) input fields should + # be named in such a way that in the controller their values appear in the + # appropriate places within the +params+ hash, and (iii) for an existing record, + # when the form is initially displayed, input fields corresponding to attributes + # of the resource should show the current values of those attributes. + # + # In Rails, this is usually achieved by creating the form using +form_for+ and + # a number of related helper methods. +form_for+ generates an appropriate <tt>form</tt> + # tag and yields a form builder object that knows the model the form is about. + # Input fields are created by calling methods defined on the form builder, which + # means they are able to generate the appropriate names and default values + # corresponding to the model attributes, as well as convenient IDs, etc. + # Conventions in the generated field names allow controllers to receive form data + # nicely structured in +params+ with no effort on your side. + # + # For example, to create a new person you typically set up a new instance of + # +Person+ in the <tt>PeopleController#new</tt> action, <tt>@person</tt>, and + # in the view template pass that object to +form_for+: + # + # <%= form_for @person do |f| %> + # <%= f.label :first_name %>: + # <%= f.text_field :first_name %><br /> + # + # <%= f.label :last_name %>: + # <%= f.text_field :last_name %><br /> + # + # <%= f.submit %> + # <% end %> + # + # The HTML generated for this would be (modulus formatting): + # + # <form action="/people" class="new_person" id="new_person" method="post"> + # <input name="authenticity_token" type="hidden" value="NrOp5bsjoLRuK8IW5+dQEYjKGUJDe7TQoZVvq95Wteg=" /> + # <label for="person_first_name">First name</label>: + # <input id="person_first_name" name="person[first_name]" type="text" /><br /> + # + # <label for="person_last_name">Last name</label>: + # <input id="person_last_name" name="person[last_name]" type="text" /><br /> + # + # <input name="commit" type="submit" value="Create Person" /> + # </form> + # + # As you see, the HTML reflects knowledge about the resource in several spots, + # like the path the form should be submitted to, or the names of the input fields. + # + # In particular, thanks to the conventions followed in the generated field names, the + # controller gets a nested hash <tt>params[:person]</tt> with the person attributes + # set in the form. That hash is ready to be passed to <tt>Person.new</tt>: + # + # @person = Person.new(params[:person]) + # if @person.save + # # success + # else + # # error handling + # end + # + # Interestingly, the exact same view code in the previous example can be used to edit + # a person. If <tt>@person</tt> is an existing record with name "John Smith" and ID 256, + # the code above as is would yield instead: + # + # <form action="/people/256" class="edit_person" id="edit_person_256" method="post"> + # <input name="_method" type="hidden" value="patch" /> + # <input name="authenticity_token" type="hidden" value="NrOp5bsjoLRuK8IW5+dQEYjKGUJDe7TQoZVvq95Wteg=" /> + # <label for="person_first_name">First name</label>: + # <input id="person_first_name" name="person[first_name]" type="text" value="John" /><br /> + # + # <label for="person_last_name">Last name</label>: + # <input id="person_last_name" name="person[last_name]" type="text" value="Smith" /><br /> + # + # <input name="commit" type="submit" value="Update Person" /> + # </form> + # + # Note that the endpoint, default values, and submit button label are tailored for <tt>@person</tt>. + # That works that way because the involved helpers know whether the resource is a new record or not, + # and generate HTML accordingly. + # + # The controller would receive the form data again in <tt>params[:person]</tt>, ready to be + # passed to <tt>Person#update</tt>: + # + # if @person.update(params[:person]) + # # success + # else + # # error handling + # end + # + # That's how you typically work with resources. + module FormHelper + extend ActiveSupport::Concern + + include FormTagHelper + include UrlHelper + include ModelNaming + include RecordIdentifier + + attr_internal :default_form_builder + + # Creates a form that allows the user to create or update the attributes + # of a specific model object. + # + # The method can be used in several slightly different ways, depending on + # how much you wish to rely on Rails to infer automatically from the model + # how the form should be constructed. For a generic model object, a form + # can be created by passing +form_for+ a string or symbol representing + # the object we are concerned with: + # + # <%= form_for :person do |f| %> + # First name: <%= f.text_field :first_name %><br /> + # Last name : <%= f.text_field :last_name %><br /> + # Biography : <%= f.text_area :biography %><br /> + # Admin? : <%= f.check_box :admin %><br /> + # <%= f.submit %> + # <% end %> + # + # The variable +f+ yielded to the block is a FormBuilder object that + # incorporates the knowledge about the model object represented by + # <tt>:person</tt> passed to +form_for+. Methods defined on the FormBuilder + # are used to generate fields bound to this model. Thus, for example, + # + # <%= f.text_field :first_name %> + # + # will get expanded to + # + # <%= text_field :person, :first_name %> + # + # which results in an HTML <tt><input></tt> tag whose +name+ attribute is + # <tt>person[first_name]</tt>. This means that when the form is submitted, + # the value entered by the user will be available in the controller as + # <tt>params[:person][:first_name]</tt>. + # + # For fields generated in this way using the FormBuilder, + # if <tt>:person</tt> also happens to be the name of an instance variable + # <tt>@person</tt>, the default value of the field shown when the form is + # initially displayed (e.g. in the situation where you are editing an + # existing record) will be the value of the corresponding attribute of + # <tt>@person</tt>. + # + # The rightmost argument to +form_for+ is an + # optional hash of options - + # + # * <tt>:url</tt> - The URL the form is to be submitted to. This may be + # represented in the same way as values passed to +url_for+ or +link_to+. + # So for example you may use a named route directly. When the model is + # represented by a string or symbol, as in the example above, if the + # <tt>:url</tt> option is not specified, by default the form will be + # sent back to the current URL (We will describe below an alternative + # resource-oriented usage of +form_for+ in which the URL does not need + # to be specified explicitly). + # * <tt>:namespace</tt> - A namespace for your form to ensure uniqueness of + # id attributes on form elements. The namespace attribute will be prefixed + # with underscore on the generated HTML id. + # * <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 with name <tt>_method</tt> is added to + # simulate the verb over post. + # * <tt>:authenticity_token</tt> - Authenticity token to use in the form. + # Use only if you need to pass custom authenticity token string, or to + # not add authenticity_token field at all (by passing <tt>false</tt>). + # 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 you're 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>:remote</tt> - If set to true, will allow the Unobtrusive + # JavaScript drivers to control the submit behavior. By default this + # behavior is an ajax submit. + # * <tt>:enforce_utf8</tt> - If set to false, a hidden input with name + # utf8 is not output. + # * <tt>:html</tt> - Optional HTML attributes for the form tag. + # + # Also note that +form_for+ doesn't create an exclusive scope. It's still + # possible to use both the stand-alone FormHelper methods and methods + # from FormTagHelper. For example: + # + # <%= form_for :person do |f| %> + # First name: <%= f.text_field :first_name %> + # Last name : <%= f.text_field :last_name %> + # Biography : <%= text_area :person, :biography %> + # Admin? : <%= check_box_tag "person[admin]", "1", @person.company.admin? %> + # <%= f.submit %> + # <% end %> + # + # This also works for the methods in FormOptionsHelper and DateHelper that + # are designed to work with an object as base, like + # FormOptionsHelper#collection_select and DateHelper#datetime_select. + # + # === #form_for with a model object + # + # In the examples above, the object to be created or edited was + # represented by a symbol passed to +form_for+, and we noted that + # a string can also be used equivalently. It is also possible, however, + # to pass a model object itself to +form_for+. For example, if <tt>@post</tt> + # is an existing record you wish to edit, you can create the form using + # + # <%= form_for @post do |f| %> + # ... + # <% end %> + # + # This behaves in almost the same way as outlined previously, with a + # couple of small exceptions. First, the prefix used to name the input + # elements within the form (hence the key that denotes them in the +params+ + # hash) is actually derived from the object's _class_, e.g. <tt>params[:post]</tt> + # if the object's class is +Post+. However, this can be overwritten using + # the <tt>:as</tt> option, e.g. - + # + # <%= form_for(@person, as: :client) do |f| %> + # ... + # <% end %> + # + # would result in <tt>params[:client]</tt>. + # + # Secondly, the field values shown when the form is initially displayed + # are taken from the attributes of the object passed to +form_for+, + # regardless of whether the object is an instance + # variable. So, for example, if we had a _local_ variable +post+ + # representing an existing record, + # + # <%= form_for post do |f| %> + # ... + # <% end %> + # + # would produce a form with fields whose initial state reflect the current + # values of the attributes of +post+. + # + # === Resource-oriented style + # + # In the examples just shown, although not indicated explicitly, we still + # need to use the <tt>:url</tt> option in order to specify where the + # form is going to be sent. However, further simplification is possible + # if the record passed to +form_for+ is a _resource_, i.e. it corresponds + # to a set of RESTful routes, e.g. defined using the +resources+ method + # in <tt>config/routes.rb</tt>. In this case Rails will simply infer the + # appropriate URL from the record itself. For example, + # + # <%= form_for @post do |f| %> + # ... + # <% end %> + # + # is then equivalent to something like: + # + # <%= form_for @post, as: :post, url: post_path(@post), method: :patch, html: { class: "edit_post", id: "edit_post_45" } do |f| %> + # ... + # <% end %> + # + # And for a new record + # + # <%= form_for(Post.new) do |f| %> + # ... + # <% end %> + # + # is equivalent to something like: + # + # <%= form_for @post, as: :post, url: posts_path, html: { class: "new_post", id: "new_post" } do |f| %> + # ... + # <% end %> + # + # However you can still overwrite individual conventions, such as: + # + # <%= form_for(@post, url: super_posts_path) do |f| %> + # ... + # <% end %> + # + # You can also set the answer format, like this: + # + # <%= form_for(@post, format: :json) do |f| %> + # ... + # <% end %> + # + # For namespaced routes, like +admin_post_url+: + # + # <%= form_for([:admin, @post]) do |f| %> + # ... + # <% 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_for([@document, @comment]) do |f| %> + # ... + # <% end %> + # + # Where <tt>@document = Document.find(params[:id])</tt> and + # <tt>@comment = Comment.new</tt>. + # + # === 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. + # + # === Unobtrusive JavaScript + # + # Specifying: + # + # remote: true + # + # in the options hash creates a form that will allow the unobtrusive JavaScript drivers to modify its + # behavior. The expected default behavior is an XMLHttpRequest in the background instead of the regular + # POST arrangement, but ultimately the behavior is the choice of the JavaScript driver implementor. + # Even though it's using JavaScript to serialize the form elements, the form submission will work just like + # a regular submission as viewed by the receiving side (all elements available in <tt>params</tt>). + # + # Example: + # + # <%= form_for(@post, remote: true) do |f| %> + # ... + # <% end %> + # + # The HTML generated for this would be: + # + # <form action='http://www.example.com' method='post' data-remote='true'> + # <input name='_method' type='hidden' value='patch' /> + # ... + # </form> + # + # === Setting HTML options + # + # You can set data attributes directly by passing in a data hash, but all other HTML options must be wrapped in + # the HTML key. Example: + # + # <%= form_for(@post, data: { behavior: "autosave" }, html: { name: "go" }) do |f| %> + # ... + # <% end %> + # + # The HTML generated for this would be: + # + # <form action='http://www.example.com' method='post' data-behavior='autosave' name='go'> + # <input name='_method' type='hidden' value='patch' /> + # ... + # </form> + # + # === Removing hidden model id's + # + # The form_for 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. + # + # Example: + # + # <%= form_for(@post) do |f| %> + # <%= f.fields_for(:comments, include_id: false) do |cf| %> + # ... + # <% 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_for @person, url: { action: "create" }, builder: LabellingFormBuilder do |f| %> + # <%= f.text_field :first_name %> + # <%= f.text_field :last_name %> + # <%= f.text_area :biography %> + # <%= f.check_box :admin %> + # <%= f.submit %> + # <% end %> + # + # In this case, if you use this: + # + # <%= render f %> + # + # 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_for 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_for(record_or_name_or_array, *args, &block) + # options = args.extract_options! + # form_for(record_or_name_or_array, *(args << options.merge(builder: LabellingFormBuilder)), &block) + # end + # + # If you don't need to attach a form to a model instance, then check out + # FormTagHelper#form_tag. + # + # === Form to external resources + # + # When you build forms to external resources sometimes you need to set an authenticity token or just render a form + # without it, for example when you submit data to a payment gateway number and types of fields could be limited. + # + # To set an authenticity token you need to pass an <tt>:authenticity_token</tt> parameter + # + # <%= form_for @invoice, url: external_url, authenticity_token: 'external_token' do |f| %> + # ... + # <% end %> + # + # If you don't want to an authenticity token field be rendered at all just pass <tt>false</tt>: + # + # <%= form_for @invoice, url: external_url, authenticity_token: false do |f| %> + # ... + # <% end %> + def form_for(record, options = {}, &block) + raise ArgumentError, "Missing block" unless block_given? + html_options = options[:html] ||= {} + + case record + when String, Symbol + object_name = record + object = nil + else + object = record.is_a?(Array) ? record.last : record + raise ArgumentError, "First argument in form cannot contain nil or be empty" unless object + object_name = options[:as] || model_name_from_record_or_class(object).param_key + apply_form_for_options!(record, object, options) + end + + html_options[:data] = options.delete(:data) if options.has_key?(:data) + html_options[:remote] = options.delete(:remote) if options.has_key?(:remote) + html_options[:method] = options.delete(:method) if options.has_key?(:method) + html_options[:enforce_utf8] = options.delete(:enforce_utf8) if options.has_key?(:enforce_utf8) + html_options[:authenticity_token] = options.delete(:authenticity_token) + + builder = instantiate_builder(object_name, object, options) + output = capture(builder, &block) + html_options[:multipart] ||= builder.multipart? + + html_options = html_options_for_form(options[:url] || {}, html_options) + form_tag_with_body(html_options, output) + end + + def apply_form_for_options!(record, object, options) #:nodoc: + object = convert_to_model(object) + + as = options[:as] + namespace = options[:namespace] + action, method = object.respond_to?(:persisted?) && object.persisted? ? [:edit, :patch] : [:new, :post] + options[:html].reverse_merge!( + class: as ? "#{action}_#{as}" : dom_class(object, action), + id: (as ? [namespace, action, as] : [namespace, dom_id(object, action)]).compact.join("_").presence, + method: method + ) + + options[:url] ||= if options.key?(:format) + polymorphic_path(record, format: options.delete(:format)) + else + polymorphic_path(record, {}) + end + end + private :apply_form_for_options! + + mattr_accessor :form_with_generates_remote_forms, default: true + + mattr_accessor :form_with_generates_ids, default: false + + # 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> + # + # # Though the fields don't have to correspond to model attributes: + # <%= form_with model: Cat.new do |form| %> + # <%= form.text_field :cats_dont_have_gills %> + # <%= form.text_field :but_in_forms_they_can %> + # <% end %> + # # => + # <form action="/cats" method="post" data-remote="true"> + # <input type="text" name="cat[cats_dont_have_gills]"> + # <input type="text" name="cat[but_in_forms_they_can]"> + # </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>:local</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. + # + # === Resource-oriented style + # + # In many of the examples just shown, the +:model+ passed to +form_with+ + # is a _resource_. It corresponds to a set of RESTful routes, most likely + # defined via +resources+ in <tt>config/routes.rb</tt>. + # + # So when passing such a model record, Rails infers the URL and method. + # + # <%= form_with model: @post do |form| %> + # ... + # <% end %> + # + # is then equivalent to something like: + # + # <%= form_with scope: :post, url: post_path(@post), method: :patch do |form| %> + # ... + # <% end %> + # + # And for a new record + # + # <%= form_with model: Post.new do |form| %> + # ... + # <% end %> + # + # is equivalent to something like: + # + # <%= form_with scope: :post, url: posts_path do |form| %> + # ... + # <% end %> + # + # ==== +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>:namespace</tt> - A namespace for your form to ensure uniqueness of + # id attributes on form elements. The namespace attribute will be prefixed + # with underscore on the generated HTML id. + # * <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 unobtrusive XHRs. + # Disable remote submits with <tt>local: true</tt>. + # * <tt>:skip_enforcing_utf8</tt> - If set to true, a hidden input with name + # utf8 is not output. + # * <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 FormOptionsHelper and DateHelper designed + # to work with an object as a base, like + # FormOptionsHelper#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) + options[:allow_method_names_outside_object] = true + options[:skip_default_ids] = !form_with_generates_ids + + 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. + # + # Although the usage and purpose of +fields_for+ is similar to +form_for+'s, + # its method signature is slightly different. Like +form_for+, it yields + # a FormBuilder object associated with a particular model object to a block, + # and within the block allows methods to be called on the builder to + # generate fields associated with the model object. Fields may reflect + # a model object in two ways - how they are named (hence how submitted + # values appear within the +params+ hash in the controller) and what + # default values are shown when the form the fields appear in is first + # displayed. In order for both of these features to be specified independently, + # both an object name (represented by either a symbol or string) and the + # object itself can be passed to the method separately - + # + # <%= form_for @person do |person_form| %> + # First name: <%= person_form.text_field :first_name %> + # Last name : <%= person_form.text_field :last_name %> + # + # <%= fields_for :permission, @person.permission do |permission_fields| %> + # Admin? : <%= permission_fields.check_box :admin %> + # <% end %> + # + # <%= person_form.submit %> + # <% end %> + # + # In this case, the checkbox field will be represented by an HTML +input+ + # tag with the +name+ attribute <tt>permission[admin]</tt>, and the submitted + # value will appear in the controller as <tt>params[:permission][:admin]</tt>. + # If <tt>@person.permission</tt> is an existing record with an attribute + # +admin+, the initial state of the checkbox when first displayed will + # reflect the value of <tt>@person.permission.admin</tt>. + # + # Often this can be simplified by passing just the name of the model + # object to +fields_for+ - + # + # <%= fields_for :permission do |permission_fields| %> + # Admin?: <%= permission_fields.check_box :admin %> + # <% end %> + # + # ...in which case, if <tt>:permission</tt> also happens to be the name of an + # instance variable <tt>@permission</tt>, the initial state of the input + # field will reflect the value of that variable's attribute <tt>@permission.admin</tt>. + # + # Alternatively, you can pass just the model object itself (if the first + # argument isn't a string or symbol +fields_for+ will realize that the + # name has been omitted) - + # + # <%= fields_for @person.permission do |permission_fields| %> + # Admin?: <%= permission_fields.check_box :admin %> + # <% end %> + # + # and +fields_for+ will derive the required name of the field from the + # _class_ of the model object, e.g. if <tt>@person.permission</tt>, is + # of class +Permission+, the field will still be named <tt>permission[admin]</tt>. + # + # Note: This also works for the methods in FormOptionsHelper and + # DateHelper that are designed to work with an object as base, like + # FormOptionsHelper#collection_select and DateHelper#datetime_select. + # + # === Nested Attributes Examples + # + # When the object belonging to the current scope has a nested attribute + # writer for a certain attribute, fields_for will yield a new scope + # for that attribute. This allows you to create forms that set or change + # the attributes of a parent object and its associations in one go. + # + # Nested attribute writers are normal setter methods named after an + # association. The most common way of defining these writers is either + # with +accepts_nested_attributes_for+ in a model definition or by + # defining a method with the proper name. For example: the attribute + # writer for the association <tt>:address</tt> is called + # <tt>address_attributes=</tt>. + # + # Whether a one-to-one or one-to-many style form builder will be yielded + # depends on whether the normal reader method returns a _single_ object + # or an _array_ of objects. + # + # ==== One-to-one + # + # Consider a Person class which returns a _single_ Address from the + # <tt>address</tt> reader method and responds to the + # <tt>address_attributes=</tt> writer method: + # + # class Person + # def address + # @address + # end + # + # def address_attributes=(attributes) + # # Process the attributes hash + # end + # end + # + # This model can now be used with a nested fields_for, like so: + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :address do |address_fields| %> + # Street : <%= address_fields.text_field :street %> + # Zip code: <%= address_fields.text_field :zip_code %> + # <% end %> + # ... + # <% end %> + # + # When address is already an association on a Person you can use + # +accepts_nested_attributes_for+ to define the writer method for you: + # + # class Person < ActiveRecord::Base + # has_one :address + # accepts_nested_attributes_for :address + # end + # + # If you want to destroy the associated model through the form, you have + # to enable it first using the <tt>:allow_destroy</tt> option for + # +accepts_nested_attributes_for+: + # + # class Person < ActiveRecord::Base + # has_one :address + # accepts_nested_attributes_for :address, allow_destroy: true + # end + # + # Now, when you use a form element with the <tt>_destroy</tt> parameter, + # with a value that evaluates to +true+, you will destroy the associated + # model (eg. 1, '1', true, or 'true'): + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :address do |address_fields| %> + # ... + # Delete: <%= address_fields.check_box :_destroy %> + # <% end %> + # ... + # <% end %> + # + # ==== One-to-many + # + # Consider a Person class which returns an _array_ of Project instances + # from the <tt>projects</tt> reader method and responds to the + # <tt>projects_attributes=</tt> writer method: + # + # class Person + # def projects + # [@project1, @project2] + # end + # + # def projects_attributes=(attributes) + # # Process the attributes hash + # end + # end + # + # Note that the <tt>projects_attributes=</tt> writer method is in fact + # required for fields_for to correctly identify <tt>:projects</tt> as a + # collection, and the correct indices to be set in the form markup. + # + # When projects is already an association on Person you can use + # +accepts_nested_attributes_for+ to define the writer method for you: + # + # class Person < ActiveRecord::Base + # has_many :projects + # accepts_nested_attributes_for :projects + # end + # + # This model can now be used with a nested fields_for. The block given to + # the nested fields_for call will be repeated for each instance in the + # collection: + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :projects do |project_fields| %> + # <% if project_fields.object.active? %> + # Name: <%= project_fields.text_field :name %> + # <% end %> + # <% end %> + # ... + # <% end %> + # + # It's also possible to specify the instance to be used: + # + # <%= form_for @person do |person_form| %> + # ... + # <% @person.projects.each do |project| %> + # <% if project.active? %> + # <%= person_form.fields_for :projects, project do |project_fields| %> + # Name: <%= project_fields.text_field :name %> + # <% end %> + # <% end %> + # <% end %> + # ... + # <% end %> + # + # Or a collection to be used: + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :projects, @active_projects do |project_fields| %> + # Name: <%= project_fields.text_field :name %> + # <% end %> + # ... + # <% end %> + # + # If you want to destroy any of the associated models through the + # form, you have to enable it first using the <tt>:allow_destroy</tt> + # option for +accepts_nested_attributes_for+: + # + # class Person < ActiveRecord::Base + # has_many :projects + # accepts_nested_attributes_for :projects, allow_destroy: true + # end + # + # This will allow you to specify which models to destroy in the + # attributes hash by adding a form element for the <tt>_destroy</tt> + # parameter with a value that evaluates to +true+ + # (eg. 1, '1', true, or 'true'): + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :projects do |project_fields| %> + # Delete: <%= project_fields.check_box :_destroy %> + # <% end %> + # ... + # <% end %> + # + # When a collection is used you might want to know the index of each + # object into the array. For this purpose, the <tt>index</tt> method + # is available in the FormBuilder object. + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :projects do |project_fields| %> + # Project #<%= project_fields.index %> + # ... + # <% end %> + # ... + # <% end %> + # + # Note that fields_for will automatically generate a hidden field + # to store the ID of the record. There are circumstances where this + # hidden field is not needed and you can pass <tt>include_id: false</tt> + # to prevent fields_for from rendering it automatically. + def fields_for(record_name, record_object = nil, options = {}, &block) + builder = instantiate_builder(record_name, record_object, options) + 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]"> + # + # # 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]" 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 FormOptionsHelper and DateHelper designed + # to work with an object as a base, like + # FormOptionsHelper#collection_select and DateHelper#datetime_select. + def fields(scope = nil, model: nil, **options, &block) + options[:allow_method_names_outside_object] = true + options[:skip_default_ids] = !form_with_generates_ids + + 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. + # Additional options on the label tag can be passed as a hash with +options+. These options will be tagged + # onto the HTML as an HTML element attribute as in the example shown, except for the <tt>:value</tt> option, which is designed to + # target labels for radio_button tags (where the value is used in the ID of the input tag). + # + # ==== Examples + # label(:post, :title) + # # => <label for="post_title">Title</label> + # + # You can localize your labels based on model and attribute names. + # For example you can define the following in your locale (e.g. en.yml) + # + # helpers: + # label: + # post: + # body: "Write your entire text here" + # + # Which then will result in + # + # label(:post, :body) + # # => <label for="post_body">Write your entire text here</label> + # + # Localization can also be based purely on the translation of the attribute-name + # (if you are using ActiveRecord): + # + # activerecord: + # attributes: + # post: + # cost: "Total cost" + # + # label(:post, :cost) + # # => <label for="post_cost">Total cost</label> + # + # label(:post, :title, "A short title") + # # => <label for="post_title">A short title</label> + # + # label(:post, :title, "A short title", class: "title_label") + # # => <label for="post_title" class="title_label">A short title</label> + # + # label(:post, :privacy, "Public Post", value: "public") + # # => <label for="post_privacy_public">Public Post</label> + # + # label(:post, :terms) do + # raw('Accept <a href="/terms">Terms</a>.') + # end + # # => <label for="post_terms">Accept <a href="/terms">Terms</a>.</label> + def label(object_name, method, content_or_options = nil, options = nil, &block) + Tags::Label.new(object_name, method, self, content_or_options, options).render(&block) + end + + # Returns an input tag of the "text" type tailored for accessing a specified attribute (identified by +method+) on an object + # assigned to the template (identified by +object+). Additional options on the input tag can be passed as a + # hash with +options+. These options will be tagged onto the HTML as an HTML element attribute as in the example + # shown. + # + # ==== Examples + # text_field(:post, :title, size: 20) + # # => <input type="text" id="post_title" name="post[title]" size="20" value="#{@post.title}" /> + # + # text_field(:post, :title, class: "create_input") + # # => <input type="text" id="post_title" name="post[title]" value="#{@post.title}" class="create_input" /> + # + # text_field(:post, :title, maxlength: 30, class: "title_input") + # # => <input type="text" id="post_title" name="post[title]" maxlength="30" size="30" value="#{@post.title}" class="title_input" /> + # + # text_field(:session, :user, onchange: "if ($('#session_user').val() === 'admin') { alert('Your login cannot be admin!'); }") + # # => <input type="text" id="session_user" name="session[user]" value="#{@session.user}" onchange="if ($('#session_user').val() === 'admin') { alert('Your login cannot be admin!'); }"/> + # + # text_field(:snippet, :code, size: 20, class: 'code_input') + # # => <input type="text" id="snippet_code" name="snippet[code]" size="20" value="#{@snippet.code}" class="code_input" /> + def text_field(object_name, method, options = {}) + Tags::TextField.new(object_name, method, self, options).render + end + + # Returns an input tag of the "password" type tailored for accessing a specified attribute (identified by +method+) on an object + # assigned to the template (identified by +object+). Additional options on the input tag can be passed as a + # hash with +options+. These options will be tagged onto the HTML as an HTML element attribute as in the example + # shown. For security reasons this field is blank by default; pass in a value via +options+ if this is not desired. + # + # ==== Examples + # password_field(:login, :pass, size: 20) + # # => <input type="password" id="login_pass" name="login[pass]" size="20" /> + # + # password_field(:account, :secret, class: "form_input", value: @account.secret) + # # => <input type="password" id="account_secret" name="account[secret]" value="#{@account.secret}" class="form_input" /> + # + # password_field(:user, :password, onchange: "if ($('#user_password').val().length > 30) { alert('Your password needs to be shorter!'); }") + # # => <input type="password" id="user_password" name="user[password]" onchange="if ($('#user_password').val().length > 30) { alert('Your password needs to be shorter!'); }"/> + # + # password_field(:account, :pin, size: 20, class: 'form_input') + # # => <input type="password" id="account_pin" name="account[pin]" size="20" class="form_input" /> + def password_field(object_name, method, options = {}) + Tags::PasswordField.new(object_name, method, self, options).render + end + + # Returns a hidden input tag tailored for accessing a specified attribute (identified by +method+) on an object + # assigned to the template (identified by +object+). Additional options on the input tag can be passed as a + # hash with +options+. These options will be tagged onto the HTML as an HTML element attribute as in the example + # shown. + # + # ==== Examples + # hidden_field(:signup, :pass_confirm) + # # => <input type="hidden" id="signup_pass_confirm" name="signup[pass_confirm]" value="#{@signup.pass_confirm}" /> + # + # hidden_field(:post, :tag_list) + # # => <input type="hidden" id="post_tag_list" name="post[tag_list]" value="#{@post.tag_list}" /> + # + # hidden_field(:user, :token) + # # => <input type="hidden" id="user_token" name="user[token]" value="#{@user.token}" /> + def hidden_field(object_name, method, options = {}) + Tags::HiddenField.new(object_name, method, self, options).render + end + + # Returns a file upload input tag tailored for accessing a specified attribute (identified by +method+) on an object + # assigned to the template (identified by +object+). Additional options on the input tag can be passed as a + # hash with +options+. These options will be tagged onto the HTML as an HTML element attribute as in the example + # shown. + # + # Using this method inside a +form_for+ block will set the enclosing form's encoding to <tt>multipart/form-data</tt>. + # + # ==== Options + # * Creates standard HTML attributes for the tag. + # * <tt>:disabled</tt> - If set to true, the user will not be able to use this input. + # * <tt>:multiple</tt> - If set to true, *in most updated browsers* the user will be allowed to select multiple files. + # * <tt>:accept</tt> - If set to one or multiple mime-types, the user will be suggested a filter when choosing a file. You still need to set up model validations. + # + # ==== Examples + # file_field(:user, :avatar) + # # => <input type="file" id="user_avatar" name="user[avatar]" /> + # + # file_field(:post, :image, multiple: true) + # # => <input type="file" id="post_image" name="post[image][]" multiple="multiple" /> + # + # file_field(:post, :attached, accept: 'text/html') + # # => <input accept="text/html" type="file" id="post_attached" name="post[attached]" /> + # + # file_field(:post, :image, accept: 'image/png,image/gif,image/jpeg') + # # => <input type="file" id="post_image" name="post[image]" accept="image/png,image/gif,image/jpeg" /> + # + # file_field(:attachment, :file, class: 'file_input') + # # => <input type="file" id="attachment_file" name="attachment[file]" class="file_input" /> + def file_field(object_name, method, options = {}) + Tags::FileField.new(object_name, method, self, convert_direct_upload_option_to_url(options.dup)).render + end + + # Returns a textarea opening and closing tag set tailored for accessing a specified attribute (identified by +method+) + # on an object assigned to the template (identified by +object+). Additional options on the input tag can be passed as a + # hash with +options+. + # + # ==== Examples + # text_area(:post, :body, cols: 20, rows: 40) + # # => <textarea cols="20" rows="40" id="post_body" name="post[body]"> + # # #{@post.body} + # # </textarea> + # + # text_area(:comment, :text, size: "20x30") + # # => <textarea cols="20" rows="30" id="comment_text" name="comment[text]"> + # # #{@comment.text} + # # </textarea> + # + # text_area(:application, :notes, cols: 40, rows: 15, class: 'app_input') + # # => <textarea cols="40" rows="15" id="application_notes" name="application[notes]" class="app_input"> + # # #{@application.notes} + # # </textarea> + # + # text_area(:entry, :body, size: "20x20", disabled: 'disabled') + # # => <textarea cols="20" rows="20" id="entry_body" name="entry[body]" disabled="disabled"> + # # #{@entry.body} + # # </textarea> + def text_area(object_name, method, options = {}) + Tags::TextArea.new(object_name, method, self, options).render + end + + # Returns a checkbox tag tailored for accessing a specified attribute (identified by +method+) on an object + # assigned to the template (identified by +object+). This object must be an instance object (@object) and not a local object. + # It's intended that +method+ returns an integer and if that integer is above zero, then the checkbox is checked. + # Additional options on the input tag can be passed as a hash with +options+. The +checked_value+ defaults to 1 + # while the default +unchecked_value+ is set to 0 which is convenient for boolean values. + # + # ==== Gotcha + # + # The HTML specification says unchecked check boxes are not successful, and + # thus web browsers do not send them. Unfortunately this introduces a gotcha: + # if an +Invoice+ model has a +paid+ flag, and in the form that edits a paid + # invoice the user unchecks its check box, no +paid+ parameter is sent. So, + # any mass-assignment idiom like + # + # @invoice.update(params[:invoice]) + # + # wouldn't update the flag. + # + # To prevent this the helper generates an auxiliary hidden field before + # the very check box. The hidden field has the same name and its + # attributes mimic an unchecked check box. + # + # This way, the client either sends only the hidden field (representing + # the check box is unchecked), or both fields. Since the HTML specification + # says key/value pairs have to be sent in the same order they appear in the + # form, and parameters extraction gets the last occurrence of any repeated + # key in the query string, that works for ordinary forms. + # + # Unfortunately that workaround does not work when the check box goes + # within an array-like parameter, as in + # + # <%= fields_for "project[invoice_attributes][]", invoice, index: nil do |form| %> + # <%= form.check_box :paid %> + # ... + # <% end %> + # + # because parameter name repetition is precisely what Rails seeks to distinguish + # the elements of the array. For each item with a checked check box you + # get an extra ghost item with only that attribute, assigned to "0". + # + # In that case it is preferable to either use +check_box_tag+ or to use + # hashes instead of arrays. + # + # # Let's say that @post.validated? is 1: + # check_box("post", "validated") + # # => <input name="post[validated]" type="hidden" value="0" /> + # # <input checked="checked" type="checkbox" id="post_validated" name="post[validated]" value="1" /> + # + # # Let's say that @puppy.gooddog is "no": + # check_box("puppy", "gooddog", {}, "yes", "no") + # # => <input name="puppy[gooddog]" type="hidden" value="no" /> + # # <input type="checkbox" id="puppy_gooddog" name="puppy[gooddog]" value="yes" /> + # + # check_box("eula", "accepted", { class: 'eula_check' }, "yes", "no") + # # => <input name="eula[accepted]" type="hidden" value="no" /> + # # <input type="checkbox" class="eula_check" id="eula_accepted" name="eula[accepted]" value="yes" /> + def check_box(object_name, method, options = {}, checked_value = "1", unchecked_value = "0") + Tags::CheckBox.new(object_name, method, self, checked_value, unchecked_value, options).render + end + + # Returns a radio button tag for accessing a specified attribute (identified by +method+) on an object + # assigned to the template (identified by +object+). If the current value of +method+ is +tag_value+ the + # radio button will be checked. + # + # To force the radio button to be checked pass <tt>checked: true</tt> in the + # +options+ hash. You may pass HTML options there as well. + # + # # Let's say that @post.category returns "rails": + # radio_button("post", "category", "rails") + # radio_button("post", "category", "java") + # # => <input type="radio" id="post_category_rails" name="post[category]" value="rails" checked="checked" /> + # # <input type="radio" id="post_category_java" name="post[category]" value="java" /> + # + # # Let's say that @user.receive_newsletter returns "no": + # radio_button("user", "receive_newsletter", "yes") + # radio_button("user", "receive_newsletter", "no") + # # => <input type="radio" id="user_receive_newsletter_yes" name="user[receive_newsletter]" value="yes" /> + # # <input type="radio" id="user_receive_newsletter_no" name="user[receive_newsletter]" value="no" checked="checked" /> + def radio_button(object_name, method, tag_value, options = {}) + Tags::RadioButton.new(object_name, method, self, tag_value, options).render + end + + # Returns a text_field of type "color". + # + # color_field("car", "color") + # # => <input id="car_color" name="car[color]" type="color" value="#000000" /> + def color_field(object_name, method, options = {}) + Tags::ColorField.new(object_name, method, self, options).render + end + + # Returns an input of type "search" for accessing a specified attribute (identified by +method+) on an object + # assigned to the template (identified by +object_name+). Inputs of type "search" may be styled differently by + # some browsers. + # + # search_field(:user, :name) + # # => <input id="user_name" name="user[name]" type="search" /> + # search_field(:user, :name, autosave: false) + # # => <input autosave="false" id="user_name" name="user[name]" type="search" /> + # search_field(:user, :name, results: 3) + # # => <input id="user_name" name="user[name]" results="3" type="search" /> + # # Assume request.host returns "www.example.com" + # search_field(:user, :name, autosave: true) + # # => <input autosave="com.example.www" id="user_name" name="user[name]" results="10" type="search" /> + # search_field(:user, :name, onsearch: true) + # # => <input id="user_name" incremental="true" name="user[name]" onsearch="true" type="search" /> + # search_field(:user, :name, autosave: false, onsearch: true) + # # => <input autosave="false" id="user_name" incremental="true" name="user[name]" onsearch="true" type="search" /> + # search_field(:user, :name, autosave: true, onsearch: true) + # # => <input autosave="com.example.www" id="user_name" incremental="true" name="user[name]" onsearch="true" results="10" type="search" /> + def search_field(object_name, method, options = {}) + Tags::SearchField.new(object_name, method, self, options).render + end + + # Returns a text_field of type "tel". + # + # telephone_field("user", "phone") + # # => <input id="user_phone" name="user[phone]" type="tel" /> + # + def telephone_field(object_name, method, options = {}) + Tags::TelField.new(object_name, method, self, options).render + end + # aliases telephone_field + alias phone_field telephone_field + + # Returns a text_field of type "date". + # + # date_field("user", "born_on") + # # => <input id="user_born_on" name="user[born_on]" type="date" /> + # + # The default value is generated by trying to call +strftime+ with "%Y-%m-%d" + # on the object's value, which makes it behave as expected for instances + # of DateTime and ActiveSupport::TimeWithZone. You can still override that + # by passing the "value" option explicitly, e.g. + # + # @user.born_on = Date.new(1984, 1, 27) + # date_field("user", "born_on", value: "1984-05-12") + # # => <input id="user_born_on" name="user[born_on]" type="date" value="1984-05-12" /> + # + # You can create values for the "min" and "max" attributes by passing + # instances of Date or Time to the options hash. + # + # date_field("user", "born_on", min: Date.today) + # # => <input id="user_born_on" name="user[born_on]" type="date" min="2014-05-20" /> + # + # Alternatively, you can pass a String formatted as an ISO8601 date as the + # values for "min" and "max." + # + # date_field("user", "born_on", min: "2014-05-20") + # # => <input id="user_born_on" name="user[born_on]" type="date" min="2014-05-20" /> + # + def date_field(object_name, method, options = {}) + Tags::DateField.new(object_name, method, self, options).render + end + + # Returns a text_field of type "time". + # + # The default value is generated by trying to call +strftime+ with "%T.%L" + # on the object's value. It is still possible to override that + # by passing the "value" option. + # + # === Options + # * Accepts same options as time_field_tag + # + # === Example + # time_field("task", "started_at") + # # => <input id="task_started_at" name="task[started_at]" type="time" /> + # + # You can create values for the "min" and "max" attributes by passing + # instances of Date or Time to the options hash. + # + # time_field("task", "started_at", min: Time.now) + # # => <input id="task_started_at" name="task[started_at]" type="time" min="01:00:00.000" /> + # + # Alternatively, you can pass a String formatted as an ISO8601 time as the + # values for "min" and "max." + # + # time_field("task", "started_at", min: "01:00:00") + # # => <input id="task_started_at" name="task[started_at]" type="time" min="01:00:00.000" /> + # + def time_field(object_name, method, options = {}) + Tags::TimeField.new(object_name, method, self, options).render + end + + # Returns a text_field of type "datetime-local". + # + # datetime_field("user", "born_on") + # # => <input id="user_born_on" name="user[born_on]" type="datetime-local" /> + # + # The default value is generated by trying to call +strftime+ with "%Y-%m-%dT%T" + # on the object's value, which makes it behave as expected for instances + # of DateTime and ActiveSupport::TimeWithZone. + # + # @user.born_on = Date.new(1984, 1, 12) + # datetime_field("user", "born_on") + # # => <input id="user_born_on" name="user[born_on]" type="datetime-local" value="1984-01-12T00:00:00" /> + # + # You can create values for the "min" and "max" attributes by passing + # instances of Date or Time to the options hash. + # + # datetime_field("user", "born_on", min: Date.today) + # # => <input id="user_born_on" name="user[born_on]" type="datetime-local" min="2014-05-20T00:00:00.000" /> + # + # Alternatively, you can pass a String formatted as an ISO8601 datetime as + # the values for "min" and "max." + # + # datetime_field("user", "born_on", min: "2014-05-20T00:00:00") + # # => <input id="user_born_on" name="user[born_on]" type="datetime-local" min="2014-05-20T00:00:00.000" /> + # + def datetime_field(object_name, method, options = {}) + Tags::DatetimeLocalField.new(object_name, method, self, options).render + end + + alias datetime_local_field datetime_field + + # Returns a text_field of type "month". + # + # month_field("user", "born_on") + # # => <input id="user_born_on" name="user[born_on]" type="month" /> + # + # The default value is generated by trying to call +strftime+ with "%Y-%m" + # on the object's value, which makes it behave as expected for instances + # of DateTime and ActiveSupport::TimeWithZone. + # + # @user.born_on = Date.new(1984, 1, 27) + # month_field("user", "born_on") + # # => <input id="user_born_on" name="user[born_on]" type="date" value="1984-01" /> + # + def month_field(object_name, method, options = {}) + Tags::MonthField.new(object_name, method, self, options).render + end + + # Returns a text_field of type "week". + # + # week_field("user", "born_on") + # # => <input id="user_born_on" name="user[born_on]" type="week" /> + # + # The default value is generated by trying to call +strftime+ with "%Y-W%W" + # on the object's value, which makes it behave as expected for instances + # of DateTime and ActiveSupport::TimeWithZone. + # + # @user.born_on = Date.new(1984, 5, 12) + # week_field("user", "born_on") + # # => <input id="user_born_on" name="user[born_on]" type="date" value="1984-W19" /> + # + def week_field(object_name, method, options = {}) + Tags::WeekField.new(object_name, method, self, options).render + end + + # Returns a text_field of type "url". + # + # url_field("user", "homepage") + # # => <input id="user_homepage" name="user[homepage]" type="url" /> + # + def url_field(object_name, method, options = {}) + Tags::UrlField.new(object_name, method, self, options).render + end + + # Returns a text_field of type "email". + # + # email_field("user", "address") + # # => <input id="user_address" name="user[address]" type="email" /> + # + def email_field(object_name, method, options = {}) + Tags::EmailField.new(object_name, method, self, options).render + end + + # Returns an input tag of type "number". + # + # ==== Options + # * Accepts same options as number_field_tag + def number_field(object_name, method, options = {}) + Tags::NumberField.new(object_name, method, self, options).render + end + + # Returns an input tag of type "range". + # + # ==== Options + # * Accepts same options as range_field_tag + def range_field(object_name, method, options = {}) + Tags::RangeField.new(object_name, method, self, options).render + end + + private + def html_options_for_form_with(url_for_options = nil, model = nil, html: {}, local: !form_with_generates_remote_forms, + skip_enforcing_utf8: nil, **options) + html_options = options.slice(:id, :class, :multipart, :method, :data).merge(html) + html_options[:method] ||= :patch if model.respond_to?(:persisted?) && model.persisted? + html_options[:enforce_utf8] = !skip_enforcing_utf8 unless skip_enforcing_utf8.nil? + + 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 + + html_options[:authenticity_token] = options.delete(:authenticity_token) + + if !local && html_options[:authenticity_token].blank? + html_options[:authenticity_token] = embed_authenticity_token_in_remote_forms + end + + if html_options[:authenticity_token] == true + # Include the default authenticity_token, which is only generated when it's 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 + when String, Symbol + object = record_object + object_name = record_name + else + object = record_name + object_name = model_name_from_record_or_class(object).param_key if object + end + + builder = options[:builder] || default_form_builder_class + builder.new(object_name, object, self, options) + end + + def default_form_builder_class + builder = default_form_builder || ActionView::Base.default_form_builder + builder.respond_to?(:constantize) ? builder.constantize : builder + end + end + + # A +FormBuilder+ object is associated with a particular model object and + # allows you to generate fields associated with the model object. The + # +FormBuilder+ object is yielded when using +form_for+ or +fields_for+. + # For example: + # + # <%= form_for @person do |person_form| %> + # Name: <%= person_form.text_field :name %> + # Admin: <%= person_form.check_box :admin %> + # <% end %> + # + # In the above block, a +FormBuilder+ object is yielded as the + # +person_form+ variable. This allows you to generate the +text_field+ + # and +check_box+ fields by specifying their eponymous methods, which + # modify the underlying template and associates the <tt>@person</tt> model object + # with the form. + # + # The +FormBuilder+ object can be thought of as serving as a proxy for the + # methods in the +FormHelper+ module. This class, however, allows you to + # call methods with the model object you are building the form for. + # + # You can create your own custom FormBuilder templates by subclassing this + # class. For example: + # + # class MyFormBuilder < ActionView::Helpers::FormBuilder + # def div_radio_button(method, tag_value, options = {}) + # @template.content_tag(:div, + # @template.radio_button( + # @object_name, method, tag_value, objectify_options(options) + # ) + # ) + # end + # end + # + # The above code creates a new method +div_radio_button+ which wraps a div + # around the new radio button. Note that when options are passed in, you + # must call +objectify_options+ in order for the model object to get + # correctly passed to the method. If +objectify_options+ is not called, + # then the newly created helper will not be linked back to the model. + # + # The +div_radio_button+ code from above can now be used as follows: + # + # <%= form_for @person, :builder => MyFormBuilder do |f| %> + # I am a child: <%= f.div_radio_button(:admin, "child") %> + # I am an adult: <%= f.div_radio_button(:admin, "adult") %> + # <% end -%> + # + # The standard set of helper methods for form building are located in the + # +field_helpers+ class attribute. + class FormBuilder + include ModelNaming + + # The methods which wrap a form helper call. + class_attribute :field_helpers, default: [ + :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, + :time_field, :datetime_field, :datetime_local_field, + :month_field, :week_field, :url_field, :email_field, + :number_field, :range_field + ] + + attr_accessor :object_name, :object, :options + + attr_reader :multipart, :index + alias :multipart? :multipart + + def multipart=(multipart) + @multipart = multipart + + if parent_builder = @options[:parent_builder] + parent_builder.multipart = multipart + end + end + + def self._to_partial_path + @_to_partial_path ||= name.demodulize.underscore.sub!(/_builder$/, "") + end + + def to_partial_path + self.class._to_partial_path + end + + def to_model + self + end + + def initialize(object_name, object, template, options) + @nested_child_index = {} + @object_name, @object, @template, @options = object_name, object, template, options + @default_options = @options ? @options.slice(:index, :namespace, :skip_default_ids, :allow_method_names_outside_object) : {} + @default_html_options = @default_options.except(:skip_default_ids, :allow_method_names_outside_object) + + 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 + else + 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, :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( + #{selector.inspect}, # "text_field", + @object_name, # @object_name, + method, # method, + objectify_options(options)) # objectify_options(options)) + end # end + RUBY_EVAL + 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. + # + # Although the usage and purpose of +fields_for+ is similar to +form_for+'s, + # its method signature is slightly different. Like +form_for+, it yields + # a FormBuilder object associated with a particular model object to a block, + # and within the block allows methods to be called on the builder to + # generate fields associated with the model object. Fields may reflect + # a model object in two ways - how they are named (hence how submitted + # values appear within the +params+ hash in the controller) and what + # default values are shown when the form the fields appear in is first + # displayed. In order for both of these features to be specified independently, + # both an object name (represented by either a symbol or string) and the + # object itself can be passed to the method separately - + # + # <%= form_for @person do |person_form| %> + # First name: <%= person_form.text_field :first_name %> + # Last name : <%= person_form.text_field :last_name %> + # + # <%= fields_for :permission, @person.permission do |permission_fields| %> + # Admin? : <%= permission_fields.check_box :admin %> + # <% end %> + # + # <%= person_form.submit %> + # <% end %> + # + # In this case, the checkbox field will be represented by an HTML +input+ + # tag with the +name+ attribute <tt>permission[admin]</tt>, and the submitted + # value will appear in the controller as <tt>params[:permission][:admin]</tt>. + # If <tt>@person.permission</tt> is an existing record with an attribute + # +admin+, the initial state of the checkbox when first displayed will + # reflect the value of <tt>@person.permission.admin</tt>. + # + # Often this can be simplified by passing just the name of the model + # object to +fields_for+ - + # + # <%= fields_for :permission do |permission_fields| %> + # Admin?: <%= permission_fields.check_box :admin %> + # <% end %> + # + # ...in which case, if <tt>:permission</tt> also happens to be the name of an + # instance variable <tt>@permission</tt>, the initial state of the input + # field will reflect the value of that variable's attribute <tt>@permission.admin</tt>. + # + # Alternatively, you can pass just the model object itself (if the first + # argument isn't a string or symbol +fields_for+ will realize that the + # name has been omitted) - + # + # <%= fields_for @person.permission do |permission_fields| %> + # Admin?: <%= permission_fields.check_box :admin %> + # <% end %> + # + # and +fields_for+ will derive the required name of the field from the + # _class_ of the model object, e.g. if <tt>@person.permission</tt>, is + # of class +Permission+, the field will still be named <tt>permission[admin]</tt>. + # + # Note: This also works for the methods in FormOptionsHelper and + # DateHelper that are designed to work with an object as base, like + # FormOptionsHelper#collection_select and DateHelper#datetime_select. + # + # === Nested Attributes Examples + # + # When the object belonging to the current scope has a nested attribute + # writer for a certain attribute, fields_for will yield a new scope + # for that attribute. This allows you to create forms that set or change + # the attributes of a parent object and its associations in one go. + # + # Nested attribute writers are normal setter methods named after an + # association. The most common way of defining these writers is either + # with +accepts_nested_attributes_for+ in a model definition or by + # defining a method with the proper name. For example: the attribute + # writer for the association <tt>:address</tt> is called + # <tt>address_attributes=</tt>. + # + # Whether a one-to-one or one-to-many style form builder will be yielded + # depends on whether the normal reader method returns a _single_ object + # or an _array_ of objects. + # + # ==== One-to-one + # + # Consider a Person class which returns a _single_ Address from the + # <tt>address</tt> reader method and responds to the + # <tt>address_attributes=</tt> writer method: + # + # class Person + # def address + # @address + # end + # + # def address_attributes=(attributes) + # # Process the attributes hash + # end + # end + # + # This model can now be used with a nested fields_for, like so: + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :address do |address_fields| %> + # Street : <%= address_fields.text_field :street %> + # Zip code: <%= address_fields.text_field :zip_code %> + # <% end %> + # ... + # <% end %> + # + # When address is already an association on a Person you can use + # +accepts_nested_attributes_for+ to define the writer method for you: + # + # class Person < ActiveRecord::Base + # has_one :address + # accepts_nested_attributes_for :address + # end + # + # If you want to destroy the associated model through the form, you have + # to enable it first using the <tt>:allow_destroy</tt> option for + # +accepts_nested_attributes_for+: + # + # class Person < ActiveRecord::Base + # has_one :address + # accepts_nested_attributes_for :address, allow_destroy: true + # end + # + # Now, when you use a form element with the <tt>_destroy</tt> parameter, + # with a value that evaluates to +true+, you will destroy the associated + # model (eg. 1, '1', true, or 'true'): + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :address do |address_fields| %> + # ... + # Delete: <%= address_fields.check_box :_destroy %> + # <% end %> + # ... + # <% end %> + # + # ==== One-to-many + # + # Consider a Person class which returns an _array_ of Project instances + # from the <tt>projects</tt> reader method and responds to the + # <tt>projects_attributes=</tt> writer method: + # + # class Person + # def projects + # [@project1, @project2] + # end + # + # def projects_attributes=(attributes) + # # Process the attributes hash + # end + # end + # + # Note that the <tt>projects_attributes=</tt> writer method is in fact + # required for fields_for to correctly identify <tt>:projects</tt> as a + # collection, and the correct indices to be set in the form markup. + # + # When projects is already an association on Person you can use + # +accepts_nested_attributes_for+ to define the writer method for you: + # + # class Person < ActiveRecord::Base + # has_many :projects + # accepts_nested_attributes_for :projects + # end + # + # This model can now be used with a nested fields_for. The block given to + # the nested fields_for call will be repeated for each instance in the + # collection: + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :projects do |project_fields| %> + # <% if project_fields.object.active? %> + # Name: <%= project_fields.text_field :name %> + # <% end %> + # <% end %> + # ... + # <% end %> + # + # It's also possible to specify the instance to be used: + # + # <%= form_for @person do |person_form| %> + # ... + # <% @person.projects.each do |project| %> + # <% if project.active? %> + # <%= person_form.fields_for :projects, project do |project_fields| %> + # Name: <%= project_fields.text_field :name %> + # <% end %> + # <% end %> + # <% end %> + # ... + # <% end %> + # + # Or a collection to be used: + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :projects, @active_projects do |project_fields| %> + # Name: <%= project_fields.text_field :name %> + # <% end %> + # ... + # <% end %> + # + # If you want to destroy any of the associated models through the + # form, you have to enable it first using the <tt>:allow_destroy</tt> + # option for +accepts_nested_attributes_for+: + # + # class Person < ActiveRecord::Base + # has_many :projects + # accepts_nested_attributes_for :projects, allow_destroy: true + # end + # + # This will allow you to specify which models to destroy in the + # attributes hash by adding a form element for the <tt>_destroy</tt> + # parameter with a value that evaluates to +true+ + # (eg. 1, '1', true, or 'true'): + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :projects do |project_fields| %> + # Delete: <%= project_fields.check_box :_destroy %> + # <% end %> + # ... + # <% end %> + # + # When a collection is used you might want to know the index of each + # object into the array. For this purpose, the <tt>index</tt> method + # is available in the FormBuilder object. + # + # <%= form_for @person do |person_form| %> + # ... + # <%= person_form.fields_for :projects do |project_fields| %> + # Project #<%= project_fields.index %> + # ... + # <% end %> + # ... + # <% end %> + # + # Note that fields_for will automatically generate a hidden field + # to store the ID of the record. There are circumstances where this + # hidden field is not needed and you can pass <tt>include_id: false</tt> + # to prevent fields_for from rendering it automatically. + def fields_for(record_name, record_object = nil, fields_options = {}, &block) + fields_options, record_object = record_object, nil if record_object.is_a?(Hash) && record_object.extractable_options? + fields_options[:builder] ||= options[:builder] + fields_options[:namespace] = options[:namespace] + fields_options[:parent_builder] = self + + case record_name + when String, Symbol + if nested_attributes_association?(record_name) + return fields_for_with_nested_attributes(record_name, record_object, fields_options, block) + end + else + record_object = record_name.is_a?(Array) ? record_name.last : record_name + record_name = model_name_from_record_or_class(record_object).param_key + end + + object_name = @object_name + index = if options.has_key?(:index) + options[:index] + elsif defined?(@auto_index) + object_name = object_name.to_s.sub(/\[\]$/, "") + @auto_index + end + + record_name = if index + "#{object_name}[#{index}][#{record_name}]" + elsif record_name.to_s.end_with?("[]") + record_name = record_name.to_s.sub(/(.*)\[\]$/, "[\\1][#{record_object.id}]") + "#{object_name}#{record_name}" + else + "#{object_name}[#{record_name}]" + end + fields_options[:child_index] = index + + @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) + options[:allow_method_names_outside_object] = true + options[:skip_default_ids] = !FormHelper.form_with_generates_ids + + 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. + # Additional options on the label tag can be passed as a hash with +options+. These options will be tagged + # onto the HTML as an HTML element attribute as in the example shown, except for the <tt>:value</tt> option, which is designed to + # target labels for radio_button tags (where the value is used in the ID of the input tag). + # + # ==== Examples + # label(:title) + # # => <label for="post_title">Title</label> + # + # You can localize your labels based on model and attribute names. + # For example you can define the following in your locale (e.g. en.yml) + # + # helpers: + # label: + # post: + # body: "Write your entire text here" + # + # Which then will result in + # + # label(:body) + # # => <label for="post_body">Write your entire text here</label> + # + # Localization can also be based purely on the translation of the attribute-name + # (if you are using ActiveRecord): + # + # activerecord: + # attributes: + # post: + # cost: "Total cost" + # + # label(:cost) + # # => <label for="post_cost">Total cost</label> + # + # label(:title, "A short title") + # # => <label for="post_title">A short title</label> + # + # label(:title, "A short title", class: "title_label") + # # => <label for="post_title" class="title_label">A short title</label> + # + # label(:privacy, "Public Post", value: "public") + # # => <label for="post_privacy_public">Public Post</label> + # + # label(:terms) do + # raw('Accept <a href="/terms">Terms</a>.') + # end + # # => <label for="post_terms">Accept <a href="/terms">Terms</a>.</label> + def label(method, text = nil, options = {}, &block) + @template.label(@object_name, method, text, objectify_options(options), &block) + end + + # Returns a checkbox tag tailored for accessing a specified attribute (identified by +method+) on an object + # assigned to the template (identified by +object+). This object must be an instance object (@object) and not a local object. + # It's intended that +method+ returns an integer and if that integer is above zero, then the checkbox is checked. + # Additional options on the input tag can be passed as a hash with +options+. The +checked_value+ defaults to 1 + # while the default +unchecked_value+ is set to 0 which is convenient for boolean values. + # + # ==== Gotcha + # + # The HTML specification says unchecked check boxes are not successful, and + # thus web browsers do not send them. Unfortunately this introduces a gotcha: + # if an +Invoice+ model has a +paid+ flag, and in the form that edits a paid + # invoice the user unchecks its check box, no +paid+ parameter is sent. So, + # any mass-assignment idiom like + # + # @invoice.update(params[:invoice]) + # + # wouldn't update the flag. + # + # To prevent this the helper generates an auxiliary hidden field before + # the very check box. The hidden field has the same name and its + # attributes mimic an unchecked check box. + # + # This way, the client either sends only the hidden field (representing + # the check box is unchecked), or both fields. Since the HTML specification + # says key/value pairs have to be sent in the same order they appear in the + # form, and parameters extraction gets the last occurrence of any repeated + # key in the query string, that works for ordinary forms. + # + # Unfortunately that workaround does not work when the check box goes + # within an array-like parameter, as in + # + # <%= fields_for "project[invoice_attributes][]", invoice, index: nil do |form| %> + # <%= form.check_box :paid %> + # ... + # <% end %> + # + # because parameter name repetition is precisely what Rails seeks to distinguish + # the elements of the array. For each item with a checked check box you + # get an extra ghost item with only that attribute, assigned to "0". + # + # In that case it is preferable to either use +check_box_tag+ or to use + # hashes instead of arrays. + # + # # Let's say that @post.validated? is 1: + # check_box("validated") + # # => <input name="post[validated]" type="hidden" value="0" /> + # # <input checked="checked" type="checkbox" id="post_validated" name="post[validated]" value="1" /> + # + # # Let's say that @puppy.gooddog is "no": + # check_box("gooddog", {}, "yes", "no") + # # => <input name="puppy[gooddog]" type="hidden" value="no" /> + # # <input type="checkbox" id="puppy_gooddog" name="puppy[gooddog]" value="yes" /> + # + # # Let's say that @eula.accepted is "no": + # check_box("accepted", { class: 'eula_check' }, "yes", "no") + # # => <input name="eula[accepted]" type="hidden" value="no" /> + # # <input type="checkbox" class="eula_check" id="eula_accepted" name="eula[accepted]" value="yes" /> + def check_box(method, options = {}, checked_value = "1", unchecked_value = "0") + @template.check_box(@object_name, method, objectify_options(options), checked_value, unchecked_value) + end + + # Returns a radio button tag for accessing a specified attribute (identified by +method+) on an object + # assigned to the template (identified by +object+). If the current value of +method+ is +tag_value+ the + # radio button will be checked. + # + # To force the radio button to be checked pass <tt>checked: true</tt> in the + # +options+ hash. You may pass HTML options there as well. + # + # # Let's say that @post.category returns "rails": + # radio_button("category", "rails") + # radio_button("category", "java") + # # => <input type="radio" id="post_category_rails" name="post[category]" value="rails" checked="checked" /> + # # <input type="radio" id="post_category_java" name="post[category]" value="java" /> + # + # # Let's say that @user.receive_newsletter returns "no": + # radio_button("receive_newsletter", "yes") + # radio_button("receive_newsletter", "no") + # # => <input type="radio" id="user_receive_newsletter_yes" name="user[receive_newsletter]" value="yes" /> + # # <input type="radio" id="user_receive_newsletter_no" name="user[receive_newsletter]" value="no" checked="checked" /> + def radio_button(method, tag_value, options = {}) + @template.radio_button(@object_name, method, tag_value, objectify_options(options)) + end + + # Returns a hidden input tag tailored for accessing a specified attribute (identified by +method+) on an object + # assigned to the template (identified by +object+). Additional options on the input tag can be passed as a + # hash with +options+. These options will be tagged onto the HTML as an HTML element attribute as in the example + # shown. + # + # ==== Examples + # # Let's say that @signup.pass_confirm returns true: + # hidden_field(:pass_confirm) + # # => <input type="hidden" id="signup_pass_confirm" name="signup[pass_confirm]" value="true" /> + # + # # Let's say that @post.tag_list returns "blog, ruby": + # hidden_field(:tag_list) + # # => <input type="hidden" id="post_tag_list" name="post[tag_list]" value="blog, ruby" /> + # + # # Let's say that @user.token returns "abcde": + # hidden_field(:token) + # # => <input type="hidden" id="user_token" name="user[token]" value="abcde" /> + # + def hidden_field(method, options = {}) + @emitted_hidden_id = true if method == :id + @template.hidden_field(@object_name, method, objectify_options(options)) + end + + # Returns a file upload input tag tailored for accessing a specified attribute (identified by +method+) on an object + # assigned to the template (identified by +object+). Additional options on the input tag can be passed as a + # hash with +options+. These options will be tagged onto the HTML as an HTML element attribute as in the example + # shown. + # + # Using this method inside a +form_for+ block will set the enclosing form's encoding to <tt>multipart/form-data</tt>. + # + # ==== Options + # * Creates standard HTML attributes for the tag. + # * <tt>:disabled</tt> - If set to true, the user will not be able to use this input. + # * <tt>:multiple</tt> - If set to true, *in most updated browsers* the user will be allowed to select multiple files. + # * <tt>:accept</tt> - If set to one or multiple mime-types, the user will be suggested a filter when choosing a file. You still need to set up model validations. + # + # ==== Examples + # # Let's say that @user has avatar: + # file_field(:avatar) + # # => <input type="file" id="user_avatar" name="user[avatar]" /> + # + # # Let's say that @post has image: + # file_field(:image, :multiple => true) + # # => <input type="file" id="post_image" name="post[image][]" multiple="multiple" /> + # + # # Let's say that @post has attached: + # file_field(:attached, accept: 'text/html') + # # => <input accept="text/html" type="file" id="post_attached" name="post[attached]" /> + # + # # Let's say that @post has image: + # file_field(:image, accept: 'image/png,image/gif,image/jpeg') + # # => <input type="file" id="post_image" name="post[image]" accept="image/png,image/gif,image/jpeg" /> + # + # # Let's say that @attachment has file: + # file_field(:file, class: 'file_input') + # # => <input type="file" id="attachment_file" name="attachment[file]" class="file_input" /> + def file_field(method, options = {}) + self.multipart = true + @template.file_field(@object_name, method, objectify_options(options)) + end + + # Add the submit button for the given form. When no value is given, it checks + # if the object is a new resource or not to create the proper label: + # + # <%= form_for @post do |f| %> + # <%= f.submit %> + # <% end %> + # + # In the example above, if <tt>@post</tt> is a new record, it will use "Create Post" as + # submit button label; otherwise, it uses "Update Post". + # + # Those labels can be customized using I18n under the +helpers.submit+ key and using + # <tt>%{model}</tt> for translation interpolation: + # + # en: + # helpers: + # submit: + # create: "Create a %{model}" + # update: "Confirm changes to %{model}" + # + # It also searches for a key specific to the given object: + # + # en: + # helpers: + # submit: + # post: + # create: "Add %{model}" + # + def submit(value = nil, options = {}) + value, options = nil, value if value.is_a?(Hash) + value ||= submit_default_value + @template.submit_tag(value, options) + end + + # Add the submit button for the given form. When no value is given, it checks + # if the object is a new resource or not to create the proper label: + # + # <%= form_for @post do |f| %> + # <%= f.button %> + # <% end %> + # + # In the example above, if <tt>@post</tt> is a new record, it will use "Create Post" as + # button label; otherwise, it uses "Update Post". + # + # Those labels can be customized using I18n under the +helpers.submit+ key + # (the same as submit helper) and using <tt>%{model}</tt> for translation interpolation: + # + # en: + # helpers: + # submit: + # create: "Create a %{model}" + # update: "Confirm changes to %{model}" + # + # It also searches for a key specific to the given object: + # + # en: + # helpers: + # submit: + # post: + # create: "Add %{model}" + # + # ==== Examples + # button("Create post") + # # => <button name='button' type='submit'>Create post</button> + # + # button do + # content_tag(:strong, 'Ask me!') + # end + # # => <button name='button' type='submit'> + # # <strong>Ask me!</strong> + # # </button> + # + def button(value = nil, options = {}, &block) + value, options = nil, value if value.is_a?(Hash) + value ||= submit_default_value + @template.button_tag(value, options, &block) + end + + def emitted_hidden_id? # :nodoc: + @emitted_hidden_id ||= nil + end + + private + def objectify_options(options) + @default_options.merge(options.merge(object: @object)) + end + + def submit_default_value + object = convert_to_model(@object) + key = object ? (object.persisted? ? :update : :create) : :submit + + model = if object.respond_to?(:model_name) + object.model_name.human + else + @object_name.to_s.humanize + end + + defaults = [] + # Object is a model and it is not overwritten by as and scope option. + if object.respond_to?(:model_name) && object_name.to_s == model.downcase + defaults << :"helpers.submit.#{object.model_name.i18n_key}.#{key}" + else + defaults << :"helpers.submit.#{object_name}.#{key}" + end + defaults << :"helpers.submit.#{key}" + defaults << "#{key.to_s.humanize} #{model}" + + I18n.t(defaults.shift, model: model, default: defaults) + end + + def nested_attributes_association?(association_name) + @object.respond_to?("#{association_name}_attributes=") + end + + def fields_for_with_nested_attributes(association_name, association, options, block) + name = "#{object_name}[#{association_name}_attributes]" + association = convert_to_model(association) + + if association.respond_to?(:persisted?) + association = [association] if @object.send(association_name).respond_to?(:to_ary) + elsif !association.respond_to?(:to_ary) + association = @object.send(association_name) + end + + if association.respond_to?(:to_ary) + explicit_child_index = options[:child_index] + output = ActiveSupport::SafeBuffer.new + association.each do |child| + if explicit_child_index + options[:child_index] = explicit_child_index.call if explicit_child_index.respond_to?(:call) + else + options[:child_index] = nested_child_index(name) + end + output << fields_for_nested_model("#{name}[#{options[:child_index]}]", child, options, block) + end + output + elsif association + fields_for_nested_model(name, association, options, block) + end + end + + def fields_for_nested_model(name, object, fields_options, block) + object = convert_to_model(object) + emit_hidden_id = object.persisted? && fields_options.fetch(:include_id) { + options.fetch(:include_id, true) + } + + @template.fields_for(name, object, fields_options) do |f| + output = @template.capture(f, &block) + output.concat f.hidden_field(:id) if output && emit_hidden_id && !f.emitted_hidden_id? + output + end + end + + def nested_child_index(name) + @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 + end + end + end + + ActiveSupport.on_load(:action_view) do + cattr_accessor :default_form_builder, instance_writer: false, instance_reader: false, default: ::ActionView::Helpers::FormBuilder + end +end diff --git a/actionview/lib/action_view/helpers/form_options_helper.rb b/actionview/lib/action_view/helpers/form_options_helper.rb new file mode 100644 index 0000000000..ebdd96f570 --- /dev/null +++ b/actionview/lib/action_view/helpers/form_options_helper.rb @@ -0,0 +1,895 @@ +# frozen_string_literal: true + +require "cgi" +require "erb" +require "action_view/helpers/form_helper" +require "active_support/core_ext/string/output_safety" +require "active_support/core_ext/array/extract_options" +require "active_support/core_ext/array/wrap" + +module ActionView + # = Action View Form Option Helpers + module Helpers #:nodoc: + # Provides a number of methods for turning different kinds of containers into a set of option tags. + # + # The <tt>collection_select</tt>, <tt>select</tt> and <tt>time_zone_select</tt> methods take an <tt>options</tt> parameter, a hash: + # + # * <tt>:include_blank</tt> - set to true or a prompt string if the first option element of the select element is a blank. Useful if there is not a default value required for the select element. + # + # select("post", "category", Post::CATEGORIES, { include_blank: true }) + # + # could become: + # + # <select name="post[category]" id="post_category"> + # <option value=""></option> + # <option value="joke">joke</option> + # <option value="poem">poem</option> + # </select> + # + # Another common case is a select tag for a <tt>belongs_to</tt>-associated object. + # + # Example with <tt>@post.person_id => 2</tt>: + # + # select("post", "person_id", Person.all.collect { |p| [ p.name, p.id ] }, { include_blank: 'None' }) + # + # could become: + # + # <select name="post[person_id]" id="post_person_id"> + # <option value="">None</option> + # <option value="1">David</option> + # <option value="2" selected="selected">Eileen</option> + # <option value="3">Rafael</option> + # </select> + # + # * <tt>:prompt</tt> - set to true or a prompt string. When the select element doesn't have a value yet, this prepends an option with a generic prompt -- "Please select" -- or the given prompt string. + # + # select("post", "person_id", Person.all.collect { |p| [ p.name, p.id ] }, { prompt: 'Select Person' }) + # + # could become: + # + # <select name="post[person_id]" id="post_person_id"> + # <option value="">Select Person</option> + # <option value="1">David</option> + # <option value="2">Eileen</option> + # <option value="3">Rafael</option> + # </select> + # + # * <tt>:index</tt> - like the other form helpers, +select+ can accept an <tt>:index</tt> option to manually set the ID used in the resulting output. Unlike other helpers, +select+ expects this + # option to be in the +html_options+ parameter. + # + # select("album[]", "genre", %w[rap rock country], {}, { index: nil }) + # + # becomes: + # + # <select name="album[][genre]" id="album__genre"> + # <option value="rap">rap</option> + # <option value="rock">rock</option> + # <option value="country">country</option> + # </select> + # + # * <tt>:disabled</tt> - can be a single value or an array of values that will be disabled options in the final output. + # + # select("post", "category", Post::CATEGORIES, { disabled: 'restricted' }) + # + # could become: + # + # <select name="post[category]" id="post_category"> + # <option value=""></option> + # <option value="joke">joke</option> + # <option value="poem">poem</option> + # <option disabled="disabled" value="restricted">restricted</option> + # </select> + # + # When used with the <tt>collection_select</tt> helper, <tt>:disabled</tt> can also be a Proc that identifies those options that should be disabled. + # + # collection_select(:post, :category_id, Category.all, :id, :name, { disabled: -> (category) { category.archived? } }) + # + # If the categories "2008 stuff" and "Christmas" return true when the method <tt>archived?</tt> is called, this would return: + # <select name="post[category_id]" id="post_category_id"> + # <option value="1" disabled="disabled">2008 stuff</option> + # <option value="2" disabled="disabled">Christmas</option> + # <option value="3">Jokes</option> + # <option value="4">Poems</option> + # </select> + # + module FormOptionsHelper + # ERB::Util can mask some helpers like textilize. Make sure to include them. + include TextHelper + + # Create a select tag and a series of contained option tags for the provided object and method. + # The option currently held by the object will be selected, provided that the object is available. + # + # There are two possible formats for the +choices+ parameter, corresponding to other helpers' output: + # + # * A flat collection (see +options_for_select+). + # + # * A nested collection (see +grouped_options_for_select+). + # + # For example: + # + # select("post", "person_id", Person.all.collect { |p| [ p.name, p.id ] }, { include_blank: true }) + # + # would become: + # + # <select name="post[person_id]" id="post_person_id"> + # <option value=""></option> + # <option value="1" selected="selected">David</option> + # <option value="2">Eileen</option> + # <option value="3">Rafael</option> + # </select> + # + # assuming the associated person has ID 1. + # + # This can be used to provide a default set of options in the standard way: before rendering the create form, a + # new model instance is assigned the default options and bound to @model_name. Usually this model is not saved + # to the database. Instead, a second model object is created when the create request is received. + # This allows the user to submit a form page more than once with the expected results of creating multiple records. + # In addition, this allows a single partial to be used to generate form inputs for both edit and create forms. + # + # By default, <tt>post.person_id</tt> is the selected option. Specify <tt>selected: value</tt> to use a different selection + # or <tt>selected: nil</tt> to leave all options unselected. Similarly, you can specify values to be disabled in the option + # tags by specifying the <tt>:disabled</tt> option. This can either be a single value or an array of values to be disabled. + # + # A block can be passed to +select+ to customize how the options tags will be rendered. This + # is useful when the options tag has complex attributes. + # + # select(report, "campaign_ids") do + # available_campaigns.each do |c| + # content_tag(:option, c.name, value: c.id, data: { tags: c.tags.to_json }) + # end + # end + # + # ==== Gotcha + # + # The HTML specification says when +multiple+ parameter passed to select and all options got deselected + # web browsers do not send any value to server. Unfortunately this introduces a gotcha: + # if an +User+ model has many +roles+ and have +role_ids+ accessor, and in the form that edits roles of the user + # the user deselects all roles from +role_ids+ multiple select box, no +role_ids+ parameter is sent. So, + # any mass-assignment idiom like + # + # @user.update(params[:user]) + # + # wouldn't update roles. + # + # To prevent this the helper generates an auxiliary hidden field before + # every multiple select. The hidden field has the same name as multiple select and blank value. + # + # <b>Note:</b> The client either sends only the hidden field (representing + # the deselected multiple select box), or both fields. This means that the resulting array + # always contains a blank string. + # + # In case if you don't want the helper to generate this hidden field you can specify + # <tt>include_hidden: false</tt> option. + # + def select(object, method, choices = nil, options = {}, html_options = {}, &block) + Tags::Select.new(object, method, self, choices, options, html_options, &block).render + end + + # Returns <tt><select></tt> and <tt><option></tt> tags for the collection of existing return values of + # +method+ for +object+'s class. The value returned from calling +method+ on the instance +object+ will + # be selected. If calling +method+ returns +nil+, no selection is made without including <tt>:prompt</tt> + # or <tt>:include_blank</tt> in the +options+ hash. + # + # The <tt>:value_method</tt> and <tt>:text_method</tt> parameters are methods to be called on each member + # of +collection+. The return values are used as the +value+ attribute and contents of each + # <tt><option></tt> tag, respectively. They can also be any object that responds to +call+, such + # as a +proc+, that will be called for each member of the +collection+ to + # retrieve the value/text. + # + # Example object structure for use with this method: + # + # class Post < ActiveRecord::Base + # belongs_to :author + # end + # + # class Author < ActiveRecord::Base + # has_many :posts + # def name_with_initial + # "#{first_name.first}. #{last_name}" + # end + # end + # + # Sample usage (selecting the associated Author for an instance of Post, <tt>@post</tt>): + # + # collection_select(:post, :author_id, Author.all, :id, :name_with_initial, prompt: true) + # + # If <tt>@post.author_id</tt> is already <tt>1</tt>, this would return: + # <select name="post[author_id]" id="post_author_id"> + # <option value="">Please select</option> + # <option value="1" selected="selected">D. Heinemeier Hansson</option> + # <option value="2">D. Thomas</option> + # <option value="3">M. Clark</option> + # </select> + def collection_select(object, method, collection, value_method, text_method, options = {}, html_options = {}) + Tags::CollectionSelect.new(object, method, self, collection, value_method, text_method, options, html_options).render + end + + # Returns <tt><select></tt>, <tt><optgroup></tt> and <tt><option></tt> tags for the collection of existing return values of + # +method+ for +object+'s class. The value returned from calling +method+ on the instance +object+ will + # be selected. If calling +method+ returns +nil+, no selection is made without including <tt>:prompt</tt> + # or <tt>:include_blank</tt> in the +options+ hash. + # + # Parameters: + # * +object+ - The instance of the class to be used for the select tag + # * +method+ - The attribute of +object+ corresponding to the select tag + # * +collection+ - An array of objects representing the <tt><optgroup></tt> tags. + # * +group_method+ - The name of a method which, when called on a member of +collection+, returns an + # array of child objects representing the <tt><option></tt> tags. It can also be any object that responds + # to +call+, such as a +proc+, that will be called for each member of the +collection+ to retrieve the + # value. + # * +group_label_method+ - The name of a method which, when called on a member of +collection+, returns a + # string to be used as the +label+ attribute for its <tt><optgroup></tt> tag. It can also be any object + # that responds to +call+, such as a +proc+, that will be called for each member of the +collection+ to + # retrieve the label. + # * +option_key_method+ - The name of a method which, when called on a child object of a member of + # +collection+, returns a value to be used as the +value+ attribute for its <tt><option></tt> tag. + # * +option_value_method+ - The name of a method which, when called on a child object of a member of + # +collection+, returns a value to be used as the contents of its <tt><option></tt> tag. + # + # Example object structure for use with this method: + # + # class Continent < ActiveRecord::Base + # has_many :countries + # # attribs: id, name + # end + # + # class Country < ActiveRecord::Base + # belongs_to :continent + # # attribs: id, name, continent_id + # end + # + # class City < ActiveRecord::Base + # belongs_to :country + # # attribs: id, name, country_id + # end + # + # Sample usage: + # + # grouped_collection_select(:city, :country_id, @continents, :countries, :name, :id, :name) + # + # Possible output: + # + # <select name="city[country_id]" id="city_country_id"> + # <optgroup label="Africa"> + # <option value="1">South Africa</option> + # <option value="3">Somalia</option> + # </optgroup> + # <optgroup label="Europe"> + # <option value="7" selected="selected">Denmark</option> + # <option value="2">Ireland</option> + # </optgroup> + # </select> + # + def grouped_collection_select(object, method, collection, group_method, group_label_method, option_key_method, option_value_method, options = {}, html_options = {}) + Tags::GroupedCollectionSelect.new(object, method, self, collection, group_method, group_label_method, option_key_method, option_value_method, options, html_options).render + end + + # Returns select and option tags for the given object and method, using + # #time_zone_options_for_select to generate the list of option tags. + # + # In addition to the <tt>:include_blank</tt> option documented above, + # this method also supports a <tt>:model</tt> option, which defaults + # to ActiveSupport::TimeZone. This may be used by users to specify a + # different time zone model object. (See +time_zone_options_for_select+ + # for more information.) + # + # You can also supply an array of ActiveSupport::TimeZone objects + # as +priority_zones+ so that they will be listed above the rest of the + # (long) list. You can use ActiveSupport::TimeZone.us_zones for a list + # of US time zones, ActiveSupport::TimeZone.country_zones(country_code) + # for another country's time zones, or a Regexp to select the zones of + # your choice. + # + # Finally, this method supports a <tt>:default</tt> option, which selects + # a default ActiveSupport::TimeZone if the object's time zone is +nil+. + # + # time_zone_select("user", "time_zone", nil, include_blank: true) + # + # time_zone_select("user", "time_zone", nil, default: "Pacific Time (US & Canada)") + # + # time_zone_select("user", 'time_zone', ActiveSupport::TimeZone.us_zones, default: "Pacific Time (US & Canada)") + # + # time_zone_select("user", 'time_zone', [ ActiveSupport::TimeZone['Alaska'], ActiveSupport::TimeZone['Hawaii'] ]) + # + # time_zone_select("user", 'time_zone', /Australia/) + # + # time_zone_select("user", "time_zone", ActiveSupport::TimeZone.all.sort, model: ActiveSupport::TimeZone) + def time_zone_select(object, method, priority_zones = nil, options = {}, html_options = {}) + Tags::TimeZoneSelect.new(object, method, self, priority_zones, options, html_options).render + end + + # Accepts a container (hash, array, enumerable, your type) and returns a string of option tags. Given a container + # where the elements respond to first and last (such as a two-element array), the "lasts" serve as option values and + # the "firsts" as option text. Hashes are turned into this form automatically, so the keys become "firsts" and values + # become lasts. If +selected+ is specified, the matching "last" or element will get the selected option-tag. +selected+ + # may also be an array of values to be selected when using a multiple select. + # + # options_for_select([["Dollar", "$"], ["Kroner", "DKK"]]) + # # => <option value="$">Dollar</option> + # # => <option value="DKK">Kroner</option> + # + # options_for_select([ "VISA", "MasterCard" ], "MasterCard") + # # => <option value="VISA">VISA</option> + # # => <option selected="selected" value="MasterCard">MasterCard</option> + # + # options_for_select({ "Basic" => "$20", "Plus" => "$40" }, "$40") + # # => <option value="$20">Basic</option> + # # => <option value="$40" selected="selected">Plus</option> + # + # options_for_select([ "VISA", "MasterCard", "Discover" ], ["VISA", "Discover"]) + # # => <option selected="selected" value="VISA">VISA</option> + # # => <option value="MasterCard">MasterCard</option> + # # => <option selected="selected" value="Discover">Discover</option> + # + # You can optionally provide HTML attributes as the last element of the array. + # + # options_for_select([ "Denmark", ["USA", { class: 'bold' }], "Sweden" ], ["USA", "Sweden"]) + # # => <option value="Denmark">Denmark</option> + # # => <option value="USA" class="bold" selected="selected">USA</option> + # # => <option value="Sweden" selected="selected">Sweden</option> + # + # options_for_select([["Dollar", "$", { class: "bold" }], ["Kroner", "DKK", { onclick: "alert('HI');" }]]) + # # => <option value="$" class="bold">Dollar</option> + # # => <option value="DKK" onclick="alert('HI');">Kroner</option> + # + # If you wish to specify disabled option tags, set +selected+ to be a hash, with <tt>:disabled</tt> being either a value + # or array of values to be disabled. In this case, you can use <tt>:selected</tt> to specify selected option tags. + # + # options_for_select(["Free", "Basic", "Advanced", "Super Platinum"], disabled: "Super Platinum") + # # => <option value="Free">Free</option> + # # => <option value="Basic">Basic</option> + # # => <option value="Advanced">Advanced</option> + # # => <option value="Super Platinum" disabled="disabled">Super Platinum</option> + # + # options_for_select(["Free", "Basic", "Advanced", "Super Platinum"], disabled: ["Advanced", "Super Platinum"]) + # # => <option value="Free">Free</option> + # # => <option value="Basic">Basic</option> + # # => <option value="Advanced" disabled="disabled">Advanced</option> + # # => <option value="Super Platinum" disabled="disabled">Super Platinum</option> + # + # options_for_select(["Free", "Basic", "Advanced", "Super Platinum"], selected: "Free", disabled: "Super Platinum") + # # => <option value="Free" selected="selected">Free</option> + # # => <option value="Basic">Basic</option> + # # => <option value="Advanced">Advanced</option> + # # => <option value="Super Platinum" disabled="disabled">Super Platinum</option> + # + # NOTE: Only the option tags are returned, you have to wrap this call in a regular HTML select tag. + def options_for_select(container, selected = nil) + return container if String === container + + selected, disabled = extract_selected_and_disabled(selected).map do |r| + Array(r).map(&:to_s) + end + + container.map do |element| + html_attributes = option_html_attributes(element) + text, value = option_text_and_value(element).map(&:to_s) + + html_attributes[:selected] ||= option_value_selected?(value, selected) + html_attributes[:disabled] ||= disabled && option_value_selected?(value, disabled) + html_attributes[:value] = value + + tag_builder.content_tag_string(:option, text, html_attributes) + end.join("\n").html_safe + end + + # Returns a string of option tags that have been compiled by iterating over the +collection+ and assigning + # the result of a call to the +value_method+ as the option value and the +text_method+ as the option text. + # + # options_from_collection_for_select(@people, 'id', 'name') + # # => <option value="#{person.id}">#{person.name}</option> + # + # This is more often than not used inside a #select_tag like this example: + # + # select_tag 'person', options_from_collection_for_select(@people, 'id', 'name') + # + # If +selected+ is specified as a value or array of values, the element(s) returning a match on +value_method+ + # will be selected option tag(s). + # + # If +selected+ is specified as a Proc, those members of the collection that return true for the anonymous + # function are the selected values. + # + # +selected+ can also be a hash, specifying both <tt>:selected</tt> and/or <tt>:disabled</tt> values as required. + # + # Be sure to specify the same class as the +value_method+ when specifying selected or disabled options. + # Failure to do this will produce undesired results. Example: + # options_from_collection_for_select(@people, 'id', 'name', '1') + # Will not select a person with the id of 1 because 1 (an Integer) is not the same as '1' (a string) + # options_from_collection_for_select(@people, 'id', 'name', 1) + # should produce the desired results. + def options_from_collection_for_select(collection, value_method, text_method, selected = nil) + options = collection.map do |element| + [value_for_collection(element, text_method), value_for_collection(element, value_method), option_html_attributes(element)] + end + selected, disabled = extract_selected_and_disabled(selected) + select_deselect = { + selected: extract_values_from_collection(collection, value_method, selected), + disabled: extract_values_from_collection(collection, value_method, disabled) + } + + options_for_select(options, select_deselect) + end + + # Returns a string of <tt><option></tt> tags, like <tt>options_from_collection_for_select</tt>, but + # groups them by <tt><optgroup></tt> tags based on the object relationships of the arguments. + # + # Parameters: + # * +collection+ - An array of objects representing the <tt><optgroup></tt> tags. + # * +group_method+ - The name of a method which, when called on a member of +collection+, returns an + # array of child objects representing the <tt><option></tt> tags. + # * +group_label_method+ - The name of a method which, when called on a member of +collection+, returns a + # string to be used as the +label+ attribute for its <tt><optgroup></tt> tag. + # * +option_key_method+ - The name of a method which, when called on a child object of a member of + # +collection+, returns a value to be used as the +value+ attribute for its <tt><option></tt> tag. + # * +option_value_method+ - The name of a method which, when called on a child object of a member of + # +collection+, returns a value to be used as the contents of its <tt><option></tt> tag. + # * +selected_key+ - A value equal to the +value+ attribute for one of the <tt><option></tt> tags, + # which will have the +selected+ attribute set. Corresponds to the return value of one of the calls + # to +option_key_method+. If +nil+, no selection is made. Can also be a hash if disabled values are + # to be specified. + # + # Example object structure for use with this method: + # + # class Continent < ActiveRecord::Base + # has_many :countries + # # attribs: id, name + # end + # + # class Country < ActiveRecord::Base + # belongs_to :continent + # # attribs: id, name, continent_id + # end + # + # Sample usage: + # option_groups_from_collection_for_select(@continents, :countries, :name, :id, :name, 3) + # + # Possible output: + # <optgroup label="Africa"> + # <option value="1">Egypt</option> + # <option value="4">Rwanda</option> + # ... + # </optgroup> + # <optgroup label="Asia"> + # <option value="3" selected="selected">China</option> + # <option value="12">India</option> + # <option value="5">Japan</option> + # ... + # </optgroup> + # + # <b>Note:</b> Only the <tt><optgroup></tt> and <tt><option></tt> tags are returned, so you still have to + # wrap the output in an appropriate <tt><select></tt> tag. + def option_groups_from_collection_for_select(collection, group_method, group_label_method, option_key_method, option_value_method, selected_key = nil) + collection.map do |group| + option_tags = options_from_collection_for_select( + value_for_collection(group, group_method), option_key_method, option_value_method, selected_key) + + content_tag("optgroup", option_tags, label: value_for_collection(group, group_label_method)) + end.join.html_safe + end + + # Returns a string of <tt><option></tt> tags, like <tt>options_for_select</tt>, but + # wraps them with <tt><optgroup></tt> tags: + # + # grouped_options = [ + # ['North America', + # [['United States','US'],'Canada']], + # ['Europe', + # ['Denmark','Germany','France']] + # ] + # grouped_options_for_select(grouped_options) + # + # grouped_options = { + # 'North America' => [['United States','US'], 'Canada'], + # 'Europe' => ['Denmark','Germany','France'] + # } + # grouped_options_for_select(grouped_options) + # + # Possible output: + # <optgroup label="North America"> + # <option value="US">United States</option> + # <option value="Canada">Canada</option> + # </optgroup> + # <optgroup label="Europe"> + # <option value="Denmark">Denmark</option> + # <option value="Germany">Germany</option> + # <option value="France">France</option> + # </optgroup> + # + # Parameters: + # * +grouped_options+ - Accepts a nested array or hash of strings. The first value serves as the + # <tt><optgroup></tt> label while the second value must be an array of options. The second value can be a + # nested array of text-value pairs. See <tt>options_for_select</tt> for more info. + # Ex. ["North America",[["United States","US"],["Canada","CA"]]] + # * +selected_key+ - A value equal to the +value+ attribute for one of the <tt><option></tt> tags, + # which will have the +selected+ attribute set. Note: It is possible for this value to match multiple options + # as you might have the same option in multiple groups. Each will then get <tt>selected="selected"</tt>. + # + # Options: + # * <tt>:prompt</tt> - set to true or a prompt string. When the select element doesn't have a value yet, this + # prepends an option with a generic prompt - "Please select" - or the given prompt string. + # * <tt>:divider</tt> - the divider for the options groups. + # + # grouped_options = [ + # [['United States','US'], 'Canada'], + # ['Denmark','Germany','France'] + # ] + # grouped_options_for_select(grouped_options, nil, divider: '---------') + # + # Possible output: + # <optgroup label="---------"> + # <option value="US">United States</option> + # <option value="Canada">Canada</option> + # </optgroup> + # <optgroup label="---------"> + # <option value="Denmark">Denmark</option> + # <option value="Germany">Germany</option> + # <option value="France">France</option> + # </optgroup> + # + # <b>Note:</b> Only the <tt><optgroup></tt> and <tt><option></tt> tags are returned, so you still have to + # wrap the output in an appropriate <tt><select></tt> tag. + def grouped_options_for_select(grouped_options, selected_key = nil, options = {}) + prompt = options[:prompt] + divider = options[:divider] + + body = "".html_safe + + if prompt + body.safe_concat content_tag("option", prompt_text(prompt), value: "") + end + + grouped_options.each do |container| + html_attributes = option_html_attributes(container) + + if divider + label = divider + else + label, container = container + end + + html_attributes = { label: label }.merge!(html_attributes) + body.safe_concat content_tag("optgroup", options_for_select(container, selected_key), html_attributes) + end + + body + end + + # Returns a string of option tags for pretty much any time zone in the + # world. Supply an ActiveSupport::TimeZone name as +selected+ to have it + # marked as the selected option tag. You can also supply an array of + # ActiveSupport::TimeZone objects as +priority_zones+, so that they will + # be listed above the rest of the (long) list. (You can use + # ActiveSupport::TimeZone.us_zones as a convenience for obtaining a list + # of the US time zones, or a Regexp to select the zones of your choice) + # + # The +selected+ parameter must be either +nil+, or a string that names + # an ActiveSupport::TimeZone. + # + # By default, +model+ is the ActiveSupport::TimeZone constant (which can + # be obtained in Active Record as a value object). The only requirement + # is that the +model+ parameter be an object that responds to +all+, and + # returns an array of objects that represent time zones. + # + # NOTE: Only the option tags are returned, you have to wrap this call in + # a regular HTML select tag. + def time_zone_options_for_select(selected = nil, priority_zones = nil, model = ::ActiveSupport::TimeZone) + zone_options = "".html_safe + + zones = model.all + convert_zones = lambda { |list| list.map { |z| [ z.to_s, z.name ] } } + + if priority_zones + if priority_zones.is_a?(Regexp) + priority_zones = zones.select { |z| z =~ priority_zones } + end + + zone_options.safe_concat options_for_select(convert_zones[priority_zones], selected) + zone_options.safe_concat content_tag("option", "-------------", value: "", disabled: true) + zone_options.safe_concat "\n" + + zones = zones - priority_zones + end + + zone_options.safe_concat options_for_select(convert_zones[zones], selected) + end + + # Returns radio button tags for the collection of existing return values + # of +method+ for +object+'s class. The value returned from calling + # +method+ on the instance +object+ will be selected. If calling +method+ + # returns +nil+, no selection is made. + # + # The <tt>:value_method</tt> and <tt>:text_method</tt> parameters are + # methods to be called on each member of +collection+. The return values + # are used as the +value+ attribute and contents of each radio button tag, + # respectively. They can also be any object that responds to +call+, such + # as a +proc+, that will be called for each member of the +collection+ to + # retrieve the value/text. + # + # Example object structure for use with this method: + # class Post < ActiveRecord::Base + # belongs_to :author + # end + # class Author < ActiveRecord::Base + # has_many :posts + # def name_with_initial + # "#{first_name.first}. #{last_name}" + # end + # end + # + # Sample usage (selecting the associated Author for an instance of Post, <tt>@post</tt>): + # collection_radio_buttons(:post, :author_id, Author.all, :id, :name_with_initial) + # + # If <tt>@post.author_id</tt> is already <tt>1</tt>, this would return: + # <input id="post_author_id_1" name="post[author_id]" type="radio" value="1" checked="checked" /> + # <label for="post_author_id_1">D. Heinemeier Hansson</label> + # <input id="post_author_id_2" name="post[author_id]" type="radio" value="2" /> + # <label for="post_author_id_2">D. Thomas</label> + # <input id="post_author_id_3" name="post[author_id]" type="radio" value="3" /> + # <label for="post_author_id_3">M. Clark</label> + # + # It is also possible to customize the way the elements will be shown by + # giving a block to the method: + # collection_radio_buttons(:post, :author_id, Author.all, :id, :name_with_initial) do |b| + # b.label { b.radio_button } + # end + # + # The argument passed to the block is a special kind of builder for this + # collection, which has the ability to generate the label and radio button + # for the current item in the collection, with proper text and value. + # Using it, you can change the label and radio button display order or + # even use the label as wrapper, as in the example above. + # + # The builder methods <tt>label</tt> and <tt>radio_button</tt> also accept + # extra HTML options: + # collection_radio_buttons(:post, :author_id, Author.all, :id, :name_with_initial) do |b| + # b.label(class: "radio_button") { b.radio_button(class: "radio_button") } + # end + # + # There are also three special methods available: <tt>object</tt>, <tt>text</tt> and + # <tt>value</tt>, which are the current item being rendered, its text and value methods, + # respectively. You can use them like this: + # collection_radio_buttons(:post, :author_id, Author.all, :id, :name_with_initial) do |b| + # b.label(:"data-value" => b.value) { b.radio_button + b.text } + # end + # + # ==== Gotcha + # + # The HTML specification says when nothing is select on a collection of radio buttons + # web browsers do not send any value to server. + # Unfortunately this introduces a gotcha: + # if a +User+ model has a +category_id+ field and in the form no category is selected, no +category_id+ parameter is sent. So, + # any strong parameters idiom like: + # + # params.require(:user).permit(...) + # + # will raise an error since no <tt>{user: ...}</tt> will be present. + # + # To prevent this the helper generates an auxiliary hidden field before + # every collection of radio buttons. The hidden field has the same name as collection radio button and blank value. + # + # In case if you don't want the helper to generate this hidden field you can specify + # <tt>include_hidden: false</tt> option. + def collection_radio_buttons(object, method, collection, value_method, text_method, options = {}, html_options = {}, &block) + Tags::CollectionRadioButtons.new(object, method, self, collection, value_method, text_method, options, html_options).render(&block) + end + + # Returns check box tags for the collection of existing return values of + # +method+ for +object+'s class. The value returned from calling +method+ + # on the instance +object+ will be selected. If calling +method+ returns + # +nil+, no selection is made. + # + # The <tt>:value_method</tt> and <tt>:text_method</tt> parameters are + # methods to be called on each member of +collection+. The return values + # are used as the +value+ attribute and contents of each check box tag, + # respectively. They can also be any object that responds to +call+, such + # as a +proc+, that will be called for each member of the +collection+ to + # retrieve the value/text. + # + # Example object structure for use with this method: + # class Post < ActiveRecord::Base + # has_and_belongs_to_many :authors + # end + # class Author < ActiveRecord::Base + # has_and_belongs_to_many :posts + # def name_with_initial + # "#{first_name.first}. #{last_name}" + # end + # end + # + # Sample usage (selecting the associated Author for an instance of Post, <tt>@post</tt>): + # collection_check_boxes(:post, :author_ids, Author.all, :id, :name_with_initial) + # + # If <tt>@post.author_ids</tt> is already <tt>[1]</tt>, this would return: + # <input id="post_author_ids_1" name="post[author_ids][]" type="checkbox" value="1" checked="checked" /> + # <label for="post_author_ids_1">D. Heinemeier Hansson</label> + # <input id="post_author_ids_2" name="post[author_ids][]" type="checkbox" value="2" /> + # <label for="post_author_ids_2">D. Thomas</label> + # <input id="post_author_ids_3" name="post[author_ids][]" type="checkbox" value="3" /> + # <label for="post_author_ids_3">M. Clark</label> + # <input name="post[author_ids][]" type="hidden" value="" /> + # + # It is also possible to customize the way the elements will be shown by + # giving a block to the method: + # collection_check_boxes(:post, :author_ids, Author.all, :id, :name_with_initial) do |b| + # b.label { b.check_box } + # end + # + # The argument passed to the block is a special kind of builder for this + # collection, which has the ability to generate the label and check box + # for the current item in the collection, with proper text and value. + # Using it, you can change the label and check box display order or even + # use the label as wrapper, as in the example above. + # + # The builder methods <tt>label</tt> and <tt>check_box</tt> also accept + # extra HTML options: + # collection_check_boxes(:post, :author_ids, Author.all, :id, :name_with_initial) do |b| + # b.label(class: "check_box") { b.check_box(class: "check_box") } + # end + # + # There are also three special methods available: <tt>object</tt>, <tt>text</tt> and + # <tt>value</tt>, which are the current item being rendered, its text and value methods, + # respectively. You can use them like this: + # collection_check_boxes(:post, :author_ids, Author.all, :id, :name_with_initial) do |b| + # b.label(:"data-value" => b.value) { b.check_box + b.text } + # end + # + # ==== Gotcha + # + # When no selection is made for a collection of checkboxes most + # web browsers will not send any value. + # + # For example, if we have a +User+ model with +category_ids+ field and we + # have the following code in our update action: + # + # @user.update(params[:user]) + # + # If no +category_ids+ are selected then we can safely assume this field + # will not be updated. + # + # This is possible thanks to a hidden field generated by the helper method + # for every collection of checkboxes. + # This hidden field is given the same field name as the checkboxes with a + # blank value. + # + # In the rare case you don't want this hidden field, you can pass the + # <tt>include_hidden: false</tt> option to the helper method. + def collection_check_boxes(object, method, collection, value_method, text_method, options = {}, html_options = {}, &block) + Tags::CollectionCheckBoxes.new(object, method, self, collection, value_method, text_method, options, html_options).render(&block) + end + + private + def option_html_attributes(element) + if Array === element + element.select { |e| Hash === e }.reduce({}, :merge!) + else + {} + end + end + + def option_text_and_value(option) + # Options are [text, value] pairs or strings used for both. + if !option.is_a?(String) && option.respond_to?(:first) && option.respond_to?(:last) + option = option.reject { |e| Hash === e } if Array === option + [option.first, option.last] + else + [option, option] + end + end + + def option_value_selected?(value, selected) + Array(selected).include? value + end + + def extract_selected_and_disabled(selected) + if selected.is_a?(Proc) + [selected, nil] + else + selected = Array.wrap(selected) + options = selected.extract_options!.symbolize_keys + selected_items = options.fetch(:selected, selected) + [selected_items, options[:disabled]] + end + end + + def extract_values_from_collection(collection, value_method, selected) + if selected.is_a?(Proc) + collection.map do |element| + public_or_deprecated_send(element, value_method) if selected.call(element) + end.compact + else + selected + end + end + + def value_for_collection(item, value) + value.respond_to?(:call) ? value.call(item) : public_or_deprecated_send(item, value) + end + + def public_or_deprecated_send(item, value) + item.public_send(value) + rescue NoMethodError + raise unless item.respond_to?(value, true) && !item.respond_to?(value) + ActiveSupport::Deprecation.warn "Using private methods from view helpers is deprecated (calling private #{item.class}##{value})" + item.send(value) + end + + def prompt_text(prompt) + prompt.kind_of?(String) ? prompt : I18n.translate("helpers.select.prompt", default: "Please select") + end + end + + class FormBuilder + # Wraps ActionView::Helpers::FormOptionsHelper#select for form builders: + # + # <%= form_for @post do |f| %> + # <%= f.select :person_id, Person.all.collect { |p| [ p.name, p.id ] }, include_blank: true %> + # <%= f.submit %> + # <% end %> + # + # Please refer to the documentation of the base helper for details. + def select(method, choices = nil, options = {}, html_options = {}, &block) + @template.select(@object_name, method, choices, objectify_options(options), @default_html_options.merge(html_options), &block) + end + + # Wraps ActionView::Helpers::FormOptionsHelper#collection_select for form builders: + # + # <%= form_for @post do |f| %> + # <%= f.collection_select :person_id, Author.all, :id, :name_with_initial, prompt: true %> + # <%= f.submit %> + # <% end %> + # + # Please refer to the documentation of the base helper for details. + def collection_select(method, collection, value_method, text_method, options = {}, html_options = {}) + @template.collection_select(@object_name, method, collection, value_method, text_method, objectify_options(options), @default_html_options.merge(html_options)) + end + + # Wraps ActionView::Helpers::FormOptionsHelper#grouped_collection_select for form builders: + # + # <%= form_for @city do |f| %> + # <%= f.grouped_collection_select :country_id, @continents, :countries, :name, :id, :name %> + # <%= f.submit %> + # <% end %> + # + # Please refer to the documentation of the base helper for details. + def grouped_collection_select(method, collection, group_method, group_label_method, option_key_method, option_value_method, options = {}, html_options = {}) + @template.grouped_collection_select(@object_name, method, collection, group_method, group_label_method, option_key_method, option_value_method, objectify_options(options), @default_html_options.merge(html_options)) + end + + # Wraps ActionView::Helpers::FormOptionsHelper#time_zone_select for form builders: + # + # <%= form_for @user do |f| %> + # <%= f.time_zone_select :time_zone, nil, include_blank: true %> + # <%= f.submit %> + # <% end %> + # + # Please refer to the documentation of the base helper for details. + def time_zone_select(method, priority_zones = nil, options = {}, html_options = {}) + @template.time_zone_select(@object_name, method, priority_zones, objectify_options(options), @default_html_options.merge(html_options)) + end + + # Wraps ActionView::Helpers::FormOptionsHelper#collection_check_boxes for form builders: + # + # <%= form_for @post do |f| %> + # <%= f.collection_check_boxes :author_ids, Author.all, :id, :name_with_initial %> + # <%= f.submit %> + # <% end %> + # + # Please refer to the documentation of the base helper for details. + def collection_check_boxes(method, collection, value_method, text_method, options = {}, html_options = {}, &block) + @template.collection_check_boxes(@object_name, method, collection, value_method, text_method, objectify_options(options), @default_html_options.merge(html_options), &block) + end + + # Wraps ActionView::Helpers::FormOptionsHelper#collection_radio_buttons for form builders: + # + # <%= form_for @post do |f| %> + # <%= f.collection_radio_buttons :author_id, Author.all, :id, :name_with_initial %> + # <%= f.submit %> + # <% end %> + # + # Please refer to the documentation of the base helper for details. + def collection_radio_buttons(method, collection, value_method, text_method, options = {}, html_options = {}, &block) + @template.collection_radio_buttons(@object_name, method, collection, value_method, text_method, objectify_options(options), @default_html_options.merge(html_options), &block) + end + end + end +end diff --git a/actionview/lib/action_view/helpers/form_tag_helper.rb b/actionview/lib/action_view/helpers/form_tag_helper.rb new file mode 100644 index 0000000000..c0996049f0 --- /dev/null +++ b/actionview/lib/action_view/helpers/form_tag_helper.rb @@ -0,0 +1,919 @@ +# frozen_string_literal: true + +require "cgi" +require "action_view/helpers/tag_helper" +require "active_support/core_ext/string/output_safety" +require "active_support/core_ext/module/attribute_accessors" + +module ActionView + # = Action View Form Tag Helpers + module Helpers #:nodoc: + # Provides a number of methods for creating form tags that don't rely on an Active Record object assigned to the template like + # FormHelper does. Instead, you provide the names and values manually. + # + # NOTE: The HTML options <tt>disabled</tt>, <tt>readonly</tt>, and <tt>multiple</tt> can all be treated as booleans. So specifying + # <tt>disabled: true</tt> will give <tt>disabled="disabled"</tt>. + module FormTagHelper + extend ActiveSupport::Concern + + include UrlHelper + include TextHelper + + mattr_accessor :embed_authenticity_token_in_remote_forms + self.embed_authenticity_token_in_remote_forms = nil + + mattr_accessor :default_enforce_utf8, default: true + + # Starts a form tag that points the action to a url configured with <tt>url_for_options</tt> just like + # ActionController::Base#url_for. The method for the form defaults to POST. + # + # ==== Options + # * <tt>:multipart</tt> - If set to true, the enctype is set to "multipart/form-data". + # * <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 with name <tt>_method</tt> + # is added to simulate the verb over post. + # * <tt>:authenticity_token</tt> - Authenticity token to use in the form. Use only if you need to + # pass custom authenticity token string, or to not add authenticity_token field at all + # (by passing <tt>false</tt>). 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 you're 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>:remote</tt> - If set to true, will allow the Unobtrusive JavaScript drivers to control the + # submit behavior. By default this behavior is an ajax submit. + # * <tt>:enforce_utf8</tt> - If set to false, a hidden input with name utf8 is not output. + # * Any other key creates standard HTML attributes for the tag. + # + # ==== Examples + # form_tag('/posts') + # # => <form action="/posts" method="post"> + # + # form_tag('/posts/1', method: :put) + # # => <form action="/posts/1" method="post"> ... <input name="_method" type="hidden" value="put" /> ... + # + # form_tag('/upload', multipart: true) + # # => <form action="/upload" method="post" enctype="multipart/form-data"> + # + # <%= form_tag('/posts') do -%> + # <div><%= submit_tag 'Save' %></div> + # <% end -%> + # # => <form action="/posts" method="post"><div><input type="submit" name="commit" value="Save" /></div></form> + # + # <%= form_tag('/posts', remote: true) %> + # # => <form action="/posts" method="post" data-remote="true"> + # + # form_tag('http://far.away.com/form', authenticity_token: false) + # # form without authenticity token + # + # form_tag('http://far.away.com/form', authenticity_token: "cf50faa3fe97702ca1ae") + # # form with custom authenticity token + # + def form_tag(url_for_options = {}, options = {}, &block) + html_options = html_options_for_form(url_for_options, options) + if block_given? + form_tag_with_body(html_options, capture(&block)) + else + form_tag_html(html_options) + end + end + + # Creates a dropdown selection box, or if the <tt>:multiple</tt> option is set to true, a multiple + # choice selection box. + # + # Helpers::FormOptions can be used to create common select boxes such as countries, time zones, or + # associated records. <tt>option_tags</tt> is a string containing the option tags for the select box. + # + # ==== Options + # * <tt>:multiple</tt> - If set to true, the selection will allow multiple choices. + # * <tt>:disabled</tt> - If set to true, the user will not be able to use this input. + # * <tt>:include_blank</tt> - If set to true, an empty option will be created. If set to a string, the string will be used as the option's content and the value will be empty. + # * <tt>:prompt</tt> - Create a prompt option with blank value and the text asking user to select something. + # * Any other key creates standard HTML attributes for the tag. + # + # ==== Examples + # select_tag "people", options_from_collection_for_select(@people, "id", "name") + # # <select id="people" name="people"><option value="1">David</option></select> + # + # select_tag "people", options_from_collection_for_select(@people, "id", "name", "1") + # # <select id="people" name="people"><option value="1" selected="selected">David</option></select> + # + # select_tag "people", raw("<option>David</option>") + # # => <select id="people" name="people"><option>David</option></select> + # + # select_tag "count", raw("<option>1</option><option>2</option><option>3</option><option>4</option>") + # # => <select id="count" name="count"><option>1</option><option>2</option> + # # <option>3</option><option>4</option></select> + # + # select_tag "colors", raw("<option>Red</option><option>Green</option><option>Blue</option>"), multiple: true + # # => <select id="colors" multiple="multiple" name="colors[]"><option>Red</option> + # # <option>Green</option><option>Blue</option></select> + # + # select_tag "locations", raw("<option>Home</option><option selected='selected'>Work</option><option>Out</option>") + # # => <select id="locations" name="locations"><option>Home</option><option selected='selected'>Work</option> + # # <option>Out</option></select> + # + # select_tag "access", raw("<option>Read</option><option>Write</option>"), multiple: true, class: 'form_input', id: 'unique_id' + # # => <select class="form_input" id="unique_id" multiple="multiple" name="access[]"><option>Read</option> + # # <option>Write</option></select> + # + # select_tag "people", options_from_collection_for_select(@people, "id", "name"), include_blank: true + # # => <select id="people" name="people"><option value="" label=" "></option><option value="1">David</option></select> + # + # select_tag "people", options_from_collection_for_select(@people, "id", "name"), include_blank: "All" + # # => <select id="people" name="people"><option value="">All</option><option value="1">David</option></select> + # + # select_tag "people", options_from_collection_for_select(@people, "id", "name"), prompt: "Select something" + # # => <select id="people" name="people"><option value="">Select something</option><option value="1">David</option></select> + # + # select_tag "destination", raw("<option>NYC</option><option>Paris</option><option>Rome</option>"), disabled: true + # # => <select disabled="disabled" id="destination" name="destination"><option>NYC</option> + # # <option>Paris</option><option>Rome</option></select> + # + # select_tag "credit_card", options_for_select([ "VISA", "MasterCard" ], "MasterCard") + # # => <select id="credit_card" name="credit_card"><option>VISA</option> + # # <option selected="selected">MasterCard</option></select> + def select_tag(name, option_tags = nil, options = {}) + option_tags ||= "" + html_name = (options[:multiple] == true && !name.to_s.ends_with?("[]")) ? "#{name}[]" : name + + if options.include?(:include_blank) + include_blank = options.delete(:include_blank) + options_for_blank_options_tag = { value: "" } + + if include_blank == true + include_blank = "" + options_for_blank_options_tag[:label] = " " + end + + if include_blank + option_tags = content_tag("option", include_blank, options_for_blank_options_tag).safe_concat(option_tags) + end + end + + if prompt = options.delete(:prompt) + option_tags = content_tag("option", prompt, value: "").safe_concat(option_tags) + end + + content_tag "select", option_tags, { "name" => html_name, "id" => sanitize_to_id(name) }.update(options.stringify_keys) + end + + # Creates a standard text field; use these text fields to input smaller chunks of text like a username + # or a search query. + # + # ==== Options + # * <tt>:disabled</tt> - If set to true, the user will not be able to use this input. + # * <tt>:size</tt> - The number of visible characters that will fit in the input. + # * <tt>:maxlength</tt> - The maximum number of characters that the browser will allow the user to enter. + # * <tt>:placeholder</tt> - The text contained in the field by default which is removed when the field receives focus. + # * Any other key creates standard HTML attributes for the tag. + # + # ==== Examples + # text_field_tag 'name' + # # => <input id="name" name="name" type="text" /> + # + # text_field_tag 'query', 'Enter your search query here' + # # => <input id="query" name="query" type="text" value="Enter your search query here" /> + # + # text_field_tag 'search', nil, placeholder: 'Enter search term...' + # # => <input id="search" name="search" placeholder="Enter search term..." type="text" /> + # + # text_field_tag 'request', nil, class: 'special_input' + # # => <input class="special_input" id="request" name="request" type="text" /> + # + # text_field_tag 'address', '', size: 75 + # # => <input id="address" name="address" size="75" type="text" value="" /> + # + # text_field_tag 'zip', nil, maxlength: 5 + # # => <input id="zip" maxlength="5" name="zip" type="text" /> + # + # text_field_tag 'payment_amount', '$0.00', disabled: true + # # => <input disabled="disabled" id="payment_amount" name="payment_amount" type="text" value="$0.00" /> + # + # text_field_tag 'ip', '0.0.0.0', maxlength: 15, size: 20, class: "ip-input" + # # => <input class="ip-input" id="ip" maxlength="15" name="ip" size="20" type="text" value="0.0.0.0" /> + def text_field_tag(name, value = nil, options = {}) + tag :input, { "type" => "text", "name" => name, "id" => sanitize_to_id(name), "value" => value }.update(options.stringify_keys) + end + + # Creates a label element. Accepts a block. + # + # ==== Options + # * Creates standard HTML attributes for the tag. + # + # ==== Examples + # label_tag 'name' + # # => <label for="name">Name</label> + # + # label_tag 'name', 'Your name' + # # => <label for="name">Your name</label> + # + # label_tag 'name', nil, class: 'small_label' + # # => <label for="name" class="small_label">Name</label> + def label_tag(name = nil, content_or_options = nil, options = nil, &block) + if block_given? && content_or_options.is_a?(Hash) + options = content_or_options = content_or_options.stringify_keys + else + options ||= {} + options = options.stringify_keys + end + options["for"] = sanitize_to_id(name) unless name.blank? || options.has_key?("for") + content_tag :label, content_or_options || name.to_s.humanize, options, &block + end + + # Creates a hidden form input field used to transmit data that would be lost due to HTTP's statelessness or + # data that should be hidden from the user. + # + # ==== Options + # * Creates standard HTML attributes for the tag. + # + # ==== Examples + # hidden_field_tag 'tags_list' + # # => <input id="tags_list" name="tags_list" type="hidden" /> + # + # hidden_field_tag 'token', 'VUBJKB23UIVI1UU1VOBVI@' + # # => <input id="token" name="token" type="hidden" value="VUBJKB23UIVI1UU1VOBVI@" /> + # + # hidden_field_tag 'collected_input', '', onchange: "alert('Input collected!')" + # # => <input id="collected_input" name="collected_input" onchange="alert('Input collected!')" + # # type="hidden" value="" /> + def hidden_field_tag(name, value = nil, options = {}) + text_field_tag(name, value, options.merge(type: :hidden)) + end + + # Creates a file upload field. If you are using file uploads then you will also need + # to set the multipart option for the form tag: + # + # <%= form_tag '/upload', multipart: true do %> + # <label for="file">File to Upload</label> <%= file_field_tag "file" %> + # <%= submit_tag %> + # <% end %> + # + # The specified URL will then be passed a File object containing the selected file, or if the field + # was left blank, a StringIO object. + # + # ==== Options + # * Creates standard HTML attributes for the tag. + # * <tt>:disabled</tt> - If set to true, the user will not be able to use this input. + # * <tt>:multiple</tt> - If set to true, *in most updated browsers* the user will be allowed to select multiple files. + # * <tt>:accept</tt> - If set to one or multiple mime-types, the user will be suggested a filter when choosing a file. You still need to set up model validations. + # + # ==== Examples + # file_field_tag 'attachment' + # # => <input id="attachment" name="attachment" type="file" /> + # + # file_field_tag 'avatar', class: 'profile_input' + # # => <input class="profile_input" id="avatar" name="avatar" type="file" /> + # + # file_field_tag 'picture', disabled: true + # # => <input disabled="disabled" id="picture" name="picture" type="file" /> + # + # file_field_tag 'resume', value: '~/resume.doc' + # # => <input id="resume" name="resume" type="file" value="~/resume.doc" /> + # + # file_field_tag 'user_pic', accept: 'image/png,image/gif,image/jpeg' + # # => <input accept="image/png,image/gif,image/jpeg" id="user_pic" name="user_pic" type="file" /> + # + # file_field_tag 'file', accept: 'text/html', class: 'upload', value: 'index.html' + # # => <input accept="text/html" class="upload" id="file" name="file" type="file" value="index.html" /> + def file_field_tag(name, options = {}) + text_field_tag(name, nil, convert_direct_upload_option_to_url(options.merge(type: :file))) + end + + # Creates a password field, a masked text field that will hide the users input behind a mask character. + # + # ==== Options + # * <tt>:disabled</tt> - If set to true, the user will not be able to use this input. + # * <tt>:size</tt> - The number of visible characters that will fit in the input. + # * <tt>:maxlength</tt> - The maximum number of characters that the browser will allow the user to enter. + # * Any other key creates standard HTML attributes for the tag. + # + # ==== Examples + # password_field_tag 'pass' + # # => <input id="pass" name="pass" type="password" /> + # + # password_field_tag 'secret', 'Your secret here' + # # => <input id="secret" name="secret" type="password" value="Your secret here" /> + # + # password_field_tag 'masked', nil, class: 'masked_input_field' + # # => <input class="masked_input_field" id="masked" name="masked" type="password" /> + # + # password_field_tag 'token', '', size: 15 + # # => <input id="token" name="token" size="15" type="password" value="" /> + # + # password_field_tag 'key', nil, maxlength: 16 + # # => <input id="key" maxlength="16" name="key" type="password" /> + # + # password_field_tag 'confirm_pass', nil, disabled: true + # # => <input disabled="disabled" id="confirm_pass" name="confirm_pass" type="password" /> + # + # password_field_tag 'pin', '1234', maxlength: 4, size: 6, class: "pin_input" + # # => <input class="pin_input" id="pin" maxlength="4" name="pin" size="6" type="password" value="1234" /> + def password_field_tag(name = "password", value = nil, options = {}) + text_field_tag(name, value, options.merge(type: :password)) + end + + # Creates a text input area; use a textarea for longer text inputs such as blog posts or descriptions. + # + # ==== Options + # * <tt>:size</tt> - A string specifying the dimensions (columns by rows) of the textarea (e.g., "25x10"). + # * <tt>:rows</tt> - Specify the number of rows in the textarea + # * <tt>:cols</tt> - Specify the number of columns in the textarea + # * <tt>:disabled</tt> - If set to true, the user will not be able to use this input. + # * <tt>:escape</tt> - By default, the contents of the text input are HTML escaped. + # If you need unescaped contents, set this to false. + # * Any other key creates standard HTML attributes for the tag. + # + # ==== Examples + # text_area_tag 'post' + # # => <textarea id="post" name="post"></textarea> + # + # text_area_tag 'bio', @user.bio + # # => <textarea id="bio" name="bio">This is my biography.</textarea> + # + # text_area_tag 'body', nil, rows: 10, cols: 25 + # # => <textarea cols="25" id="body" name="body" rows="10"></textarea> + # + # text_area_tag 'body', nil, size: "25x10" + # # => <textarea name="body" id="body" cols="25" rows="10"></textarea> + # + # text_area_tag 'description', "Description goes here.", disabled: true + # # => <textarea disabled="disabled" id="description" name="description">Description goes here.</textarea> + # + # text_area_tag 'comment', nil, class: 'comment_input' + # # => <textarea class="comment_input" id="comment" name="comment"></textarea> + def text_area_tag(name, content = nil, options = {}) + options = options.stringify_keys + + if size = options.delete("size") + options["cols"], options["rows"] = size.split("x") if size.respond_to?(:split) + end + + escape = options.delete("escape") { true } + content = ERB::Util.html_escape(content) if escape + + content_tag :textarea, content.to_s.html_safe, { "name" => name, "id" => sanitize_to_id(name) }.update(options) + end + + # Creates a check box form input tag. + # + # ==== Options + # * <tt>:disabled</tt> - If set to true, the user will not be able to use this input. + # * Any other key creates standard HTML options for the tag. + # + # ==== Examples + # check_box_tag 'accept' + # # => <input id="accept" name="accept" type="checkbox" value="1" /> + # + # check_box_tag 'rock', 'rock music' + # # => <input id="rock" name="rock" type="checkbox" value="rock music" /> + # + # check_box_tag 'receive_email', 'yes', true + # # => <input checked="checked" id="receive_email" name="receive_email" type="checkbox" value="yes" /> + # + # check_box_tag 'tos', 'yes', false, class: 'accept_tos' + # # => <input class="accept_tos" id="tos" name="tos" type="checkbox" value="yes" /> + # + # check_box_tag 'eula', 'accepted', false, disabled: true + # # => <input disabled="disabled" id="eula" name="eula" type="checkbox" value="accepted" /> + def check_box_tag(name, value = "1", checked = false, options = {}) + html_options = { "type" => "checkbox", "name" => name, "id" => sanitize_to_id(name), "value" => value }.update(options.stringify_keys) + html_options["checked"] = "checked" if checked + tag :input, html_options + end + + # Creates a radio button; use groups of radio buttons named the same to allow users to + # select from a group of options. + # + # ==== Options + # * <tt>:disabled</tt> - If set to true, the user will not be able to use this input. + # * Any other key creates standard HTML options for the tag. + # + # ==== Examples + # radio_button_tag 'favorite_color', 'maroon' + # # => <input id="favorite_color_maroon" name="favorite_color" type="radio" value="maroon" /> + # + # radio_button_tag 'receive_updates', 'no', true + # # => <input checked="checked" id="receive_updates_no" name="receive_updates" type="radio" value="no" /> + # + # radio_button_tag 'time_slot', "3:00 p.m.", false, disabled: true + # # => <input disabled="disabled" id="time_slot_3:00_p.m." name="time_slot" type="radio" value="3:00 p.m." /> + # + # radio_button_tag 'color', "green", true, class: "color_input" + # # => <input checked="checked" class="color_input" id="color_green" name="color" type="radio" value="green" /> + def radio_button_tag(name, value, checked = false, options = {}) + html_options = { "type" => "radio", "name" => name, "id" => "#{sanitize_to_id(name)}_#{sanitize_to_id(value)}", "value" => value }.update(options.stringify_keys) + html_options["checked"] = "checked" if checked + tag :input, html_options + end + + # Creates a submit button with the text <tt>value</tt> as the caption. + # + # ==== Options + # * <tt>:data</tt> - This option can be used to add custom data attributes. + # * <tt>:disabled</tt> - If true, the user will not be able to use this input. + # * Any other key creates standard HTML options for the tag. + # + # ==== Data attributes + # + # * <tt>confirm: 'question?'</tt> - If present the unobtrusive JavaScript + # drivers will provide a prompt with the question specified. If the user accepts, + # the form 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. To disable this feature for a single submit tag + # pass <tt>:data => { disable_with: false }</tt> Defaults to value attribute. + # + # ==== Examples + # submit_tag + # # => <input name="commit" data-disable-with="Save changes" type="submit" value="Save changes" /> + # + # submit_tag "Edit this article" + # # => <input name="commit" data-disable-with="Edit this article" type="submit" value="Edit this article" /> + # + # submit_tag "Save edits", disabled: true + # # => <input disabled="disabled" name="commit" data-disable-with="Save edits" type="submit" value="Save edits" /> + # + # submit_tag "Complete sale", data: { disable_with: "Submitting..." } + # # => <input name="commit" data-disable-with="Submitting..." type="submit" value="Complete sale" /> + # + # submit_tag nil, class: "form_submit" + # # => <input class="form_submit" name="commit" type="submit" /> + # + # submit_tag "Edit", class: "edit_button" + # # => <input class="edit_button" data-disable-with="Edit" name="commit" type="submit" value="Edit" /> + # + # submit_tag "Save", data: { confirm: "Are you sure?" } + # # => <input name='commit' type='submit' value='Save' data-disable-with="Save" data-confirm="Are you sure?" /> + # + def submit_tag(value = "Save changes", options = {}) + options = options.deep_stringify_keys + tag_options = { "type" => "submit", "name" => "commit", "value" => value }.update(options) + set_default_disable_with value, tag_options + tag :input, tag_options + end + + # Creates a button element that defines a <tt>submit</tt> button, + # <tt>reset</tt> button or a generic button which can be used in + # JavaScript, for example. You can use the button tag as a regular + # submit tag but it isn't supported in legacy browsers. However, + # the button tag does allow for richer labels such as images and emphasis, + # so this helper will also accept a block. By default, it will create + # a button tag with type <tt>submit</tt>, if type is not given. + # + # ==== Options + # * <tt>:data</tt> - This option can be used to add custom data attributes. + # * <tt>:disabled</tt> - If true, the user will not be able to + # use this input. + # * Any other key creates standard HTML options for the tag. + # + # ==== Data attributes + # + # * <tt>confirm: 'question?'</tt> - If present, the + # unobtrusive JavaScript drivers will provide a prompt with + # the question specified. If the user accepts, the form 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. + # + # ==== Examples + # button_tag + # # => <button name="button" type="submit">Button</button> + # + # button_tag 'Reset', type: 'reset' + # # => <button name="button" type="reset">Reset</button> + # + # button_tag 'Button', type: 'button' + # # => <button name="button" type="button">Button</button> + # + # button_tag 'Reset', type: 'reset', disabled: true + # # => <button name="button" type="reset" disabled="disabled">Reset</button> + # + # button_tag(type: 'button') do + # content_tag(:strong, 'Ask me!') + # end + # # => <button name="button" type="button"> + # # <strong>Ask me!</strong> + # # </button> + # + # button_tag "Save", data: { confirm: "Are you sure?" } + # # => <button name="button" type="submit" data-confirm="Are you sure?">Save</button> + # + # button_tag "Checkout", data: { disable_with: "Please wait..." } + # # => <button data-disable-with="Please wait..." name="button" type="submit">Checkout</button> + # + def button_tag(content_or_options = nil, options = nil, &block) + if content_or_options.is_a? Hash + options = content_or_options + else + options ||= {} + end + + options = { "name" => "button", "type" => "submit" }.merge!(options.stringify_keys) + + if block_given? + content_tag :button, options, &block + else + content_tag :button, content_or_options || "Button", options + end + end + + # Displays an image which when clicked will submit the form. + # + # <tt>source</tt> is passed to AssetTagHelper#path_to_image + # + # ==== Options + # * <tt>:data</tt> - This option can be used to add custom data attributes. + # * <tt>:disabled</tt> - If set to true, the user will not be able to use this input. + # * Any other key creates standard HTML options for the tag. + # + # ==== Data attributes + # + # * <tt>confirm: 'question?'</tt> - This will add a JavaScript confirm + # prompt with the question specified. If the user accepts, the form is + # processed normally, otherwise no action is taken. + # + # ==== Examples + # image_submit_tag("login.png") + # # => <input src="/assets/login.png" type="image" /> + # + # image_submit_tag("purchase.png", disabled: true) + # # => <input disabled="disabled" src="/assets/purchase.png" type="image" /> + # + # image_submit_tag("search.png", class: 'search_button', alt: 'Find') + # # => <input class="search_button" src="/assets/search.png" type="image" /> + # + # image_submit_tag("agree.png", disabled: true, class: "agree_disagree_button") + # # => <input class="agree_disagree_button" disabled="disabled" src="/assets/agree.png" type="image" /> + # + # image_submit_tag("save.png", data: { confirm: "Are you sure?" }) + # # => <input src="/assets/save.png" data-confirm="Are you sure?" type="image" /> + def image_submit_tag(source, options = {}) + options = options.stringify_keys + src = path_to_image(source, skip_pipeline: options.delete("skip_pipeline")) + tag :input, { "type" => "image", "src" => src }.update(options) + end + + # Creates a field set for grouping HTML form elements. + # + # <tt>legend</tt> will become the fieldset's title (optional as per W3C). + # <tt>options</tt> accept the same values as tag. + # + # ==== Examples + # <%= field_set_tag do %> + # <p><%= text_field_tag 'name' %></p> + # <% end %> + # # => <fieldset><p><input id="name" name="name" type="text" /></p></fieldset> + # + # <%= field_set_tag 'Your details' do %> + # <p><%= text_field_tag 'name' %></p> + # <% end %> + # # => <fieldset><legend>Your details</legend><p><input id="name" name="name" type="text" /></p></fieldset> + # + # <%= field_set_tag nil, class: 'format' do %> + # <p><%= text_field_tag 'name' %></p> + # <% end %> + # # => <fieldset class="format"><p><input id="name" name="name" type="text" /></p></fieldset> + def field_set_tag(legend = nil, options = nil, &block) + output = tag(:fieldset, options, true) + output.safe_concat(content_tag("legend", legend)) unless legend.blank? + output.concat(capture(&block)) if block_given? + output.safe_concat("</fieldset>") + end + + # Creates a text field of type "color". + # + # ==== Options + # * Accepts the same options as text_field_tag. + # + # ==== Examples + # color_field_tag 'name' + # # => <input id="name" name="name" type="color" /> + # + # color_field_tag 'color', '#DEF726' + # # => <input id="color" name="color" type="color" value="#DEF726" /> + # + # color_field_tag 'color', nil, class: 'special_input' + # # => <input class="special_input" id="color" name="color" type="color" /> + # + # color_field_tag 'color', '#DEF726', class: 'special_input', disabled: true + # # => <input disabled="disabled" class="special_input" id="color" name="color" type="color" value="#DEF726" /> + def color_field_tag(name, value = nil, options = {}) + text_field_tag(name, value, options.merge(type: :color)) + end + + # Creates a text field of type "search". + # + # ==== Options + # * Accepts the same options as text_field_tag. + # + # ==== Examples + # search_field_tag 'name' + # # => <input id="name" name="name" type="search" /> + # + # search_field_tag 'search', 'Enter your search query here' + # # => <input id="search" name="search" type="search" value="Enter your search query here" /> + # + # search_field_tag 'search', nil, class: 'special_input' + # # => <input class="special_input" id="search" name="search" type="search" /> + # + # search_field_tag 'search', 'Enter your search query here', class: 'special_input', disabled: true + # # => <input disabled="disabled" class="special_input" id="search" name="search" type="search" value="Enter your search query here" /> + def search_field_tag(name, value = nil, options = {}) + text_field_tag(name, value, options.merge(type: :search)) + end + + # Creates a text field of type "tel". + # + # ==== Options + # * Accepts the same options as text_field_tag. + # + # ==== Examples + # telephone_field_tag 'name' + # # => <input id="name" name="name" type="tel" /> + # + # telephone_field_tag 'tel', '0123456789' + # # => <input id="tel" name="tel" type="tel" value="0123456789" /> + # + # telephone_field_tag 'tel', nil, class: 'special_input' + # # => <input class="special_input" id="tel" name="tel" type="tel" /> + # + # telephone_field_tag 'tel', '0123456789', class: 'special_input', disabled: true + # # => <input disabled="disabled" class="special_input" id="tel" name="tel" type="tel" value="0123456789" /> + def telephone_field_tag(name, value = nil, options = {}) + text_field_tag(name, value, options.merge(type: :tel)) + end + alias phone_field_tag telephone_field_tag + + # Creates a text field of type "date". + # + # ==== Options + # * Accepts the same options as text_field_tag. + # + # ==== Examples + # date_field_tag 'name' + # # => <input id="name" name="name" type="date" /> + # + # date_field_tag 'date', '01/01/2014' + # # => <input id="date" name="date" type="date" value="01/01/2014" /> + # + # date_field_tag 'date', nil, class: 'special_input' + # # => <input class="special_input" id="date" name="date" type="date" /> + # + # date_field_tag 'date', '01/01/2014', class: 'special_input', disabled: true + # # => <input disabled="disabled" class="special_input" id="date" name="date" type="date" value="01/01/2014" /> + def date_field_tag(name, value = nil, options = {}) + text_field_tag(name, value, options.merge(type: :date)) + end + + # Creates a text field of type "time". + # + # === Options + # * <tt>:min</tt> - The minimum acceptable value. + # * <tt>:max</tt> - The maximum acceptable value. + # * <tt>:step</tt> - The acceptable value granularity. + # * Otherwise accepts the same options as text_field_tag. + def time_field_tag(name, value = nil, options = {}) + text_field_tag(name, value, options.merge(type: :time)) + end + + # Creates a text field of type "datetime-local". + # + # === Options + # * <tt>:min</tt> - The minimum acceptable value. + # * <tt>:max</tt> - The maximum acceptable value. + # * <tt>:step</tt> - The acceptable value granularity. + # * Otherwise accepts the same options as text_field_tag. + def datetime_field_tag(name, value = nil, options = {}) + text_field_tag(name, value, options.merge(type: "datetime-local")) + end + + alias datetime_local_field_tag datetime_field_tag + + # Creates a text field of type "month". + # + # === Options + # * <tt>:min</tt> - The minimum acceptable value. + # * <tt>:max</tt> - The maximum acceptable value. + # * <tt>:step</tt> - The acceptable value granularity. + # * Otherwise accepts the same options as text_field_tag. + def month_field_tag(name, value = nil, options = {}) + text_field_tag(name, value, options.merge(type: :month)) + end + + # Creates a text field of type "week". + # + # === Options + # * <tt>:min</tt> - The minimum acceptable value. + # * <tt>:max</tt> - The maximum acceptable value. + # * <tt>:step</tt> - The acceptable value granularity. + # * Otherwise accepts the same options as text_field_tag. + def week_field_tag(name, value = nil, options = {}) + text_field_tag(name, value, options.merge(type: :week)) + end + + # Creates a text field of type "url". + # + # ==== Options + # * Accepts the same options as text_field_tag. + # + # ==== Examples + # url_field_tag 'name' + # # => <input id="name" name="name" type="url" /> + # + # url_field_tag 'url', 'http://rubyonrails.org' + # # => <input id="url" name="url" type="url" value="http://rubyonrails.org" /> + # + # url_field_tag 'url', nil, class: 'special_input' + # # => <input class="special_input" id="url" name="url" type="url" /> + # + # url_field_tag 'url', 'http://rubyonrails.org', class: 'special_input', disabled: true + # # => <input disabled="disabled" class="special_input" id="url" name="url" type="url" value="http://rubyonrails.org" /> + def url_field_tag(name, value = nil, options = {}) + text_field_tag(name, value, options.merge(type: :url)) + end + + # Creates a text field of type "email". + # + # ==== Options + # * Accepts the same options as text_field_tag. + # + # ==== Examples + # email_field_tag 'name' + # # => <input id="name" name="name" type="email" /> + # + # email_field_tag 'email', 'email@example.com' + # # => <input id="email" name="email" type="email" value="email@example.com" /> + # + # email_field_tag 'email', nil, class: 'special_input' + # # => <input class="special_input" id="email" name="email" type="email" /> + # + # email_field_tag 'email', 'email@example.com', class: 'special_input', disabled: true + # # => <input disabled="disabled" class="special_input" id="email" name="email" type="email" value="email@example.com" /> + def email_field_tag(name, value = nil, options = {}) + text_field_tag(name, value, options.merge(type: :email)) + end + + # Creates a number field. + # + # ==== Options + # * <tt>:min</tt> - The minimum acceptable value. + # * <tt>:max</tt> - The maximum acceptable value. + # * <tt>:in</tt> - A range specifying the <tt>:min</tt> and + # <tt>:max</tt> values. + # * <tt>:within</tt> - Same as <tt>:in</tt>. + # * <tt>:step</tt> - The acceptable value granularity. + # * Otherwise accepts the same options as text_field_tag. + # + # ==== Examples + # number_field_tag 'quantity' + # # => <input id="quantity" name="quantity" type="number" /> + # + # number_field_tag 'quantity', '1' + # # => <input id="quantity" name="quantity" type="number" value="1" /> + # + # number_field_tag 'quantity', nil, class: 'special_input' + # # => <input class="special_input" id="quantity" name="quantity" type="number" /> + # + # number_field_tag 'quantity', nil, min: 1 + # # => <input id="quantity" name="quantity" min="1" type="number" /> + # + # number_field_tag 'quantity', nil, max: 9 + # # => <input id="quantity" name="quantity" max="9" type="number" /> + # + # number_field_tag 'quantity', nil, in: 1...10 + # # => <input id="quantity" name="quantity" min="1" max="9" type="number" /> + # + # number_field_tag 'quantity', nil, within: 1...10 + # # => <input id="quantity" name="quantity" min="1" max="9" type="number" /> + # + # number_field_tag 'quantity', nil, min: 1, max: 10 + # # => <input id="quantity" name="quantity" min="1" max="10" type="number" /> + # + # number_field_tag 'quantity', nil, min: 1, max: 10, step: 2 + # # => <input id="quantity" name="quantity" min="1" max="10" step="2" type="number" /> + # + # number_field_tag 'quantity', '1', class: 'special_input', disabled: true + # # => <input disabled="disabled" class="special_input" id="quantity" name="quantity" type="number" value="1" /> + def number_field_tag(name, value = nil, options = {}) + options = options.stringify_keys + options["type"] ||= "number" + if range = options.delete("in") || options.delete("within") + options.update("min" => range.min, "max" => range.max) + end + text_field_tag(name, value, options) + end + + # Creates a range form element. + # + # ==== Options + # * Accepts the same options as number_field_tag. + def range_field_tag(name, value = nil, options = {}) + number_field_tag(name, value, options.merge(type: :range)) + end + + # Creates the hidden UTF8 enforcer tag. Override this method in a helper + # to customize the tag. + def utf8_enforcer_tag + # Use raw HTML to ensure the value is written as an HTML entity; it + # needs to be the right character regardless of which encoding the + # browser infers. + '<input name="utf8" type="hidden" value="✓" />'.html_safe + end + + private + def html_options_for_form(url_for_options, options) + options.stringify_keys.tap do |html_options| + 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 if html_options.delete("remote") + + if html_options["data-remote"] && + !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 + end + end + + def extra_tags_for_form(html_options) + authenticity_token = html_options.delete("authenticity_token") + method = html_options.delete("method").to_s.downcase + + method_tag = \ + case method + when "get" + html_options["method"] = "get" + "" + when "post", "" + html_options["method"] = "post" + token_tag(authenticity_token, form_options: { + action: html_options["action"], + method: "post" + }) + else + html_options["method"] = "post" + method_tag(method) + token_tag(authenticity_token, form_options: { + action: html_options["action"], + method: method + }) + end + + if html_options.delete("enforce_utf8") { default_enforce_utf8 } + utf8_enforcer_tag + method_tag + else + method_tag + end + end + + def form_tag_html(html_options) + extra_tags = extra_tags_for_form(html_options) + tag(:form, html_options, true) + extra_tags + end + + def form_tag_with_body(html_options, content) + output = form_tag_html(html_options) + output << content + output.safe_concat("</form>") + end + + # see http://www.w3.org/TR/html4/types.html#type-name + def sanitize_to_id(name) + name.to_s.delete("]").tr("^-a-zA-Z0-9:.", "_") + end + + def set_default_disable_with(value, tag_options) + return unless ActionView::Base.automatically_disable_submit_tag + data = tag_options["data"] + + unless tag_options["data-disable-with"] == false || (data && data["disable_with"] == false) + disable_with_text = tag_options["data-disable-with"] + disable_with_text ||= data["disable_with"] if data + disable_with_text ||= value.to_s.clone + tag_options.deep_merge!("data" => { "disable_with" => disable_with_text }) + else + data.delete("disable_with") if data + end + + tag_options.delete("data-disable-with") + end + + def convert_direct_upload_option_to_url(options) + if options.delete(:direct_upload) && respond_to?(:rails_direct_uploads_url) + options["data-direct-upload-url"] = rails_direct_uploads_url + end + options + end + end + end +end diff --git a/actionview/lib/action_view/helpers/javascript_helper.rb b/actionview/lib/action_view/helpers/javascript_helper.rb new file mode 100644 index 0000000000..b680cb1bd3 --- /dev/null +++ b/actionview/lib/action_view/helpers/javascript_helper.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "action_view/helpers/tag_helper" + +module ActionView + module Helpers #:nodoc: + module JavaScriptHelper + JS_ESCAPE_MAP = { + '\\' => '\\\\', + "</" => '<\/', + "\r\n" => '\n', + "\n" => '\n', + "\r" => '\n', + '"' => '\\"', + "'" => "\\'" + } + + JS_ESCAPE_MAP[(+"\342\200\250").force_encoding(Encoding::UTF_8).encode!] = "
" + JS_ESCAPE_MAP[(+"\342\200\251").force_encoding(Encoding::UTF_8).encode!] = "
" + + # Escapes carriage returns and single and double quotes for JavaScript segments. + # + # Also available through the alias j(). This is particularly helpful in JavaScript + # responses, like: + # + # $('some_element').replaceWith('<%= j render 'some/element_template' %>'); + def escape_javascript(javascript) + javascript = javascript.to_s + if javascript.empty? + result = "" + else + result = javascript.gsub(/(\\|<\/|\r\n|\342\200\250|\342\200\251|[\n\r"'])/u) { |match| JS_ESCAPE_MAP[match] } + end + javascript.html_safe? ? result.html_safe : result + end + + alias_method :j, :escape_javascript + + # Returns a JavaScript tag with the +content+ inside. Example: + # javascript_tag "alert('All is good')" + # + # Returns: + # <script> + # //<![CDATA[ + # alert('All is good') + # //]]> + # </script> + # + # +html_options+ may be a hash of attributes for the <tt>\<script></tt> + # tag. + # + # javascript_tag "alert('All is good')", defer: 'defer' + # + # Returns: + # <script defer="defer"> + # //<![CDATA[ + # alert('All is good') + # //]]> + # </script> + # + # Instead of passing the content as an argument, you can also use a block + # in which case, you pass your +html_options+ as the first parameter. + # + # <%= javascript_tag defer: 'defer' do -%> + # alert('All is good') + # <% end -%> + # + # If you have a content security policy enabled then you can add an automatic + # nonce value by passing <tt>nonce: true</tt> as part of +html_options+. Example: + # + # <%= javascript_tag nonce: true do -%> + # alert('All is good') + # <% end -%> + def javascript_tag(content_or_options_with_block = nil, html_options = {}, &block) + content = + if block_given? + html_options = content_or_options_with_block if content_or_options_with_block.is_a?(Hash) + capture(&block) + else + content_or_options_with_block + end + + if html_options[:nonce] == true + html_options[:nonce] = content_security_policy_nonce + end + + content_tag("script", javascript_cdata_section(content), html_options) + end + + def javascript_cdata_section(content) #:nodoc: + "\n//#{cdata_section("\n#{content}\n//")}\n".html_safe + end + end + end +end diff --git a/actionview/lib/action_view/helpers/number_helper.rb b/actionview/lib/action_view/helpers/number_helper.rb new file mode 100644 index 0000000000..35206b7e48 --- /dev/null +++ b/actionview/lib/action_view/helpers/number_helper.rb @@ -0,0 +1,456 @@ +# frozen_string_literal: true + +require "active_support/core_ext/hash/keys" +require "active_support/core_ext/string/output_safety" +require "active_support/number_helper" + +module ActionView + # = Action View Number Helpers + module Helpers #:nodoc: + # Provides methods for converting numbers into formatted strings. + # Methods are provided for phone numbers, currency, percentage, + # precision, positional notation, file size and pretty printing. + # + # Most methods expect a +number+ argument, and will return it + # unchanged if can't be converted into a valid number. + module NumberHelper + # Raised when argument +number+ param given to the helpers is invalid and + # the option :raise is set to +true+. + class InvalidNumberError < StandardError + attr_accessor :number + def initialize(number) + @number = number + end + end + + # Formats a +number+ into a phone number (US by default e.g., (555) + # 123-9876). You can customize the format in the +options+ hash. + # + # ==== Options + # + # * <tt>:area_code</tt> - Adds parentheses around the area code. + # * <tt>:delimiter</tt> - Specifies the delimiter to use + # (defaults to "-"). + # * <tt>:extension</tt> - Specifies an extension to add to the + # end of the generated number. + # * <tt>:country_code</tt> - Sets the country code for the phone + # number. + # * <tt>:pattern</tt> - Specifies how the number is divided into three + # groups with the custom regexp to override the default format. + # * <tt>:raise</tt> - If true, raises +InvalidNumberError+ when + # the argument is invalid. + # + # ==== Examples + # + # number_to_phone(5551234) # => 555-1234 + # number_to_phone("5551234") # => 555-1234 + # number_to_phone(1235551234) # => 123-555-1234 + # number_to_phone(1235551234, area_code: true) # => (123) 555-1234 + # number_to_phone(1235551234, delimiter: " ") # => 123 555 1234 + # number_to_phone(1235551234, area_code: true, extension: 555) # => (123) 555-1234 x 555 + # number_to_phone(1235551234, country_code: 1) # => +1-123-555-1234 + # number_to_phone("123a456") # => 123a456 + # number_to_phone("1234a567", raise: true) # => InvalidNumberError + # + # number_to_phone(1235551234, country_code: 1, extension: 1343, delimiter: ".") + # # => +1.123.555.1234 x 1343 + # + # number_to_phone(75561234567, pattern: /(\d{1,4})(\d{4})(\d{4})$/, area_code: true) + # # => "(755) 6123-4567" + # number_to_phone(13312345678, pattern: /(\d{3})(\d{4})(\d{4})$/)) + # # => "133-1234-5678" + def number_to_phone(number, options = {}) + return unless number + options = options.symbolize_keys + + parse_float(number, true) if options.delete(:raise) + ERB::Util.html_escape(ActiveSupport::NumberHelper.number_to_phone(number, options)) + end + + # Formats a +number+ into a currency string (e.g., $13.65). You + # can customize the format in the +options+ hash. + # + # The currency unit and number formatting of the current locale will be used + # unless otherwise specified in the provided options. No currency conversion + # is performed. If the user is given a way to change their locale, they will + # also be able to change the relative value of the currency displayed with + # this helper. If your application will ever support multiple locales, you + # may want to specify a constant <tt>:locale</tt> option or consider + # using a library capable of currency conversion. + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:precision</tt> - Sets the level of precision (defaults + # to 2). + # * <tt>:unit</tt> - Sets the denomination of the currency + # (defaults to "$"). + # * <tt>:separator</tt> - Sets the separator between the units + # (defaults to "."). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ","). + # * <tt>:format</tt> - Sets the format for non-negative numbers + # (defaults to "%u%n"). Fields are <tt>%u</tt> for the + # currency, and <tt>%n</tt> for the number. + # * <tt>:negative_format</tt> - Sets the format for negative + # numbers (defaults to prepending a hyphen to the formatted + # number given by <tt>:format</tt>). Accepts the same fields + # than <tt>:format</tt>, except <tt>%n</tt> is here the + # absolute value of the number. + # * <tt>:raise</tt> - If true, raises +InvalidNumberError+ when + # the argument is invalid. + # * <tt>:strip_insignificant_zeros</tt> - If +true+ removes + # insignificant zeros after the decimal separator (defaults to + # +false+). + # + # ==== Examples + # + # number_to_currency(1234567890.50) # => $1,234,567,890.50 + # number_to_currency(1234567890.506) # => $1,234,567,890.51 + # number_to_currency(1234567890.506, precision: 3) # => $1,234,567,890.506 + # number_to_currency(1234567890.506, locale: :fr) # => 1 234 567 890,51 € + # number_to_currency("123a456") # => $123a456 + # + # number_to_currency("123a456", raise: true) # => InvalidNumberError + # + # number_to_currency(-1234567890.50, negative_format: "(%u%n)") + # # => ($1,234,567,890.50) + # number_to_currency(1234567890.50, unit: "R$", separator: ",", delimiter: "") + # # => R$1234567890,50 + # number_to_currency(1234567890.50, unit: "R$", separator: ",", delimiter: "", format: "%n %u") + # # => 1234567890,50 R$ + # number_to_currency(1234567890.50, strip_insignificant_zeros: true) + # # => "$1,234,567,890.5" + def number_to_currency(number, options = {}) + delegate_number_helper_method(:number_to_currency, number, options) + end + + # Formats a +number+ as a percentage string (e.g., 65%). You can + # customize the format in the +options+ hash. + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:precision</tt> - Sets the precision of the number + # (defaults to 3). + # * <tt>:significant</tt> - If +true+, precision will be the number + # of significant_digits. If +false+, the number of fractional + # digits (defaults to +false+). + # * <tt>:separator</tt> - Sets the separator between the + # fractional and integer digits (defaults to "."). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ""). + # * <tt>:strip_insignificant_zeros</tt> - If +true+ removes + # insignificant zeros after the decimal separator (defaults to + # +false+). + # * <tt>:format</tt> - Specifies the format of the percentage + # string The number field is <tt>%n</tt> (defaults to "%n%"). + # * <tt>:raise</tt> - If true, raises +InvalidNumberError+ when + # the argument is invalid. + # + # ==== Examples + # + # number_to_percentage(100) # => 100.000% + # number_to_percentage("98") # => 98.000% + # number_to_percentage(100, precision: 0) # => 100% + # number_to_percentage(1000, delimiter: '.', separator: ',') # => 1.000,000% + # number_to_percentage(302.24398923423, precision: 5) # => 302.24399% + # number_to_percentage(1000, locale: :fr) # => 1 000,000% + # number_to_percentage("98a") # => 98a% + # number_to_percentage(100, format: "%n %") # => 100.000 % + # + # number_to_percentage("98a", raise: true) # => InvalidNumberError + def number_to_percentage(number, options = {}) + delegate_number_helper_method(:number_to_percentage, number, options) + end + + # Formats a +number+ with grouped thousands using +delimiter+ + # (e.g., 12,324). You can customize the format in the +options+ + # hash. + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ","). + # * <tt>:separator</tt> - Sets the separator between the + # fractional and integer digits (defaults to "."). + # * <tt>:delimiter_pattern</tt> - Sets a custom regular expression used for + # deriving the placement of delimiter. Helpful when using currency formats + # like INR. + # * <tt>:raise</tt> - If true, raises +InvalidNumberError+ when + # the argument is invalid. + # + # ==== Examples + # + # number_with_delimiter(12345678) # => 12,345,678 + # number_with_delimiter("123456") # => 123,456 + # number_with_delimiter(12345678.05) # => 12,345,678.05 + # number_with_delimiter(12345678, delimiter: ".") # => 12.345.678 + # number_with_delimiter(12345678, delimiter: ",") # => 12,345,678 + # number_with_delimiter(12345678.05, separator: " ") # => 12,345,678 05 + # number_with_delimiter(12345678.05, locale: :fr) # => 12 345 678,05 + # number_with_delimiter("112a") # => 112a + # number_with_delimiter(98765432.98, delimiter: " ", separator: ",") + # # => 98 765 432,98 + # + # number_with_delimiter("123456.78", + # delimiter_pattern: /(\d+?)(?=(\d\d)+(\d)(?!\d))/) # => "1,23,456.78" + # + # number_with_delimiter("112a", raise: true) # => raise InvalidNumberError + def number_with_delimiter(number, options = {}) + delegate_number_helper_method(:number_to_delimited, number, options) + end + + # Formats a +number+ with the specified level of + # <tt>:precision</tt> (e.g., 112.32 has a precision of 2 if + # +:significant+ is +false+, and 5 if +:significant+ is +true+). + # You can customize the format in the +options+ hash. + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:precision</tt> - Sets the precision of the number + # (defaults to 3). + # * <tt>:significant</tt> - If +true+, precision will be the number + # of significant_digits. If +false+, the number of fractional + # digits (defaults to +false+). + # * <tt>:separator</tt> - Sets the separator between the + # fractional and integer digits (defaults to "."). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ""). + # * <tt>:strip_insignificant_zeros</tt> - If +true+ removes + # insignificant zeros after the decimal separator (defaults to + # +false+). + # * <tt>:raise</tt> - If true, raises +InvalidNumberError+ when + # the argument is invalid. + # + # ==== Examples + # + # number_with_precision(111.2345) # => 111.235 + # number_with_precision(111.2345, precision: 2) # => 111.23 + # number_with_precision(13, precision: 5) # => 13.00000 + # number_with_precision(389.32314, precision: 0) # => 389 + # number_with_precision(111.2345, significant: true) # => 111 + # number_with_precision(111.2345, precision: 1, significant: true) # => 100 + # number_with_precision(13, precision: 5, significant: true) # => 13.000 + # number_with_precision(111.234, locale: :fr) # => 111,234 + # + # number_with_precision(13, precision: 5, significant: true, strip_insignificant_zeros: true) + # # => 13 + # + # number_with_precision(389.32314, precision: 4, significant: true) # => 389.3 + # number_with_precision(1111.2345, precision: 2, separator: ',', delimiter: '.') + # # => 1.111,23 + def number_with_precision(number, options = {}) + delegate_number_helper_method(:number_to_rounded, number, options) + end + + # Formats the bytes in +number+ into a more understandable + # representation (e.g., giving it 1500 yields 1.5 KB). This + # method is useful for reporting file sizes to users. You can + # customize the format in the +options+ hash. + # + # See <tt>number_to_human</tt> if you want to pretty-print a + # generic number. + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:precision</tt> - Sets the precision of the number + # (defaults to 3). + # * <tt>:significant</tt> - If +true+, precision will be the number + # of significant_digits. If +false+, the number of fractional + # digits (defaults to +true+) + # * <tt>:separator</tt> - Sets the separator between the + # fractional and integer digits (defaults to "."). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ""). + # * <tt>:strip_insignificant_zeros</tt> - If +true+ removes + # insignificant zeros after the decimal separator (defaults to + # +true+) + # * <tt>:raise</tt> - If true, raises +InvalidNumberError+ when + # the argument is invalid. + # + # ==== Examples + # + # number_to_human_size(123) # => 123 Bytes + # number_to_human_size(1234) # => 1.21 KB + # number_to_human_size(12345) # => 12.1 KB + # number_to_human_size(1234567) # => 1.18 MB + # number_to_human_size(1234567890) # => 1.15 GB + # number_to_human_size(1234567890123) # => 1.12 TB + # number_to_human_size(1234567890123456) # => 1.1 PB + # number_to_human_size(1234567890123456789) # => 1.07 EB + # number_to_human_size(1234567, precision: 2) # => 1.2 MB + # number_to_human_size(483989, precision: 2) # => 470 KB + # number_to_human_size(1234567, precision: 2, separator: ',') # => 1,2 MB + # number_to_human_size(1234567890123, precision: 5) # => "1.1228 TB" + # number_to_human_size(524288000, precision: 5) # => "500 MB" + def number_to_human_size(number, options = {}) + delegate_number_helper_method(:number_to_human_size, number, options) + end + + # Pretty prints (formats and approximates) a number in a way it + # is more readable by humans (eg.: 1200000000 becomes "1.2 + # Billion"). This is useful for numbers that can get very large + # (and too hard to read). + # + # See <tt>number_to_human_size</tt> if you want to print a file + # size. + # + # You can also define your own unit-quantifier names if you want + # to use other decimal units (eg.: 1500 becomes "1.5 + # kilometers", 0.150 becomes "150 milliliters", etc). You may + # define a wide range of unit quantifiers, even fractional ones + # (centi, deci, mili, etc). + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:precision</tt> - Sets the precision of the number + # (defaults to 3). + # * <tt>:significant</tt> - If +true+, precision will be the number + # of significant_digits. If +false+, the number of fractional + # digits (defaults to +true+) + # * <tt>:separator</tt> - Sets the separator between the + # fractional and integer digits (defaults to "."). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ""). + # * <tt>:strip_insignificant_zeros</tt> - If +true+ removes + # insignificant zeros after the decimal separator (defaults to + # +true+) + # * <tt>:units</tt> - A Hash of unit quantifier names. Or a + # string containing an i18n scope where to find this hash. It + # might have the following keys: + # * *integers*: <tt>:unit</tt>, <tt>:ten</tt>, + # <tt>:hundred</tt>, <tt>:thousand</tt>, <tt>:million</tt>, + # <tt>:billion</tt>, <tt>:trillion</tt>, + # <tt>:quadrillion</tt> + # * *fractionals*: <tt>:deci</tt>, <tt>:centi</tt>, + # <tt>:mili</tt>, <tt>:micro</tt>, <tt>:nano</tt>, + # <tt>:pico</tt>, <tt>:femto</tt> + # * <tt>:format</tt> - Sets the format of the output string + # (defaults to "%n %u"). The field types are: + # * %u - The quantifier (ex.: 'thousand') + # * %n - The number + # * <tt>:raise</tt> - If true, raises +InvalidNumberError+ when + # the argument is invalid. + # + # ==== Examples + # + # number_to_human(123) # => "123" + # number_to_human(1234) # => "1.23 Thousand" + # number_to_human(12345) # => "12.3 Thousand" + # number_to_human(1234567) # => "1.23 Million" + # number_to_human(1234567890) # => "1.23 Billion" + # number_to_human(1234567890123) # => "1.23 Trillion" + # number_to_human(1234567890123456) # => "1.23 Quadrillion" + # number_to_human(1234567890123456789) # => "1230 Quadrillion" + # number_to_human(489939, precision: 2) # => "490 Thousand" + # number_to_human(489939, precision: 4) # => "489.9 Thousand" + # number_to_human(1234567, precision: 4, + # significant: false) # => "1.2346 Million" + # number_to_human(1234567, precision: 1, + # separator: ',', + # significant: false) # => "1,2 Million" + # + # number_to_human(500000000, precision: 5) # => "500 Million" + # number_to_human(12345012345, significant: false) # => "12.345 Billion" + # + # Non-significant zeros after the decimal separator are stripped + # out by default (set <tt>:strip_insignificant_zeros</tt> to + # +false+ to change that): + # + # number_to_human(12.00001) # => "12" + # number_to_human(12.00001, strip_insignificant_zeros: false) # => "12.0" + # + # ==== Custom Unit Quantifiers + # + # You can also use your own custom unit quantifiers: + # number_to_human(500000, units: {unit: "ml", thousand: "lt"}) # => "500 lt" + # + # If in your I18n locale you have: + # distance: + # centi: + # one: "centimeter" + # other: "centimeters" + # unit: + # one: "meter" + # other: "meters" + # thousand: + # one: "kilometer" + # other: "kilometers" + # billion: "gazillion-distance" + # + # Then you could do: + # + # number_to_human(543934, units: :distance) # => "544 kilometers" + # number_to_human(54393498, units: :distance) # => "54400 kilometers" + # number_to_human(54393498000, units: :distance) # => "54.4 gazillion-distance" + # number_to_human(343, units: :distance, precision: 1) # => "300 meters" + # number_to_human(1, units: :distance) # => "1 meter" + # number_to_human(0.34, units: :distance) # => "34 centimeters" + # + def number_to_human(number, options = {}) + delegate_number_helper_method(:number_to_human, number, options) + end + + private + + def delegate_number_helper_method(method, number, options) + return unless number + options = escape_unsafe_options(options.symbolize_keys) + + wrap_with_output_safety_handling(number, options.delete(:raise)) { + ActiveSupport::NumberHelper.public_send(method, number, options) + } + end + + def escape_unsafe_options(options) + options[:format] = ERB::Util.html_escape(options[:format]) if options[:format] + options[:negative_format] = ERB::Util.html_escape(options[:negative_format]) if options[:negative_format] + options[:separator] = ERB::Util.html_escape(options[:separator]) if options[:separator] + options[:delimiter] = ERB::Util.html_escape(options[:delimiter]) if options[:delimiter] + options[:unit] = ERB::Util.html_escape(options[:unit]) if options[:unit] && !options[:unit].html_safe? + options[:units] = escape_units(options[:units]) if options[:units] && Hash === options[:units] + options + end + + def escape_units(units) + Hash[units.map do |k, v| + [k, ERB::Util.html_escape(v)] + end] + end + + def wrap_with_output_safety_handling(number, raise_on_invalid, &block) + valid_float = valid_float?(number) + raise InvalidNumberError, number if raise_on_invalid && !valid_float + + formatted_number = yield + + if valid_float || number.html_safe? + formatted_number.html_safe + else + formatted_number + end + end + + def valid_float?(number) + !parse_float(number, false).nil? + end + + def parse_float(number, raise_error) + Float(number) + rescue ArgumentError, TypeError + raise InvalidNumberError, number if raise_error + end + end + end +end diff --git a/actionview/lib/action_view/helpers/output_safety_helper.rb b/actionview/lib/action_view/helpers/output_safety_helper.rb new file mode 100644 index 0000000000..279cde5e76 --- /dev/null +++ b/actionview/lib/action_view/helpers/output_safety_helper.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/output_safety" + +module ActionView #:nodoc: + # = Action View Raw Output Helper + module Helpers #:nodoc: + module OutputSafetyHelper + # This method outputs without escaping a string. Since escaping tags is + # now default, this can be used when you don't want Rails to automatically + # escape tags. This is not recommended if the data is coming from the user's + # input. + # + # For example: + # + # raw @user.name + # # => 'Jimmy <alert>Tables</alert>' + def raw(stringish) + stringish.to_s.html_safe + end + + # This method returns an HTML safe string similar to what <tt>Array#join</tt> + # would return. The array is flattened, and all items, including + # the supplied separator, are HTML escaped unless they are HTML + # safe, and the returned string is marked as HTML safe. + # + # safe_join([raw("<p>foo</p>"), "<p>bar</p>"], "<br />") + # # => "<p>foo</p><br /><p>bar</p>" + # + # safe_join([raw("<p>foo</p>"), raw("<p>bar</p>")], raw("<br />")) + # # => "<p>foo</p><br /><p>bar</p>" + # + def safe_join(array, sep = $,) + sep = ERB::Util.unwrapped_html_escape(sep) + + array.flatten.map! { |i| ERB::Util.unwrapped_html_escape(i) }.join(sep).html_safe + end + + # Converts the array to a comma-separated sentence where the last element is + # joined by the connector word. This is the html_safe-aware version of + # ActiveSupport's {Array#to_sentence}[http://api.rubyonrails.org/classes/Array.html#method-i-to_sentence]. + # + def to_sentence(array, options = {}) + options.assert_valid_keys(:words_connector, :two_words_connector, :last_word_connector, :locale) + + default_connectors = { + words_connector: ", ", + two_words_connector: " and ", + last_word_connector: ", and " + } + if defined?(I18n) + i18n_connectors = I18n.translate(:'support.array', locale: options[:locale], default: {}) + default_connectors.merge!(i18n_connectors) + end + options = default_connectors.merge!(options) + + case array.length + when 0 + "".html_safe + when 1 + ERB::Util.html_escape(array[0]) + when 2 + safe_join([array[0], array[1]], options[:two_words_connector]) + else + safe_join([safe_join(array[0...-1], options[:words_connector]), options[:last_word_connector], array[-1]], nil) + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/rendering_helper.rb b/actionview/lib/action_view/helpers/rendering_helper.rb new file mode 100644 index 0000000000..1e12aa2736 --- /dev/null +++ b/actionview/lib/action_view/helpers/rendering_helper.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module ActionView + module Helpers #:nodoc: + # = Action View Rendering + # + # Implements methods that allow rendering from a view context. + # In order to use this module, all you need is to implement + # view_renderer that returns an ActionView::Renderer object. + module RenderingHelper + # Returns the result of a render that's dictated by the options hash. The primary options are: + # + # * <tt>:partial</tt> - See <tt>ActionView::PartialRenderer</tt>. + # * <tt>:file</tt> - Renders an explicit template file (this used to be the old default), add :locals to pass in those. + # * <tt>:inline</tt> - Renders an inline template similar to how it's done in the controller. + # * <tt>:plain</tt> - Renders the text passed in out. Setting the content + # type as <tt>text/plain</tt>. + # * <tt>:html</tt> - Renders the HTML safe string passed in out, otherwise + # performs HTML escape on the string first. Setting the content type as + # <tt>text/html</tt>. + # * <tt>:body</tt> - Renders the text passed in, and inherits the content + # type of <tt>text/plain</tt> from <tt>ActionDispatch::Response</tt> + # object. + # + # If no options hash is passed or :update specified, the default is to render a partial and use the second parameter + # as the locals hash. + def render(options = {}, locals = {}, &block) + case options + when Hash + if block_given? + view_renderer.render_partial(self, options.merge(partial: options[:layout]), &block) + else + view_renderer.render(self, options) + end + else + view_renderer.render_partial(self, partial: options, locals: locals, &block) + end + end + + # Overwrites _layout_for in the context object so it supports the case a block is + # passed to a partial. Returns the contents that are yielded to a layout, given a + # name or a block. + # + # You can think of a layout as a method that is called with a block. If the user calls + # <tt>yield :some_name</tt>, the block, by default, returns <tt>content_for(:some_name)</tt>. + # If the user calls simply +yield+, the default block returns <tt>content_for(:layout)</tt>. + # + # The user can override this default by passing a block to the layout: + # + # # The template + # <%= render layout: "my_layout" do %> + # Content + # <% end %> + # + # # The layout + # <html> + # <%= yield %> + # </html> + # + # In this case, instead of the default block, which would return <tt>content_for(:layout)</tt>, + # this method returns the block that was passed in to <tt>render :layout</tt>, and the response + # would be + # + # <html> + # Content + # </html> + # + # Finally, the block can take block arguments, which can be passed in by +yield+: + # + # # The template + # <%= render layout: "my_layout" do |customer| %> + # Hello <%= customer.name %> + # <% end %> + # + # # The layout + # <html> + # <%= yield Struct.new(:name).new("David") %> + # </html> + # + # In this case, the layout would receive the block passed into <tt>render :layout</tt>, + # and the struct specified would be passed into the block as an argument. The result + # would be + # + # <html> + # Hello David + # </html> + # + def _layout_for(*args, &block) + name = args.first + + if block && !name.is_a?(Symbol) + capture(*args, &block) + else + super + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/sanitize_helper.rb b/actionview/lib/action_view/helpers/sanitize_helper.rb new file mode 100644 index 0000000000..f4fa133f55 --- /dev/null +++ b/actionview/lib/action_view/helpers/sanitize_helper.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/try" +require "rails-html-sanitizer" + +module ActionView + # = Action View Sanitize Helpers + module Helpers #:nodoc: + # The SanitizeHelper module provides a set of methods for scrubbing text of undesired HTML elements. + # These helper methods extend Action View making them callable within your template files. + module SanitizeHelper + extend ActiveSupport::Concern + # Sanitizes HTML input, stripping all but known-safe tags and attributes. + # + # It also strips href/src attributes with unsafe protocols like + # <tt>javascript:</tt>, while also protecting against attempts to use Unicode, + # ASCII, and hex character references to work around these protocol filters. + # All special characters will be escaped. + # + # The default sanitizer is Rails::Html::WhiteListSanitizer. See {Rails HTML + # Sanitizers}[https://github.com/rails/rails-html-sanitizer] for more information. + # + # Custom sanitization rules can also be provided. + # + # Please note that sanitizing user-provided text does not guarantee that the + # resulting markup is valid or even well-formed. + # + # ==== Options + # + # * <tt>:tags</tt> - An array of allowed tags. + # * <tt>:attributes</tt> - An array of allowed attributes. + # * <tt>:scrubber</tt> - A {Rails::Html scrubber}[https://github.com/rails/rails-html-sanitizer] + # or {Loofah::Scrubber}[https://github.com/flavorjones/loofah] object that + # defines custom sanitization rules. A custom scrubber takes precedence over + # custom tags and attributes. + # + # ==== Examples + # + # Normal use: + # + # <%= sanitize @comment.body %> + # + # Providing custom lists of permitted tags and attributes: + # + # <%= sanitize @comment.body, tags: %w(strong em a), attributes: %w(href) %> + # + # Providing a custom Rails::Html scrubber: + # + # class CommentScrubber < Rails::Html::PermitScrubber + # def initialize + # super + # self.tags = %w( form script comment blockquote ) + # self.attributes = %w( style ) + # end + # + # def skip_node?(node) + # node.text? + # end + # end + # + # <%= sanitize @comment.body, scrubber: CommentScrubber.new %> + # + # See {Rails HTML Sanitizer}[https://github.com/rails/rails-html-sanitizer] for + # documentation about Rails::Html scrubbers. + # + # Providing a custom Loofah::Scrubber: + # + # scrubber = Loofah::Scrubber.new do |node| + # node.remove if node.name == 'script' + # end + # + # <%= sanitize @comment.body, scrubber: scrubber %> + # + # See {Loofah's documentation}[https://github.com/flavorjones/loofah] for more + # information about defining custom Loofah::Scrubber objects. + # + # To set the default allowed tags or attributes across your application: + # + # # In config/application.rb + # config.action_view.sanitized_allowed_tags = ['strong', 'em', 'a'] + # config.action_view.sanitized_allowed_attributes = ['href', 'title'] + def sanitize(html, options = {}) + self.class.white_list_sanitizer.sanitize(html, options).try(:html_safe) + end + + # Sanitizes a block of CSS code. Used by +sanitize+ when it comes across a style attribute. + def sanitize_css(style) + self.class.white_list_sanitizer.sanitize_css(style) + end + + # Strips all HTML tags from +html+, including comments and special characters. + # + # strip_tags("Strip <i>these</i> tags!") + # # => Strip these tags! + # + # strip_tags("<b>Bold</b> no more! <a href='more.html'>See more here</a>...") + # # => Bold no more! See more here... + # + # strip_tags("<div id='top-bar'>Welcome to my website!</div>") + # # => Welcome to my website! + # + # strip_tags("> A quote from Smith & Wesson") + # # => > A quote from Smith & Wesson + def strip_tags(html) + self.class.full_sanitizer.sanitize(html) + end + + # Strips all link tags from +html+ leaving just the link text. + # + # strip_links('<a href="http://www.rubyonrails.org">Ruby on Rails</a>') + # # => Ruby on Rails + # + # strip_links('Please e-mail me at <a href="mailto:me@email.com">me@email.com</a>.') + # # => Please e-mail me at me@email.com. + # + # strip_links('Blog: <a href="http://www.myblog.com/" class="nav" target=\"_blank\">Visit</a>.') + # # => Blog: Visit. + # + # strip_links('<<a href="https://example.org">malformed & link</a>') + # # => <malformed & link + def strip_links(html) + self.class.link_sanitizer.sanitize(html) + end + + module ClassMethods #:nodoc: + attr_writer :full_sanitizer, :link_sanitizer, :white_list_sanitizer + + # Vendors the full, link and white list sanitizers. + # Provided strictly for compatibility and can be removed in Rails 6. + def sanitizer_vendor + Rails::Html::Sanitizer + end + + def sanitized_allowed_tags + sanitizer_vendor.white_list_sanitizer.allowed_tags + end + + def sanitized_allowed_attributes + sanitizer_vendor.white_list_sanitizer.allowed_attributes + end + + # Gets the Rails::Html::FullSanitizer instance used by +strip_tags+. Replace with + # any object that responds to +sanitize+. + # + # class Application < Rails::Application + # config.action_view.full_sanitizer = MySpecialSanitizer.new + # end + # + def full_sanitizer + @full_sanitizer ||= sanitizer_vendor.full_sanitizer.new + end + + # Gets the Rails::Html::LinkSanitizer instance used by +strip_links+. + # Replace with any object that responds to +sanitize+. + # + # class Application < Rails::Application + # config.action_view.link_sanitizer = MySpecialSanitizer.new + # end + # + def link_sanitizer + @link_sanitizer ||= sanitizer_vendor.link_sanitizer.new + end + + # Gets the Rails::Html::WhiteListSanitizer instance used by sanitize and +sanitize_css+. + # Replace with any object that responds to +sanitize+. + # + # class Application < Rails::Application + # config.action_view.white_list_sanitizer = MySpecialSanitizer.new + # end + # + def white_list_sanitizer + @white_list_sanitizer ||= sanitizer_vendor.white_list_sanitizer.new + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tag_helper.rb b/actionview/lib/action_view/helpers/tag_helper.rb new file mode 100644 index 0000000000..3979721d34 --- /dev/null +++ b/actionview/lib/action_view/helpers/tag_helper.rb @@ -0,0 +1,314 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/output_safety" +require "set" + +module ActionView + # = Action View Tag Helpers + module Helpers #:nodoc: + # Provides methods to generate HTML tags programmatically both as a modern + # HTML5 compliant builder style and legacy XHTML compliant tags. + module TagHelper + extend ActiveSupport::Concern + include CaptureHelper + include OutputSafetyHelper + + BOOLEAN_ATTRIBUTES = %w(allowfullscreen async autofocus autoplay checked + compact controls declare default defaultchecked + defaultmuted defaultselected defer disabled + enabled formnovalidate hidden indeterminate inert + ismap itemscope loop multiple muted nohref + noresize noshade novalidate nowrap open + pauseonexit readonly required reversed scoped + seamless selected sortable truespeed typemustmatch + visible).to_set + + BOOLEAN_ATTRIBUTES.merge(BOOLEAN_ATTRIBUTES.map(&:to_sym)) + + TAG_PREFIXES = ["aria", "data", :aria, :data].to_set + + PRE_CONTENT_STRINGS = Hash.new { "" } + PRE_CONTENT_STRINGS[:textarea] = "\n" + PRE_CONTENT_STRINGS["textarea"] = "\n" + + class TagBuilder #:nodoc: + include CaptureHelper + include OutputSafetyHelper + + VOID_ELEMENTS = %i(area base br col embed hr img input keygen link meta param source track wbr).to_set + + def initialize(view_context) + @view_context = view_context + end + + def tag_string(name, content = nil, escape_attributes: true, **options, &block) + content = @view_context.capture(self, &block) if block_given? + if VOID_ELEMENTS.include?(name) && content.nil? + "<#{name.to_s.dasherize}#{tag_options(options, escape_attributes)}>".html_safe + else + content_tag_string(name.to_s.dasherize, content || "", options, escape_attributes) + end + end + + def content_tag_string(name, content, options, escape = true) + tag_options = tag_options(options, escape) if options + content = ERB::Util.unwrapped_html_escape(content) if escape + "<#{name}#{tag_options}>#{PRE_CONTENT_STRINGS[name]}#{content}</#{name}>".html_safe + end + + def tag_options(options, escape = true) + return if options.blank? + output = +"" + sep = " " + options.each_pair do |key, value| + if TAG_PREFIXES.include?(key) && value.is_a?(Hash) + value.each_pair do |k, v| + next if v.nil? + output << sep + output << prefix_tag_option(key, k, v, escape) + end + elsif BOOLEAN_ATTRIBUTES.include?(key) + if value + output << sep + output << boolean_tag_option(key) + end + elsif !value.nil? + output << sep + output << tag_option(key, value, escape) + end + end + output unless output.empty? + end + + def boolean_tag_option(key) + %(#{key}="#{key}") + end + + def tag_option(key, value, escape) + if value.is_a?(Array) + value = escape ? safe_join(value, " ") : value.join(" ") + else + value = escape ? ERB::Util.unwrapped_html_escape(value) : value.to_s.dup + end + value.gsub!('"', """) + %(#{key}="#{value}") + end + + private + def prefix_tag_option(prefix, key, value, escape) + key = "#{prefix}-#{key.to_s.dasherize}" + unless value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(BigDecimal) + value = value.to_json + end + tag_option(key, value, escape) + end + + def respond_to_missing?(*args) + true + end + + def method_missing(called, *args, &block) + tag_string(called, *args, &block) + end + end + + # Returns an HTML tag. + # + # === Building HTML tags + # + # Builds HTML5 compliant tags with a tag proxy. Every tag can be built with: + # + # tag.<tag name>(optional content, options) + # + # where tag name can be e.g. br, div, section, article, or any tag really. + # + # ==== Passing content + # + # Tags can pass content to embed within it: + # + # tag.h1 'All titles fit to print' # => <h1>All titles fit to print</h1> + # + # tag.div tag.p('Hello world!') # => <div><p>Hello world!</p></div> + # + # Content can also be captured with a block, which is useful in templates: + # + # <%= tag.p do %> + # The next great American novel starts here. + # <% end %> + # # => <p>The next great American novel starts here.</p> + # + # ==== Options + # + # Use symbol keyed options to add attributes to the generated tag. + # + # tag.section class: %w( kitties puppies ) + # # => <section class="kitties puppies"></section> + # + # tag.section id: dom_id(@post) + # # => <section id="<generated dom id>"></section> + # + # Pass +true+ for any attributes that can render with no values, like +disabled+ and +readonly+. + # + # tag.input type: 'text', disabled: true + # # => <input type="text" disabled="disabled"> + # + # HTML5 <tt>data-*</tt> attributes can be set with a single +data+ key + # pointing to a hash of sub-attributes. + # + # To play nicely with JavaScript conventions, sub-attributes are dasherized. + # + # tag.article data: { user_id: 123 } + # # => <article data-user-id="123"></article> + # + # Thus <tt>data-user-id</tt> can be accessed as <tt>dataset.userId</tt>. + # + # Data attribute values are encoded to JSON, with the exception of strings, symbols and + # BigDecimals. + # This may come in handy when using jQuery's HTML5-aware <tt>.data()</tt> + # from 1.4.3. + # + # tag.div data: { city_state: %w( Chicago IL ) } + # # => <div data-city-state="["Chicago","IL"]"></div> + # + # The generated attributes are escaped by default. This can be disabled using + # +escape_attributes+. + # + # tag.img src: 'open & shut.png' + # # => <img src="open & shut.png"> + # + # tag.img src: 'open & shut.png', escape_attributes: false + # # => <img src="open & shut.png"> + # + # The tag builder respects + # {HTML5 void elements}[https://www.w3.org/TR/html5/syntax.html#void-elements] + # if no content is passed, and omits closing tags for those elements. + # + # # A standard element: + # tag.div # => <div></div> + # + # # A void element: + # tag.br # => <br> + # + # === Legacy syntax + # + # The following format is for legacy syntax support. It will be deprecated in future versions of Rails. + # + # tag(name, options = nil, open = false, escape = true) + # + # It returns an empty HTML tag of type +name+ which by default is XHTML + # compliant. Set +open+ to true to create an open tag compatible + # with HTML 4.0 and below. Add HTML attributes by passing an attributes + # hash to +options+. Set +escape+ to false to disable attribute value + # escaping. + # + # ==== Options + # + # You can use symbols or strings for the attribute names. + # + # Use +true+ with boolean attributes that can render with no value, like + # +disabled+ and +readonly+. + # + # HTML5 <tt>data-*</tt> attributes can be set with a single +data+ key + # pointing to a hash of sub-attributes. + # + # ==== Examples + # + # tag("br") + # # => <br /> + # + # tag("br", nil, true) + # # => <br> + # + # tag("input", type: 'text', disabled: true) + # # => <input type="text" disabled="disabled" /> + # + # tag("input", type: 'text', class: ["strong", "highlight"]) + # # => <input class="strong highlight" type="text" /> + # + # tag("img", src: "open & shut.png") + # # => <img src="open & shut.png" /> + # + # tag("img", { src: "open & shut.png" }, false, false) + # # => <img src="open & shut.png" /> + # + # tag("div", data: { name: 'Stephen', city_state: %w(Chicago IL) }) + # # => <div data-name="Stephen" data-city-state="["Chicago","IL"]" /> + def tag(name = nil, options = nil, open = false, escape = true) + if name.nil? + tag_builder + else + "<#{name}#{tag_builder.tag_options(options, escape) if options}#{open ? ">" : " />"}".html_safe + end + end + + # Returns an HTML block tag of type +name+ surrounding the +content+. Add + # HTML attributes by passing an attributes hash to +options+. + # Instead of passing the content as an argument, you can also use a block + # in which case, you pass your +options+ as the second parameter. + # Set escape to false to disable attribute value escaping. + # Note: this is legacy syntax, see +tag+ method description for details. + # + # ==== Options + # The +options+ hash can be used with attributes with no value like (<tt>disabled</tt> and + # <tt>readonly</tt>), which you can give a value of true in the +options+ hash. You can use + # symbols or strings for the attribute names. + # + # ==== Examples + # content_tag(:p, "Hello world!") + # # => <p>Hello world!</p> + # content_tag(:div, content_tag(:p, "Hello world!"), class: "strong") + # # => <div class="strong"><p>Hello world!</p></div> + # content_tag(:div, "Hello world!", class: ["strong", "highlight"]) + # # => <div class="strong highlight">Hello world!</div> + # content_tag("select", options, multiple: true) + # # => <select multiple="multiple">...options...</select> + # + # <%= content_tag :div, class: "strong" do -%> + # Hello world! + # <% end -%> + # # => <div class="strong">Hello world!</div> + def content_tag(name, content_or_options_with_block = nil, options = nil, escape = true, &block) + if block_given? + options = content_or_options_with_block if content_or_options_with_block.is_a?(Hash) + tag_builder.content_tag_string(name, capture(&block), options, escape) + else + tag_builder.content_tag_string(name, content_or_options_with_block, options, escape) + end + end + + # Returns a CDATA section with the given +content+. CDATA sections + # are used to escape blocks of text containing characters which would + # otherwise be recognized as markup. CDATA sections begin with the string + # <tt><![CDATA[</tt> and end with (and may not contain) the string <tt>]]></tt>. + # + # cdata_section("<hello world>") + # # => <![CDATA[<hello world>]]> + # + # cdata_section(File.read("hello_world.txt")) + # # => <![CDATA[<hello from a text file]]> + # + # cdata_section("hello]]>world") + # # => <![CDATA[hello]]]]><![CDATA[>world]]> + def cdata_section(content) + splitted = content.to_s.gsub(/\]\]\>/, "]]]]><![CDATA[>") + "<![CDATA[#{splitted}]]>".html_safe + end + + # Returns an escaped version of +html+ without affecting existing escaped entities. + # + # escape_once("1 < 2 & 3") + # # => "1 < 2 & 3" + # + # escape_once("<< Accept & Checkout") + # # => "<< Accept & Checkout" + def escape_once(html) + ERB::Util.html_escape_once(html) + end + + private + def tag_builder + @tag_builder ||= TagBuilder.new(self) + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags.rb b/actionview/lib/action_view/helpers/tags.rb new file mode 100644 index 0000000000..566668b958 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ActionView + module Helpers #:nodoc: + module Tags #:nodoc: + extend ActiveSupport::Autoload + + eager_autoload do + autoload :Base + autoload :Translator + autoload :CheckBox + autoload :CollectionCheckBoxes + autoload :CollectionRadioButtons + autoload :CollectionSelect + autoload :ColorField + autoload :DateField + autoload :DateSelect + autoload :DatetimeField + autoload :DatetimeLocalField + autoload :DatetimeSelect + autoload :EmailField + autoload :FileField + autoload :GroupedCollectionSelect + autoload :HiddenField + autoload :Label + autoload :MonthField + autoload :NumberField + autoload :PasswordField + autoload :RadioButton + autoload :RangeField + autoload :SearchField + autoload :Select + autoload :TelField + autoload :TextArea + autoload :TextField + autoload :TimeField + autoload :TimeSelect + autoload :TimeZoneSelect + autoload :UrlField + autoload :WeekField + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/base.rb b/actionview/lib/action_view/helpers/tags/base.rb new file mode 100644 index 0000000000..eef527d36f --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/base.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class Base # :nodoc: + include Helpers::ActiveModelInstanceTag, Helpers::TagHelper, Helpers::FormTagHelper + include FormOptionsHelper + + attr_reader :object + + def initialize(object_name, method_name, template_object, options = {}) + @object_name, @method_name = object_name.to_s.dup, method_name.to_s.dup + @template_object = template_object + + @object_name.sub!(/\[\]$/, "") || @object_name.sub!(/\[\]\]$/, "]") + @object = retrieve_object(options.delete(:object)) + @skip_default_ids = options.delete(:skip_default_ids) + @allow_method_names_outside_object = options.delete(:allow_method_names_outside_object) + @options = options + + if Regexp.last_match + @generate_indexed_names = true + @auto_index = retrieve_autoindex(Regexp.last_match.pre_match) + else + @generate_indexed_names = false + @auto_index = nil + end + end + + # This is what child classes implement. + def render + raise NotImplementedError, "Subclasses must implement a render method" + end + + private + + def value + if @allow_method_names_outside_object + object.public_send @method_name if object && object.respond_to?(@method_name) + else + object.public_send @method_name if object + end + end + + def value_before_type_cast + unless object.nil? + method_before_type_cast = @method_name + "_before_type_cast" + + if value_came_from_user? && object.respond_to?(method_before_type_cast) + object.public_send(method_before_type_cast) + else + value + end + end + end + + def value_came_from_user? + method_name = "#{@method_name}_came_from_user?" + !object.respond_to?(method_name) || object.public_send(method_name) + end + + def retrieve_object(object) + if object + object + elsif @template_object.instance_variable_defined?("@#{@object_name}") + @template_object.instance_variable_get("@#{@object_name}") + end + rescue NameError + # As @object_name may contain the nested syntax (item[subobject]) we need to fallback to nil. + nil + end + + def retrieve_autoindex(pre_match) + object = self.object || @template_object.instance_variable_get("@#{pre_match}") + if object && object.respond_to?(:to_param) + object.to_param + else + raise ArgumentError, "object[] naming but object param and @object var don't exist or don't respond to to_param: #{object.inspect}" + end + end + + def add_default_name_and_id_for_value(tag_value, options) + if tag_value.nil? + add_default_name_and_id(options) + else + specified_id = options["id"] + add_default_name_and_id(options) + + if specified_id.blank? && options["id"].present? + options["id"] += "_#{sanitized_value(tag_value)}" + end + end + end + + def add_default_name_and_id(options) + index = name_and_id_index(options) + options["name"] = options.fetch("name") { tag_name(options["multiple"], index) } + + if generate_ids? + options["id"] = options.fetch("id") { tag_id(index) } + if namespace = options.delete("namespace") + options["id"] = options["id"] ? "#{namespace}_#{options['id']}" : namespace + end + end + end + + def tag_name(multiple = false, index = nil) + # a little duplication to construct less strings + case + when @object_name.empty? + "#{sanitized_method_name}#{multiple ? "[]" : ""}" + when index + "#{@object_name}[#{index}][#{sanitized_method_name}]#{multiple ? "[]" : ""}" + else + "#{@object_name}[#{sanitized_method_name}]#{multiple ? "[]" : ""}" + end + end + + def tag_id(index = nil) + # a little duplication to construct less strings + case + when @object_name.empty? + sanitized_method_name.dup + when index + "#{sanitized_object_name}_#{index}_#{sanitized_method_name}" + else + "#{sanitized_object_name}_#{sanitized_method_name}" + end + end + + def sanitized_object_name + @sanitized_object_name ||= @object_name.gsub(/\]\[|[^-a-zA-Z0-9:.]/, "_").sub(/_$/, "") + end + + def sanitized_method_name + @sanitized_method_name ||= @method_name.sub(/\?$/, "") + end + + def sanitized_value(value) + value.to_s.gsub(/\s/, "_").gsub(/[^-[[:word:]]]/, "").mb_chars.downcase.to_s + end + + def select_content_tag(option_tags, options, html_options) + html_options = html_options.stringify_keys + add_default_name_and_id(html_options) + + if placeholder_required?(html_options) + raise ArgumentError, "include_blank cannot be false for a required field." if options[:include_blank] == false + options[:include_blank] ||= true unless options[:prompt] + end + + value = options.fetch(:selected) { value() } + select = content_tag("select", add_options(option_tags, options, value), html_options) + + if html_options["multiple"] && options.fetch(:include_hidden, true) + tag("input", disabled: html_options["disabled"], name: html_options["name"], type: "hidden", value: "") + select + else + select + end + end + + def placeholder_required?(html_options) + # See https://html.spec.whatwg.org/multipage/forms.html#attr-select-required + html_options["required"] && !html_options["multiple"] && html_options.fetch("size", 1).to_i == 1 + end + + def add_options(option_tags, options, value = nil) + if options[:include_blank] + option_tags = tag_builder.content_tag_string("option", options[:include_blank].kind_of?(String) ? options[:include_blank] : nil, value: "") + "\n" + option_tags + end + if value.blank? && options[:prompt] + tag_options = { value: "" }.tap do |prompt_opts| + prompt_opts[:disabled] = true if options[:disabled] == "" + prompt_opts[:selected] = true if options[:selected] == "" + end + option_tags = tag_builder.content_tag_string("option", prompt_text(options[:prompt]), tag_options) + "\n" + option_tags + end + option_tags + end + + def name_and_id_index(options) + if options.key?("index") + options.delete("index") || "" + elsif @generate_indexed_names + @auto_index || "" + end + end + + def generate_ids? + !@skip_default_ids + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/check_box.rb b/actionview/lib/action_view/helpers/tags/check_box.rb new file mode 100644 index 0000000000..4327e07cae --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/check_box.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "action_view/helpers/tags/checkable" + +module ActionView + module Helpers + module Tags # :nodoc: + class CheckBox < Base #:nodoc: + include Checkable + + def initialize(object_name, method_name, template_object, checked_value, unchecked_value, options) + @checked_value = checked_value + @unchecked_value = unchecked_value + super(object_name, method_name, template_object, options) + end + + def render + options = @options.stringify_keys + options["type"] = "checkbox" + options["value"] = @checked_value + options["checked"] = "checked" if input_checked?(options) + + if options["multiple"] + add_default_name_and_id_for_value(@checked_value, options) + options.delete("multiple") + else + add_default_name_and_id(options) + end + + include_hidden = options.delete("include_hidden") { true } + checkbox = tag("input", options) + + if include_hidden + hidden = hidden_field_for_checkbox(options) + hidden + checkbox + else + checkbox + end + end + + private + + def checked?(value) + case value + when TrueClass, FalseClass + value == !!@checked_value + when NilClass + false + when String + value == @checked_value + else + if value.respond_to?(:include?) + value.include?(@checked_value) + else + value.to_i == @checked_value.to_i + end + end + end + + def hidden_field_for_checkbox(options) + @unchecked_value ? tag("input", options.slice("name", "disabled", "form").merge!("type" => "hidden", "value" => @unchecked_value)) : "".html_safe + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/checkable.rb b/actionview/lib/action_view/helpers/tags/checkable.rb new file mode 100644 index 0000000000..776fefe778 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/checkable.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + module Checkable # :nodoc: + def input_checked?(options) + if options.has_key?("checked") + checked = options.delete "checked" + checked == true || checked == "checked" + else + checked?(value) + end + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/collection_check_boxes.rb b/actionview/lib/action_view/helpers/tags/collection_check_boxes.rb new file mode 100644 index 0000000000..455442178e --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/collection_check_boxes.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "action_view/helpers/tags/collection_helpers" + +module ActionView + module Helpers + module Tags # :nodoc: + class CollectionCheckBoxes < Base # :nodoc: + include CollectionHelpers + + class CheckBoxBuilder < Builder # :nodoc: + def check_box(extra_html_options = {}) + html_options = extra_html_options.merge(@input_html_options) + html_options[:multiple] = true + html_options[:skip_default_ids] = false + @template_object.check_box(@object_name, @method_name, html_options, @value, nil) + end + end + + def render(&block) + render_collection_for(CheckBoxBuilder, &block) + end + + private + + def render_component(builder) + builder.check_box + builder.label + end + + def hidden_field_name + "#{super}[]" + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/collection_helpers.rb b/actionview/lib/action_view/helpers/tags/collection_helpers.rb new file mode 100644 index 0000000000..e1ad11bff8 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/collection_helpers.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + module CollectionHelpers # :nodoc: + class Builder # :nodoc: + attr_reader :object, :text, :value + + def initialize(template_object, object_name, method_name, object, + sanitized_attribute_name, text, value, input_html_options) + @template_object = template_object + @object_name = object_name + @method_name = method_name + @object = object + @sanitized_attribute_name = sanitized_attribute_name + @text = text + @value = value + @input_html_options = input_html_options + end + + def label(label_html_options = {}, &block) + html_options = @input_html_options.slice(:index, :namespace).merge(label_html_options) + html_options[:for] ||= @input_html_options[:id] if @input_html_options[:id] + + @template_object.label(@object_name, @sanitized_attribute_name, @text, html_options, &block) + end + end + + def initialize(object_name, method_name, template_object, collection, value_method, text_method, options, html_options) + @collection = collection + @value_method = value_method + @text_method = text_method + @html_options = html_options + + super(object_name, method_name, template_object, options) + end + + private + + def instantiate_builder(builder_class, item, value, text, html_options) + builder_class.new(@template_object, @object_name, @method_name, item, + sanitize_attribute_name(value), text, value, html_options) + end + + # Generate default options for collection helpers, such as :checked and + # :disabled. + def default_html_options_for_collection(item, value) + html_options = @html_options.dup + + [:checked, :selected, :disabled, :readonly].each do |option| + current_value = @options[option] + next if current_value.nil? + + accept = if current_value.respond_to?(:call) + current_value.call(item) + else + Array(current_value).map(&:to_s).include?(value.to_s) + end + + if accept + html_options[option] = true + elsif option == :checked + html_options[option] = false + end + end + + html_options[:object] = @object + html_options + end + + def sanitize_attribute_name(value) + "#{sanitized_method_name}_#{sanitized_value(value)}" + end + + def render_collection + @collection.map do |item| + value = value_for_collection(item, @value_method) + text = value_for_collection(item, @text_method) + default_html_options = default_html_options_for_collection(item, value) + additional_html_options = option_html_attributes(item) + + yield item, value, text, default_html_options.merge(additional_html_options) + end.join.html_safe + end + + def render_collection_for(builder_class, &block) + options = @options.stringify_keys + rendered_collection = render_collection do |item, value, text, default_html_options| + builder = instantiate_builder(builder_class, item, value, text, default_html_options) + + if block_given? + @template_object.capture(builder, &block) + else + render_component(builder) + end + end + + # Prepend a hidden field to make sure something will be sent back to the + # server if all radio buttons are unchecked. + if options.fetch("include_hidden", true) + hidden_field + rendered_collection + else + rendered_collection + end + end + + def hidden_field + hidden_name = @html_options[:name] || hidden_field_name + @template_object.hidden_field_tag(hidden_name, "", id: nil) + end + + def hidden_field_name + "#{tag_name(false, @options[:index])}" + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/collection_radio_buttons.rb b/actionview/lib/action_view/helpers/tags/collection_radio_buttons.rb new file mode 100644 index 0000000000..16d37134e5 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/collection_radio_buttons.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "action_view/helpers/tags/collection_helpers" + +module ActionView + module Helpers + module Tags # :nodoc: + class CollectionRadioButtons < Base # :nodoc: + include CollectionHelpers + + class RadioButtonBuilder < Builder # :nodoc: + def radio_button(extra_html_options = {}) + html_options = extra_html_options.merge(@input_html_options) + html_options[:skip_default_ids] = false + @template_object.radio_button(@object_name, @method_name, @value, html_options) + end + end + + def render(&block) + render_collection_for(RadioButtonBuilder, &block) + end + + private + + def render_component(builder) + builder.radio_button + builder.label + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/collection_select.rb b/actionview/lib/action_view/helpers/tags/collection_select.rb new file mode 100644 index 0000000000..6a3af1b256 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/collection_select.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class CollectionSelect < Base #:nodoc: + def initialize(object_name, method_name, template_object, collection, value_method, text_method, options, html_options) + @collection = collection + @value_method = value_method + @text_method = text_method + @html_options = html_options + + super(object_name, method_name, template_object, options) + end + + def render + option_tags_options = { + selected: @options.fetch(:selected) { value }, + disabled: @options[:disabled] + } + + select_content_tag( + options_from_collection_for_select(@collection, @value_method, @text_method, option_tags_options), + @options, @html_options + ) + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/color_field.rb b/actionview/lib/action_view/helpers/tags/color_field.rb new file mode 100644 index 0000000000..39ab1285c3 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/color_field.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class ColorField < TextField # :nodoc: + def render + options = @options.stringify_keys + options["value"] ||= validate_color_string(value) + @options = options + super + end + + private + + def validate_color_string(string) + regex = /#[0-9a-fA-F]{6}/ + if regex.match?(string) + string.downcase + else + "#000000" + end + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/date_field.rb b/actionview/lib/action_view/helpers/tags/date_field.rb new file mode 100644 index 0000000000..b17a907651 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/date_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class DateField < DatetimeField # :nodoc: + private + + def format_date(value) + value.try(:strftime, "%Y-%m-%d") + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/date_select.rb b/actionview/lib/action_view/helpers/tags/date_select.rb new file mode 100644 index 0000000000..fe4e3914d7 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/date_select.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "active_support/core_ext/time/calculations" + +module ActionView + module Helpers + module Tags # :nodoc: + class DateSelect < Base # :nodoc: + def initialize(object_name, method_name, template_object, options, html_options) + @html_options = html_options + + super(object_name, method_name, template_object, options) + end + + def render + error_wrapping(datetime_selector(@options, @html_options).send("select_#{select_type}").html_safe) + end + + class << self + def select_type + @select_type ||= name.split("::").last.sub("Select", "").downcase + end + end + + private + + def select_type + self.class.select_type + end + + def datetime_selector(options, html_options) + datetime = options.fetch(:selected) { value || default_datetime(options) } + @auto_index ||= nil + + options = options.dup + options[:field_name] = @method_name + options[:include_position] = true + options[:prefix] ||= @object_name + options[:index] = @auto_index if @auto_index && !options.has_key?(:index) + + DateTimeSelector.new(datetime, options, html_options) + end + + def default_datetime(options) + return if options[:include_blank] || options[:prompt] + + case options[:default] + when nil + Time.current + when Date, Time + options[:default] + else + default = options[:default].dup + + # Rename :minute and :second to :min and :sec + default[:min] ||= default[:minute] + default[:sec] ||= default[:second] + + time = Time.current + + [:year, :month, :day, :hour, :min, :sec].each do |key| + default[key] ||= time.send(key) + end + + Time.utc( + default[:year], default[:month], default[:day], + default[:hour], default[:min], default[:sec] + ) + end + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/datetime_field.rb b/actionview/lib/action_view/helpers/tags/datetime_field.rb new file mode 100644 index 0000000000..5d9b639b1b --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/datetime_field.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class DatetimeField < TextField # :nodoc: + def render + options = @options.stringify_keys + options["value"] ||= format_date(value) + options["min"] = format_date(datetime_value(options["min"])) + options["max"] = format_date(datetime_value(options["max"])) + @options = options + super + end + + private + + def format_date(value) + raise NotImplementedError + end + + def datetime_value(value) + if value.is_a? String + DateTime.parse(value) rescue nil + else + value + end + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/datetime_local_field.rb b/actionview/lib/action_view/helpers/tags/datetime_local_field.rb new file mode 100644 index 0000000000..d8f8fd00d1 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/datetime_local_field.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class DatetimeLocalField < DatetimeField # :nodoc: + class << self + def field_type + @field_type ||= "datetime-local" + end + end + + private + + def format_date(value) + value.try(:strftime, "%Y-%m-%dT%T") + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/datetime_select.rb b/actionview/lib/action_view/helpers/tags/datetime_select.rb new file mode 100644 index 0000000000..dc5570931d --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/datetime_select.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class DatetimeSelect < DateSelect # :nodoc: + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/email_field.rb b/actionview/lib/action_view/helpers/tags/email_field.rb new file mode 100644 index 0000000000..0c3b9224fa --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/email_field.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class EmailField < TextField # :nodoc: + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/file_field.rb b/actionview/lib/action_view/helpers/tags/file_field.rb new file mode 100644 index 0000000000..0b1d9bb778 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/file_field.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class FileField < TextField # :nodoc: + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/grouped_collection_select.rb b/actionview/lib/action_view/helpers/tags/grouped_collection_select.rb new file mode 100644 index 0000000000..f24cb4beea --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/grouped_collection_select.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class GroupedCollectionSelect < Base # :nodoc: + def initialize(object_name, method_name, template_object, collection, group_method, group_label_method, option_key_method, option_value_method, options, html_options) + @collection = collection + @group_method = group_method + @group_label_method = group_label_method + @option_key_method = option_key_method + @option_value_method = option_value_method + @html_options = html_options + + super(object_name, method_name, template_object, options) + end + + def render + option_tags_options = { + selected: @options.fetch(:selected) { value }, + disabled: @options[:disabled] + } + + select_content_tag( + option_groups_from_collection_for_select(@collection, @group_method, @group_label_method, @option_key_method, @option_value_method, option_tags_options), @options, @html_options + ) + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/hidden_field.rb b/actionview/lib/action_view/helpers/tags/hidden_field.rb new file mode 100644 index 0000000000..e014bd3aef --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/hidden_field.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class HiddenField < TextField # :nodoc: + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/label.rb b/actionview/lib/action_view/helpers/tags/label.rb new file mode 100644 index 0000000000..02bd099784 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/label.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class Label < Base # :nodoc: + class LabelBuilder # :nodoc: + attr_reader :object + + def initialize(template_object, object_name, method_name, object, tag_value) + @template_object = template_object + @object_name = object_name + @method_name = method_name + @object = object + @tag_value = tag_value + end + + def translation + method_and_value = @tag_value.present? ? "#{@method_name}.#{@tag_value}" : @method_name + + content ||= Translator + .new(object, @object_name, method_and_value, scope: "helpers.label") + .translate + content ||= @method_name.humanize + + content + end + end + + def initialize(object_name, method_name, template_object, content_or_options = nil, options = nil) + options ||= {} + + content_is_options = content_or_options.is_a?(Hash) + if content_is_options + options.merge! content_or_options + @content = nil + else + @content = content_or_options + end + + super(object_name, method_name, template_object, options) + end + + def render(&block) + options = @options.stringify_keys + tag_value = options.delete("value") + name_and_id = options.dup + + if name_and_id["for"] + name_and_id["id"] = name_and_id["for"] + else + name_and_id.delete("id") + end + + add_default_name_and_id_for_value(tag_value, name_and_id) + options.delete("index") + options.delete("namespace") + options["for"] = name_and_id["id"] unless options.key?("for") + + builder = LabelBuilder.new(@template_object, @object_name, @method_name, @object, tag_value) + + content = if block_given? + @template_object.capture(builder, &block) + elsif @content.present? + @content.to_s + else + render_component(builder) + end + + label_tag(name_and_id["id"], content, options) + end + + private + + def render_component(builder) + builder.translation + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/month_field.rb b/actionview/lib/action_view/helpers/tags/month_field.rb new file mode 100644 index 0000000000..93b2bf11f0 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/month_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class MonthField < DatetimeField # :nodoc: + private + + def format_date(value) + value.try(:strftime, "%Y-%m") + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/number_field.rb b/actionview/lib/action_view/helpers/tags/number_field.rb new file mode 100644 index 0000000000..41c696423c --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/number_field.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class NumberField < TextField # :nodoc: + def render + options = @options.stringify_keys + + if range = options.delete("in") || options.delete("within") + options.update("min" => range.min, "max" => range.max) + end + + @options = options + super + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/password_field.rb b/actionview/lib/action_view/helpers/tags/password_field.rb new file mode 100644 index 0000000000..9f10f5236e --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/password_field.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class PasswordField < TextField # :nodoc: + def render + @options = { value: nil }.merge!(@options) + super + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/placeholderable.rb b/actionview/lib/action_view/helpers/tags/placeholderable.rb new file mode 100644 index 0000000000..e9f7601e57 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/placeholderable.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + module Placeholderable # :nodoc: + def initialize(*) + super + + if tag_value = @options[:placeholder] + placeholder = tag_value if tag_value.is_a?(String) + method_and_value = tag_value.is_a?(TrueClass) ? @method_name : "#{@method_name}.#{tag_value}" + + placeholder ||= Tags::Translator + .new(object, @object_name, method_and_value, scope: "helpers.placeholder") + .translate + placeholder ||= @method_name.humanize + @options[:placeholder] = placeholder + end + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/radio_button.rb b/actionview/lib/action_view/helpers/tags/radio_button.rb new file mode 100644 index 0000000000..621db2b1b5 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/radio_button.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "action_view/helpers/tags/checkable" + +module ActionView + module Helpers + module Tags # :nodoc: + class RadioButton < Base # :nodoc: + include Checkable + + def initialize(object_name, method_name, template_object, tag_value, options) + @tag_value = tag_value + super(object_name, method_name, template_object, options) + end + + def render + options = @options.stringify_keys + options["type"] = "radio" + options["value"] = @tag_value + options["checked"] = "checked" if input_checked?(options) + add_default_name_and_id_for_value(@tag_value, options) + tag("input", options) + end + + private + + def checked?(value) + value.to_s == @tag_value.to_s + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/range_field.rb b/actionview/lib/action_view/helpers/tags/range_field.rb new file mode 100644 index 0000000000..66d1bbac5b --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/range_field.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class RangeField < NumberField # :nodoc: + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/search_field.rb b/actionview/lib/action_view/helpers/tags/search_field.rb new file mode 100644 index 0000000000..f209348904 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/search_field.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class SearchField < TextField # :nodoc: + def render + options = @options.stringify_keys + + if options["autosave"] + if options["autosave"] == true + options["autosave"] = request.host.split(".").reverse.join(".") + end + options["results"] ||= 10 + end + + if options["onsearch"] + options["incremental"] = true unless options.has_key?("incremental") + end + + @options = options + super + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/select.rb b/actionview/lib/action_view/helpers/tags/select.rb new file mode 100644 index 0000000000..790721a0b7 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/select.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class Select < Base # :nodoc: + def initialize(object_name, method_name, template_object, choices, options, html_options) + @choices = block_given? ? template_object.capture { yield || "" } : choices + @choices = @choices.to_a if @choices.is_a?(Range) + + @html_options = html_options + + super(object_name, method_name, template_object, options) + end + + def render + option_tags_options = { + selected: @options.fetch(:selected) { value }, + disabled: @options[:disabled] + } + + option_tags = if grouped_choices? + grouped_options_for_select(@choices, option_tags_options) + else + options_for_select(@choices, option_tags_options) + end + + select_content_tag(option_tags, @options, @html_options) + end + + private + + # Grouped choices look like this: + # + # [nil, []] + # { nil => [] } + def grouped_choices? + !@choices.blank? && @choices.first.respond_to?(:last) && Array === @choices.first.last + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/tel_field.rb b/actionview/lib/action_view/helpers/tags/tel_field.rb new file mode 100644 index 0000000000..ab1caaac48 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/tel_field.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class TelField < TextField # :nodoc: + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/text_area.rb b/actionview/lib/action_view/helpers/tags/text_area.rb new file mode 100644 index 0000000000..4519082ff6 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/text_area.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "action_view/helpers/tags/placeholderable" + +module ActionView + module Helpers + module Tags # :nodoc: + class TextArea < Base # :nodoc: + include Placeholderable + + def render + options = @options.stringify_keys + add_default_name_and_id(options) + + if size = options.delete("size") + options["cols"], options["rows"] = size.split("x") if size.respond_to?(:split) + end + + content_tag("textarea", options.delete("value") { value_before_type_cast }, options) + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/text_field.rb b/actionview/lib/action_view/helpers/tags/text_field.rb new file mode 100644 index 0000000000..d92967e212 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/text_field.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "action_view/helpers/tags/placeholderable" + +module ActionView + module Helpers + module Tags # :nodoc: + class TextField < Base # :nodoc: + include Placeholderable + + def render + options = @options.stringify_keys + options["size"] = options["maxlength"] unless options.key?("size") + options["type"] ||= field_type + options["value"] = options.fetch("value") { value_before_type_cast } unless field_type == "file" + add_default_name_and_id(options) + tag("input", options) + end + + class << self + def field_type + @field_type ||= name.split("::").last.sub("Field", "").downcase + end + end + + private + + def field_type + self.class.field_type + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/time_field.rb b/actionview/lib/action_view/helpers/tags/time_field.rb new file mode 100644 index 0000000000..9384a83a3e --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/time_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class TimeField < DatetimeField # :nodoc: + private + + def format_date(value) + value.try(:strftime, "%T.%L") + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/time_select.rb b/actionview/lib/action_view/helpers/tags/time_select.rb new file mode 100644 index 0000000000..ba3dcb64e3 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/time_select.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class TimeSelect < DateSelect # :nodoc: + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/time_zone_select.rb b/actionview/lib/action_view/helpers/tags/time_zone_select.rb new file mode 100644 index 0000000000..1d06096096 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/time_zone_select.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class TimeZoneSelect < Base # :nodoc: + def initialize(object_name, method_name, template_object, priority_zones, options, html_options) + @priority_zones = priority_zones + @html_options = html_options + + super(object_name, method_name, template_object, options) + end + + def render + select_content_tag( + time_zone_options_for_select(value || @options[:default], @priority_zones, @options[:model] || ActiveSupport::TimeZone), @options, @html_options + ) + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/translator.rb b/actionview/lib/action_view/helpers/tags/translator.rb new file mode 100644 index 0000000000..e81ca3aef0 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/translator.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class Translator # :nodoc: + def initialize(object, object_name, method_and_value, scope:) + @object_name = object_name.gsub(/\[(.*)_attributes\]\[\d+\]/, '.\1') + @method_and_value = method_and_value + @scope = scope + @model = object.respond_to?(:to_model) ? object.to_model : nil + end + + def translate + translated_attribute = I18n.t("#{object_name}.#{method_and_value}", default: i18n_default, scope: scope).presence + translated_attribute || human_attribute_name + end + + private + attr_reader :object_name, :method_and_value, :scope, :model + + def i18n_default + if model + key = model.model_name.i18n_key + ["#{key}.#{method_and_value}".to_sym, ""] + else + "" + end + end + + def human_attribute_name + if model && model.class.respond_to?(:human_attribute_name) + model.class.human_attribute_name(method_and_value) + end + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/url_field.rb b/actionview/lib/action_view/helpers/tags/url_field.rb new file mode 100644 index 0000000000..395fec67e7 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/url_field.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class UrlField < TextField # :nodoc: + end + end + end +end diff --git a/actionview/lib/action_view/helpers/tags/week_field.rb b/actionview/lib/action_view/helpers/tags/week_field.rb new file mode 100644 index 0000000000..572535d1d6 --- /dev/null +++ b/actionview/lib/action_view/helpers/tags/week_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ActionView + module Helpers + module Tags # :nodoc: + class WeekField < DatetimeField # :nodoc: + private + + def format_date(value) + value.try(:strftime, "%Y-W%V") + end + end + end + end +end diff --git a/actionview/lib/action_view/helpers/text_helper.rb b/actionview/lib/action_view/helpers/text_helper.rb new file mode 100644 index 0000000000..c282505e13 --- /dev/null +++ b/actionview/lib/action_view/helpers/text_helper.rb @@ -0,0 +1,486 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/filters" +require "active_support/core_ext/array/extract_options" + +module ActionView + # = Action View Text Helpers + module Helpers #:nodoc: + # The TextHelper module provides a set of methods for filtering, formatting + # and transforming strings, which can reduce the amount of inline Ruby code in + # your views. These helper methods extend Action View making them callable + # within your template files. + # + # ==== Sanitization + # + # Most text helpers that generate HTML output sanitize the given input by default, + # but do not escape it. This means HTML tags will appear in the page but all malicious + # code will be removed. Let's look at some examples using the +simple_format+ method: + # + # simple_format('<a href="http://example.com/">Example</a>') + # # => "<p><a href=\"http://example.com/\">Example</a></p>" + # + # simple_format('<a href="javascript:alert(\'no!\')">Example</a>') + # # => "<p><a>Example</a></p>" + # + # If you want to escape all content, you should invoke the +h+ method before + # calling the text helper. + # + # simple_format h('<a href="http://example.com/">Example</a>') + # # => "<p><a href=\"http://example.com/\">Example</a></p>" + module TextHelper + extend ActiveSupport::Concern + + include SanitizeHelper + include TagHelper + include OutputSafetyHelper + + # The preferred method of outputting text in your views is to use the + # <%= "text" %> eRuby syntax. The regular _puts_ and _print_ methods + # do not operate as expected in an eRuby code block. If you absolutely must + # output text within a non-output code block (i.e., <% %>), you can use the concat method. + # + # <% + # concat "hello" + # # is the equivalent of <%= "hello" %> + # + # if logged_in + # concat "Logged in!" + # else + # concat link_to('login', action: :login) + # end + # # will either display "Logged in!" or a login link + # %> + def concat(string) + output_buffer << string + end + + def safe_concat(string) + output_buffer.respond_to?(:safe_concat) ? output_buffer.safe_concat(string) : concat(string) + end + + # Truncates a given +text+ after a given <tt>:length</tt> if +text+ is longer than <tt>:length</tt> + # (defaults to 30). The last characters will be replaced with the <tt>:omission</tt> (defaults to "...") + # for a total length not exceeding <tt>:length</tt>. + # + # Pass a <tt>:separator</tt> to truncate +text+ at a natural break. + # + # Pass a block if you want to show extra content when the text is truncated. + # + # The result is marked as HTML-safe, but it is escaped by default, unless <tt>:escape</tt> is + # +false+. Care should be taken if +text+ contains HTML tags or entities, because truncation + # may produce invalid HTML (such as unbalanced or incomplete tags). + # + # truncate("Once upon a time in a world far far away") + # # => "Once upon a time in a world..." + # + # truncate("Once upon a time in a world far far away", length: 17) + # # => "Once upon a ti..." + # + # truncate("Once upon a time in a world far far away", length: 17, separator: ' ') + # # => "Once upon a..." + # + # truncate("And they found that many people were sleeping better.", length: 25, omission: '... (continued)') + # # => "And they f... (continued)" + # + # truncate("<p>Once upon a time in a world far far away</p>") + # # => "<p>Once upon a time in a wo..." + # + # truncate("<p>Once upon a time in a world far far away</p>", escape: false) + # # => "<p>Once upon a time in a wo..." + # + # truncate("Once upon a time in a world far far away") { link_to "Continue", "#" } + # # => "Once upon a time in a wo...<a href="#">Continue</a>" + def truncate(text, options = {}, &block) + if text + length = options.fetch(:length, 30) + + content = text.truncate(length, options) + content = options[:escape] == false ? content.html_safe : ERB::Util.html_escape(content) + content << capture(&block) if block_given? && text.length > length + content + end + end + + # Highlights one or more +phrases+ everywhere in +text+ by inserting it into + # a <tt>:highlighter</tt> string. The highlighter can be specialized by passing <tt>:highlighter</tt> + # as a single-quoted string with <tt>\1</tt> where the phrase is to be inserted (defaults to + # '<mark>\1</mark>') or passing a block that receives each matched term. By default +text+ + # is sanitized to prevent possible XSS attacks. If the input is trustworthy, passing false + # for <tt>:sanitize</tt> will turn sanitizing off. + # + # highlight('You searched for: rails', 'rails') + # # => You searched for: <mark>rails</mark> + # + # highlight('You searched for: rails', /for|rails/) + # # => You searched <mark>for</mark>: <mark>rails</mark> + # + # highlight('You searched for: ruby, rails, dhh', 'actionpack') + # # => You searched for: ruby, rails, dhh + # + # highlight('You searched for: rails', ['for', 'rails'], highlighter: '<em>\1</em>') + # # => You searched <em>for</em>: <em>rails</em> + # + # highlight('You searched for: rails', 'rails', highlighter: '<a href="search?q=\1">\1</a>') + # # => You searched for: <a href="search?q=rails">rails</a> + # + # highlight('You searched for: rails', 'rails') { |match| link_to(search_path(q: match, match)) } + # # => You searched for: <a href="search?q=rails">rails</a> + # + # highlight('<a href="javascript:alert(\'no!\')">ruby</a> on rails', 'rails', sanitize: false) + # # => <a href="javascript:alert('no!')">ruby</a> on <mark>rails</mark> + def highlight(text, phrases, options = {}) + text = sanitize(text) if options.fetch(:sanitize, true) + + if text.blank? || phrases.blank? + text || "" + else + match = Array(phrases).map do |p| + Regexp === p ? p.to_s : Regexp.escape(p) + end.join("|") + + if block_given? + text.gsub(/(#{match})(?![^<]*?>)/i) { |found| yield found } + else + highlighter = options.fetch(:highlighter, '<mark>\1</mark>') + text.gsub(/(#{match})(?![^<]*?>)/i, highlighter) + end + end.html_safe + end + + # Extracts an excerpt from +text+ that matches the first instance of +phrase+. + # The <tt>:radius</tt> option expands the excerpt on each side of the first occurrence of +phrase+ by the number of characters + # defined in <tt>:radius</tt> (which defaults to 100). If the excerpt radius overflows the beginning or end of the +text+, + # then the <tt>:omission</tt> option (which defaults to "...") will be prepended/appended accordingly. Use the + # <tt>:separator</tt> option to choose the delimitation. The resulting string will be stripped in any case. If the +phrase+ + # isn't found, +nil+ is returned. + # + # excerpt('This is an example', 'an', radius: 5) + # # => ...s is an exam... + # + # excerpt('This is an example', 'is', radius: 5) + # # => This is a... + # + # excerpt('This is an example', 'is') + # # => This is an example + # + # excerpt('This next thing is an example', 'ex', radius: 2) + # # => ...next... + # + # excerpt('This is also an example', 'an', radius: 8, omission: '<chop> ') + # # => <chop> is also an example + # + # excerpt('This is a very beautiful morning', 'very', separator: ' ', radius: 1) + # # => ...a very beautiful... + def excerpt(text, phrase, options = {}) + return unless text && phrase + + separator = options.fetch(:separator, nil) || "" + case phrase + when Regexp + regex = phrase + else + regex = /#{Regexp.escape(phrase)}/i + end + + return unless matches = text.match(regex) + phrase = matches[0] + + unless separator.empty? + text.split(separator).each do |value| + if value.match?(regex) + phrase = value + break + end + end + end + + first_part, second_part = text.split(phrase, 2) + + prefix, first_part = cut_excerpt_part(:first, first_part, separator, options) + postfix, second_part = cut_excerpt_part(:second, second_part, separator, options) + + affix = [first_part, separator, phrase, separator, second_part].join.strip + [prefix, affix, postfix].join + end + + # Attempts to pluralize the +singular+ word unless +count+ is 1. If + # +plural+ is supplied, it will use that when count is > 1, otherwise + # it will use the Inflector to determine the plural form for the given locale, + # which defaults to I18n.locale + # + # The word will be pluralized using rules defined for the locale + # (you must define your own inflection rules for languages other than English). + # See ActiveSupport::Inflector.pluralize + # + # pluralize(1, 'person') + # # => 1 person + # + # pluralize(2, 'person') + # # => 2 people + # + # pluralize(3, 'person', plural: 'users') + # # => 3 users + # + # pluralize(0, 'person') + # # => 0 people + # + # pluralize(2, 'Person', locale: :de) + # # => 2 Personen + def pluralize(count, singular, plural_arg = nil, plural: plural_arg, locale: I18n.locale) + word = if count == 1 || count.to_s =~ /^1(\.0+)?$/ + singular + else + plural || singular.pluralize(locale) + end + + "#{count || 0} #{word}" + end + + # Wraps the +text+ into lines no longer than +line_width+ width. This method + # breaks on the first whitespace character that does not exceed +line_width+ + # (which is 80 by default). + # + # word_wrap('Once upon a time') + # # => Once upon a time + # + # word_wrap('Once upon a time, in a kingdom called Far Far Away, a king fell ill, and finding a successor to the throne turned out to be more trouble than anyone could have imagined...') + # # => Once upon a time, in a kingdom called Far Far Away, a king fell ill, and finding\na successor to the throne turned out to be more trouble than anyone could have\nimagined... + # + # word_wrap('Once upon a time', line_width: 8) + # # => Once\nupon a\ntime + # + # word_wrap('Once upon a time', line_width: 1) + # # => Once\nupon\na\ntime + # + # You can also specify a custom +break_sequence+ ("\n" by default) + # + # word_wrap('Once upon a time', line_width: 1, break_sequence: "\r\n") + # # => Once\r\nupon\r\na\r\ntime + def word_wrap(text, line_width: 80, break_sequence: "\n") + text.split("\n").collect! do |line| + line.length > line_width ? line.gsub(/(.{1,#{line_width}})(\s+|$)/, "\\1#{break_sequence}").rstrip : line + end * break_sequence + end + + # Returns +text+ transformed into HTML using simple formatting rules. + # Two or more consecutive newlines(<tt>\n\n</tt> or <tt>\r\n\r\n</tt>) are + # considered a paragraph and wrapped in <tt><p></tt> tags. One newline + # (<tt>\n</tt> or <tt>\r\n</tt>) is considered a linebreak and a + # <tt><br /></tt> tag is appended. This method does not remove the + # newlines from the +text+. + # + # You can pass any HTML attributes into <tt>html_options</tt>. These + # will be added to all created paragraphs. + # + # ==== Options + # * <tt>:sanitize</tt> - If +false+, does not sanitize +text+. + # * <tt>:wrapper_tag</tt> - String representing the wrapper tag, defaults to <tt>"p"</tt> + # + # ==== Examples + # my_text = "Here is some basic text...\n...with a line break." + # + # simple_format(my_text) + # # => "<p>Here is some basic text...\n<br />...with a line break.</p>" + # + # simple_format(my_text, {}, wrapper_tag: "div") + # # => "<div>Here is some basic text...\n<br />...with a line break.</div>" + # + # more_text = "We want to put a paragraph...\n\n...right there." + # + # simple_format(more_text) + # # => "<p>We want to put a paragraph...</p>\n\n<p>...right there.</p>" + # + # simple_format("Look ma! A class!", class: 'description') + # # => "<p class='description'>Look ma! A class!</p>" + # + # simple_format("<blink>Unblinkable.</blink>") + # # => "<p>Unblinkable.</p>" + # + # simple_format("<blink>Blinkable!</blink> It's true.", {}, sanitize: false) + # # => "<p><blink>Blinkable!</blink> It's true.</p>" + def simple_format(text, html_options = {}, options = {}) + wrapper_tag = options.fetch(:wrapper_tag, :p) + + text = sanitize(text) if options.fetch(:sanitize, true) + paragraphs = split_paragraphs(text) + + if paragraphs.empty? + content_tag(wrapper_tag, nil, html_options) + else + paragraphs.map! { |paragraph| + content_tag(wrapper_tag, raw(paragraph), html_options) + }.join("\n\n").html_safe + end + end + + # Creates a Cycle object whose _to_s_ method cycles through elements of an + # array every time it is called. This can be used for example, to alternate + # classes for table rows. You can use named cycles to allow nesting in loops. + # Passing a Hash as the last parameter with a <tt>:name</tt> key will create a + # named cycle. The default name for a cycle without a +:name+ key is + # <tt>"default"</tt>. You can manually reset a cycle by calling reset_cycle + # and passing the name of the cycle. The current cycle string can be obtained + # anytime using the current_cycle method. + # + # # Alternate CSS classes for even and odd numbers... + # @items = [1,2,3,4] + # <table> + # <% @items.each do |item| %> + # <tr class="<%= cycle("odd", "even") -%>"> + # <td><%= item %></td> + # </tr> + # <% end %> + # </table> + # + # + # # Cycle CSS classes for rows, and text colors for values within each row + # @items = x = [{first: 'Robert', middle: 'Daniel', last: 'James'}, + # {first: 'Emily', middle: 'Shannon', maiden: 'Pike', last: 'Hicks'}, + # {first: 'June', middle: 'Dae', last: 'Jones'}] + # <% @items.each do |item| %> + # <tr class="<%= cycle("odd", "even", name: "row_class") -%>"> + # <td> + # <% item.values.each do |value| %> + # <%# Create a named cycle "colors" %> + # <span style="color:<%= cycle("red", "green", "blue", name: "colors") -%>"> + # <%= value %> + # </span> + # <% end %> + # <% reset_cycle("colors") %> + # </td> + # </tr> + # <% end %> + def cycle(first_value, *values) + options = values.extract_options! + name = options.fetch(:name, "default") + + values.unshift(*first_value) + + cycle = get_cycle(name) + unless cycle && cycle.values == values + cycle = set_cycle(name, Cycle.new(*values)) + end + cycle.to_s + end + + # Returns the current cycle string after a cycle has been started. Useful + # for complex table highlighting or any other design need which requires + # the current cycle string in more than one place. + # + # # Alternate background colors + # @items = [1,2,3,4] + # <% @items.each do |item| %> + # <div style="background-color:<%= cycle("red","white","blue") %>"> + # <span style="background-color:<%= current_cycle %>"><%= item %></span> + # </div> + # <% end %> + def current_cycle(name = "default") + cycle = get_cycle(name) + cycle.current_value if cycle + end + + # Resets a cycle so that it starts from the first element the next time + # it is called. Pass in +name+ to reset a named cycle. + # + # # Alternate CSS classes for even and odd numbers... + # @items = [[1,2,3,4], [5,6,3], [3,4,5,6,7,4]] + # <table> + # <% @items.each do |item| %> + # <tr class="<%= cycle("even", "odd") -%>"> + # <% item.each do |value| %> + # <span style="color:<%= cycle("#333", "#666", "#999", name: "colors") -%>"> + # <%= value %> + # </span> + # <% end %> + # + # <% reset_cycle("colors") %> + # </tr> + # <% end %> + # </table> + def reset_cycle(name = "default") + cycle = get_cycle(name) + cycle.reset if cycle + end + + class Cycle #:nodoc: + attr_reader :values + + def initialize(first_value, *values) + @values = values.unshift(first_value) + reset + end + + def reset + @index = 0 + end + + def current_value + @values[previous_index].to_s + end + + def to_s + value = @values[@index].to_s + @index = next_index + value + end + + private + + def next_index + step_index(1) + end + + def previous_index + step_index(-1) + end + + def step_index(n) + (@index + n) % @values.size + end + end + + private + # The cycle helpers need to store the cycles in a place that is + # guaranteed to be reset every time a page is rendered, so it + # uses an instance variable of ActionView::Base. + def get_cycle(name) + @_cycles = Hash.new unless defined?(@_cycles) + @_cycles[name] + end + + def set_cycle(name, cycle_object) + @_cycles = Hash.new unless defined?(@_cycles) + @_cycles[name] = cycle_object + end + + def split_paragraphs(text) + return [] if text.blank? + + text.to_str.gsub(/\r\n?/, "\n").split(/\n\n+/).map! do |t| + t.gsub!(/([^\n]\n)(?=[^\n])/, '\1<br />') || t + end + end + + def cut_excerpt_part(part_position, part, separator, options) + return "", "" unless part + + radius = options.fetch(:radius, 100) + omission = options.fetch(:omission, "...") + + part = part.split(separator) + part.delete("") + affix = part.size > radius ? omission : "" + + part = if part_position == :first + drop_index = [part.length - radius, 0].max + part.drop(drop_index) + else + part.first(radius) + end + + return affix, part.join(separator) + end + end + end +end diff --git a/actionview/lib/action_view/helpers/translation_helper.rb b/actionview/lib/action_view/helpers/translation_helper.rb new file mode 100644 index 0000000000..ae1c93e12f --- /dev/null +++ b/actionview/lib/action_view/helpers/translation_helper.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "action_view/helpers/tag_helper" +require "active_support/core_ext/string/access" +require "i18n/exceptions" + +module ActionView + # = Action View Translation Helpers + module Helpers #:nodoc: + module TranslationHelper + extend ActiveSupport::Concern + + include TagHelper + + included do + mattr_accessor :debug_missing_translation, default: true + end + + # Delegates to <tt>I18n#translate</tt> but also performs three additional + # functions. + # + # First, it will ensure that any thrown +MissingTranslation+ messages will + # be rendered as inline spans that: + # + # * Have a <tt>translation-missing</tt> class applied + # * Contain the missing key as the value of the +title+ attribute + # * Have a titleized version of the last key segment as text + # + # For example, the value returned for the missing translation key + # <tt>"blog.post.title"</tt> will be: + # + # <span + # class="translation_missing" + # title="translation missing: en.blog.post.title">Title</span> + # + # This allows for views to display rather reasonable strings while still + # giving developers a way to find missing translations. + # + # If you would prefer missing translations to raise an error, you can + # opt out of span-wrapping behavior globally by setting + # <tt>ActionView::Base.raise_on_missing_translations = true</tt> or + # individually by passing <tt>raise: true</tt> as an option to + # <tt>translate</tt>. + # + # Second, if the key starts with a period <tt>translate</tt> will scope + # the key by the current partial. Calling <tt>translate(".foo")</tt> from + # the <tt>people/index.html.erb</tt> template is equivalent to calling + # <tt>translate("people.index.foo")</tt>. This makes it less + # repetitive to translate many keys within the same partial and provides + # a convention to scope keys consistently. + # + # Third, the translation will be marked as <tt>html_safe</tt> if the key + # has the suffix "_html" or the last element of the key is "html". Calling + # <tt>translate("footer_html")</tt> or <tt>translate("footer.html")</tt> + # will return an HTML safe string that won't be escaped by other HTML + # helper methods. This naming convention helps to identify translations + # that include HTML tags so that you know what kind of output to expect + # when you call translate in a template and translators know which keys + # they can provide HTML values for. + def translate(key, options = {}) + options = options.dup + if options.has_key?(:default) + remaining_defaults = Array(options.delete(:default)).compact + options[:default] = remaining_defaults unless remaining_defaults.first.kind_of?(Symbol) + end + + # If the user has explicitly decided to NOT raise errors, pass that option to I18n. + # Otherwise, tell I18n to raise an exception, which we rescue further in this method. + # Note: `raise_error` refers to us re-raising the error in this method. I18n is forced to raise by default. + if options[:raise] == false + raise_error = false + i18n_raise = false + else + raise_error = options[:raise] || ActionView::Base.raise_on_missing_translations + i18n_raise = true + end + + if html_safe_translation_key?(key) + html_safe_options = options.dup + options.except(*I18n::RESERVED_KEYS).each do |name, value| + unless name == :count && value.is_a?(Numeric) + html_safe_options[name] = ERB::Util.html_escape(value.to_s) + end + end + translation = I18n.translate(scope_key_by_partial(key), html_safe_options.merge(raise: i18n_raise)) + if translation.respond_to?(:map) + translation.map { |element| element.respond_to?(:html_safe) ? element.html_safe : element } + else + translation.respond_to?(:html_safe) ? translation.html_safe : translation + end + else + I18n.translate(scope_key_by_partial(key), options.merge(raise: i18n_raise)) + end + rescue I18n::MissingTranslationData => e + if remaining_defaults.present? + translate remaining_defaults.shift, options.merge(default: remaining_defaults) + else + raise e if raise_error + + keys = I18n.normalize_keys(e.locale, e.key, e.options[:scope]) + title = +"translation missing: #{keys.join('.')}" + + interpolations = options.except(:default, :scope) + if interpolations.any? + title << ", " << interpolations.map { |k, v| "#{k}: #{ERB::Util.html_escape(v)}" }.join(", ") + end + + return title unless ActionView::Base.debug_missing_translation + + content_tag("span", keys.last.to_s.titleize, class: "translation_missing", title: title) + end + end + alias :t :translate + + # Delegates to <tt>I18n.localize</tt> with no additional functionality. + # + # See http://rubydoc.info/github/svenfuchs/i18n/master/I18n/Backend/Base:localize + # for more information. + def localize(*args) + I18n.localize(*args) + end + alias :l :localize + + private + def scope_key_by_partial(key) + stringified_key = key.to_s + if stringified_key.first == "." + if @virtual_path + @_scope_key_by_partial_cache ||= {} + @_scope_key_by_partial_cache[@virtual_path] ||= @virtual_path.gsub(%r{/_?}, ".") + "#{@_scope_key_by_partial_cache[@virtual_path]}#{stringified_key}" + else + raise "Cannot use t(#{key.inspect}) shortcut because path is not available" + end + else + key + end + end + + def html_safe_translation_key?(key) + /(\b|_|\.)html$/.match?(key.to_s) + end + end + end +end diff --git a/actionview/lib/action_view/helpers/url_helper.rb b/actionview/lib/action_view/helpers/url_helper.rb new file mode 100644 index 0000000000..948dd1551f --- /dev/null +++ b/actionview/lib/action_view/helpers/url_helper.rb @@ -0,0 +1,676 @@ +# frozen_string_literal: true + +require "action_view/helpers/javascript_helper" +require "active_support/core_ext/array/access" +require "active_support/core_ext/hash/keys" +require "active_support/core_ext/string/output_safety" + +module ActionView + # = Action View URL Helpers + module Helpers #:nodoc: + # Provides a set of methods for making links and getting URLs that + # depend on the routing subsystem (see ActionDispatch::Routing). + # This allows you to use the same format for links in views + # and controllers. + module UrlHelper + # This helper may be included in any class that includes the + # URL helpers of a routes (routes.url_helpers). Some methods + # provided here will only work in the context of a request + # (link_to_unless_current, for instance), which must be provided + # as a method called #request on the context. + BUTTON_TAG_METHOD_VERBS = %w{patch put delete} + extend ActiveSupport::Concern + + include TagHelper + + module ClassMethods + def _url_for_modules + ActionView::RoutingUrlFor + end + end + + # Basic implementation of url_for to allow use helpers without routes existence + def url_for(options = nil) # :nodoc: + case options + when String + options + when :back + _back_url + else + raise ArgumentError, "arguments passed to url_for can't be handled. Please require " \ + "routes or provide your own implementation" + end + end + + def _back_url # :nodoc: + _filtered_referrer || "javascript:history.back()" + end + protected :_back_url + + def _filtered_referrer # :nodoc: + if controller.respond_to?(:request) + referrer = controller.request.env["HTTP_REFERER"] + if referrer && URI(referrer).scheme != "javascript" + referrer + end + end + rescue URI::InvalidURIError + end + protected :_filtered_referrer + + # Creates an anchor element of the given +name+ using a URL created by the set of +options+. + # See the valid options in the documentation for +url_for+. It's also possible to + # pass a String instead of an options hash, which generates an anchor element that uses the + # value of the String as the href for the link. Using a <tt>:back</tt> Symbol instead + # of an options hash will generate a link to the referrer (a JavaScript back link + # will be used in place of a referrer if none exists). If +nil+ is passed as the name + # the value of the link itself will become the name. + # + # ==== Signatures + # + # link_to(body, url, html_options = {}) + # # url is a String; you can use URL helpers like + # # posts_path + # + # link_to(body, url_options = {}, html_options = {}) + # # url_options, except :method, is passed to url_for + # + # link_to(options = {}, html_options = {}) do + # # name + # end + # + # link_to(url, html_options = {}) do + # # name + # end + # + # ==== Options + # * <tt>:data</tt> - This option can be used to add custom data attributes. + # * <tt>method: symbol of HTTP verb</tt> - This modifier will dynamically + # create an HTML form and immediately submit the form for processing using + # the HTTP verb specified. Useful for having links perform a POST operation + # in dangerous actions like deleting a record (which search bots can follow + # while spidering your site). Supported verbs are <tt>:post</tt>, <tt>:delete</tt>, <tt>:patch</tt>, and <tt>:put</tt>. + # Note that if the user has JavaScript disabled, the request will fall back + # to using GET. If <tt>href: '#'</tt> is used and the user has JavaScript + # disabled clicking the link will have no effect. If you are relying on the + # POST behavior, you should check for it in your controller's action by using + # the request object's methods for <tt>post?</tt>, <tt>delete?</tt>, <tt>patch?</tt>, or <tt>put?</tt>. + # * <tt>remote: true</tt> - This will allow the unobtrusive JavaScript + # driver to make an Ajax request to the URL in question instead of following + # the link. The drivers each provide mechanisms for listening for the + # completion of the Ajax request and performing JavaScript operations once + # they're complete + # + # ==== Data attributes + # + # * <tt>confirm: 'question?'</tt> - This will allow the unobtrusive JavaScript + # 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 + # 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 + # and newer RESTful routes. Current Rails style favors RESTful routes whenever possible, so base + # your application on resources and use + # + # link_to "Profile", profile_path(@profile) + # # => <a href="/profiles/1">Profile</a> + # + # or the even pithier + # + # link_to "Profile", @profile + # # => <a href="/profiles/1">Profile</a> + # + # in place of the older more verbose, non-resource-oriented + # + # link_to "Profile", controller: "profiles", action: "show", id: @profile + # # => <a href="/profiles/show/1">Profile</a> + # + # Similarly, + # + # link_to "Profiles", profiles_path + # # => <a href="/profiles">Profiles</a> + # + # is better than + # + # link_to "Profiles", controller: "profiles" + # # => <a href="/profiles">Profiles</a> + # + # When name is +nil+ the href is presented instead + # + # link_to nil, "http://example.com" + # # => <a href="http://www.example.com">http://www.example.com</a> + # + # You can use a block as well if your link target is hard to fit into the name parameter. ERB example: + # + # <%= link_to(@profile) do %> + # <strong><%= @profile.name %></strong> -- <span>Check it out!</span> + # <% end %> + # # => <a href="/profiles/1"> + # <strong>David</strong> -- <span>Check it out!</span> + # </a> + # + # Classes and ids for CSS are easy to produce: + # + # link_to "Articles", articles_path, id: "news", class: "article" + # # => <a href="/articles" class="article" id="news">Articles</a> + # + # Be careful when using the older argument style, as an extra literal hash is needed: + # + # link_to "Articles", { controller: "articles" }, id: "news", class: "article" + # # => <a href="/articles" class="article" id="news">Articles</a> + # + # Leaving the hash off gives the wrong link: + # + # link_to "WRONG!", controller: "articles", id: "news", class: "article" + # # => <a href="/articles/index/news?class=article">WRONG!</a> + # + # +link_to+ can also produce links with anchors or query strings: + # + # link_to "Comment wall", profile_path(@profile, anchor: "wall") + # # => <a href="/profiles/1#wall">Comment wall</a> + # + # link_to "Ruby on Rails search", controller: "searches", query: "ruby on rails" + # # => <a href="/searches?query=ruby+on+rails">Ruby on Rails search</a> + # + # link_to "Nonsense search", searches_path(foo: "bar", baz: "quux") + # # => <a href="/searches?foo=bar&baz=quux">Nonsense search</a> + # + # The only option specific to +link_to+ (<tt>:method</tt>) is used as follows: + # + # link_to("Destroy", "http://www.example.com", method: :delete) + # # => <a href='http://www.example.com' rel="nofollow" data-method="delete">Destroy</a> + # + # You can also use custom data attributes using the <tt>:data</tt> option: + # + # link_to "Visit Other Site", "http://www.rubyonrails.org/", data: { confirm: "Are you sure?" } + # # => <a href="http://www.rubyonrails.org/" data-confirm="Are you sure?">Visit Other Site</a> + # + # Also you can set any link attributes such as <tt>target</tt>, <tt>rel</tt>, <tt>type</tt>: + # + # link_to "External link", "http://www.rubyonrails.org/", target: "_blank", rel: "nofollow" + # # => <a href="http://www.rubyonrails.org/" target="_blank" rel="nofollow">External link</a> + def link_to(name = nil, options = nil, html_options = nil, &block) + html_options, options, name = options, name, block if block_given? + options ||= {} + + html_options = convert_options_to_data_attributes(options, html_options) + + url = url_for(options) + html_options["href"] ||= url + + content_tag("a", name || url, html_options, &block) + end + + # Generates a form containing a single button that submits to the URL created + # by the set of +options+. This is the safest method to ensure links that + # cause changes to your data are not triggered by search bots or accelerators. + # If the HTML button does not work with your layout, you can also consider + # using the +link_to+ method with the <tt>:method</tt> modifier as described in + # the +link_to+ documentation. + # + # By default, the generated form element has a class name of <tt>button_to</tt> + # to allow styling of the form itself and its children. This can be changed + # using the <tt>:form_class</tt> modifier within +html_options+. You can control + # the form submission and input element behavior using +html_options+. + # This method accepts the <tt>:method</tt> modifier described in the +link_to+ documentation. + # If no <tt>:method</tt> modifier is given, it will default to performing a POST operation. + # You can also disable the button by passing <tt>disabled: true</tt> in +html_options+. + # If you are using RESTful routes, you can pass the <tt>:method</tt> + # to change the HTTP verb used to submit the form. + # + # ==== Options + # The +options+ hash accepts the same options as +url_for+. + # + # There are a few special +html_options+: + # * <tt>:method</tt> - Symbol of HTTP verb. Supported verbs are <tt>:post</tt>, <tt>:get</tt>, + # <tt>:delete</tt>, <tt>:patch</tt>, and <tt>:put</tt>. By default it will be <tt>:post</tt>. + # * <tt>:disabled</tt> - If set to true, it will generate a disabled button. + # * <tt>:data</tt> - This option can be used to add custom data attributes. + # * <tt>:remote</tt> - If set to true, will allow the Unobtrusive JavaScript drivers to control the + # submit behavior. By default this behavior is an ajax submit. + # * <tt>:form</tt> - This hash will be form attributes + # * <tt>:form_class</tt> - This controls the class of the form within which the submit button will + # be placed + # * <tt>:params</tt> - Hash of parameters to be rendered as hidden fields within the form. + # + # ==== Data attributes + # + # * <tt>:confirm</tt> - This will use the unobtrusive JavaScript driver to + # prompt with the question specified. 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. + # + # ==== Examples + # <%= button_to "New", action: "new" %> + # # => "<form method="post" action="/controller/new" class="button_to"> + # # <input value="New" type="submit" /> + # # </form>" + # + # <%= button_to "New", new_articles_path %> + # # => "<form method="post" action="/articles/new" class="button_to"> + # # <input value="New" type="submit" /> + # # </form>" + # + # <%= button_to [:make_happy, @user] do %> + # Make happy <strong><%= @user.name %></strong> + # <% end %> + # # => "<form method="post" action="/users/1/make_happy" class="button_to"> + # # <button type="submit"> + # # Make happy <strong><%= @user.name %></strong> + # # </button> + # # </form>" + # + # <%= button_to "New", { action: "new" }, form_class: "new-thing" %> + # # => "<form method="post" action="/controller/new" class="new-thing"> + # # <input value="New" type="submit" /> + # # </form>" + # + # + # <%= button_to "Create", { action: "create" }, remote: true, form: { "data-type" => "json" } %> + # # => "<form method="post" action="/images/create" class="button_to" data-remote="true" data-type="json"> + # # <input value="Create" type="submit" /> + # # <input name="authenticity_token" type="hidden" value="10f2163b45388899ad4d5ae948988266befcb6c3d1b2451cf657a0c293d605a6"/> + # # </form>" + # + # + # <%= button_to "Delete Image", { action: "delete", id: @image.id }, + # method: :delete, data: { confirm: "Are you sure?" } %> + # # => "<form method="post" action="/images/delete/1" class="button_to"> + # # <input type="hidden" name="_method" value="delete" /> + # # <input data-confirm='Are you sure?' value="Delete Image" type="submit" /> + # # <input name="authenticity_token" type="hidden" value="10f2163b45388899ad4d5ae948988266befcb6c3d1b2451cf657a0c293d605a6"/> + # # </form>" + # + # + # <%= button_to('Destroy', 'http://www.example.com', + # method: "delete", remote: true, data: { confirm: 'Are you sure?', disable_with: 'loading...' }) %> + # # => "<form class='button_to' method='post' action='http://www.example.com' data-remote='true'> + # # <input name='_method' value='delete' type='hidden' /> + # # <input value='Destroy' type='submit' data-disable-with='loading...' data-confirm='Are you sure?' /> + # # <input name="authenticity_token" type="hidden" value="10f2163b45388899ad4d5ae948988266befcb6c3d1b2451cf657a0c293d605a6"/> + # # </form>" + # # + def button_to(name = nil, options = nil, html_options = nil, &block) + html_options, options = options, name if block_given? + options ||= {} + html_options ||= {} + html_options = html_options.stringify_keys + + url = options.is_a?(String) ? options : url_for(options) + remote = html_options.delete("remote") + params = html_options.delete("params") + + method = html_options.delete("method").to_s + method_tag = BUTTON_TAG_METHOD_VERBS.include?(method) ? method_tag(method) : "".html_safe + + form_method = method == "get" ? "get" : "post" + form_options = html_options.delete("form") || {} + form_options[:class] ||= html_options.delete("form_class") || "button_to" + form_options[:method] = form_method + form_options[:action] = url + form_options[:'data-remote'] = true if remote + + request_token_tag = if form_method == "post" + request_method = method.empty? ? "post" : method + token_tag(nil, form_options: { action: url, method: request_method }) + else + "" + end + + html_options = convert_options_to_data_attributes(options, html_options) + html_options["type"] = "submit" + + button = if block_given? + content_tag("button", html_options, &block) + else + html_options["value"] = name || url + tag("input", html_options) + end + + inner_tags = method_tag.safe_concat(button).safe_concat(request_token_tag) + if params + to_form_params(params).each do |param| + inner_tags.safe_concat tag(:input, type: "hidden", name: param[:name], value: param[:value]) + end + end + content_tag("form", inner_tags, form_options) + end + + # Creates a link tag of the given +name+ using a URL created by the set of + # +options+ unless the current request URI is the same as the links, in + # which case only the name is returned (or the given block is yielded, if + # one exists). You can give +link_to_unless_current+ a block which will + # specialize the default behavior (e.g., show a "Start Here" link rather + # than the link's text). + # + # ==== Examples + # Let's say you have a navigation menu... + # + # <ul id="navbar"> + # <li><%= link_to_unless_current("Home", { action: "index" }) %></li> + # <li><%= link_to_unless_current("About Us", { action: "about" }) %></li> + # </ul> + # + # If in the "about" action, it will render... + # + # <ul id="navbar"> + # <li><a href="/controller/index">Home</a></li> + # <li>About Us</li> + # </ul> + # + # ...but if in the "index" action, it will render: + # + # <ul id="navbar"> + # <li>Home</li> + # <li><a href="/controller/about">About Us</a></li> + # </ul> + # + # The implicit block given to +link_to_unless_current+ is evaluated if the current + # action is the action given. So, if we had a comments page and wanted to render a + # "Go Back" link instead of a link to the comments page, we could do something like this... + # + # <%= + # link_to_unless_current("Comment", { controller: "comments", action: "new" }) do + # link_to("Go back", { controller: "posts", action: "index" }) + # end + # %> + def link_to_unless_current(name, options = {}, html_options = {}, &block) + link_to_unless current_page?(options), name, options, html_options, &block + end + + # Creates a link tag of the given +name+ using a URL created by the set of + # +options+ unless +condition+ is true, in which case only the name is + # returned. To specialize the default behavior (i.e., show a login link rather + # than just the plaintext link text), you can pass a block that + # accepts the name or the full argument list for +link_to_unless+. + # + # ==== Examples + # <%= link_to_unless(@current_user.nil?, "Reply", { action: "reply" }) %> + # # If the user is logged in... + # # => <a href="/controller/reply/">Reply</a> + # + # <%= + # link_to_unless(@current_user.nil?, "Reply", { action: "reply" }) do |name| + # link_to(name, { controller: "accounts", action: "signup" }) + # end + # %> + # # If the user is logged in... + # # => <a href="/controller/reply/">Reply</a> + # # If not... + # # => <a href="/accounts/signup">Reply</a> + def link_to_unless(condition, name, options = {}, html_options = {}, &block) + link_to_if !condition, name, options, html_options, &block + end + + # Creates a link tag of the given +name+ using a URL created by the set of + # +options+ if +condition+ is true, otherwise only the name is + # returned. To specialize the default behavior, you can pass a block that + # accepts the name or the full argument list for +link_to_unless+ (see the examples + # in +link_to_unless+). + # + # ==== Examples + # <%= link_to_if(@current_user.nil?, "Login", { controller: "sessions", action: "new" }) %> + # # If the user isn't logged in... + # # => <a href="/sessions/new/">Login</a> + # + # <%= + # link_to_if(@current_user.nil?, "Login", { controller: "sessions", action: "new" }) do + # link_to(@current_user.login, { controller: "accounts", action: "show", id: @current_user }) + # end + # %> + # # If the user isn't logged in... + # # => <a href="/sessions/new/">Login</a> + # # If they are logged in... + # # => <a href="/accounts/show/3">my_username</a> + def link_to_if(condition, name, options = {}, html_options = {}, &block) + if condition + link_to(name, options, html_options) + else + if block_given? + block.arity <= 1 ? capture(name, &block) : capture(name, options, html_options, &block) + else + ERB::Util.html_escape(name) + end + end + end + + # Creates a mailto link tag to the specified +email_address+, which is + # also used as the name of the link unless +name+ is specified. Additional + # HTML attributes for the link can be passed in +html_options+. + # + # +mail_to+ has several methods for customizing the email itself by + # passing special keys to +html_options+. + # + # ==== Options + # * <tt>:subject</tt> - Preset the subject line of the email. + # * <tt>:body</tt> - Preset the body of the email. + # * <tt>:cc</tt> - Carbon Copy additional recipients on the email. + # * <tt>:bcc</tt> - Blind Carbon Copy additional recipients on the email. + # * <tt>:reply_to</tt> - Preset the Reply-To field of the email. + # + # ==== Obfuscation + # Prior to Rails 4.0, +mail_to+ provided options for encoding the address + # in order to hinder email harvesters. To take advantage of these options, + # install the +actionview-encoded_mail_to+ gem. + # + # ==== Examples + # mail_to "me@domain.com" + # # => <a href="mailto:me@domain.com">me@domain.com</a> + # + # mail_to "me@domain.com", "My email" + # # => <a href="mailto:me@domain.com">My email</a> + # + # mail_to "me@domain.com", "My email", cc: "ccaddress@domain.com", + # subject: "This is an example email" + # # => <a href="mailto:me@domain.com?cc=ccaddress@domain.com&subject=This%20is%20an%20example%20email">My email</a> + # + # You can use a block as well if your link target is hard to fit into the name parameter. ERB example: + # + # <%= mail_to "me@domain.com" do %> + # <strong>Email me:</strong> <span>me@domain.com</span> + # <% end %> + # # => <a href="mailto:me@domain.com"> + # <strong>Email me:</strong> <span>me@domain.com</span> + # </a> + def mail_to(email_address, name = nil, html_options = {}, &block) + html_options, name = name, nil if block_given? + html_options = (html_options || {}).stringify_keys + + extras = %w{ cc bcc body subject reply_to }.map! { |item| + option = html_options.delete(item).presence || next + "#{item.dasherize}=#{ERB::Util.url_encode(option)}" + }.compact + extras = extras.empty? ? "" : "?" + extras.join("&") + + encoded_email_address = ERB::Util.url_encode(email_address).gsub("%40", "@") + html_options["href"] = "mailto:#{encoded_email_address}#{extras}" + + content_tag("a", name || email_address, html_options, &block) + end + + # True if the current request URI was generated by the given +options+. + # + # ==== Examples + # Let's say we're in the <tt>http://www.example.com/shop/checkout?order=desc&page=1</tt> action. + # + # current_page?(action: 'process') + # # => false + # + # current_page?(action: 'checkout') + # # => true + # + # current_page?(controller: 'library', action: 'checkout') + # # => false + # + # current_page?(controller: 'shop', action: 'checkout') + # # => true + # + # current_page?(controller: 'shop', action: 'checkout', order: 'asc') + # # => false + # + # current_page?(controller: 'shop', action: 'checkout', order: 'desc', page: '1') + # # => true + # + # current_page?(controller: 'shop', action: 'checkout', order: 'desc', page: '2') + # # => false + # + # current_page?('http://www.example.com/shop/checkout') + # # => true + # + # current_page?('http://www.example.com/shop/checkout', check_parameters: true) + # # => false + # + # current_page?('/shop/checkout') + # # => true + # + # current_page?('http://www.example.com/shop/checkout?order=desc&page=1') + # # => true + # + # Let's say we're in the <tt>http://www.example.com/products</tt> action with method POST in case of invalid product. + # + # current_page?(controller: 'product', action: 'index') + # # => false + # + # We can also pass in the symbol arguments instead of strings. + # + def current_page?(options, check_parameters: false) + unless request + raise "You cannot use helpers that need to determine the current " \ + "page unless your view context provides a Request object " \ + "in a #request method" + end + + return false unless request.get? || request.head? + + check_parameters ||= options.is_a?(Hash) && options.delete(:check_parameters) + url_string = URI.parser.unescape(url_for(options)).force_encoding(Encoding::BINARY) + + # We ignore any extra parameters in the request_uri if the + # submitted url doesn't have any either. This lets the function + # work with things like ?order=asc + # the behaviour can be disabled with check_parameters: true + request_uri = url_string.index("?") || check_parameters ? request.fullpath : request.path + request_uri = URI.parser.unescape(request_uri).force_encoding(Encoding::BINARY) + + if url_string.start_with?("/") && url_string != "/" + url_string.chomp!("/") + request_uri.chomp!("/") + end + + if %r{^\w+://}.match?(url_string) + url_string == "#{request.protocol}#{request.host_with_port}#{request_uri}" + else + url_string == request_uri + end + end + + private + def convert_options_to_data_attributes(options, html_options) + if html_options + html_options = html_options.stringify_keys + html_options["data-remote"] = "true" if link_to_remote_options?(options) || link_to_remote_options?(html_options) + + method = html_options.delete("method") + + add_method_to_attributes!(html_options, method) if method + + html_options + else + link_to_remote_options?(options) ? { "data-remote" => "true" } : {} + end + end + + def link_to_remote_options?(options) + if options.is_a?(Hash) + options.delete("remote") || options.delete(:remote) + end + end + + def add_method_to_attributes!(html_options, method) + if method_not_get_method?(method) && html_options["rel"] !~ /nofollow/ + if html_options["rel"].blank? + html_options["rel"] = "nofollow" + else + html_options["rel"] = "#{html_options["rel"]} nofollow" + end + end + html_options["data-method"] = method + end + + STRINGIFIED_COMMON_METHODS = { + get: "get", + delete: "delete", + patch: "patch", + post: "post", + put: "put", + }.freeze + + def method_not_get_method?(method) + return false unless method + (STRINGIFIED_COMMON_METHODS[method] || method.to_s.downcase) != "get" + end + + def token_tag(token = nil, form_options: {}) + if token != false && protect_against_forgery? + token ||= form_authenticity_token(form_options: form_options) + tag(:input, type: "hidden", name: request_forgery_protection_token.to_s, value: token) + else + "" + end + end + + def method_tag(method) + tag("input", type: "hidden", name: "_method", value: method.to_s) + end + + # Returns an array of hashes each containing :name and :value keys + # suitable for use as the names and values of form input fields: + # + # to_form_params(name: 'David', nationality: 'Danish') + # # => [{name: 'name', value: 'David'}, {name: 'nationality', value: 'Danish'}] + # + # to_form_params(country: { name: 'Denmark' }) + # # => [{name: 'country[name]', value: 'Denmark'}] + # + # to_form_params(countries: ['Denmark', 'Sweden']}) + # # => [{name: 'countries[]', value: 'Denmark'}, {name: 'countries[]', value: 'Sweden'}] + # + # An optional namespace can be passed to enclose key names: + # + # to_form_params({ name: 'Denmark' }, 'country') + # # => [{name: 'country[name]', value: 'Denmark'}] + def to_form_params(attribute, namespace = nil) + attribute = if attribute.respond_to?(:permitted?) + attribute.to_h + else + attribute + end + + params = [] + case attribute + when Hash + attribute.each do |key, value| + prefix = namespace ? "#{namespace}[#{key}]" : key + params.push(*to_form_params(value, prefix)) + end + when Array + array_prefix = "#{namespace}[]" + attribute.each do |value| + params.push(*to_form_params(value, array_prefix)) + end + else + params << { name: namespace.to_s, value: attribute.to_param } + end + + params.sort_by { |pair| pair[:name] } + end + end + end +end diff --git a/actionview/lib/action_view/layouts.rb b/actionview/lib/action_view/layouts.rb new file mode 100644 index 0000000000..3e6d352c15 --- /dev/null +++ b/actionview/lib/action_view/layouts.rb @@ -0,0 +1,433 @@ +# frozen_string_literal: true + +require "action_view/rendering" +require "active_support/core_ext/module/redefine_method" + +module ActionView + # Layouts reverse the common pattern of including shared headers and footers in many templates to isolate changes in + # repeated setups. The inclusion pattern has pages that look like this: + # + # <%= render "shared/header" %> + # Hello World + # <%= render "shared/footer" %> + # + # This approach is a decent way of keeping common structures isolated from the changing content, but it's verbose + # and if you ever want to change the structure of these two includes, you'll have to change all the templates. + # + # With layouts, you can flip it around and have the common structure know where to insert changing content. This means + # that the header and footer are only mentioned in one place, like this: + # + # // The header part of this layout + # <%= yield %> + # // The footer part of this layout + # + # And then you have content pages that look like this: + # + # hello world + # + # At rendering time, the content page is computed and then inserted in the layout, like this: + # + # // The header part of this layout + # hello world + # // The footer part of this layout + # + # == Accessing shared variables + # + # Layouts have access to variables specified in the content pages and vice versa. This allows you to have layouts with + # references that won't materialize before rendering time: + # + # <h1><%= @page_title %></h1> + # <%= yield %> + # + # ...and content pages that fulfill these references _at_ rendering time: + # + # <% @page_title = "Welcome" %> + # Off-world colonies offers you a chance to start a new life + # + # The result after rendering is: + # + # <h1>Welcome</h1> + # Off-world colonies offers you a chance to start a new life + # + # == Layout assignment + # + # You can either specify a layout declaratively (using the #layout class method) or give + # it the same name as your controller, and place it in <tt>app/views/layouts</tt>. + # If a subclass does not have a layout specified, it inherits its layout using normal Ruby inheritance. + # + # For instance, if you have PostsController and a template named <tt>app/views/layouts/posts.html.erb</tt>, + # that template will be used for all actions in PostsController and controllers inheriting + # from PostsController. + # + # If you use a module, for instance Weblog::PostsController, you will need a template named + # <tt>app/views/layouts/weblog/posts.html.erb</tt>. + # + # Since all your controllers inherit from ApplicationController, they will use + # <tt>app/views/layouts/application.html.erb</tt> if no other layout is specified + # or provided. + # + # == Inheritance Examples + # + # class BankController < ActionController::Base + # # bank.html.erb exists + # + # class ExchangeController < BankController + # # exchange.html.erb exists + # + # class CurrencyController < BankController + # + # class InformationController < BankController + # layout "information" + # + # class TellerController < InformationController + # # teller.html.erb exists + # + # class EmployeeController < InformationController + # # employee.html.erb exists + # layout nil + # + # class VaultController < BankController + # layout :access_level_layout + # + # class TillController < BankController + # layout false + # + # In these examples, we have three implicit lookup scenarios: + # * The +BankController+ uses the "bank" layout. + # * The +ExchangeController+ uses the "exchange" layout. + # * The +CurrencyController+ inherits the layout from BankController. + # + # However, when a layout is explicitly set, the explicitly set layout wins: + # * The +InformationController+ uses the "information" layout, explicitly set. + # * The +TellerController+ also uses the "information" layout, because the parent explicitly set it. + # * The +EmployeeController+ uses the "employee" layout, because it set the layout to +nil+, resetting the parent configuration. + # * The +VaultController+ chooses a layout dynamically by calling the <tt>access_level_layout</tt> method. + # * The +TillController+ does not use a layout at all. + # + # == Types of layouts + # + # Layouts are basically just regular templates, but the name of this template needs not be specified statically. Sometimes + # you want to alternate layouts depending on runtime information, such as whether someone is logged in or not. This can + # be done either by specifying a method reference as a symbol or using an inline method (as a proc). + # + # The method reference is the preferred approach to variable layouts and is used like this: + # + # class WeblogController < ActionController::Base + # layout :writers_and_readers + # + # def index + # # fetching posts + # end + # + # private + # def writers_and_readers + # logged_in? ? "writer_layout" : "reader_layout" + # end + # end + # + # Now when a new request for the index action is processed, the layout will vary depending on whether the person accessing + # is logged in or not. + # + # If you want to use an inline method, such as a proc, do something like this: + # + # class WeblogController < ActionController::Base + # layout proc { |controller| controller.logged_in? ? "writer_layout" : "reader_layout" } + # end + # + # If an argument isn't given to the proc, it's evaluated in the context of + # the current controller anyway. + # + # class WeblogController < ActionController::Base + # layout proc { logged_in? ? "writer_layout" : "reader_layout" } + # end + # + # Of course, the most common way of specifying a layout is still just as a plain template name: + # + # class WeblogController < ActionController::Base + # layout "weblog_standard" + # end + # + # The template will be looked always in <tt>app/views/layouts/</tt> folder. But you can point + # <tt>layouts</tt> folder direct also. <tt>layout "layouts/demo"</tt> is the same as <tt>layout "demo"</tt>. + # + # Setting the layout to +nil+ forces it to be looked up in the filesystem and fallbacks to the parent behavior if none exists. + # Setting it to +nil+ is useful to re-enable template lookup overriding a previous configuration set in the parent: + # + # class ApplicationController < ActionController::Base + # layout "application" + # end + # + # class PostsController < ApplicationController + # # Will use "application" layout + # end + # + # class CommentsController < ApplicationController + # # Will search for "comments" layout and fallback "application" layout + # layout nil + # end + # + # == Conditional layouts + # + # If you have a layout that by default is applied to all the actions of a controller, you still have the option of rendering + # a given action or set of actions without a layout, or restricting a layout to only a single action or a set of actions. The + # <tt>:only</tt> and <tt>:except</tt> options can be passed to the layout call. For example: + # + # class WeblogController < ActionController::Base + # layout "weblog_standard", except: :rss + # + # # ... + # + # end + # + # This will assign "weblog_standard" as the WeblogController's layout for all actions except for the +rss+ action, which will + # be rendered directly, without wrapping a layout around the rendered view. + # + # Both the <tt>:only</tt> and <tt>:except</tt> condition can accept an arbitrary number of method references, so + # #<tt>except: [ :rss, :text_only ]</tt> is valid, as is <tt>except: :rss</tt>. + # + # == Using a different layout in the action render call + # + # If most of your actions use the same layout, it makes perfect sense to define a controller-wide layout as described above. + # Sometimes you'll have exceptions where one action wants to use a different layout than the rest of the controller. + # You can do this by passing a <tt>:layout</tt> option to the <tt>render</tt> call. For example: + # + # class WeblogController < ActionController::Base + # layout "weblog_standard" + # + # def help + # render action: "help", layout: "help" + # end + # end + # + # This will override the controller-wide "weblog_standard" layout, and will render the help action with the "help" layout instead. + module Layouts + extend ActiveSupport::Concern + + include ActionView::Rendering + + included do + class_attribute :_layout, instance_accessor: false + class_attribute :_layout_conditions, instance_accessor: false, default: {} + + _write_layout_method + end + + delegate :_layout_conditions, to: :class + + module ClassMethods + def inherited(klass) # :nodoc: + super + klass._write_layout_method + end + + # This module is mixed in if layout conditions are provided. This means + # that if no layout conditions are used, this method is not used + module LayoutConditions # :nodoc: + private + + # Determines whether the current action has a layout definition by + # checking the action name against the :only and :except conditions + # set by the <tt>layout</tt> method. + # + # ==== Returns + # * <tt>Boolean</tt> - True if the action has a layout definition, false otherwise. + def _conditional_layout? + return unless super + + conditions = _layout_conditions + + if only = conditions[:only] + only.include?(action_name) + elsif except = conditions[:except] + !except.include?(action_name) + else + true + end + end + end + + # Specify the layout to use for this class. + # + # If the specified layout is a: + # String:: the String is the template name + # Symbol:: call the method specified by the symbol + # Proc:: call the passed Proc + # false:: There is no layout + # true:: raise an ArgumentError + # nil:: Force default layout behavior with inheritance + # + # Return value of +Proc+ and +Symbol+ arguments should be +String+, +false+, +true+ or +nil+ + # with the same meaning as described above. + # ==== Parameters + # * <tt>layout</tt> - The layout to use. + # + # ==== Options (conditions) + # * :only - A list of actions to apply this layout to. + # * :except - Apply this layout to all actions but this one. + def layout(layout, conditions = {}) + include LayoutConditions unless conditions.empty? + + conditions.each { |k, v| conditions[k] = Array(v).map(&:to_s) } + self._layout_conditions = conditions + + self._layout = layout + _write_layout_method + end + + # Creates a _layout method to be called by _default_layout . + # + # If a layout is not explicitly mentioned then look for a layout with the controller's name. + # if nothing is found then try same procedure to find super class's layout. + def _write_layout_method # :nodoc: + silence_redefinition_of_method(:_layout) + + prefixes = /\blayouts/.match?(_implied_layout_name) ? [] : ["layouts"] + default_behavior = "lookup_context.find_all('#{_implied_layout_name}', #{prefixes.inspect}, false, [], { formats: formats }).first || super" + name_clause = if name + default_behavior + else + <<-RUBY + super + RUBY + end + + layout_definition = \ + case _layout + when String + _layout.inspect + when Symbol + <<-RUBY + #{_layout}.tap do |layout| + return #{default_behavior} if layout.nil? + unless layout.is_a?(String) || !layout + raise ArgumentError, "Your layout method :#{_layout} returned \#{layout}. It " \ + "should have returned a String, false, or nil" + end + end + RUBY + when Proc + define_method :_layout_from_proc, &_layout + protected :_layout_from_proc + <<-RUBY + result = _layout_from_proc(#{_layout.arity == 0 ? '' : 'self'}) + return #{default_behavior} if result.nil? + result + RUBY + when false + nil + when true + raise ArgumentError, "Layouts must be specified as a String, Symbol, Proc, false, or nil" + when nil + name_clause + end + + class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def _layout(formats) + if _conditional_layout? + #{layout_definition} + else + #{name_clause} + end + end + private :_layout + RUBY + end + + private + + # If no layout is supplied, look for a template named the return + # value of this method. + # + # ==== Returns + # * <tt>String</tt> - A template name + def _implied_layout_name + controller_path + end + end + + def _normalize_options(options) # :nodoc: + super + + if _include_layout?(options) + layout = options.delete(:layout) { :default } + options[:layout] = _layout_for_option(layout) + end + end + + attr_internal_writer :action_has_layout + + def initialize(*) # :nodoc: + @_action_has_layout = true + super + end + + # Controls whether an action should be rendered using a layout. + # If you want to disable any <tt>layout</tt> settings for the + # current action so that it is rendered without a layout then + # either override this method in your controller to return false + # for that action or set the <tt>action_has_layout</tt> attribute + # to false before rendering. + def action_has_layout? + @_action_has_layout + end + + private + + def _conditional_layout? + true + end + + # This will be overwritten by _write_layout_method + def _layout(*); end + + # Determine the layout for a given name, taking into account the name type. + # + # ==== Parameters + # * <tt>name</tt> - The name of the template + def _layout_for_option(name) + case name + when String then _normalize_layout(name) + when Proc then name + when true then Proc.new { |formats| _default_layout(formats, true) } + when :default then Proc.new { |formats| _default_layout(formats, false) } + when false, nil then nil + else + raise ArgumentError, + "String, Proc, :default, true, or false, expected for `layout'; you passed #{name.inspect}" + end + end + + def _normalize_layout(value) + value.is_a?(String) && value !~ /\blayouts/ ? "layouts/#{value}" : value + end + + # Returns the default layout for this controller. + # Optionally raises an exception if the layout could not be found. + # + # ==== Parameters + # * <tt>formats</tt> - The formats accepted to this layout + # * <tt>require_layout</tt> - If set to +true+ and layout is not found, + # an +ArgumentError+ exception is raised (defaults to +false+) + # + # ==== Returns + # * <tt>template</tt> - The template object for the default layout (or +nil+) + def _default_layout(formats, require_layout = false) + begin + value = _layout(formats) if action_has_layout? + rescue NameError => e + raise e, "Could not render layout: #{e.message}" + end + + if require_layout && action_has_layout? && !value + raise ArgumentError, + "There was no default layout for #{self.class} in #{view_paths.inspect}" + end + + _normalize_layout(value) + end + + def _include_layout?(options) + (options.keys & [:body, :plain, :html, :inline, :partial]).empty? || options.key?(:layout) + end + end +end diff --git a/actionview/lib/action_view/locale/en.yml b/actionview/lib/action_view/locale/en.yml new file mode 100644 index 0000000000..8a56f147b8 --- /dev/null +++ b/actionview/lib/action_view/locale/en.yml @@ -0,0 +1,56 @@ +"en": + # Used in distance_of_time_in_words(), distance_of_time_in_words_to_now(), time_ago_in_words() + datetime: + distance_in_words: + half_a_minute: "half a minute" + less_than_x_seconds: + one: "less than 1 second" + other: "less than %{count} seconds" + x_seconds: + one: "1 second" + other: "%{count} seconds" + less_than_x_minutes: + one: "less than a minute" + other: "less than %{count} minutes" + x_minutes: + one: "1 minute" + other: "%{count} minutes" + about_x_hours: + one: "about 1 hour" + other: "about %{count} hours" + x_days: + one: "1 day" + other: "%{count} days" + about_x_months: + one: "about 1 month" + other: "about %{count} months" + x_months: + one: "1 month" + other: "%{count} months" + about_x_years: + one: "about 1 year" + other: "about %{count} years" + over_x_years: + one: "over 1 year" + other: "over %{count} years" + almost_x_years: + one: "almost 1 year" + other: "almost %{count} years" + prompts: + year: "Year" + month: "Month" + day: "Day" + hour: "Hour" + minute: "Minute" + second: "Seconds" + + helpers: + select: + # Default value for :prompt => true in FormOptionsHelper + prompt: "Please select" + + # Default translation keys for submit and button FormHelper + submit: + create: 'Create %{model}' + update: 'Update %{model}' + submit: 'Save %{model}' diff --git a/actionview/lib/action_view/log_subscriber.rb b/actionview/lib/action_view/log_subscriber.rb new file mode 100644 index 0000000000..227f025385 --- /dev/null +++ b/actionview/lib/action_view/log_subscriber.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "active_support/log_subscriber" + +module ActionView + # = Action View Log Subscriber + # + # Provides functionality so that Rails can output logs from Action View. + class LogSubscriber < ActiveSupport::LogSubscriber + VIEWS_PATTERN = /^app\/views\// + + def initialize + @root = nil + super + end + + def render_template(event) + info do + message = +" Rendered #{from_rails_root(event.payload[:identifier])}" + message << " within #{from_rails_root(event.payload[:layout])}" if event.payload[:layout] + message << " (Duration: #{event.duration.round(1)}ms | Allocations: #{event.allocations})" + end + end + + def render_partial(event) + info do + message = +" Rendered #{from_rails_root(event.payload[:identifier])}" + message << " within #{from_rails_root(event.payload[:layout])}" if event.payload[:layout] + message << " (Duration: #{event.duration.round(1)}ms | Allocations: #{event.allocations})" + message << " #{cache_message(event.payload)}" unless event.payload[:cache_hit].nil? + message + end + end + + def render_collection(event) + identifier = event.payload[:identifier] || "templates" + + info do + " Rendered collection of #{from_rails_root(identifier)}" \ + " #{render_count(event.payload)} (Duration: #{event.duration.round(1)}ms | Allocations: #{event.allocations})" + end + end + + def start(name, id, payload) + if name == "render_template.action_view" + log_rendering_start(payload) + end + + super + end + + def logger + ActionView::Base.logger + end + + private + + EMPTY = "" + def from_rails_root(string) # :doc: + string = string.sub(rails_root, EMPTY) + string.sub!(VIEWS_PATTERN, EMPTY) + string + end + + def rails_root # :doc: + @root ||= "#{Rails.root}/" + end + + def render_count(payload) # :doc: + if payload[:cache_hits] + "[#{payload[:cache_hits]} / #{payload[:count]} cache hits]" + else + "[#{payload[:count]} times]" + end + end + + def cache_message(payload) # :doc: + case payload[:cache_hit] + when :hit + "[cache hit]" + when :miss + "[cache miss]" + end + end + + def log_rendering_start(payload) + info do + message = +" Rendering #{from_rails_root(payload[:identifier])}" + message << " within #{from_rails_root(payload[:layout])}" if payload[:layout] + message + end + end + end +end + +ActionView::LogSubscriber.attach_to :action_view diff --git a/actionview/lib/action_view/lookup_context.rb b/actionview/lib/action_view/lookup_context.rb new file mode 100644 index 0000000000..554d223c0e --- /dev/null +++ b/actionview/lib/action_view/lookup_context.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +require "concurrent/map" +require "active_support/core_ext/module/remove_method" +require "active_support/core_ext/module/attribute_accessors" +require "action_view/template/resolver" + +module ActionView + # = Action View Lookup Context + # + # <tt>LookupContext</tt> is the object responsible for holding all information + # required for looking up templates, i.e. view paths and details. + # <tt>LookupContext</tt> is also responsible for generating a key, given to + # view paths, used in the resolver cache lookup. Since this key is generated + # only once during the request, it speeds up all cache accesses. + class LookupContext #:nodoc: + attr_accessor :prefixes, :rendered_format + + mattr_accessor :fallbacks, default: FallbackFileSystemResolver.instances + + mattr_accessor :registered_details, default: [] + + def self.register_detail(name, &block) + registered_details << name + Accessors::DEFAULT_PROCS[name] = block + + Accessors.define_method(:"default_#{name}", &block) + Accessors.module_eval <<-METHOD, __FILE__, __LINE__ + 1 + def #{name} + @details.fetch(:#{name}, []) + end + + def #{name}=(value) + value = value.present? ? Array(value) : default_#{name} + _set_detail(:#{name}, value) if value != @details[:#{name}] + end + METHOD + end + + # Holds accessors for the registered details. + module Accessors #:nodoc: + DEFAULT_PROCS = {} + end + + register_detail(:locale) do + locales = [I18n.locale] + locales.concat(I18n.fallbacks[I18n.locale]) if I18n.respond_to? :fallbacks + locales << I18n.default_locale + locales.uniq! + locales + end + register_detail(:formats) { ActionView::Base.default_formats || [:html, :text, :js, :css, :xml, :json] } + register_detail(:variants) { [] } + register_detail(:handlers) { Template::Handlers.extensions } + + class DetailsKey #:nodoc: + alias :eql? :equal? + + @details_keys = Concurrent::Map.new + + def self.get(details) + if details[:formats] + details = details.dup + details[:formats] &= Template::Types.symbols + end + @details_keys[details] ||= Concurrent::Map.new + end + + def self.clear + @details_keys.clear + end + + def self.digest_caches + @details_keys.values + end + end + + # Add caching behavior on top of Details. + module DetailsCache + attr_accessor :cache + + # Calculate the details key. Remove the handlers from calculation to improve performance + # since the user cannot modify it explicitly. + def details_key #:nodoc: + @details_key ||= DetailsKey.get(@details) if @cache + end + + # Temporary skip passing the details_key forward. + def disable_cache + old_value, @cache = @cache, false + yield + ensure + @cache = old_value + end + + private + + def _set_detail(key, value) # :doc: + @details = @details.dup if @details_key + @details_key = nil + @details[key] = value + end + end + + # Helpers related to template lookup using the lookup context information. + module ViewPaths + attr_reader :view_paths, :html_fallback_for_js + + # Whenever setting view paths, makes a copy so that we can manipulate them in + # instance objects as we wish. + def view_paths=(paths) + @view_paths = ActionView::PathSet.new(Array(paths)) + end + + def find(name, prefixes = [], partial = false, keys = [], options = {}) + @view_paths.find(*args_for_lookup(name, prefixes, partial, keys, options)) + end + alias :find_template :find + + def find_file(name, prefixes = [], partial = false, keys = [], options = {}) + @view_paths.find_file(*args_for_lookup(name, prefixes, partial, keys, options)) + end + + def find_all(name, prefixes = [], partial = false, keys = [], options = {}) + @view_paths.find_all(*args_for_lookup(name, prefixes, partial, keys, options)) + end + + def exists?(name, prefixes = [], partial = false, keys = [], **options) + @view_paths.exists?(*args_for_lookup(name, prefixes, partial, keys, options)) + end + alias :template_exists? :exists? + + def any?(name, prefixes = [], partial = false) + @view_paths.exists?(*args_for_any(name, prefixes, partial)) + end + alias :any_templates? :any? + + # Adds fallbacks to the view paths. Useful in cases when you are rendering + # a :file. + def with_fallbacks + added_resolvers = 0 + self.class.fallbacks.each do |resolver| + next if view_paths.include?(resolver) + view_paths.push(resolver) + added_resolvers += 1 + end + yield + ensure + added_resolvers.times { view_paths.pop } + end + + private + + def args_for_lookup(name, prefixes, partial, keys, details_options) + name, prefixes = normalize_name(name, prefixes) + details, details_key = detail_args_for(details_options) + [name, prefixes, partial || false, details, details_key, keys] + end + + # Compute details hash and key according to user options (e.g. passed from #render). + def detail_args_for(options) # :doc: + return @details, details_key if options.empty? # most common path. + user_details = @details.merge(options) + + if @cache + details_key = DetailsKey.get(user_details) + else + details_key = nil + end + + [user_details, details_key] + end + + def args_for_any(name, prefixes, partial) + name, prefixes = normalize_name(name, prefixes) + details, details_key = detail_args_for_any + [name, prefixes, partial || false, details, details_key] + end + + def detail_args_for_any + @detail_args_for_any ||= begin + details = {} + + registered_details.each do |k| + if k == :variants + details[k] = :any + else + details[k] = Accessors::DEFAULT_PROCS[k].call + end + end + + if @cache + [details, DetailsKey.get(details)] + else + [details, nil] + end + end + end + + # Support legacy foo.erb names even though we now ignore .erb + # as well as incorrectly putting part of the path in the template + # name instead of the prefix. + def normalize_name(name, prefixes) + prefixes = prefixes.presence + parts = name.to_s.split("/") + parts.shift if parts.first.empty? + name = parts.pop + + return name, prefixes || [""] if parts.empty? + + parts = parts.join("/") + prefixes = prefixes ? prefixes.map { |p| "#{p}/#{parts}" } : [parts] + + return name, prefixes + end + end + + include Accessors + include DetailsCache + include ViewPaths + + def initialize(view_paths, details = {}, prefixes = []) + @details_key = nil + @cache = true + @prefixes = prefixes + @rendered_format = nil + + @details = initialize_details({}, details) + self.view_paths = view_paths + end + + def digest_cache + details_key + end + + def initialize_details(target, details) + registered_details.each do |k| + target[k] = details[k] || Accessors::DEFAULT_PROCS[k].call + end + target + end + private :initialize_details + + # Override formats= to expand ["*/*"] values and automatically + # add :html as fallback to :js. + def formats=(values) + if values + values.concat(default_formats) if values.delete "*/*" + if values == [:js] + values << :html + @html_fallback_for_js = true + end + end + super(values) + end + + # Override locale to return a symbol instead of array. + def locale + @details[:locale].first + end + + # Overload locale= to also set the I18n.locale. If the current I18n.config object responds + # to original_config, it means that it has a copy of the original I18n configuration and it's + # acting as proxy, which we need to skip. + def locale=(value) + if value + config = I18n.config.respond_to?(:original_config) ? I18n.config.original_config : I18n.config + config.locale = value + end + + super(default_locale) + end + end +end diff --git a/actionview/lib/action_view/model_naming.rb b/actionview/lib/action_view/model_naming.rb new file mode 100644 index 0000000000..23cca8d607 --- /dev/null +++ b/actionview/lib/action_view/model_naming.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ActionView + module ModelNaming #:nodoc: + # Converts the given object to an ActiveModel compliant one. + def convert_to_model(object) + object.respond_to?(:to_model) ? object.to_model : object + end + + def model_name_from_record_or_class(record_or_class) + convert_to_model(record_or_class).model_name + end + end +end diff --git a/actionview/lib/action_view/path_set.rb b/actionview/lib/action_view/path_set.rb new file mode 100644 index 0000000000..691b53e2da --- /dev/null +++ b/actionview/lib/action_view/path_set.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module ActionView #:nodoc: + # = Action View PathSet + # + # This class is used to store and access paths in Action View. A number of + # operations are defined so that you can search among the paths in this + # set and also perform operations on other +PathSet+ objects. + # + # A +LookupContext+ will use a +PathSet+ to store the paths in its context. + class PathSet #:nodoc: + include Enumerable + + attr_reader :paths + + delegate :[], :include?, :pop, :size, :each, to: :paths + + def initialize(paths = []) + @paths = typecast paths + end + + def initialize_copy(other) + @paths = other.paths.dup + self + end + + def to_ary + paths.dup + end + + def compact + PathSet.new paths.compact + end + + def +(array) + PathSet.new(paths + array) + end + + %w(<< concat push insert unshift).each do |method| + class_eval <<-METHOD, __FILE__, __LINE__ + 1 + def #{method}(*args) + paths.#{method}(*typecast(args)) + end + METHOD + end + + def find(*args) + find_all(*args).first || raise(MissingTemplate.new(self, *args)) + end + + def find_file(path, prefixes = [], *args) + _find_all(path, prefixes, args, true).first || raise(MissingTemplate.new(self, path, prefixes, *args)) + end + + def find_all(path, prefixes = [], *args) + _find_all path, prefixes, args, false + end + + def exists?(path, prefixes, *args) + find_all(path, prefixes, *args).any? + end + + def find_all_with_query(query) # :nodoc: + paths.each do |resolver| + templates = resolver.find_all_with_query(query) + return templates unless templates.empty? + end + + [] + end + + private + + def _find_all(path, prefixes, args, outside_app) + prefixes = [prefixes] if String === prefixes + prefixes.each do |prefix| + paths.each do |resolver| + if outside_app + templates = resolver.find_all_anywhere(path, prefix, *args) + else + templates = resolver.find_all(path, prefix, *args) + end + return templates unless templates.empty? + end + end + [] + end + + def typecast(paths) + paths.map do |path| + case path + when Pathname, String + OptimizedFileSystemResolver.new path.to_s + else + path + end + end + end + end +end diff --git a/actionview/lib/action_view/railtie.rb b/actionview/lib/action_view/railtie.rb new file mode 100644 index 0000000000..12d06bf376 --- /dev/null +++ b/actionview/lib/action_view/railtie.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "action_view" +require "rails" + +module ActionView + # = Action View Railtie + class Railtie < Rails::Engine # :nodoc: + config.action_view = ActiveSupport::OrderedOptions.new + config.action_view.embed_authenticity_token_in_remote_forms = nil + config.action_view.debug_missing_translation = true + config.action_view.default_enforce_utf8 = nil + config.action_view.finalize_compiled_template_methods = true + + config.eager_load_namespaces << ActionView + + initializer "action_view.embed_authenticity_token_in_remote_forms" do |app| + ActiveSupport.on_load(:action_view) do + ActionView::Helpers::FormTagHelper.embed_authenticity_token_in_remote_forms = + app.config.action_view.delete(:embed_authenticity_token_in_remote_forms) + end + end + + initializer "action_view.form_with_generates_remote_forms" do |app| + ActiveSupport.on_load(:action_view) do + form_with_generates_remote_forms = app.config.action_view.delete(:form_with_generates_remote_forms) + ActionView::Helpers::FormHelper.form_with_generates_remote_forms = form_with_generates_remote_forms + end + end + + initializer "action_view.form_with_generates_ids" do |app| + ActiveSupport.on_load(:action_view) do + form_with_generates_ids = app.config.action_view.delete(:form_with_generates_ids) + unless form_with_generates_ids.nil? + ActionView::Helpers::FormHelper.form_with_generates_ids = form_with_generates_ids + end + end + end + + initializer "action_view.default_enforce_utf8" do |app| + ActiveSupport.on_load(:action_view) do + default_enforce_utf8 = app.config.action_view.delete(:default_enforce_utf8) + unless default_enforce_utf8.nil? + ActionView::Helpers::FormTagHelper.default_enforce_utf8 = default_enforce_utf8 + end + end + end + + initializer "action_view.finalize_compiled_template_methods" do |app| + ActiveSupport.on_load(:action_view) do + ActionView::Template.finalize_compiled_template_methods = + app.config.action_view.delete(:finalize_compiled_template_methods) + end + end + + initializer "action_view.logger" do + ActiveSupport.on_load(:action_view) { self.logger ||= Rails.logger } + end + + initializer "action_view.set_configs" do |app| + ActiveSupport.on_load(:action_view) do + app.config.action_view.each do |k, v| + send "#{k}=", v + end + end + end + + initializer "action_view.caching" do |app| + ActiveSupport.on_load(:action_view) do + if app.config.action_view.cache_template_loading.nil? + ActionView::Resolver.caching = app.config.cache_classes + end + end + end + + initializer "action_view.per_request_digest_cache" do |app| + ActiveSupport.on_load(:action_view) do + unless ActionView::Resolver.caching? + app.executor.to_run ActionView::Digestor::PerExecutionDigestCacheExpiry + end + end + end + + initializer "action_view.setup_action_pack" do |app| + ActiveSupport.on_load(:action_controller) do + ActionView::RoutingUrlFor.include(ActionDispatch::Routing::UrlFor) + end + end + + initializer "action_view.collection_caching", after: "action_controller.set_configs" do |app| + PartialRenderer.collection_cache = app.config.action_controller.cache_store + end + + rake_tasks do |app| + unless app.config.api_only + load "action_view/tasks/cache_digests.rake" + end + end + end +end diff --git a/actionview/lib/action_view/record_identifier.rb b/actionview/lib/action_view/record_identifier.rb new file mode 100644 index 0000000000..ee39b6050d --- /dev/null +++ b/actionview/lib/action_view/record_identifier.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module" +require "action_view/model_naming" + +module ActionView + # RecordIdentifier encapsulates methods used by various ActionView helpers + # to associate records with DOM elements. + # + # Consider for example the following code that form of post: + # + # <%= form_for(post) do |f| %> + # <%= f.text_field :body %> + # <% end %> + # + # When +post+ is a new, unsaved ActiveRecord::Base instance, the resulting HTML + # is: + # + # <form class="new_post" id="new_post" action="/posts" accept-charset="UTF-8" method="post"> + # <input type="text" name="post[body]" id="post_body" /> + # </form> + # + # When +post+ is a persisted ActiveRecord::Base instance, the resulting HTML + # is: + # + # <form class="edit_post" id="edit_post_42" action="/posts/42" accept-charset="UTF-8" method="post"> + # <input type="text" value="What a wonderful world!" name="post[body]" id="post_body" /> + # </form> + # + # In both cases, the +id+ and +class+ of the wrapping DOM element are + # automatically generated, following naming conventions encapsulated by the + # RecordIdentifier methods #dom_id and #dom_class: + # + # dom_id(Post.new) # => "new_post" + # dom_class(Post.new) # => "post" + # dom_id(Post.find 42) # => "post_42" + # dom_class(Post.find 42) # => "post" + # + # Note that these methods do not strictly require +Post+ to be a subclass of + # ActiveRecord::Base. + # Any +Post+ class will work as long as its instances respond to +to_key+ + # and +model_name+, given that +model_name+ responds to +param_key+. + # For instance: + # + # class Post + # attr_accessor :to_key + # + # def model_name + # OpenStruct.new param_key: 'post' + # end + # + # def self.find(id) + # new.tap { |post| post.to_key = [id] } + # end + # end + module RecordIdentifier + extend self + extend ModelNaming + + include ModelNaming + + JOIN = "_" + NEW = "new" + + # The DOM class convention is to use the singular form of an object or class. + # + # dom_class(post) # => "post" + # dom_class(Person) # => "person" + # + # If you need to address multiple instances of the same class in the same view, you can prefix the dom_class: + # + # dom_class(post, :edit) # => "edit_post" + # dom_class(Person, :edit) # => "edit_person" + def dom_class(record_or_class, prefix = nil) + singular = model_name_from_record_or_class(record_or_class).param_key + prefix ? "#{prefix}#{JOIN}#{singular}" : singular + end + + # The DOM id convention is to use the singular form of an object or class with the id following an underscore. + # If no id is found, prefix with "new_" instead. + # + # dom_id(Post.find(45)) # => "post_45" + # dom_id(Post.new) # => "new_post" + # + # If you need to address multiple instances of the same class in the same view, you can prefix the dom_id: + # + # dom_id(Post.find(45), :edit) # => "edit_post_45" + # dom_id(Post.new, :custom) # => "custom_post" + def dom_id(record, prefix = nil) + if record_id = record_key_for_dom_id(record) + "#{dom_class(record, prefix)}#{JOIN}#{record_id}" + else + dom_class(record, prefix || NEW) + end + end + + private + + # Returns a string representation of the key attribute(s) that is suitable for use in an HTML DOM id. + # This can be overwritten to customize the default generated string representation if desired. + # If you need to read back a key from a dom_id in order to query for the underlying database record, + # you should write a helper like 'person_record_from_dom_id' that will extract the key either based + # on the default implementation (which just joins all key attributes with '_') or on your own + # overwritten version of the method. By default, this implementation passes the key string through a + # method that replaces all characters that are invalid inside DOM ids, with valid ones. You need to + # make sure yourself that your dom ids are valid, in case you overwrite this method. + def record_key_for_dom_id(record) # :doc: + key = convert_to_model(record).to_key + key ? key.join(JOIN) : key + end + end +end diff --git a/actionview/lib/action_view/renderer/abstract_renderer.rb b/actionview/lib/action_view/renderer/abstract_renderer.rb new file mode 100644 index 0000000000..20b2523cac --- /dev/null +++ b/actionview/lib/action_view/renderer/abstract_renderer.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module ActionView + # This class defines the interface for a renderer. Each class that + # subclasses +AbstractRenderer+ is used by the base +Renderer+ class to + # render a specific type of object. + # + # The base +Renderer+ class uses its +render+ method to delegate to the + # renderers. These currently consist of + # + # PartialRenderer - Used for rendering partials + # TemplateRenderer - Used for rendering other types of templates + # StreamingTemplateRenderer - Used for streaming + # + # Whenever the +render+ method is called on the base +Renderer+ class, a new + # renderer object of the correct type is created, and the +render+ method on + # that new object is called in turn. This abstracts the setup and rendering + # into a separate classes for partials and templates. + class AbstractRenderer #:nodoc: + delegate :find_template, :find_file, :template_exists?, :any_templates?, :with_fallbacks, :with_layout_format, :formats, to: :@lookup_context + + def initialize(lookup_context) + @lookup_context = lookup_context + end + + def render + raise NotImplementedError + end + + private + + def extract_details(options) # :doc: + @lookup_context.registered_details.each_with_object({}) do |key, details| + value = options[key] + + details[key] = Array(value) if value + end + end + + def instrument(name, **options) # :doc: + options[:identifier] ||= (@template && @template.identifier) || @path + + ActiveSupport::Notifications.instrument("render_#{name}.action_view", options) do |payload| + yield payload + end + end + + def prepend_formats(formats) # :doc: + formats = Array(formats) + return if formats.empty? || @lookup_context.html_fallback_for_js + + @lookup_context.formats = formats | @lookup_context.formats + end + end +end diff --git a/actionview/lib/action_view/renderer/partial_renderer.rb b/actionview/lib/action_view/renderer/partial_renderer.rb new file mode 100644 index 0000000000..cb850d75ee --- /dev/null +++ b/actionview/lib/action_view/renderer/partial_renderer.rb @@ -0,0 +1,552 @@ +# frozen_string_literal: true + +require "concurrent/map" +require "action_view/renderer/partial_renderer/collection_caching" + +module ActionView + class PartialIteration + # The number of iterations that will be done by the partial. + attr_reader :size + + # The current iteration of the partial. + attr_reader :index + + def initialize(size) + @size = size + @index = 0 + end + + # Check if this is the first iteration of the partial. + def first? + index == 0 + end + + # Check if this is the last iteration of the partial. + def last? + index == size - 1 + end + + def iterate! # :nodoc: + @index += 1 + end + end + + # = Action View Partials + # + # There's also a convenience method for rendering sub templates within the current controller that depends on a + # single object (we call this kind of sub templates for partials). It relies on the fact that partials should + # follow the naming convention of being prefixed with an underscore -- as to separate them from regular + # templates that could be rendered on their own. + # + # In a template for Advertiser#account: + # + # <%= render partial: "account" %> + # + # This would render "advertiser/_account.html.erb". + # + # In another template for Advertiser#buy, we could have: + # + # <%= render partial: "account", locals: { account: @buyer } %> + # + # <% @advertisements.each do |ad| %> + # <%= render partial: "ad", locals: { ad: ad } %> + # <% end %> + # + # This would first render <tt>advertiser/_account.html.erb</tt> with <tt>@buyer</tt> passed in as the local variable +account+, then + # render <tt>advertiser/_ad.html.erb</tt> and pass the local variable +ad+ to the template for display. + # + # == The :as and :object options + # + # By default ActionView::PartialRenderer doesn't have any local variables. + # The <tt>:object</tt> option can be used to pass an object to the partial. For instance: + # + # <%= render partial: "account", object: @buyer %> + # + # would provide the <tt>@buyer</tt> object to the partial, available under the local variable +account+ and is + # equivalent to: + # + # <%= render partial: "account", locals: { account: @buyer } %> + # + # With the <tt>:as</tt> option we can specify a different name for said local variable. For example, if we + # wanted it to be +user+ instead of +account+ we'd do: + # + # <%= render partial: "account", object: @buyer, as: 'user' %> + # + # This is equivalent to + # + # <%= render partial: "account", locals: { user: @buyer } %> + # + # == \Rendering a collection of partials + # + # The example of partial use describes a familiar pattern where a template needs to iterate over an array and + # render a sub template for each of the elements. This pattern has been implemented as a single method that + # accepts an array and renders a partial by the same name as the elements contained within. So the three-lined + # example in "Using partials" can be rewritten with a single line: + # + # <%= render partial: "ad", collection: @advertisements %> + # + # This will render <tt>advertiser/_ad.html.erb</tt> and pass the local variable +ad+ to the template for display. An + # iteration object will automatically be made available to the template with a name of the form + # +partial_name_iteration+. The iteration object has knowledge about which index the current object has in + # the collection and the total size of the collection. The iteration object also has two convenience methods, + # +first?+ and +last?+. In the case of the example above, the template would be fed +ad_iteration+. + # For backwards compatibility the +partial_name_counter+ is still present and is mapped to the iteration's + # +index+ method. + # + # The <tt>:as</tt> option may be used when rendering partials. + # + # You can specify a partial to be rendered between elements via the <tt>:spacer_template</tt> option. + # The following example will render <tt>advertiser/_ad_divider.html.erb</tt> between each ad partial: + # + # <%= render partial: "ad", collection: @advertisements, spacer_template: "ad_divider" %> + # + # If the given <tt>:collection</tt> is +nil+ or empty, <tt>render</tt> will return +nil+. This will allow you + # to specify a text which will be displayed instead by using this form: + # + # <%= render(partial: "ad", collection: @advertisements) || "There's no ad to be displayed" %> + # + # NOTE: Due to backwards compatibility concerns, the collection can't be one of hashes. Normally you'd also + # just keep domain objects, like Active Records, in there. + # + # == \Rendering shared partials + # + # Two controllers can share a set of partials and render them like this: + # + # <%= render partial: "advertisement/ad", locals: { ad: @advertisement } %> + # + # This will render the partial <tt>advertisement/_ad.html.erb</tt> regardless of which controller this is being called from. + # + # == \Rendering objects that respond to +to_partial_path+ + # + # Instead of explicitly naming the location of a partial, you can also let PartialRenderer do the work + # and pick the proper path by checking +to_partial_path+ method. + # + # # @account.to_partial_path returns 'accounts/account', so it can be used to replace: + # # <%= render partial: "accounts/account", locals: { account: @account} %> + # <%= render partial: @account %> + # + # # @posts is an array of Post instances, so every post record returns 'posts/post' on +to_partial_path+, + # # that's why we can replace: + # # <%= render partial: "posts/post", collection: @posts %> + # <%= render partial: @posts %> + # + # == \Rendering the default case + # + # If you're not going to be using any of the options like collections or layouts, you can also use the short-hand + # defaults of render to render partials. Examples: + # + # # Instead of <%= render partial: "account" %> + # <%= render "account" %> + # + # # Instead of <%= render partial: "account", locals: { account: @buyer } %> + # <%= render "account", account: @buyer %> + # + # # @account.to_partial_path returns 'accounts/account', so it can be used to replace: + # # <%= render partial: "accounts/account", locals: { account: @account} %> + # <%= render @account %> + # + # # @posts is an array of Post instances, so every post record returns 'posts/post' on +to_partial_path+, + # # that's why we can replace: + # # <%= render partial: "posts/post", collection: @posts %> + # <%= render @posts %> + # + # == \Rendering partials with layouts + # + # Partials can have their own layouts applied to them. These layouts are different than the ones that are + # specified globally for the entire action, but they work in a similar fashion. Imagine a list with two types + # of users: + # + # <%# app/views/users/index.html.erb %> + # Here's the administrator: + # <%= render partial: "user", layout: "administrator", locals: { user: administrator } %> + # + # Here's the editor: + # <%= render partial: "user", layout: "editor", locals: { user: editor } %> + # + # <%# app/views/users/_user.html.erb %> + # Name: <%= user.name %> + # + # <%# app/views/users/_administrator.html.erb %> + # <div id="administrator"> + # Budget: $<%= user.budget %> + # <%= yield %> + # </div> + # + # <%# app/views/users/_editor.html.erb %> + # <div id="editor"> + # Deadline: <%= user.deadline %> + # <%= yield %> + # </div> + # + # ...this will return: + # + # Here's the administrator: + # <div id="administrator"> + # Budget: $<%= user.budget %> + # Name: <%= user.name %> + # </div> + # + # Here's the editor: + # <div id="editor"> + # Deadline: <%= user.deadline %> + # Name: <%= user.name %> + # </div> + # + # If a collection is given, the layout will be rendered once for each item in + # the collection. For example, these two snippets have the same output: + # + # <%# app/views/users/_user.html.erb %> + # Name: <%= user.name %> + # + # <%# app/views/users/index.html.erb %> + # <%# This does not use layouts %> + # <ul> + # <% users.each do |user| -%> + # <li> + # <%= render partial: "user", locals: { user: user } %> + # </li> + # <% end -%> + # </ul> + # + # <%# app/views/users/_li_layout.html.erb %> + # <li> + # <%= yield %> + # </li> + # + # <%# app/views/users/index.html.erb %> + # <ul> + # <%= render partial: "user", layout: "li_layout", collection: users %> + # </ul> + # + # Given two users whose names are Alice and Bob, these snippets return: + # + # <ul> + # <li> + # Name: Alice + # </li> + # <li> + # Name: Bob + # </li> + # </ul> + # + # The current object being rendered, as well as the object_counter, will be + # available as local variables inside the layout template under the same names + # as available in the partial. + # + # You can also apply a layout to a block within any template: + # + # <%# app/views/users/_chief.html.erb %> + # <%= render(layout: "administrator", locals: { user: chief }) do %> + # Title: <%= chief.title %> + # <% end %> + # + # ...this will return: + # + # <div id="administrator"> + # Budget: $<%= user.budget %> + # Title: <%= chief.name %> + # </div> + # + # As you can see, the <tt>:locals</tt> hash is shared between both the partial and its layout. + # + # If you pass arguments to "yield" then this will be passed to the block. One way to use this is to pass + # an array to layout and treat it as an enumerable. + # + # <%# app/views/users/_user.html.erb %> + # <div class="user"> + # Budget: $<%= user.budget %> + # <%= yield user %> + # </div> + # + # <%# app/views/users/index.html.erb %> + # <%= render layout: @users do |user| %> + # Title: <%= user.title %> + # <% end %> + # + # This will render the layout for each user and yield to the block, passing the user, each time. + # + # You can also yield multiple times in one layout and use block arguments to differentiate the sections. + # + # <%# app/views/users/_user.html.erb %> + # <div class="user"> + # <%= yield user, :header %> + # Budget: $<%= user.budget %> + # <%= yield user, :footer %> + # </div> + # + # <%# app/views/users/index.html.erb %> + # <%= render layout: @users do |user, section| %> + # <%- case section when :header -%> + # Title: <%= user.title %> + # <%- when :footer -%> + # Deadline: <%= user.deadline %> + # <%- end -%> + # <% end %> + class PartialRenderer < AbstractRenderer + include CollectionCaching + + PREFIXED_PARTIAL_NAMES = Concurrent::Map.new do |h, k| + h[k] = Concurrent::Map.new + end + + def initialize(*) + super + @context_prefix = @lookup_context.prefixes.first + end + + def render(context, options, block) + setup(context, options, block) + @template = find_partial + + @lookup_context.rendered_format ||= begin + if @template && @template.formats.present? + @template.formats.first + else + formats.first + end + end + + if @collection + render_collection + else + render_partial + end + end + + private + + def render_collection + instrument(:collection, count: @collection.size) do |payload| + return nil if @collection.blank? + + if @options.key?(:spacer_template) + spacer = find_template(@options[:spacer_template], @locals.keys).render(@view, @locals) + end + + cache_collection_render(payload) do + @template ? collection_with_template : collection_without_template + end.join(spacer).html_safe + end + end + + def render_partial + instrument(:partial) do |payload| + view, locals, block = @view, @locals, @block + object, as = @object, @variable + + if !block && (layout = @options[:layout]) + layout = find_template(layout.to_s, @template_keys) + end + + object = locals[as] if object.nil? # Respect object when object is false + locals[as] = object if @has_object + + content = @template.render(view, locals) do |*name| + view._layout_for(*name, &block) + end + + content = layout.render(view, locals) { content } if layout + payload[:cache_hit] = view.view_renderer.cache_hits[@template.virtual_path] + content + end + end + + # Sets up instance variables needed for rendering a partial. This method + # finds the options and details and extracts them. The method also contains + # logic that handles the type of object passed in as the partial. + # + # If +options[:partial]+ is a string, then the <tt>@path</tt> instance variable is + # set to that string. Otherwise, the +options[:partial]+ object must + # respond to +to_partial_path+ in order to setup the path. + def setup(context, options, block) + @view = context + @options = options + @block = block + + @locals = options[:locals] ? options[:locals].symbolize_keys : {} + @details = extract_details(options) + + prepend_formats(options[:formats]) + + partial = options[:partial] + + if String === partial + @has_object = options.key?(:object) + @object = options[:object] + @collection = collection_from_options + @path = partial + else + @has_object = true + @object = partial + @collection = collection_from_object || collection_from_options + + if @collection + paths = @collection_data = @collection.map { |o| partial_path(o) } + @path = paths.uniq.one? ? paths.first : nil + else + @path = partial_path + end + end + + if as = options[:as] + raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s) + as = as.to_sym + end + + if @path + @variable, @variable_counter, @variable_iteration = retrieve_variable(@path, as) + @template_keys = retrieve_template_keys + else + paths.map! { |path| retrieve_variable(path, as).unshift(path) } + end + + self + end + + def collection_from_options + if @options.key?(:collection) + collection = @options[:collection] + collection ? collection.to_a : [] + end + end + + def collection_from_object + @object.to_ary if @object.respond_to?(:to_ary) + end + + def find_partial + find_template(@path, @template_keys) if @path + end + + def find_template(path, locals) + prefixes = path.include?(?/) ? [] : @lookup_context.prefixes + @lookup_context.find_template(path, prefixes, true, locals, @details) + end + + def collection_with_template + view, locals, template = @view, @locals, @template + as, counter, iteration = @variable, @variable_counter, @variable_iteration + + if layout = @options[:layout] + layout = find_template(layout, @template_keys) + end + + partial_iteration = PartialIteration.new(@collection.size) + locals[iteration] = partial_iteration + + @collection.map do |object| + locals[as] = object + locals[counter] = partial_iteration.index + + content = template.render(view, locals) + content = layout.render(view, locals) { content } if layout + partial_iteration.iterate! + content + end + end + + def collection_without_template + view, locals, collection_data = @view, @locals, @collection_data + cache = {} + keys = @locals.keys + + partial_iteration = PartialIteration.new(@collection.size) + + @collection.map do |object| + index = partial_iteration.index + path, as, counter, iteration = collection_data[index] + + locals[as] = object + locals[counter] = index + locals[iteration] = partial_iteration + + template = (cache[path] ||= find_template(path, keys + [as, counter, iteration])) + content = template.render(view, locals) + partial_iteration.iterate! + content + end + end + + # Obtains the path to where the object's partial is located. If the object + # responds to +to_partial_path+, then +to_partial_path+ will be called and + # will provide the path. If the object does not respond to +to_partial_path+, + # then an +ArgumentError+ is raised. + # + # If +prefix_partial_path_with_controller_namespace+ is true, then this + # method will prefix the partial paths with a namespace. + def partial_path(object = @object) + object = object.to_model if object.respond_to?(:to_model) + + path = if object.respond_to?(:to_partial_path) + object.to_partial_path + else + raise ArgumentError.new("'#{object.inspect}' is not an ActiveModel-compatible object. It must implement :to_partial_path.") + end + + if @view.prefix_partial_path_with_controller_namespace + prefixed_partial_names[path] ||= merge_prefix_into_object_path(@context_prefix, path.dup) + else + path + end + end + + def prefixed_partial_names + @prefixed_partial_names ||= PREFIXED_PARTIAL_NAMES[@context_prefix] + end + + def merge_prefix_into_object_path(prefix, object_path) + if prefix.include?(?/) && object_path.include?(?/) + prefixes = [] + prefix_array = File.dirname(prefix).split("/") + object_path_array = object_path.split("/")[0..-3] # skip model dir & partial + + prefix_array.each_with_index do |dir, index| + break if dir == object_path_array[index] + prefixes << dir + end + + (prefixes << object_path).join("/") + else + object_path + end + end + + def retrieve_template_keys + keys = @locals.keys + keys << @variable if @has_object || @collection + if @collection + keys << @variable_counter + keys << @variable_iteration + end + keys + end + + def retrieve_variable(path, as) + variable = as || begin + base = path[-1] == "/" ? "" : File.basename(path) + raise_invalid_identifier(path) unless base =~ /\A_?(.*?)(?:\.\w+)*\z/ + $1.to_sym + end + if @collection + variable_counter = :"#{variable}_counter" + variable_iteration = :"#{variable}_iteration" + end + [variable, variable_counter, variable_iteration] + end + + IDENTIFIER_ERROR_MESSAGE = "The partial name (%s) is not a valid Ruby identifier; " \ + "make sure your partial name starts with underscore." + + OPTION_AS_ERROR_MESSAGE = "The value (%s) of the option `as` is not a valid Ruby identifier; " \ + "make sure it starts with lowercase letter, " \ + "and is followed by any combination of letters, numbers and underscores." + + def raise_invalid_identifier(path) + raise ArgumentError.new(IDENTIFIER_ERROR_MESSAGE % (path)) + end + + def raise_invalid_option_as(as) + raise ArgumentError.new(OPTION_AS_ERROR_MESSAGE % (as)) + end + end +end diff --git a/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb b/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb new file mode 100644 index 0000000000..5aa6f77902 --- /dev/null +++ b/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module ActionView + module CollectionCaching # :nodoc: + extend ActiveSupport::Concern + + included do + # Fallback cache store if Action View is used without Rails. + # Otherwise overridden in Railtie to use Rails.cache. + mattr_accessor :collection_cache, default: ActiveSupport::Cache::MemoryStore.new + end + + private + def cache_collection_render(instrumentation_payload) + return yield unless @options[:cached] + + # Result is a hash with the key represents the + # key used for cache lookup and the value is the item + # on which the partial is being rendered + keyed_collection = collection_by_cache_keys + + # Pull all partials from cache + # Result is a hash, key matches the entry in + # `keyed_collection` where the cache was retrieved and the + # value is the value that was present in the cache + cached_partials = collection_cache.read_multi(*keyed_collection.keys) + instrumentation_payload[:cache_hits] = cached_partials.size + + # Extract the items for the keys that are not found + # Set the uncached values to instance variable @collection + # which is used by the caller + @collection = keyed_collection.reject { |key, _| cached_partials.key?(key) }.values + + # If all elements are already in cache then + # rendered partials will be an empty array + # + # If the cache is missing elements then + # the block will be called against the remaining items + # in the @collection. + rendered_partials = @collection.empty? ? [] : yield + + index = 0 + fetch_or_cache_partial(cached_partials, order_by: keyed_collection.each_key) do + # This block is called once + # for every cache miss while preserving order. + rendered_partials[index].tap { index += 1 } + end + end + + def callable_cache_key? + @options[:cached].respond_to?(:call) + end + + def collection_by_cache_keys + seed = callable_cache_key? ? @options[:cached] : ->(i) { i } + + @collection.each_with_object({}) do |item, hash| + hash[expanded_cache_key(seed.call(item))] = item + end + end + + def expanded_cache_key(key) + key = @view.combined_fragment_cache_key(@view.cache_fragment_name(key, virtual_path: @template.virtual_path, digest_path: digest_path)) + key.frozen? ? key.dup : key # #read_multi & #write may require mutability, Dalli 2.6.0. + end + + def digest_path + @digest_path ||= @view.digest_path_from_virtual(@template.virtual_path) + end + + # `order_by` is an enumerable object containing keys of the cache, + # all keys are passed in whether found already or not. + # + # `cached_partials` is a hash. If the value exists + # it represents the rendered partial from the cache + # otherwise `Hash#fetch` will take the value of its block. + # + # This method expects a block that will return the rendered + # partial. An example is to render all results + # for each element that was not found in the cache and store it as an array. + # Order it so that the first empty cache element in `cached_partials` + # corresponds to the first element in `rendered_partials`. + # + # If the partial is not already cached it will also be + # written back to the underlying cache store. + def fetch_or_cache_partial(cached_partials, order_by:) + order_by.map do |cache_key| + cached_partials.fetch(cache_key) do + yield.tap do |rendered_partial| + collection_cache.write(cache_key, rendered_partial) + end + end + end + end + end +end diff --git a/actionview/lib/action_view/renderer/renderer.rb b/actionview/lib/action_view/renderer/renderer.rb new file mode 100644 index 0000000000..3f3a97529d --- /dev/null +++ b/actionview/lib/action_view/renderer/renderer.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module ActionView + # This is the main entry point for rendering. It basically delegates + # to other objects like TemplateRenderer and PartialRenderer which + # actually renders the template. + # + # The Renderer will parse the options from the +render+ or +render_body+ + # method and render a partial or a template based on the options. The + # +TemplateRenderer+ and +PartialRenderer+ objects are wrappers which do all + # the setup and logic necessary to render a view and a new object is created + # each time +render+ is called. + class Renderer + attr_accessor :lookup_context + + def initialize(lookup_context) + @lookup_context = lookup_context + end + + # Main render entry point shared by Action View and Action Controller. + def render(context, options) + if options.key?(:partial) + render_partial(context, options) + else + render_template(context, options) + end + end + + # Render but returns a valid Rack body. If fibers are defined, we return + # a streaming body that renders the template piece by piece. + # + # Note that partials are not supported to be rendered with streaming, + # so in such cases, we just wrap them in an array. + def render_body(context, options) + if options.key?(:partial) + [render_partial(context, options)] + else + StreamingTemplateRenderer.new(@lookup_context).render(context, options) + end + end + + # Direct access to template rendering. + def render_template(context, options) #:nodoc: + TemplateRenderer.new(@lookup_context).render(context, options) + end + + # Direct access to partial rendering. + def render_partial(context, options, &block) #:nodoc: + PartialRenderer.new(@lookup_context).render(context, options, block) + end + + def cache_hits # :nodoc: + @cache_hits ||= {} + end + end +end diff --git a/actionview/lib/action_view/renderer/streaming_template_renderer.rb b/actionview/lib/action_view/renderer/streaming_template_renderer.rb new file mode 100644 index 0000000000..bb9db21e32 --- /dev/null +++ b/actionview/lib/action_view/renderer/streaming_template_renderer.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "fiber" + +module ActionView + # == TODO + # + # * Support streaming from child templates, partials and so on. + # * Rack::Cache needs to support streaming bodies + class StreamingTemplateRenderer < TemplateRenderer #:nodoc: + # A valid Rack::Body (i.e. it responds to each). + # It is initialized with a block that, when called, starts + # rendering the template. + class Body #:nodoc: + def initialize(&start) + @start = start + end + + def each(&block) + begin + @start.call(block) + rescue Exception => exception + log_error(exception) + block.call ActionView::Base.streaming_completion_on_exception + end + self + end + + private + + # This is the same logging logic as in ShowExceptions middleware. + def log_error(exception) + logger = ActionView::Base.logger + return unless logger + + message = +"\n#{exception.class} (#{exception.message}):\n" + message << exception.annoted_source_code.to_s if exception.respond_to?(:annoted_source_code) + message << " " << exception.backtrace.join("\n ") + logger.fatal("#{message}\n\n") + end + end + + # For streaming, instead of rendering a given a template, we return a Body + # object that responds to each. This object is initialized with a block + # that knows how to render the template. + def render_template(template, layout_name = nil, locals = {}) #:nodoc: + return [super] unless layout_name && template.supports_streaming? + + locals ||= {} + layout = layout_name && find_layout(layout_name, locals.keys, [formats.first]) + + Body.new do |buffer| + delayed_render(buffer, template, layout, @view, locals) + end + end + + private + + def delayed_render(buffer, template, layout, view, locals) + # Wrap the given buffer in the StreamingBuffer and pass it to the + # underlying template handler. Now, every time something is concatenated + # to the buffer, it is not appended to an array, but streamed straight + # to the client. + output = ActionView::StreamingBuffer.new(buffer) + yielder = lambda { |*name| view._layout_for(*name) } + + instrument(:template, identifier: template.identifier, layout: layout.try(:virtual_path)) do + outer_config = I18n.config + fiber = Fiber.new do + I18n.config = outer_config + if layout + layout.render(view, locals, output, &yielder) + else + # If you don't have a layout, just render the thing + # and concatenate the final result. This is the same + # as a layout with just <%= yield %> + output.safe_concat view._layout_for + end + end + + # Set the view flow to support streaming. It will be aware + # when to stop rendering the layout because it needs to search + # something in the template and vice-versa. + view.view_flow = StreamingFlow.new(view, fiber) + + # Yo! Start the fiber! + fiber.resume + + # If the fiber is still alive, it means we need something + # from the template, so start rendering it. If not, it means + # the layout exited without requiring anything from the template. + if fiber.alive? + content = template.render(view, locals, &yielder) + + # Once rendering the template is done, sets its content in the :layout key. + view.view_flow.set(:layout, content) + + # In case the layout continues yielding, we need to resume + # the fiber until all yields are handled. + fiber.resume while fiber.alive? + end + end + end + end +end diff --git a/actionview/lib/action_view/renderer/template_renderer.rb b/actionview/lib/action_view/renderer/template_renderer.rb new file mode 100644 index 0000000000..ce8908924a --- /dev/null +++ b/actionview/lib/action_view/renderer/template_renderer.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/try" + +module ActionView + class TemplateRenderer < AbstractRenderer #:nodoc: + def render(context, options) + @view = context + @details = extract_details(options) + template = determine_template(options) + + prepend_formats(template.formats) + + @lookup_context.rendered_format ||= (template.formats.first || formats.first) + + render_template(template, options[:layout], options[:locals]) + end + + private + + # Determine the template to be rendered using the given options. + def determine_template(options) + keys = options.has_key?(:locals) ? options[:locals].keys : [] + + if options.key?(:body) + Template::Text.new(options[:body]) + elsif options.key?(:plain) + Template::Text.new(options[:plain]) + elsif options.key?(:html) + Template::HTML.new(options[:html], formats.first) + elsif options.key?(:file) + with_fallbacks { find_file(options[:file], nil, false, keys, @details) } + elsif options.key?(:inline) + handler = Template.handler_for_extension(options[:type] || "erb") + Template.new(options[:inline], "inline template", handler, locals: keys) + elsif options.key?(:template) + if options[:template].respond_to?(:render) + options[:template] + else + find_template(options[:template], options[:prefixes], false, keys, @details) + end + else + raise ArgumentError, "You invoked render but did not give any of :partial, :template, :inline, :file, :plain, :html or :body option." + end + end + + # Renders the given template. A string representing the layout can be + # supplied as well. + def render_template(template, layout_name = nil, locals = nil) + view, locals = @view, locals || {} + + render_with_layout(layout_name, locals) do |layout| + instrument(:template, identifier: template.identifier, layout: layout.try(:virtual_path)) do + template.render(view, locals) { |*name| view._layout_for(*name) } + end + end + end + + def render_with_layout(path, locals) + layout = path && find_layout(path, locals.keys, [formats.first]) + content = yield(layout) + + if layout + view = @view + view.view_flow.set(:layout, content) + layout.render(view, locals) { |*name| view._layout_for(*name) } + else + content + end + end + + # This is the method which actually finds the layout using details in the lookup + # context object. If no layout is found, it checks if at least a layout with + # the given name exists across all details before raising the error. + def find_layout(layout, keys, formats) + resolve_layout(layout, keys, formats) + end + + def resolve_layout(layout, keys, formats) + details = @details.dup + details[:formats] = formats + + case layout + when String + begin + if layout.start_with?("/") + with_fallbacks { find_template(layout, nil, false, [], details) } + else + find_template(layout, nil, false, [], details) + end + rescue ActionView::MissingTemplate + all_details = @details.merge(formats: @lookup_context.default_formats) + raise unless template_exists?(layout, nil, false, [], all_details) + end + when Proc + resolve_layout(layout.call(formats), keys, formats) + else + layout + end + end + end +end diff --git a/actionview/lib/action_view/rendering.rb b/actionview/lib/action_view/rendering.rb new file mode 100644 index 0000000000..cb4327cf16 --- /dev/null +++ b/actionview/lib/action_view/rendering.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require "action_view/view_paths" + +module ActionView + # This is a class to fix I18n global state. Whenever you provide I18n.locale during a request, + # it will trigger the lookup_context and consequently expire the cache. + class I18nProxy < ::I18n::Config #:nodoc: + attr_reader :original_config, :lookup_context + + def initialize(original_config, lookup_context) + original_config = original_config.original_config if original_config.respond_to?(:original_config) + @original_config, @lookup_context = original_config, lookup_context + end + + def locale + @original_config.locale + end + + def locale=(value) + @lookup_context.locale = value + end + end + + module Rendering + extend ActiveSupport::Concern + include ActionView::ViewPaths + + # Overwrite process to setup I18n proxy. + def process(*) #:nodoc: + old_config, I18n.config = I18n.config, I18nProxy.new(I18n.config, lookup_context) + super + ensure + I18n.config = old_config + end + + module ClassMethods + def view_context_class + @view_context_class ||= begin + supports_path = supports_path? + routes = respond_to?(:_routes) && _routes + helpers = respond_to?(:_helpers) && _helpers + + Class.new(ActionView::Base) do + if routes + include routes.url_helpers(supports_path) + include routes.mounted_helpers + end + + if helpers + include helpers + end + end + end + end + end + + attr_internal_writer :view_context_class + + def view_context_class + @_view_context_class ||= self.class.view_context_class + end + + # An instance of a view class. The default view class is ActionView::Base. + # + # The view class must have the following methods: + # + # * <tt>View.new(lookup_context, assigns, controller)</tt> — Create a new + # ActionView instance for a controller and we can also pass the arguments. + # + # * <tt>View#render(option)</tt> — Returns String with the rendered template. + # + # Override this method in a module to change the default behavior. + def view_context + view_context_class.new(view_renderer, view_assigns, self) + end + + # Returns an object that is able to render templates. + def view_renderer # :nodoc: + @_view_renderer ||= ActionView::Renderer.new(lookup_context) + end + + def render_to_body(options = {}) + _process_options(options) + _render_template(options) + end + + def rendered_format + Template::Types[lookup_context.rendered_format] + end + + private + + # Find and render a template based on the options given. + def _render_template(options) + variant = options.delete(:variant) + assigns = options.delete(:assigns) + context = view_context + + context.assign assigns if assigns + lookup_context.rendered_format = nil if options[:formats] + lookup_context.variants = variant if variant + + view_renderer.render(context, options) + end + + # Assign the rendered format to look up context. + def _process_format(format) + super + lookup_context.formats = [format.to_sym] + lookup_context.rendered_format = lookup_context.formats.first + end + + # Normalize args by converting render "foo" to render :action => "foo" and + # render "foo/bar" to render :template => "foo/bar". + def _normalize_args(action = nil, options = {}) + options = super(action, options) + case action + when NilClass + when Hash + options = action + when String, Symbol + action = action.to_s + key = action.include?(?/) ? :template : :action + options[key] = action + else + if action.respond_to?(:permitted?) && action.permitted? + options = action + else + options[:partial] = action + end + end + + options + end + + # Normalize options. + def _normalize_options(options) + options = super(options) + if options[:partial] == true + options[:partial] = action_name + end + + if (options.keys & [:partial, :file, :template]).empty? + options[:prefixes] ||= _prefixes + end + + options[:template] ||= (options[:action] || action_name).to_s + options + end + end +end diff --git a/actionview/lib/action_view/routing_url_for.rb b/actionview/lib/action_view/routing_url_for.rb new file mode 100644 index 0000000000..f8ea3aa770 --- /dev/null +++ b/actionview/lib/action_view/routing_url_for.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "action_dispatch/routing/polymorphic_routes" + +module ActionView + module RoutingUrlFor + # Returns the URL for the set of +options+ provided. This takes the + # same options as +url_for+ in Action Controller (see the + # documentation for <tt>ActionController::Base#url_for</tt>). Note that by default + # <tt>:only_path</tt> is <tt>true</tt> so you'll get the relative "/controller/action" + # instead of the fully qualified URL like "http://example.com/controller/action". + # + # ==== Options + # * <tt>:anchor</tt> - Specifies the anchor name to be appended to the path. + # * <tt>:only_path</tt> - If true, returns the relative URL (omitting the protocol, host name, and port) (<tt>true</tt> by default unless <tt>:host</tt> is specified). + # * <tt>:trailing_slash</tt> - If true, adds a trailing slash, as in "/archive/2005/". Note that this + # is currently not recommended since it breaks caching. + # * <tt>:host</tt> - Overrides the default (current) host if provided. + # * <tt>:protocol</tt> - Overrides the default (current) protocol if provided. + # * <tt>:user</tt> - Inline HTTP authentication (only plucked out if <tt>:password</tt> is also present). + # * <tt>:password</tt> - Inline HTTP authentication (only plucked out if <tt>:user</tt> is also present). + # + # ==== Relying on named routes + # + # Passing a record (like an Active Record) instead of a hash as the options parameter will + # trigger the named route for that record. The lookup will happen on the name of the class. So passing a + # Workshop object will attempt to use the +workshop_path+ route. If you have a nested route, such as + # +admin_workshop_path+ you'll have to call that explicitly (it's impossible for +url_for+ to guess that route). + # + # ==== Implicit Controller Namespacing + # + # Controllers passed in using the +:controller+ option will retain their namespace unless it is an absolute one. + # + # ==== Examples + # <%= url_for(action: 'index') %> + # # => /blogs/ + # + # <%= url_for(action: 'find', controller: 'books') %> + # # => /books/find + # + # <%= url_for(action: 'login', controller: 'members', only_path: false, protocol: 'https') %> + # # => https://www.example.com/members/login/ + # + # <%= url_for(action: 'play', anchor: 'player') %> + # # => /messages/play/#player + # + # <%= url_for(action: 'jump', anchor: 'tax&ship') %> + # # => /testing/jump/#tax&ship + # + # <%= url_for(Workshop.new) %> + # # relies on Workshop answering a persisted? call (and in this case returning false) + # # => /workshops + # + # <%= url_for(@workshop) %> + # # calls @workshop.to_param which by default returns the id + # # => /workshops/5 + # + # # to_param can be re-defined in a model to provide different URL names: + # # => /workshops/1-workshop-name + # + # <%= url_for("http://www.example.com") %> + # # => http://www.example.com + # + # <%= url_for(:back) %> + # # if request.env["HTTP_REFERER"] is set to "http://www.example.com" + # # => http://www.example.com + # + # <%= url_for(:back) %> + # # if request.env["HTTP_REFERER"] is not set or is blank + # # => javascript:history.back() + # + # <%= url_for(action: 'index', controller: 'users') %> + # # Assuming an "admin" namespace + # # => /admin/users + # + # <%= url_for(action: 'index', controller: '/users') %> + # # Specify absolute path with beginning slash + # # => /users + def url_for(options = nil) + case options + when String + options + when nil + super(only_path: _generate_paths_by_default) + when Hash + options = options.symbolize_keys + ensure_only_path_option(options) + + super(options) + when ActionController::Parameters + ensure_only_path_option(options) + + super(options) + when :back + _back_url + when Array + components = options.dup + options = components.extract_options! + ensure_only_path_option(options) + + if options[:only_path] + polymorphic_path(components, options) + else + polymorphic_url(components, options) + end + else + method = _generate_paths_by_default ? :path : :url + builder = ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.send(method) + + case options + when Symbol + builder.handle_string_call(self, options) + when Class + builder.handle_class_call(self, options) + else + builder.handle_model_call(self, options) + end + end + end + + def url_options #:nodoc: + return super unless controller.respond_to?(:url_options) + controller.url_options + end + + private + def _routes_context + controller + end + + def optimize_routes_generation? + controller.respond_to?(:optimize_routes_generation?, true) ? + controller.optimize_routes_generation? : super + end + + def _generate_paths_by_default + true + end + + def ensure_only_path_option(options) + unless options.key?(:only_path) + options[:only_path] = _generate_paths_by_default unless options[:host] + end + end + end +end diff --git a/actionview/lib/action_view/tasks/cache_digests.rake b/actionview/lib/action_view/tasks/cache_digests.rake new file mode 100644 index 0000000000..dd8e94bd88 --- /dev/null +++ b/actionview/lib/action_view/tasks/cache_digests.rake @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +namespace :cache_digests do + desc "Lookup nested dependencies for TEMPLATE (like messages/show or comments/_comment.html)" + task nested_dependencies: :environment do + abort "You must provide TEMPLATE for the task to run" unless ENV["TEMPLATE"].present? + puts JSON.pretty_generate ActionView::Digestor.tree(CacheDigests.template_name, CacheDigests.finder).children.map(&:to_dep_map) + end + + desc "Lookup first-level dependencies for TEMPLATE (like messages/show or comments/_comment.html)" + task dependencies: :environment do + abort "You must provide TEMPLATE for the task to run" unless ENV["TEMPLATE"].present? + puts JSON.pretty_generate ActionView::Digestor.tree(CacheDigests.template_name, CacheDigests.finder).children.map(&:name) + end + + class CacheDigests + def self.template_name + ENV["TEMPLATE"].split(".", 2).first + end + + def self.finder + ApplicationController.new.lookup_context + end + end +end diff --git a/actionview/lib/action_view/template.rb b/actionview/lib/action_view/template.rb new file mode 100644 index 0000000000..070d82cf17 --- /dev/null +++ b/actionview/lib/action_view/template.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/try" +require "active_support/core_ext/kernel/singleton_class" +require "thread" + +module ActionView + # = Action View Template + class Template + extend ActiveSupport::Autoload + + mattr_accessor :finalize_compiled_template_methods, default: true + + # === Encodings in ActionView::Template + # + # ActionView::Template is one of a few sources of potential + # encoding issues in Rails. This is because the source for + # templates are usually read from disk, and Ruby (like most + # encoding-aware programming languages) assumes that the + # String retrieved through File IO is encoded in the + # <tt>default_external</tt> encoding. In Rails, the default + # <tt>default_external</tt> encoding is UTF-8. + # + # As a result, if a user saves their template as ISO-8859-1 + # (for instance, using a non-Unicode-aware text editor), + # and uses characters outside of the ASCII range, their + # users will see diamonds with question marks in them in + # the browser. + # + # For the rest of this documentation, when we say "UTF-8", + # we mean "UTF-8 or whatever the default_internal encoding + # is set to". By default, it will be UTF-8. + # + # To mitigate this problem, we use a few strategies: + # 1. If the source is not valid UTF-8, we raise an exception + # when the template is compiled to alert the user + # to the problem. + # 2. The user can specify the encoding using Ruby-style + # encoding comments in any template engine. If such + # a comment is supplied, Rails will apply that encoding + # to the resulting compiled source returned by the + # template handler. + # 3. In all cases, we transcode the resulting String to + # the UTF-8. + # + # This means that other parts of Rails can always assume + # that templates are encoded in UTF-8, even if the original + # source of the template was not UTF-8. + # + # From a user's perspective, the easiest thing to do is + # to save your templates as UTF-8. If you do this, you + # do not need to do anything else for things to "just work". + # + # === Instructions for template handlers + # + # The easiest thing for you to do is to simply ignore + # encodings. Rails will hand you the template source + # as the default_internal (generally UTF-8), raising + # an exception for the user before sending the template + # to you if it could not determine the original encoding. + # + # For the greatest simplicity, you can support only + # UTF-8 as the <tt>default_internal</tt>. This means + # that from the perspective of your handler, the + # entire pipeline is just UTF-8. + # + # === Advanced: Handlers with alternate metadata sources + # + # If you want to provide an alternate mechanism for + # specifying encodings (like ERB does via <%# encoding: ... %>), + # you may indicate that you will handle encodings yourself + # by implementing <tt>handles_encoding?</tt> on your handler. + # + # If you do, Rails will not try to encode the String + # into the default_internal, passing you the unaltered + # bytes tagged with the assumed encoding (from + # default_external). + # + # In this case, make sure you return a String from + # your handler encoded in the default_internal. Since + # you are handling out-of-band metadata, you are + # also responsible for alerting the user to any + # problems with converting the user's data to + # the <tt>default_internal</tt>. + # + # To do so, simply raise +WrongEncodingError+ as follows: + # + # raise WrongEncodingError.new( + # problematic_string, + # expected_encoding + # ) + + ## + # :method: local_assigns + # + # Returns a hash with the defined local variables. + # + # Given this sub template rendering: + # + # <%= render "shared/header", { headline: "Welcome", person: person } %> + # + # You can use +local_assigns+ in the sub templates to access the local variables: + # + # local_assigns[:headline] # => "Welcome" + + eager_autoload do + autoload :Error + autoload :Handlers + autoload :HTML + autoload :Text + autoload :Types + end + + extend Template::Handlers + + attr_accessor :locals, :formats, :variants, :virtual_path + + attr_reader :source, :identifier, :handler, :original_encoding, :updated_at + + # This finalizer is needed (and exactly with a proc inside another proc) + # otherwise templates leak in development. + Finalizer = proc do |method_name, mod| # :nodoc: + proc do + mod.module_eval do + remove_possible_method method_name + end + end + end + + def initialize(source, identifier, handler, details) + format = details[:format] || (handler.default_format if handler.respond_to?(:default_format)) + + @source = source + @identifier = identifier + @handler = handler + @compiled = false + @original_encoding = nil + @locals = details[:locals] || [] + @virtual_path = details[:virtual_path] + @updated_at = details[:updated_at] || Time.now + @formats = Array(format).map { |f| f.respond_to?(:ref) ? f.ref : f } + @variants = [details[:variant]] + @compile_mutex = Mutex.new + end + + # Returns whether the underlying handler supports streaming. If so, + # a streaming buffer *may* be passed when it starts rendering. + def supports_streaming? + handler.respond_to?(:supports_streaming?) && handler.supports_streaming? + end + + # Render a template. If the template was not compiled yet, it is done + # exactly before rendering. + # + # This method is instrumented as "!render_template.action_view". Notice that + # we use a bang in this instrumentation because you don't want to + # consume this in production. This is only slow if it's being listened to. + def render(view, locals, buffer = nil, &block) + instrument_render_template do + compile!(view) + view.send(method_name, locals, buffer, &block) + end + rescue => e + handle_render_error(view, e) + end + + def type + @type ||= Types[@formats.first] if @formats.first + end + + # Receives a view object and return a template similar to self by using @virtual_path. + # + # This method is useful if you have a template object but it does not contain its source + # anymore since it was already compiled. In such cases, all you need to do is to call + # refresh passing in the view object. + # + # Notice this method raises an error if the template to be refreshed does not have a + # virtual path set (true just for inline templates). + def refresh(view) + raise "A template needs to have a virtual path in order to be refreshed" unless @virtual_path + lookup = view.lookup_context + pieces = @virtual_path.split("/") + name = pieces.pop + partial = !!name.sub!(/^_/, "") + lookup.disable_cache do + lookup.find_template(name, [ pieces.join("/") ], partial, @locals) + end + end + + def inspect + @inspect ||= defined?(Rails.root) ? identifier.sub("#{Rails.root}/", "") : identifier + end + + # This method is responsible for properly setting the encoding of the + # source. Until this point, we assume that the source is BINARY data. + # If no additional information is supplied, we assume the encoding is + # the same as <tt>Encoding.default_external</tt>. + # + # The user can also specify the encoding via a comment on the first + # line of the template (# encoding: NAME-OF-ENCODING). This will work + # with any template engine, as we process out the encoding comment + # before passing the source on to the template engine, leaving a + # blank line in its stead. + def encode! + return unless source.encoding == Encoding::BINARY + + # Look for # encoding: *. If we find one, we'll encode the + # String in that encoding, otherwise, we'll use the + # default external encoding. + if source.sub!(/\A#{ENCODING_FLAG}/, "") + encoding = magic_encoding = $1 + else + encoding = Encoding.default_external + end + + # Tag the source with the default external encoding + # or the encoding specified in the file + source.force_encoding(encoding) + + # If the user didn't specify an encoding, and the handler + # handles encodings, we simply pass the String as is to + # the handler (with the default_external tag) + if !magic_encoding && @handler.respond_to?(:handles_encoding?) && @handler.handles_encoding? + source + # Otherwise, if the String is valid in the encoding, + # encode immediately to default_internal. This means + # that if a handler doesn't handle encodings, it will + # always get Strings in the default_internal + elsif source.valid_encoding? + source.encode! + # Otherwise, since the String is invalid in the encoding + # specified, raise an exception + else + raise WrongEncodingError.new(source, encoding) + end + end + + + # Exceptions are marshalled when using the parallel test runner with DRb, so we need + # to ensure that references to the template object can be marshalled as well. This means forgoing + # the marshalling of the compiler mutex and instantiating that again on unmarshalling. + def marshal_dump # :nodoc: + [ @source, @identifier, @handler, @compiled, @original_encoding, @locals, @virtual_path, @updated_at, @formats, @variants ] + end + + def marshal_load(array) # :nodoc: + @source, @identifier, @handler, @compiled, @original_encoding, @locals, @virtual_path, @updated_at, @formats, @variants = *array + @compile_mutex = Mutex.new + end + + private + + # Compile a template. This method ensures a template is compiled + # just once and removes the source after it is compiled. + def compile!(view) + return if @compiled + + # Templates can be used concurrently in threaded environments + # so compilation and any instance variable modification must + # be synchronized + @compile_mutex.synchronize do + # Any thread holding this lock will be compiling the template needed + # by the threads waiting. So re-check the @compiled flag to avoid + # re-compilation + return if @compiled + + if view.is_a?(ActionView::CompiledTemplates) + mod = ActionView::CompiledTemplates + else + mod = view.singleton_class + end + + instrument("!compile_template") do + compile(mod) + end + + # Just discard the source if we have a virtual path. This + # means we can get the template back. + @source = nil if @virtual_path + @compiled = true + end + end + + # Among other things, this method is responsible for properly setting + # the encoding of the compiled template. + # + # If the template engine handles encodings, we send the encoded + # String to the engine without further processing. This allows + # the template engine to support additional mechanisms for + # specifying the encoding. For instance, ERB supports <%# encoding: %> + # + # Otherwise, after we figure out the correct encoding, we then + # encode the source into <tt>Encoding.default_internal</tt>. + # In general, this means that templates will be UTF-8 inside of Rails, + # regardless of the original source encoding. + def compile(mod) + encode! + code = @handler.call(self) + + # Make sure that the resulting String to be eval'd is in the + # encoding of the code + source = +<<-end_src + def #{method_name}(local_assigns, output_buffer) + _old_virtual_path, @virtual_path = @virtual_path, #{@virtual_path.inspect};_old_output_buffer = @output_buffer;#{locals_code};#{code} + ensure + @virtual_path, @output_buffer = _old_virtual_path, _old_output_buffer + end + end_src + + # Make sure the source is in the encoding of the returned code + source.force_encoding(code.encoding) + + # In case we get back a String from a handler that is not in + # BINARY or the default_internal, encode it to the default_internal + source.encode! + + # Now, validate that the source we got back from the template + # handler is valid in the default_internal. This is for handlers + # that handle encoding but screw up + unless source.valid_encoding? + raise WrongEncodingError.new(@source, Encoding.default_internal) + end + + mod.module_eval(source, identifier, 0) + if finalize_compiled_template_methods + ObjectSpace.define_finalizer(self, Finalizer[method_name, mod]) + end + end + + def handle_render_error(view, e) + if e.is_a?(Template::Error) + e.sub_template_of(self) + raise e + else + template = self + unless template.source + template = refresh(view) + template.encode! + end + raise Template::Error.new(template) + end + end + + def locals_code + # Only locals with valid variable names get set directly. Others will + # still be available in local_assigns. + locals = @locals - Module::RUBY_RESERVED_KEYWORDS + locals = locals.grep(/\A@?(?![A-Z0-9])(?:[[:alnum:]_]|[^\0-\177])+\z/) + + # Assign for the same variable is to suppress unused variable warning + locals.each_with_object(+"") { |key, code| code << "#{key} = local_assigns[:#{key}]; #{key} = #{key};" } + end + + def method_name + @method_name ||= begin + m = +"_#{identifier_method_name}__#{@identifier.hash}_#{__id__}" + m.tr!("-", "_") + m + end + end + + def identifier_method_name + inspect.tr("^a-z_", "_") + end + + def instrument(action, &block) # :doc: + ActiveSupport::Notifications.instrument("#{action}.action_view", instrument_payload, &block) + end + + def instrument_render_template(&block) + ActiveSupport::Notifications.instrument("!render_template.action_view", instrument_payload, &block) + end + + def instrument_payload + { virtual_path: @virtual_path, identifier: @identifier } + end + end +end diff --git a/actionview/lib/action_view/template/error.rb b/actionview/lib/action_view/template/error.rb new file mode 100644 index 0000000000..4e3c02e05e --- /dev/null +++ b/actionview/lib/action_view/template/error.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "active_support/core_ext/enumerable" + +module ActionView + # = Action View Errors + class ActionViewError < StandardError #:nodoc: + end + + class EncodingError < StandardError #:nodoc: + end + + class WrongEncodingError < EncodingError #:nodoc: + def initialize(string, encoding) + @string, @encoding = string, encoding + end + + def message + @string.force_encoding(Encoding::ASCII_8BIT) + "Your template was not saved as valid #{@encoding}. Please " \ + "either specify #{@encoding} as the encoding for your template " \ + "in your text editor, or mark the template with its " \ + "encoding by inserting the following as the first line " \ + "of the template:\n\n# encoding: <name of correct encoding>.\n\n" \ + "The source of your template was:\n\n#{@string}" + end + end + + class MissingTemplate < ActionViewError #:nodoc: + attr_reader :path + + def initialize(paths, path, prefixes, partial, details, *) + @path = path + prefixes = Array(prefixes) + template_type = if partial + "partial" + elsif /layouts/i.match?(path) + "layout" + else + "template" + end + + if partial && path.present? + path = path.sub(%r{([^/]+)$}, "_\\1") + end + searched_paths = prefixes.map { |prefix| [prefix, path].join("/") } + + out = "Missing #{template_type} #{searched_paths.join(", ")} with #{details.inspect}. Searched in:\n" + out += paths.compact.map { |p| " * #{p.to_s.inspect}\n" }.join + super out + end + end + + class Template + # The Template::Error exception is raised when the compilation or rendering of the template + # fails. This exception then gathers a bunch of intimate details and uses it to report a + # precise exception message. + class Error < ActionViewError #:nodoc: + SOURCE_CODE_RADIUS = 3 + + # Override to prevent #cause resetting during re-raise. + attr_reader :cause + + def initialize(template) + super($!.message) + set_backtrace($!.backtrace) + @cause = $! + @template, @sub_templates = template, nil + end + + def file_name + @template.identifier + end + + def sub_template_message + if @sub_templates + "Trace of template inclusion: " + + @sub_templates.collect(&:inspect).join(", ") + else + "" + end + end + + def source_extract(indentation = 0, output = :console) + return unless num = line_number + num = num.to_i + + source_code = @template.source.split("\n") + + start_on_line = [ num - SOURCE_CODE_RADIUS - 1, 0 ].max + end_on_line = [ num + SOURCE_CODE_RADIUS - 1, source_code.length].min + + indent = end_on_line.to_s.size + indentation + return unless source_code = source_code[start_on_line..end_on_line] + + formatted_code_for(source_code, start_on_line, indent, output) + end + + def sub_template_of(template_path) + @sub_templates ||= [] + @sub_templates << template_path + end + + def line_number + @line_number ||= + if file_name + regexp = /#{Regexp.escape File.basename(file_name)}:(\d+)/ + $1 if message =~ regexp || backtrace.find { |line| line =~ regexp } + end + end + + def annoted_source_code + source_extract(4) + end + + private + + def source_location + if line_number + "on line ##{line_number} of " + else + "in " + end + file_name + end + + def formatted_code_for(source_code, line_counter, indent, output) + start_value = (output == :html) ? {} : [] + source_code.inject(start_value) do |result, line| + line_counter += 1 + if output == :html + result.update(line_counter.to_s => "%#{indent}s %s\n" % ["", line]) + else + result << "%#{indent}s: %s" % [line_counter, line] + end + end + end + end + end + + TemplateError = Template::Error +end diff --git a/actionview/lib/action_view/template/handlers.rb b/actionview/lib/action_view/template/handlers.rb new file mode 100644 index 0000000000..7ec76dcc3f --- /dev/null +++ b/actionview/lib/action_view/template/handlers.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module ActionView #:nodoc: + # = Action View Template Handlers + class Template #:nodoc: + module Handlers #:nodoc: + autoload :Raw, "action_view/template/handlers/raw" + autoload :ERB, "action_view/template/handlers/erb" + autoload :Html, "action_view/template/handlers/html" + autoload :Builder, "action_view/template/handlers/builder" + + def self.extended(base) + base.register_default_template_handler :raw, Raw.new + base.register_template_handler :erb, ERB.new + base.register_template_handler :html, Html.new + base.register_template_handler :builder, Builder.new + base.register_template_handler :ruby, :source.to_proc + end + + @@template_handlers = {} + @@default_template_handlers = nil + + def self.extensions + @@template_extensions ||= @@template_handlers.keys + end + + # Register an object that knows how to handle template files with the given + # extensions. This can be used to implement new template types. + # The handler must respond to +:call+, which will be passed the template + # and should return the rendered template as a String. + def register_template_handler(*extensions, handler) + raise(ArgumentError, "Extension is required") if extensions.empty? + extensions.each do |extension| + @@template_handlers[extension.to_sym] = handler + end + @@template_extensions = nil + end + + # Opposite to register_template_handler. + def unregister_template_handler(*extensions) + extensions.each do |extension| + handler = @@template_handlers.delete extension.to_sym + @@default_template_handlers = nil if @@default_template_handlers == handler + end + @@template_extensions = nil + end + + def template_handler_extensions + @@template_handlers.keys.map(&:to_s).sort + end + + def registered_template_handler(extension) + extension && @@template_handlers[extension.to_sym] + end + + def register_default_template_handler(extension, klass) + register_template_handler(extension, klass) + @@default_template_handlers = klass + end + + def handler_for_extension(extension) + registered_template_handler(extension) || @@default_template_handlers + end + end + end +end diff --git a/actionview/lib/action_view/template/handlers/builder.rb b/actionview/lib/action_view/template/handlers/builder.rb new file mode 100644 index 0000000000..61492ce448 --- /dev/null +++ b/actionview/lib/action_view/template/handlers/builder.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ActionView + module Template::Handlers + class Builder + class_attribute :default_format, default: :xml + + def call(template) + require_engine + "xml = ::Builder::XmlMarkup.new(:indent => 2);" \ + "self.output_buffer = xml.target!;" + + template.source + + ";xml.target!;" + end + + private + def require_engine # :doc: + @required ||= begin + require "builder" + true + end + end + end + end +end diff --git a/actionview/lib/action_view/template/handlers/erb.rb b/actionview/lib/action_view/template/handlers/erb.rb new file mode 100644 index 0000000000..7d1a6767d7 --- /dev/null +++ b/actionview/lib/action_view/template/handlers/erb.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module ActionView + class Template + module Handlers + class ERB + autoload :Erubi, "action_view/template/handlers/erb/erubi" + + # Specify trim mode for the ERB compiler. Defaults to '-'. + # See ERB documentation for suitable values. + class_attribute :erb_trim_mode, default: "-" + + # Default implementation used. + class_attribute :erb_implementation, default: Erubi + + # Do not escape templates of these mime types. + class_attribute :escape_ignore_list, default: ["text/plain"] + + [self, singleton_class].each do |base| + base.alias_method :escape_whitelist, :escape_ignore_list + base.alias_method :escape_whitelist=, :escape_ignore_list= + + base.deprecate( + escape_whitelist: "use #escape_ignore_list instead", + :escape_whitelist= => "use #escape_ignore_list= instead" + ) + end + + ENCODING_TAG = Regexp.new("\\A(<%#{ENCODING_FLAG}-?%>)[ \\t]*") + + def self.call(template) + new.call(template) + end + + def supports_streaming? + true + end + + def handles_encoding? + true + end + + def call(template) + # First, convert to BINARY, so in case the encoding is + # wrong, we can still find an encoding tag + # (<%# encoding %>) inside the String using a regular + # expression + template_source = template.source.dup.force_encoding(Encoding::ASCII_8BIT) + + erb = template_source.gsub(ENCODING_TAG, "") + encoding = $2 + + erb.force_encoding valid_encoding(template.source.dup, encoding) + + # Always make sure we return a String in the default_internal + erb.encode! + + self.class.erb_implementation.new( + erb, + escape: (self.class.escape_ignore_list.include? template.type), + trim: (self.class.erb_trim_mode == "-") + ).src + end + + private + + def valid_encoding(string, encoding) + # If a magic encoding comment was found, tag the + # String with this encoding. This is for a case + # where the original String was assumed to be, + # for instance, UTF-8, but a magic comment + # proved otherwise + string.force_encoding(encoding) if encoding + + # If the String is valid, return the encoding we found + return string.encoding if string.valid_encoding? + + # Otherwise, raise an exception + raise WrongEncodingError.new(string, string.encoding) + end + end + end + end +end diff --git a/actionview/lib/action_view/template/handlers/erb/erubi.rb b/actionview/lib/action_view/template/handlers/erb/erubi.rb new file mode 100644 index 0000000000..db75f028ed --- /dev/null +++ b/actionview/lib/action_view/template/handlers/erb/erubi.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "erubi" + +module ActionView + class Template + module Handlers + class ERB + class Erubi < ::Erubi::Engine + # :nodoc: all + def initialize(input, properties = {}) + @newline_pending = 0 + + # Dup properties so that we don't modify argument + properties = Hash[properties] + properties[:preamble] = "@output_buffer = output_buffer || ActionView::OutputBuffer.new;" + properties[:postamble] = "@output_buffer.to_s" + properties[:bufvar] = "@output_buffer" + properties[:escapefunc] = "" + + super + end + + def evaluate(action_view_erb_handler_context) + pr = eval("proc { #{@src} }", binding, @filename || "(erubi)") + action_view_erb_handler_context.instance_eval(&pr) + end + + private + def add_text(text) + return if text.empty? + + if text == "\n" + @newline_pending += 1 + else + src << "@output_buffer.safe_append='" + src << "\n" * @newline_pending if @newline_pending > 0 + src << text.gsub(/['\\]/, '\\\\\&') + src << "'.freeze;" + + @newline_pending = 0 + end + end + + BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/ + + def add_expression(indicator, code) + flush_newline_if_pending(src) + + if (indicator == "==") || @escape + src << "@output_buffer.safe_expr_append=" + else + src << "@output_buffer.append=" + end + + if BLOCK_EXPR.match?(code) + src << " " << code + else + src << "(" << code << ");" + end + end + + def add_code(code) + flush_newline_if_pending(src) + super + end + + def add_postamble(_) + flush_newline_if_pending(src) + super + end + + def flush_newline_if_pending(src) + if @newline_pending > 0 + src << "@output_buffer.safe_append='#{"\n" * @newline_pending}'.freeze;" + @newline_pending = 0 + end + end + end + end + end + end +end diff --git a/actionview/lib/action_view/template/handlers/html.rb b/actionview/lib/action_view/template/handlers/html.rb new file mode 100644 index 0000000000..27004a318c --- /dev/null +++ b/actionview/lib/action_view/template/handlers/html.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ActionView + module Template::Handlers + class Html < Raw + def call(template) + "ActionView::OutputBuffer.new #{super}" + end + end + end +end diff --git a/actionview/lib/action_view/template/handlers/raw.rb b/actionview/lib/action_view/template/handlers/raw.rb new file mode 100644 index 0000000000..5cd23a0060 --- /dev/null +++ b/actionview/lib/action_view/template/handlers/raw.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ActionView + module Template::Handlers + class Raw + def call(template) + "#{template.source.inspect}.html_safe;" + end + end + end +end diff --git a/actionview/lib/action_view/template/html.rb b/actionview/lib/action_view/template/html.rb new file mode 100644 index 0000000000..a262c6d9ad --- /dev/null +++ b/actionview/lib/action_view/template/html.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ActionView #:nodoc: + # = Action View HTML Template + class Template #:nodoc: + class HTML #:nodoc: + attr_accessor :type + + def initialize(string, type = nil) + @string = string.to_s + @type = Types[type] || type if type + @type ||= Types[:html] + end + + def identifier + "html template" + end + + alias_method :inspect, :identifier + + def to_str + ERB::Util.h(@string) + end + + def render(*args) + to_str + end + + def formats + [@type.respond_to?(:ref) ? @type.ref : @type.to_s] + end + end + end +end diff --git a/actionview/lib/action_view/template/resolver.rb b/actionview/lib/action_view/template/resolver.rb new file mode 100644 index 0000000000..12ae82f8c5 --- /dev/null +++ b/actionview/lib/action_view/template/resolver.rb @@ -0,0 +1,431 @@ +# frozen_string_literal: true + +require "pathname" +require "active_support/core_ext/class" +require "active_support/core_ext/module/attribute_accessors" +require "action_view/template" +require "thread" +require "concurrent/map" + +module ActionView + # = Action View Resolver + class Resolver + # Keeps all information about view path and builds virtual path. + class Path + attr_reader :name, :prefix, :partial, :virtual + alias_method :partial?, :partial + + def self.build(name, prefix, partial) + virtual = +"" + virtual << "#{prefix}/" unless prefix.empty? + virtual << (partial ? "_#{name}" : name) + new name, prefix, partial, virtual + end + + def initialize(name, prefix, partial, virtual) + @name = name + @prefix = prefix + @partial = partial + @virtual = virtual + end + + def to_str + @virtual + end + alias :to_s :to_str + end + + # Threadsafe template cache + class Cache #:nodoc: + class SmallCache < Concurrent::Map + def initialize(options = {}) + super(options.merge(initial_capacity: 2)) + end + end + + # preallocate all the default blocks for performance/memory consumption reasons + PARTIAL_BLOCK = lambda { |cache, partial| cache[partial] = SmallCache.new } + PREFIX_BLOCK = lambda { |cache, prefix| cache[prefix] = SmallCache.new(&PARTIAL_BLOCK) } + NAME_BLOCK = lambda { |cache, name| cache[name] = SmallCache.new(&PREFIX_BLOCK) } + KEY_BLOCK = lambda { |cache, key| cache[key] = SmallCache.new(&NAME_BLOCK) } + + # usually a majority of template look ups return nothing, use this canonical preallocated array to save memory + NO_TEMPLATES = [].freeze + + def initialize + @data = SmallCache.new(&KEY_BLOCK) + @query_cache = SmallCache.new + end + + def inspect + "#<#{self.class.name}:0x#{(object_id << 1).to_s(16)} keys=#{@data.size} queries=#{@query_cache.size}>" + end + + # Cache the templates returned by the block + def cache(key, name, prefix, partial, locals) + if Resolver.caching? + @data[key][name][prefix][partial][locals] ||= canonical_no_templates(yield) + else + fresh_templates = yield + cached_templates = @data[key][name][prefix][partial][locals] + + if templates_have_changed?(cached_templates, fresh_templates) + @data[key][name][prefix][partial][locals] = canonical_no_templates(fresh_templates) + else + cached_templates || NO_TEMPLATES + end + end + end + + def cache_query(query) # :nodoc: + if Resolver.caching? + @query_cache[query] ||= canonical_no_templates(yield) + else + yield + end + end + + def clear + @data.clear + @query_cache.clear + end + + # Get the cache size. Do not call this + # method. This method is not guaranteed to be here ever. + def size # :nodoc: + size = 0 + @data.each_value do |v1| + v1.each_value do |v2| + v2.each_value do |v3| + v3.each_value do |v4| + size += v4.size + end + end + end + end + + size + @query_cache.size + end + + private + + def canonical_no_templates(templates) + templates.empty? ? NO_TEMPLATES : templates + end + + def templates_have_changed?(cached_templates, fresh_templates) + # if either the old or new template list is empty, we don't need to (and can't) + # compare modification times, and instead just check whether the lists are different + if cached_templates.blank? || fresh_templates.blank? + return fresh_templates.blank? != cached_templates.blank? + end + + cached_templates_max_updated_at = cached_templates.map(&:updated_at).max + + # if a template has changed, it will be now be newer than all the cached templates + fresh_templates.any? { |t| t.updated_at > cached_templates_max_updated_at } + end + end + + cattr_accessor :caching, default: true + + class << self + alias :caching? :caching + end + + def initialize + @cache = Cache.new + end + + def clear_cache + @cache.clear + end + + # Normalizes the arguments and passes it on to find_templates. + def find_all(name, prefix = nil, partial = false, details = {}, key = nil, locals = []) + cached(key, [name, prefix, partial], details, locals) do + find_templates(name, prefix, partial, details) + end + end + + def find_all_anywhere(name, prefix, partial = false, details = {}, key = nil, locals = []) + cached(key, [name, prefix, partial], details, locals) do + find_templates(name, prefix, partial, details, true) + end + end + + def find_all_with_query(query) # :nodoc: + @cache.cache_query(query) { find_template_paths(File.join(@path, query)) } + end + + private + + delegate :caching?, to: :class + + # This is what child classes implement. No defaults are needed + # because Resolver guarantees that the arguments are present and + # normalized. + def find_templates(name, prefix, partial, details, outside_app_allowed = false) + raise NotImplementedError, "Subclasses must implement a find_templates(name, prefix, partial, details, outside_app_allowed = false) method" + end + + # Helpers that builds a path. Useful for building virtual paths. + def build_path(name, prefix, partial) + Path.build(name, prefix, partial) + end + + # Handles templates caching. If a key is given and caching is on + # always check the cache before hitting the resolver. Otherwise, + # it always hits the resolver but if the key is present, check if the + # resolver is fresher before returning it. + def cached(key, path_info, details, locals) + name, prefix, partial = path_info + locals = locals.map(&:to_s).sort! + + if key + @cache.cache(key, name, prefix, partial, locals) do + decorate(yield, path_info, details, locals) + end + else + decorate(yield, path_info, details, locals) + end + end + + # Ensures all the resolver information is set in the template. + def decorate(templates, path_info, details, locals) + cached = nil + templates.each do |t| + t.locals = locals + t.formats = details[:formats] || [:html] if t.formats.empty? + t.variants = details[:variants] || [] if t.variants.empty? + t.virtual_path ||= (cached ||= build_path(*path_info)) + end + end + end + + # An abstract class that implements a Resolver with path semantics. + class PathResolver < Resolver #:nodoc: + EXTENSIONS = { locale: ".", formats: ".", variants: "+", handlers: "." } + DEFAULT_PATTERN = ":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}" + + def initialize(pattern = nil) + @pattern = pattern || DEFAULT_PATTERN + super() + end + + private + + def find_templates(name, prefix, partial, details, outside_app_allowed = false) + path = Path.build(name, prefix, partial) + query(path, details, details[:formats], outside_app_allowed) + end + + def query(path, details, formats, outside_app_allowed) + template_paths = find_template_paths_from_details(path, details) + template_paths = reject_files_external_to_app(template_paths) unless outside_app_allowed + + template_paths.map do |template| + handler, format, variant = extract_handler_and_format_and_variant(template) + contents = File.binread(template) + + Template.new(contents, File.expand_path(template), handler, + virtual_path: path.virtual, + format: format, + variant: variant, + updated_at: mtime(template) + ) + end + end + + def reject_files_external_to_app(files) + files.reject { |filename| !inside_path?(@path, filename) } + end + + def find_template_paths_from_details(path, details) + query = build_query(path, details) + find_template_paths(query) + end + + def find_template_paths(query) + Dir[query].uniq.reject do |filename| + File.directory?(filename) || + # deals with case-insensitive file systems. + !File.fnmatch(query, filename, File::FNM_EXTGLOB) + end + end + + def inside_path?(path, filename) + filename = File.expand_path(filename) + path = File.join(path, "") + filename.start_with?(path) + end + + # Helper for building query glob string based on resolver's pattern. + def build_query(path, details) + query = @pattern.dup + + prefix = path.prefix.empty? ? "" : "#{escape_entry(path.prefix)}\\1" + query.gsub!(/:prefix(\/)?/, prefix) + + partial = escape_entry(path.partial? ? "_#{path.name}" : path.name) + query.gsub!(/:action/, partial) + + details.each do |ext, candidates| + if ext == :variants && candidates == :any + query.gsub!(/:#{ext}/, "*") + else + query.gsub!(/:#{ext}/, "{#{candidates.compact.uniq.join(',')}}") + end + end + + File.expand_path(query, @path) + end + + def escape_entry(entry) + entry.gsub(/[*?{}\[\]]/, '\\\\\\&') + end + + # Returns the file mtime from the filesystem. + def mtime(p) + File.mtime(p) + end + + # Extract handler, formats and variant from path. If a format cannot be found neither + # from the path, or the handler, we should return the array of formats given + # to the resolver. + def extract_handler_and_format_and_variant(path) + pieces = File.basename(path).split(".") + pieces.shift + + extension = pieces.pop + + handler = Template.handler_for_extension(extension) + format, variant = pieces.last.split(EXTENSIONS[:variants], 2) if pieces.last + format &&= Template::Types[format] + + [handler, format, variant] + end + end + + # A resolver that loads files from the filesystem. It allows setting your own + # resolving pattern. Such pattern can be a glob string supported by some variables. + # + # ==== Examples + # + # Default pattern, loads views the same way as previous versions of rails, eg. when you're + # looking for <tt>users/new</tt> it will produce query glob: <tt>users/new{.{en},}{.{html,js},}{.{erb,haml},}</tt> + # + # FileSystemResolver.new("/path/to/views", ":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}") + # + # This one allows you to keep files with different formats in separate subdirectories, + # eg. <tt>users/new.html</tt> will be loaded from <tt>users/html/new.erb</tt> or <tt>users/new.html.erb</tt>, + # <tt>users/new.js</tt> from <tt>users/js/new.erb</tt> or <tt>users/new.js.erb</tt>, etc. + # + # FileSystemResolver.new("/path/to/views", ":prefix/{:formats/,}:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}") + # + # If you don't specify a pattern then the default will be used. + # + # In order to use any of the customized resolvers above in a Rails application, you just need + # to configure ActionController::Base.view_paths in an initializer, for example: + # + # ActionController::Base.view_paths = FileSystemResolver.new( + # Rails.root.join("app/views"), + # ":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}", + # ) + # + # ==== Pattern format and variables + # + # Pattern has to be a valid glob string, and it allows you to use the + # following variables: + # + # * <tt>:prefix</tt> - usually the controller path + # * <tt>:action</tt> - name of the action + # * <tt>:locale</tt> - possible locale versions + # * <tt>:formats</tt> - possible request formats (for example html, json, xml...) + # * <tt>:variants</tt> - possible request variants (for example phone, tablet...) + # * <tt>:handlers</tt> - possible handlers (for example erb, haml, builder...) + # + class FileSystemResolver < PathResolver + def initialize(path, pattern = nil) + raise ArgumentError, "path already is a Resolver class" if path.is_a?(Resolver) + super(pattern) + @path = File.expand_path(path) + end + + def to_s + @path.to_s + end + alias :to_path :to_s + + def eql?(resolver) + self.class.equal?(resolver.class) && to_path == resolver.to_path + end + alias :== :eql? + end + + # An Optimized resolver for Rails' most common case. + class OptimizedFileSystemResolver < FileSystemResolver #:nodoc: + private + + def find_template_paths_from_details(path, details) + # Instead of checking for every possible path, as our other globs would + # do, scan the directory for files with the right prefix. + query = "#{escape_entry(File.join(@path, path))}*" + + regex = build_regex(path, details) + + Dir[query].uniq.reject do |filename| + # This regex match does double duty of finding only files which match + # details (instead of just matching the prefix) and also filtering for + # case-insensitive file systems. + !regex.match?(filename) || + File.directory?(filename) + end.sort_by do |filename| + # Because we scanned the directory, instead of checking for files + # one-by-one, they will be returned in an arbitrary order. + # We can use the matches found by the regex and sort by their index in + # details. + match = filename.match(regex) + EXTENSIONS.keys.reverse.map do |ext| + if ext == :variants && details[ext] == :any + match[ext].nil? ? 0 : 1 + elsif match[ext].nil? + # No match should be last + details[ext].length + else + found = match[ext].to_sym + details[ext].index(found) + end + end + end + end + + def build_regex(path, details) + query = escape_entry(File.join(@path, path)) + exts = EXTENSIONS.map do |ext, prefix| + match = + if ext == :variants && details[ext] == :any + ".*?" + else + details[ext].compact.uniq.map { |e| Regexp.escape(e) }.join("|") + end + prefix = Regexp.escape(prefix) + "(#{prefix}(?<#{ext}>#{match}))?" + end.join + + %r{\A#{query}#{exts}\z} + end + end + + # The same as FileSystemResolver but does not allow templates to store + # a virtual path since it is invalid for such resolvers. + class FallbackFileSystemResolver < FileSystemResolver #:nodoc: + def self.instances + [new(""), new("/")] + end + + def decorate(*) + super.each { |t| t.virtual_path = nil } + end + end +end diff --git a/actionview/lib/action_view/template/text.rb b/actionview/lib/action_view/template/text.rb new file mode 100644 index 0000000000..f8d6c2811f --- /dev/null +++ b/actionview/lib/action_view/template/text.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ActionView #:nodoc: + # = Action View Text Template + class Template #:nodoc: + class Text #:nodoc: + attr_accessor :type + + def initialize(string) + @string = string.to_s + @type = Types[:text] + end + + def identifier + "text template" + end + + alias_method :inspect, :identifier + + def to_str + @string + end + + def render(*args) + to_str + end + + def formats + [@type.ref] + end + end + end +end diff --git a/actionview/lib/action_view/template/types.rb b/actionview/lib/action_view/template/types.rb new file mode 100644 index 0000000000..67b7a62de6 --- /dev/null +++ b/actionview/lib/action_view/template/types.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attribute_accessors" + +module ActionView + class Template #:nodoc: + class Types + class Type + SET = Struct.new(:symbols).new([ :html, :text, :js, :css, :xml, :json ]) + + def self.[](type) + if type.is_a?(self) + type + else + new(type) + end + end + + attr_reader :symbol + + def initialize(symbol) + @symbol = symbol.to_sym + end + + def to_s + @symbol.to_s + end + alias to_str to_s + + def ref + @symbol + end + alias to_sym ref + + def ==(type) + @symbol == type.to_sym unless type.blank? + end + end + + cattr_accessor :type_klass + + def self.delegate_to(klass) + self.type_klass = klass + end + + delegate_to Type + + def self.[](type) + type_klass[type] + end + + def self.symbols + type_klass::SET.symbols + end + end + end +end diff --git a/actionview/lib/action_view/test_case.rb b/actionview/lib/action_view/test_case.rb new file mode 100644 index 0000000000..e14f7aaec7 --- /dev/null +++ b/actionview/lib/action_view/test_case.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/redefine_method" +require "action_controller" +require "action_controller/test_case" +require "action_view" + +require "rails-dom-testing" + +module ActionView + # = Action View Test Case + class TestCase < ActiveSupport::TestCase + class TestController < ActionController::Base + include ActionDispatch::TestProcess + + attr_accessor :request, :response, :params + + class << self + attr_writer :controller_path + end + + def controller_path=(path) + self.class.controller_path = (path) + end + + def initialize + super + self.class.controller_path = "" + @request = ActionController::TestRequest.create(self.class) + @response = ActionDispatch::TestResponse.new + + @request.env.delete("PATH_INFO") + @params = ActionController::Parameters.new + end + end + + module Behavior + extend ActiveSupport::Concern + + include ActionDispatch::Assertions, ActionDispatch::TestProcess + include Rails::Dom::Testing::Assertions + include ActionController::TemplateAssertions + include ActionView::Context + + include ActionDispatch::Routing::PolymorphicRoutes + + include AbstractController::Helpers + include ActionView::Helpers + include ActionView::RecordIdentifier + include ActionView::RoutingUrlFor + + include ActiveSupport::Testing::ConstantLookup + + delegate :lookup_context, to: :controller + attr_accessor :controller, :output_buffer, :rendered + + module ClassMethods + def tests(helper_class) + case helper_class + when String, Symbol + self.helper_class = "#{helper_class.to_s.underscore}_helper".camelize.safe_constantize + when Module + self.helper_class = helper_class + end + end + + def determine_default_helper_class(name) + determine_constant_from_test_name(name) do |constant| + Module === constant && !(Class === constant) + end + end + + def helper_method(*methods) + # Almost a duplicate from ActionController::Helpers + methods.flatten.each do |method| + _helpers.module_eval <<-end_eval, __FILE__, __LINE__ + 1 + def #{method}(*args, &block) # def current_user(*args, &block) + _test_case.send(%(#{method}), *args, &block) # _test_case.send(%(current_user), *args, &block) + end # end + end_eval + end + end + + attr_writer :helper_class + + def helper_class + @helper_class ||= determine_default_helper_class(name) + end + + def new(*) + include_helper_modules! + super + end + + private + + def include_helper_modules! + helper(helper_class) if helper_class + include _helpers + end + end + + def setup_with_controller + @controller = ActionView::TestCase::TestController.new + @request = @controller.request + @view_flow = ActionView::OutputFlow.new + # empty string ensures buffer has UTF-8 encoding as + # new without arguments returns ASCII-8BIT encoded buffer like String#new + @output_buffer = ActiveSupport::SafeBuffer.new "" + @rendered = +"" + + make_test_case_available_to_view! + say_no_to_protect_against_forgery! + end + + def config + @controller.config if @controller.respond_to?(:config) + end + + def render(options = {}, local_assigns = {}, &block) + view.assign(view_assigns) + @rendered << output = view.render(options, local_assigns, &block) + output + end + + def rendered_views + @_rendered_views ||= RenderedViewsCollection.new + end + + def _routes + @controller._routes if @controller.respond_to?(:_routes) + end + + # Need to experiment if this priority is the best one: rendered => output_buffer + class RenderedViewsCollection + def initialize + @rendered_views ||= Hash.new { |hash, key| hash[key] = [] } + end + + def add(view, locals) + @rendered_views[view] ||= [] + @rendered_views[view] << locals + end + + def locals_for(view) + @rendered_views[view] + end + + def rendered_views + @rendered_views.keys + end + + def view_rendered?(view, expected_locals) + locals_for(view).any? do |actual_locals| + expected_locals.all? { |key, value| value == actual_locals[key] } + end + end + end + + included do + setup :setup_with_controller + ActiveSupport.run_load_hooks(:action_view_test_case, self) + end + + private + + # Need to experiment if this priority is the best one: rendered => output_buffer + def document_root_element + Nokogiri::HTML::Document.parse(@rendered.blank? ? @output_buffer : @rendered).root + end + + def say_no_to_protect_against_forgery! + _helpers.module_eval do + silence_redefinition_of_method :protect_against_forgery? + def protect_against_forgery? + false + end + end + end + + def make_test_case_available_to_view! + test_case_instance = self + _helpers.module_eval do + unless private_method_defined?(:_test_case) + define_method(:_test_case) { test_case_instance } + private :_test_case + end + end + end + + module Locals + attr_accessor :rendered_views + + def render(options = {}, local_assigns = {}) + case options + when Hash + if block_given? + rendered_views.add options[:layout], options[:locals] + elsif options.key?(:partial) + rendered_views.add options[:partial], options[:locals] + end + else + rendered_views.add options, local_assigns + end + + super + end + end + + # The instance of ActionView::Base that is used by +render+. + def view + @view ||= begin + view = @controller.view_context + view.singleton_class.include(_helpers) + view.extend(Locals) + view.rendered_views = rendered_views + view.output_buffer = output_buffer + view + end + end + + alias_method :_view, :view + + INTERNAL_IVARS = [ + :@NAME, + :@failures, + :@assertions, + :@__io__, + :@_assertion_wrapped, + :@_assertions, + :@_result, + :@_routes, + :@controller, + :@_layouts, + :@_files, + :@_rendered_views, + :@method_name, + :@output_buffer, + :@_partials, + :@passed, + :@rendered, + :@request, + :@routes, + :@tagged_logger, + :@_templates, + :@options, + :@test_passed, + :@view, + :@view_context_class, + :@view_flow, + :@_subscribers, + :@html_document + ] + + def _user_defined_ivars + instance_variables - INTERNAL_IVARS + end + + # Returns a Hash of instance variables and their values, as defined by + # the user in the test case, which are then assigned to the view being + # rendered. This is generally intended for internal use and extension + # frameworks. + def view_assigns + Hash[_user_defined_ivars.map do |ivar| + [ivar[1..-1].to_sym, instance_variable_get(ivar)] + end] + end + + def method_missing(selector, *args) + begin + routes = @controller.respond_to?(:_routes) && @controller._routes + rescue + # Don't call routes, if there is an error on _routes call + end + + if routes && + (routes.named_routes.route_defined?(selector) || + routes.mounted_helpers.method_defined?(selector)) + @controller.__send__(selector, *args) + else + super + end + end + + def respond_to_missing?(name, include_private = false) + begin + routes = @controller.respond_to?(:_routes) && @controller._routes + rescue + # Don't call routes, if there is an error on _routes call + end + + routes && + (routes.named_routes.route_defined?(name) || + routes.mounted_helpers.method_defined?(name)) + end + end + + include Behavior + end +end diff --git a/actionview/lib/action_view/testing/resolvers.rb b/actionview/lib/action_view/testing/resolvers.rb new file mode 100644 index 0000000000..1fad08a689 --- /dev/null +++ b/actionview/lib/action_view/testing/resolvers.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "action_view/template/resolver" + +module ActionView #:nodoc: + # Use FixtureResolver in your tests to simulate the presence of files on the + # file system. This is used internally by Rails' own test suite, and is + # useful for testing extensions that have no way of knowing what the file + # system will look like at runtime. + class FixtureResolver < PathResolver + attr_reader :hash + + def initialize(hash = {}, pattern = nil) + super(pattern) + @hash = hash + end + + def to_s + @hash.keys.join(", ") + end + + private + + def query(path, exts, _, _) + query = +"" + EXTENSIONS.each_key do |ext| + query << "(" << exts[ext].map { |e| e && Regexp.escape(".#{e}") }.join("|") << "|)" + end + query = /^(#{Regexp.escape(path)})#{query}$/ + + templates = [] + @hash.each do |_path, array| + source, updated_at = array + next unless query.match?(_path) + handler, format, variant = extract_handler_and_format_and_variant(_path) + templates << Template.new(source, _path, handler, + virtual_path: path.virtual, + format: format, + variant: variant, + updated_at: updated_at + ) + end + + templates.sort_by { |t| -t.identifier.match(/^#{query}$/).captures.reject(&:blank?).size } + end + end + + class NullResolver < PathResolver + def query(path, exts, _, _) + handler, format, variant = extract_handler_and_format_and_variant(path) + [ActionView::Template.new("Template generated by Null Resolver", path.virtual, handler, virtual_path: path.virtual, format: format, variant: variant)] + end + end +end diff --git a/actionview/lib/action_view/version.rb b/actionview/lib/action_view/version.rb new file mode 100644 index 0000000000..be53797a14 --- /dev/null +++ b/actionview/lib/action_view/version.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "gem_version" + +module ActionView + # Returns the version of the currently loaded ActionView as a <tt>Gem::Version</tt> + def self.version + gem_version + end +end diff --git a/actionview/lib/action_view/view_paths.rb b/actionview/lib/action_view/view_paths.rb new file mode 100644 index 0000000000..d5694d77f4 --- /dev/null +++ b/actionview/lib/action_view/view_paths.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module ActionView + module ViewPaths + extend ActiveSupport::Concern + + included do + class_attribute :_view_paths, default: ActionView::PathSet.new.freeze + end + + delegate :template_exists?, :any_templates?, :view_paths, :formats, :formats=, + :locale, :locale=, to: :lookup_context + + module ClassMethods + def _prefixes # :nodoc: + @_prefixes ||= begin + return local_prefixes if superclass.abstract? + + local_prefixes + superclass._prefixes + end + end + + private + + # Override this method in your controller if you want to change paths prefixes for finding views. + # Prefixes defined here will still be added to parents' <tt>._prefixes</tt>. + def local_prefixes + [controller_path] + end + end + + # The prefixes used in render "foo" shortcuts. + def _prefixes # :nodoc: + self.class._prefixes + end + + # <tt>LookupContext</tt> is the object responsible for holding all + # information required for looking up templates, i.e. view paths and + # details. Check <tt>ActionView::LookupContext</tt> for more information. + def lookup_context + @_lookup_context ||= + ActionView::LookupContext.new(self.class._view_paths, details_for_lookup, _prefixes) + end + + def details_for_lookup + {} + end + + # Append a path to the list of view paths for the current <tt>LookupContext</tt>. + # + # ==== Parameters + # * <tt>path</tt> - If a String is provided, it gets converted into + # the default view path. You may also provide a custom view path + # (see ActionView::PathSet for more information) + def append_view_path(path) + lookup_context.view_paths.push(*path) + end + + # Prepend a path to the list of view paths for the current <tt>LookupContext</tt>. + # + # ==== Parameters + # * <tt>path</tt> - If a String is provided, it gets converted into + # the default view path. You may also provide a custom view path + # (see ActionView::PathSet for more information) + def prepend_view_path(path) + lookup_context.view_paths.unshift(*path) + end + + module ClassMethods + # Append a path to the list of view paths for this controller. + # + # ==== Parameters + # * <tt>path</tt> - If a String is provided, it gets converted into + # the default view path. You may also provide a custom view path + # (see ActionView::PathSet for more information) + def append_view_path(path) + self._view_paths = view_paths + Array(path) + end + + # Prepend a path to the list of view paths for this controller. + # + # ==== Parameters + # * <tt>path</tt> - If a String is provided, it gets converted into + # the default view path. You may also provide a custom view path + # (see ActionView::PathSet for more information) + def prepend_view_path(path) + self._view_paths = ActionView::PathSet.new(Array(path) + view_paths) + end + + # A list of all of the default view paths for this controller. + def view_paths + _view_paths + end + + # Set the view paths. + # + # ==== Parameters + # * <tt>paths</tt> - If a PathSet is provided, use that; + # otherwise, process the parameter into a PathSet. + def view_paths=(paths) + self._view_paths = ActionView::PathSet.new(Array(paths)) + end + end + end +end diff --git a/actionview/package.json b/actionview/package.json new file mode 100644 index 0000000000..1f74df79d3 --- /dev/null +++ b/actionview/package.json @@ -0,0 +1,36 @@ +{ + "name": "rails-ujs", + "version": "6.0.0-alpha", + "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/blob/master/actionview/app/assets/javascripts#how-to-run-tests\" && exit 1", + "lint": "coffeelint app/assets/javascripts && eslint test/ujs/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": "^2.1.0", + "eslint": "^2.13.1" + } +} diff --git a/actionview/test/abstract_unit.rb b/actionview/test/abstract_unit.rb new file mode 100644 index 0000000000..f90626ad9e --- /dev/null +++ b/actionview/test/abstract_unit.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +$:.unshift File.expand_path("lib", __dir__) +$:.unshift File.expand_path("fixtures/helpers", __dir__) +$:.unshift File.expand_path("fixtures/alternate_helpers", __dir__) + +ENV["TMPDIR"] = File.expand_path("tmp", __dir__) + +require "active_support/core_ext/kernel/reporting" + +# These are the normal settings that will be set up by Railties +# TODO: Have these tests support other combinations of these values +silence_warnings do + Encoding.default_internal = Encoding::UTF_8 + Encoding.default_external = Encoding::UTF_8 +end + +require "active_support/testing/autorun" +require "active_support/testing/method_call_assertions" +require "action_controller" +require "action_view" +require "action_view/testing/resolvers" +require "active_support/dependencies" +require "active_model" +require "active_record" + +require "pp" # require 'pp' early to prevent hidden_methods from not picking up the pretty-print methods until too late + +ActiveSupport::Dependencies.hook! + +Thread.abort_on_exception = true + +# Show backtraces for deprecated behavior for quicker cleanup. +ActiveSupport::Deprecation.debug = true + +# Disable available locale checks to avoid warnings running the test suite. +I18n.enforce_available_locales = false + +# Register danish language for testing +I18n.backend.store_translations "da", {} +I18n.backend.store_translations "pt-BR", {} +ORIGINAL_LOCALES = I18n.available_locales.map(&:to_s).sort + +FIXTURE_LOAD_PATH = File.expand_path("fixtures", __dir__) + +module RenderERBUtils + def view + @view ||= begin + path = ActionView::FileSystemResolver.new(FIXTURE_LOAD_PATH) + view_paths = ActionView::PathSet.new([path]) + ActionView::Base.new(view_paths) + end + end + + def render_erb(string) + @virtual_path = nil + + template = ActionView::Template.new( + string.strip, + "test template", + ActionView::Template::Handlers::ERB, + {}) + + template.render(self, {}).strip + end +end + +class RoutedRackApp + attr_reader :routes + + def initialize(routes, &blk) + @routes = routes + @stack = ActionDispatch::MiddlewareStack.new(&blk).build(@routes) + end + + def call(env) + @stack.call(env) + end +end + +class BasicController + attr_accessor :request + + def config + @config ||= ActiveSupport::InheritableOptions.new(ActionController::Base.config).tap do |config| + # VIEW TODO: View tests should not require a controller + public_dir = File.expand_path("fixtures/public", __dir__) + config.assets_dir = public_dir + config.javascripts_dir = "#{public_dir}/javascripts" + config.stylesheets_dir = "#{public_dir}/stylesheets" + config.assets = ActiveSupport::InheritableOptions.new(prefix: "assets") + config + end + end +end + +class ActionDispatch::IntegrationTest < ActiveSupport::TestCase + def self.build_app(routes = nil) + routes ||= ActionDispatch::Routing::RouteSet.new.tap { |rs| + rs.draw { } + } + RoutedRackApp.new(routes) do |middleware| + middleware.use ActionDispatch::ShowExceptions, ActionDispatch::PublicExceptions.new("#{FIXTURE_LOAD_PATH}/public") + middleware.use ActionDispatch::DebugExceptions + middleware.use ActionDispatch::Callbacks + middleware.use ActionDispatch::Cookies + middleware.use ActionDispatch::Flash + middleware.use Rack::Head + yield(middleware) if block_given? + end + end + + self.app = build_app + + def with_routing(&block) + temporary_routes = ActionDispatch::Routing::RouteSet.new + old_app, self.class.app = self.class.app, self.class.build_app(temporary_routes) + + yield temporary_routes + ensure + self.class.app = old_app + end +end + +ActionView::RoutingUrlFor.include(ActionDispatch::Routing::UrlFor) + +module ActionController + class Base + self.view_paths = FIXTURE_LOAD_PATH + + def self.test_routes(&block) + routes = ActionDispatch::Routing::RouteSet.new + routes.draw(&block) + include routes.url_helpers + routes + end + end + + class TestCase + include ActionDispatch::TestProcess + + def self.with_routes(&block) + routes = ActionDispatch::Routing::RouteSet.new + routes.draw(&block) + include Module.new { + define_method(:setup) do + super() + @routes = routes + @controller.singleton_class.include @routes.url_helpers + end + } + routes + end + + def with_routes(&block) + @routes = ActionDispatch::Routing::RouteSet.new + @routes.draw(&block) + @routes + end + end +end + +class Workshop + extend ActiveModel::Naming + include ActiveModel::Conversion + attr_accessor :id + + def initialize(id) + @id = id + end + + def persisted? + id.present? + end + + def to_s + id.to_s + end +end + +module ActionDispatch + class DebugExceptions + private + remove_method :stderr_logger + # Silence logger + def stderr_logger + nil + end + end +end + +class ActiveSupport::TestCase + include ActiveSupport::Testing::MethodCallAssertions + + private + # Skips the current run on Rubinius using Minitest::Assertions#skip + def rubinius_skip(message = "") + skip message if RUBY_ENGINE == "rbx" + end + + # Skips the current run on JRuby using Minitest::Assertions#skip + def jruby_skip(message = "") + skip message if defined?(JRUBY_VERSION) + end +end diff --git a/actionview/test/actionpack/abstract/abstract_controller_test.rb b/actionview/test/actionpack/abstract/abstract_controller_test.rb new file mode 100644 index 0000000000..4d4e2b8ef2 --- /dev/null +++ b/actionview/test/actionpack/abstract/abstract_controller_test.rb @@ -0,0 +1,292 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "set" + +module AbstractController + module Testing + # Test basic dispatching. + # ==== + # * Call process + # * Test that the response_body is set correctly + class SimpleController < AbstractController::Base + end + + class Me < SimpleController + def index + self.response_body = "Hello world" + "Something else" + end + end + + class TestBasic < ActiveSupport::TestCase + test "dispatching works" do + controller = Me.new + controller.process(:index) + assert_equal "Hello world", controller.response_body + end + end + + # Test Render mixin + # ==== + class RenderingController < AbstractController::Base + include AbstractController::Rendering + include ActionView::Rendering + + def _prefixes + [] + end + + def render(options = {}) + if options.is_a?(String) + options = { _template_name: options } + end + super + end + + append_view_path File.expand_path("views", __dir__) + end + + class Me2 < RenderingController + def index + render "index.erb" + end + + def index_to_string + self.response_body = render_to_string "index" + end + + def action_with_ivars + @my_ivar = "Hello" + render "action_with_ivars.erb" + end + + def naked_render + render + end + + def rendering_to_body + self.response_body = render_to_body template: "naked_render" + end + + def rendering_to_string + self.response_body = render_to_string template: "naked_render" + end + end + + class TestRenderingController < ActiveSupport::TestCase + def setup + @controller = Me2.new + end + + test "rendering templates works" do + @controller.process(:index) + assert_equal "Hello from index.erb", @controller.response_body + end + + test "render_to_string works with a String as an argument" do + @controller.process(:index_to_string) + assert_equal "Hello from index.erb", @controller.response_body + end + + test "rendering passes ivars to the view" do + @controller.process(:action_with_ivars) + assert_equal "Hello from index_with_ivars.erb", @controller.response_body + end + + test "rendering with no template name" do + @controller.process(:naked_render) + assert_equal "Hello from naked_render.erb", @controller.response_body + end + + test "rendering to a rack body" do + @controller.process(:rendering_to_body) + assert_equal "Hello from naked_render.erb", @controller.response_body + end + + test "rendering to a string" do + @controller.process(:rendering_to_string) + assert_equal "Hello from naked_render.erb", @controller.response_body + end + end + + # Test rendering with prefixes + # ==== + # * self._prefix is used when defined + class PrefixedViews < RenderingController + private + def self.prefix + name.underscore + end + + def _prefixes + [self.class.prefix] + end + end + + class Me3 < PrefixedViews + def index + render + end + + def formatted + self.formats = [:html] + render + end + end + + class TestPrefixedViews < ActiveSupport::TestCase + def setup + @controller = Me3.new + end + + test "templates are located inside their 'prefix' folder" do + @controller.process(:index) + assert_equal "Hello from me3/index.erb", @controller.response_body + end + + test "templates included their format" do + @controller.process(:formatted) + assert_equal "Hello from me3/formatted.html.erb", @controller.response_body + end + end + + class OverridingLocalPrefixes < AbstractController::Base + include AbstractController::Rendering + include ActionView::Rendering + append_view_path File.expand_path("views", __dir__) + + def index + render + end + + def self.local_prefixes + # this would usually return "abstract_controller/testing/overriding_local_prefixes" + super + ["abstract_controller/testing/me3"] + end + + class Inheriting < self + end + end + + class OverridingLocalPrefixesTest < ActiveSupport::TestCase + test "overriding .local_prefixes adds prefix" do + @controller = OverridingLocalPrefixes.new + @controller.process(:index) + assert_equal "Hello from me3/index.erb", @controller.response_body + end + + test ".local_prefixes is inherited" do + @controller = OverridingLocalPrefixes::Inheriting.new + @controller.process(:index) + assert_equal "Hello from me3/index.erb", @controller.response_body + end + end + + # Test rendering with layouts + # ==== + # self._layout is used when defined + class WithLayouts < PrefixedViews + include ActionView::Layouts + + private + def self.layout(formats) + find_template(name.underscore, { formats: formats }, { _prefixes: ["layouts"] }) + rescue ActionView::MissingTemplate + begin + find_template("application", { formats: formats }, { _prefixes: ["layouts"] }) + rescue ActionView::MissingTemplate + end + end + + def render_to_body(options = {}) + options[:_layout] = options[:layout] || _default_layout({}) + super + end + end + + class Me4 < WithLayouts + def index + render + end + end + + class TestLayouts < ActiveSupport::TestCase + test "layouts are included" do + controller = Me4.new + controller.process(:index) + assert_equal "Me4 Enter : Hello from me4/index.erb : Exit", controller.response_body + end + end + + # respond_to_action?(action_name) + # ==== + # * A method can be used as an action only if this method + # returns true when passed the method name as an argument + # * Defaults to true in AbstractController + class DefaultRespondToActionController < AbstractController::Base + def index() self.response_body = "success" end + end + + class ActionMissingRespondToActionController < AbstractController::Base + # No actions + private + def action_missing(action_name) + self.response_body = "success" + end + end + + class RespondToActionController < AbstractController::Base + def index() self.response_body = "success" end + + def fail() self.response_body = "fail" end + + private + + def method_for_action(action_name) + action_name.to_s != "fail" && action_name + end + end + + class TestRespondToAction < ActiveSupport::TestCase + def assert_dispatch(klass, body = "success", action = :index) + controller = klass.new + controller.process(action) + assert_equal body, controller.response_body + end + + test "an arbitrary method is available as an action by default" do + assert_dispatch DefaultRespondToActionController, "success", :index + end + + test "raises ActionNotFound when method does not exist and action_missing is not defined" do + assert_raise(ActionNotFound) { DefaultRespondToActionController.new.process(:fail) } + end + + test "dispatches to action_missing when method does not exist and action_missing is defined" do + assert_dispatch ActionMissingRespondToActionController, "success", :ohai + end + + test "a method is available as an action if method_for_action returns true" do + assert_dispatch RespondToActionController, "success", :index + end + + test "raises ActionNotFound if method is defined but method_for_action returns false" do + assert_raise(ActionNotFound) { RespondToActionController.new.process(:fail) } + end + end + + class Me6 < AbstractController::Base + action_methods + + def index + end + end + + class TestActionMethodsReloading < ActiveSupport::TestCase + test "action_methods should be reloaded after defining a new method" do + assert_equal Set.new(["index"]), Me6.action_methods + end + end + end +end diff --git a/actionview/test/actionpack/abstract/helper_test.rb b/actionview/test/actionpack/abstract/helper_test.rb new file mode 100644 index 0000000000..480ff60ba2 --- /dev/null +++ b/actionview/test/actionpack/abstract/helper_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "abstract_unit" + +ActionController::Base.helpers_path = File.expand_path("../../fixtures/helpers", __dir__) + +module AbstractController + module Testing + class ControllerWithHelpers < AbstractController::Base + include AbstractController::Helpers + include AbstractController::Rendering + include ActionView::Rendering + + def with_module + render inline: "Module <%= included_method %>" + end + end + + module HelperyTest + def included_method + "Included" + end + end + + class AbstractHelpers < ControllerWithHelpers + helper(HelperyTest) do + def helpery_test + "World" + end + end + + helper :abc + + def with_block + render inline: "Hello <%= helpery_test %>" + end + + def with_symbol + render inline: "I respond to bare_a: <%= respond_to?(:bare_a) %>" + end + end + + class ::HelperyTestController < AbstractHelpers + clear_helpers + end + + class AbstractHelpersBlock < ControllerWithHelpers + helper do + include AbstractController::Testing::HelperyTest + end + end + + class AbstractInvalidHelpers < AbstractHelpers + include ActionController::Helpers + + path = File.expand_path("../../fixtures/helpers_missing", __dir__) + $:.unshift(path) + self.helpers_path = path + end + + class TestHelpers < ActiveSupport::TestCase + def setup + @controller = AbstractHelpers.new + end + + def test_helpers_with_block + @controller.process(:with_block) + assert_equal "Hello World", @controller.response_body + end + + def test_helpers_with_module + @controller.process(:with_module) + assert_equal "Module Included", @controller.response_body + end + + def test_helpers_with_symbol + @controller.process(:with_symbol) + assert_equal "I respond to bare_a: true", @controller.response_body + end + + def test_declare_missing_helper + e = assert_raise AbstractController::Helpers::MissingHelperError do + AbstractHelpers.helper :missing + end + assert_equal "helpers/missing_helper.rb", e.path + end + + def test_helpers_with_module_through_block + @controller = AbstractHelpersBlock.new + @controller.process(:with_module) + assert_equal "Module Included", @controller.response_body + end + end + + class ClearHelpersTest < ActiveSupport::TestCase + def setup + @controller = HelperyTestController.new + end + + def test_clears_up_previous_helpers + @controller.process(:with_symbol) + assert_equal "I respond to bare_a: false", @controller.response_body + end + + def test_includes_controller_default_helper + @controller.process(:with_block) + assert_equal "Hello Default", @controller.response_body + end + end + + 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.rb", e.message + end + + def test_controller_raise_error_about_missing_helper + e = assert_raise(AbstractController::Helpers::MissingHelperError) { AbstractInvalidHelpers.helper(:missing) } + assert_equal "Missing helper file helpers/missing_helper.rb", e.message + end + + def test_missing_helper_error_has_the_right_path + e = assert_raise(AbstractController::Helpers::MissingHelperError) { AbstractInvalidHelpers.helper(:missing) } + assert_equal "helpers/missing_helper.rb", e.path + end + end + end +end diff --git a/actionview/test/actionpack/abstract/layouts_test.rb b/actionview/test/actionpack/abstract/layouts_test.rb new file mode 100644 index 0000000000..1146e6f64b --- /dev/null +++ b/actionview/test/actionpack/abstract/layouts_test.rb @@ -0,0 +1,568 @@ +# frozen_string_literal: true + +require "abstract_unit" + +module AbstractControllerTests + module Layouts + # Base controller for these tests + class Base < AbstractController::Base + include AbstractController::Rendering + include ActionView::Rendering + include ActionView::Layouts + + abstract! + + self.view_paths = [ActionView::FixtureResolver.new( + "some/template.erb" => "hello <%= foo %> bar", + "layouts/hello.erb" => "With String <%= yield %>", + "layouts/hello_locals.erb" => "With String <%= yield %>", + "layouts/hello_override.erb" => "With Override <%= yield %>", + "layouts/overwrite.erb" => "Overwrite <%= yield %>", + "layouts/with_false_layout.erb" => "False Layout <%= yield %>", + "abstract_controller_tests/layouts/with_string_implied_child.erb" => + "With Implied <%= yield %>", + "abstract_controller_tests/layouts/with_grand_child_of_implied.erb" => + "With Grand Child <%= yield %>" + + )] + end + + class Blank < Base + self.view_paths = [] + + def index + render template: ActionView::Template::Text.new("Hello blank!") + end + end + + class WithStringLocals < Base + layout "hello_locals" + + def index + render template: "some/template", locals: { foo: "less than 3" } + end + end + + class WithString < Base + layout "hello" + + def index + render template: ActionView::Template::Text.new("Hello string!") + end + + def action_has_layout_false + render template: ActionView::Template::Text.new("Hello string!") + end + + def overwrite_default + render template: ActionView::Template::Text.new("Hello string!"), layout: :default + end + + def overwrite_false + render template: ActionView::Template::Text.new("Hello string!"), layout: false + end + + def overwrite_string + render template: ActionView::Template::Text.new("Hello string!"), layout: "overwrite" + end + + def overwrite_skip + render plain: "Hello text!" + end + end + + class WithStringChild < WithString + end + + class WithStringOverriddenChild < WithString + layout "hello_override" + end + + class WithStringImpliedChild < WithString + layout nil + end + + class WithChildOfImplied < WithStringImpliedChild + end + + class WithGrandChildOfImplied < WithStringImpliedChild + layout nil + end + + class WithProc < Base + layout proc { "overwrite" } + + def index + render template: ActionView::Template::Text.new("Hello proc!") + end + end + + class WithProcReturningNil < WithString + layout proc { nil } + + def index + render template: ActionView::Template::Text.new("Hello nil!") + end + end + + class WithProcReturningFalse < WithString + layout proc { false } + + def index + render template: ActionView::Template::Text.new("Hello false!") + end + end + + class WithZeroArityProc < Base + layout proc { "overwrite" } + + def index + render template: ActionView::Template::Text.new("Hello zero arity proc!") + end + end + + class WithProcInContextOfInstance < Base + def an_instance_method; end + + layout proc { + break unless respond_to? :an_instance_method + "overwrite" + } + + def index + render template: ActionView::Template::Text.new("Hello again zero arity proc!") + end + end + + class WithSymbol < Base + layout :hello + + def index + render template: ActionView::Template::Text.new("Hello symbol!") + end + private + def hello + "overwrite" + end + end + + class WithSymbolReturningNil < Base + layout :nilz + + def index + render template: ActionView::Template::Text.new("Hello nilz!") + end + + def nilz() end + end + + class WithSymbolReturningObj < Base + layout :objekt + + def index + render template: ActionView::Template::Text.new("Hello nilz!") + end + + def objekt + Object.new + end + end + + class WithSymbolAndNoMethod < Base + layout :no_method + + def index + render template: ActionView::Template::Text.new("Hello boom!") + end + end + + class WithMissingLayout < Base + layout "missing" + + def index + render template: ActionView::Template::Text.new("Hello missing!") + end + end + + class WithFalseLayout < Base + layout false + + def index + render template: ActionView::Template::Text.new("Hello false!") + end + end + + class WithNilLayout < Base + layout nil + + def index + render template: ActionView::Template::Text.new("Hello nil!") + end + end + + class WithOnlyConditional < WithStringImpliedChild + layout "overwrite", only: :show + + def index + render template: ActionView::Template::Text.new("Hello index!") + end + + def show + render template: ActionView::Template::Text.new("Hello show!") + end + end + + class WithOnlyConditionalFlipped < WithOnlyConditional + layout "hello_override", only: :index + end + + class WithOnlyConditionalFlippedAndInheriting < WithOnlyConditional + layout nil, only: :index + end + + class WithExceptConditional < WithStringImpliedChild + layout "overwrite", except: :show + + def index + render template: ActionView::Template::Text.new("Hello index!") + end + + def show + render template: ActionView::Template::Text.new("Hello show!") + end + end + + class AbstractWithString < Base + layout "hello" + abstract! + end + + class AbstractWithStringChild < AbstractWithString + def index + render template: ActionView::Template::Text.new("Hello abstract child!") + end + end + + class AbstractWithStringChildDefaultsToInherited < AbstractWithString + layout nil + + def index + render template: ActionView::Template::Text.new("Hello abstract child!") + end + end + + class WithConditionalOverride < WithString + layout "overwrite", only: :overwritten + + def non_overwritten + render template: ActionView::Template::Text.new("Hello non overwritten!") + end + + def overwritten + render template: ActionView::Template::Text.new("Hello overwritten!") + end + end + + class WithConditionalOverrideFlipped < WithConditionalOverride + layout "hello_override", only: :non_overwritten + end + + class WithConditionalOverrideFlippedAndInheriting < WithConditionalOverride + layout nil, only: :non_overwritten + end + + class TestBase < ActiveSupport::TestCase + test "when no layout is specified, and no default is available, render without a layout" do + controller = Blank.new + controller.process(:index) + assert_equal "Hello blank!", controller.response_body + end + + test "with locals" do + controller = WithStringLocals.new + controller.process(:index) + assert_equal "With String hello less than 3 bar", controller.response_body + end + + test "cache should not grow when locals change for a string template" do + cache = WithString.view_paths.paths.first.instance_variable_get(:@cache) + + controller = WithString.new + controller.process(:index) # heat the cache + + size = cache.size + + 10.times do |x| + controller = WithString.new + controller.define_singleton_method :index do + render template: ActionView::Template::Text.new("Hello string!"), locals: { :"x#{x}" => :omg } + end + controller.process(:index) + end + + assert_equal size, cache.size + end + + test "when layout is specified as a string, render with that layout" do + controller = WithString.new + controller.process(:index) + assert_equal "With String Hello string!", controller.response_body + end + + test "when layout is overwritten by :default in render, render default layout" do + controller = WithString.new + controller.process(:overwrite_default) + assert_equal "With String Hello string!", controller.response_body + end + + test "when layout is overwritten by string in render, render new layout" do + controller = WithString.new + controller.process(:overwrite_string) + assert_equal "Overwrite Hello string!", controller.response_body + end + + test "when layout is overwritten by false in render, render no layout" do + controller = WithString.new + controller.process(:overwrite_false) + assert_equal "Hello string!", controller.response_body + end + + test "when text is rendered, render no layout" do + controller = WithString.new + controller.process(:overwrite_skip) + assert_equal "Hello text!", controller.response_body + end + + test "when layout is specified as a string, but the layout is missing, raise an exception" do + assert_raises(ActionView::MissingTemplate) { WithMissingLayout.new.process(:index) } + end + + test "when layout is specified as false, do not use a layout" do + controller = WithFalseLayout.new + controller.process(:index) + assert_equal "Hello false!", controller.response_body + end + + test "when layout is specified as nil, do not use a layout" do + controller = WithNilLayout.new + controller.process(:index) + assert_equal "Hello nil!", controller.response_body + end + + test "when layout is specified as a proc, do not leak any methods into controller's action_methods" do + assert_equal Set.new(["index"]), WithProc.action_methods + end + + test "when layout is specified as a proc, call it and use the layout returned" do + controller = WithProc.new + controller.process(:index) + assert_equal "Overwrite Hello proc!", controller.response_body + end + + test "when layout is specified as a proc and the proc returns nil, use inherited layout" do + controller = WithProcReturningNil.new + controller.process(:index) + assert_equal "With String Hello nil!", controller.response_body + end + + test "when layout is specified as a proc and the proc returns false, use no layout instead of inherited layout" do + controller = WithProcReturningFalse.new + controller.process(:index) + assert_equal "Hello false!", controller.response_body + end + + test "when layout is specified as a proc without parameters it works just the same" do + controller = WithZeroArityProc.new + controller.process(:index) + assert_equal "Overwrite Hello zero arity proc!", controller.response_body + end + + test "when layout is specified as a proc without parameters the block is evaluated in the context of an instance" do + controller = WithProcInContextOfInstance.new + controller.process(:index) + assert_equal "Overwrite Hello again zero arity proc!", controller.response_body + end + + test "when layout is specified as a symbol, call the requested method and use the layout returned" do + controller = WithSymbol.new + controller.process(:index) + assert_equal "Overwrite Hello symbol!", controller.response_body + end + + test "when layout is specified as a symbol and the method returns nil, don't use a layout" do + controller = WithSymbolReturningNil.new + controller.process(:index) + assert_equal "Hello nilz!", controller.response_body + end + + test "when the layout is specified as a symbol and the method doesn't exist, raise an exception" do + assert_raises(NameError) { WithSymbolAndNoMethod.new.process(:index) } + end + + test "when the layout is specified as a symbol and the method returns something besides a string/false/nil, raise an exception" do + assert_raises(ArgumentError) { WithSymbolReturningObj.new.process(:index) } + end + + test "when a child controller does not have a layout, use the parent controller layout" do + controller = WithStringChild.new + controller.process(:index) + assert_equal "With String Hello string!", controller.response_body + end + + test "when a child controller has specified a layout, use that layout and not the parent controller layout" do + controller = WithStringOverriddenChild.new + controller.process(:index) + assert_equal "With Override Hello string!", controller.response_body + end + + test "when a child controller has an implied layout, use that layout and not the parent controller layout" do + controller = WithStringImpliedChild.new + controller.process(:index) + assert_equal "With Implied Hello string!", controller.response_body + end + + test "when a grandchild has no layout specified, the child has an implied layout, and the " \ + "parent has specified a layout, use the child controller layout" do + controller = WithChildOfImplied.new + controller.process(:index) + assert_equal "With Implied Hello string!", controller.response_body + end + + test "when a grandchild has nil layout specified, the child has an implied layout, and the " \ + "parent has specified a layout, use the grand child controller layout" do + controller = WithGrandChildOfImplied.new + controller.process(:index) + assert_equal "With Grand Child Hello string!", controller.response_body + end + + test "a child inherits layout from abstract controller" do + controller = AbstractWithStringChild.new + controller.process(:index) + assert_equal "With String Hello abstract child!", controller.response_body + end + + test "a child inherits layout from abstract controller2" do + controller = AbstractWithStringChildDefaultsToInherited.new + controller.process(:index) + assert_equal "With String Hello abstract child!", controller.response_body + end + + test "raises an exception when specifying layout true" do + assert_raises ArgumentError do + Object.class_eval do + class ::BadFailLayout < AbstractControllerTests::Layouts::Base + layout true + end + end + end + end + + test "when specify an :only option which match current action name" do + controller = WithOnlyConditional.new + controller.process(:show) + assert_equal "Overwrite Hello show!", controller.response_body + end + + test "when specify an :only option which does not match current action name" do + controller = WithOnlyConditional.new + controller.process(:index) + assert_equal "With Implied Hello index!", controller.response_body + end + + test "when specify an :only option which match current action name and is opposite from parent controller" do + controller = WithOnlyConditionalFlipped.new + controller.process(:show) + assert_equal "With Implied Hello show!", controller.response_body + end + + test "when specify an :only option which does not match current action name and is opposite from parent controller" do + controller = WithOnlyConditionalFlipped.new + controller.process(:index) + assert_equal "With Override Hello index!", controller.response_body + end + + test "when specify to inherit and an :only option which match current action name and is opposite from parent controller" do + controller = WithOnlyConditionalFlippedAndInheriting.new + controller.process(:show) + assert_equal "With Implied Hello show!", controller.response_body + end + + test "when specify to inherit and an :only option which does not match current action name and is opposite from parent controller" do + controller = WithOnlyConditionalFlippedAndInheriting.new + controller.process(:index) + assert_equal "Overwrite Hello index!", controller.response_body + end + + test "when specify an :except option which match current action name" do + controller = WithExceptConditional.new + controller.process(:show) + assert_equal "With Implied Hello show!", controller.response_body + end + + test "when specify an :except option which does not match current action name" do + controller = WithExceptConditional.new + controller.process(:index) + assert_equal "Overwrite Hello index!", controller.response_body + end + + test "when specify overwrite as an :only option which match current action name" do + controller = WithConditionalOverride.new + controller.process(:overwritten) + assert_equal "Overwrite Hello overwritten!", controller.response_body + end + + test "when specify overwrite as an :only option which does not match current action name" do + controller = WithConditionalOverride.new + controller.process(:non_overwritten) + assert_equal "Hello non overwritten!", controller.response_body + end + + test "when specify overwrite as an :only option which match current action name and is opposite from parent controller" do + controller = WithConditionalOverrideFlipped.new + controller.process(:overwritten) + assert_equal "Hello overwritten!", controller.response_body + end + + test "when specify overwrite as an :only option which does not match current action name and is opposite from parent controller" do + controller = WithConditionalOverrideFlipped.new + controller.process(:non_overwritten) + assert_equal "With Override Hello non overwritten!", controller.response_body + end + + test "when specify to inherit and overwrite as an :only option which match current action name and is opposite from parent controller" do + controller = WithConditionalOverrideFlippedAndInheriting.new + controller.process(:overwritten) + assert_equal "Hello overwritten!", controller.response_body + end + + test "when specify to inherit and overwrite as an :only option which does not match current action name and is opposite from parent controller" do + controller = WithConditionalOverrideFlippedAndInheriting.new + controller.process(:non_overwritten) + assert_equal "Overwrite Hello non overwritten!", controller.response_body + end + + test "layout for anonymous controller" do + klass = Class.new(WithString) do + def index + render plain: "index", layout: true + end + end + + controller = klass.new + controller.process(:index) + assert_equal "With String index", controller.response_body + end + + test "when layout is disabled with #action_has_layout? returning false, render no layout" do + controller = WithString.new + controller.instance_eval do + def action_has_layout? + false + end + end + controller.process(:action_has_layout_false) + assert_equal "Hello string!", controller.response_body + end + end + end +end diff --git a/actionview/test/actionpack/abstract/render_test.rb b/actionview/test/actionpack/abstract/render_test.rb new file mode 100644 index 0000000000..d863548a5c --- /dev/null +++ b/actionview/test/actionpack/abstract/render_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "abstract_unit" + +module AbstractController + module Testing + class ControllerRenderer < AbstractController::Base + include AbstractController::Rendering + include ActionView::Rendering + + def _prefixes + %w[renderer] + end + + self.view_paths = [ActionView::FixtureResolver.new( + "template.erb" => "With Template", + "renderer/default.erb" => "With Default", + "renderer/string.erb" => "With String", + "renderer/symbol.erb" => "With Symbol", + "string/with_path.erb" => "With String With Path", + "some/file.erb" => "With File" + )] + + def template + render template: "template" + end + + def file + render file: "some/file" + end + + def inline + render inline: "With <%= :Inline %>" + end + + def text + render plain: "With Text" + end + + def default + render + end + + def string + render "string" + end + + def string_with_path + render "string/with_path" + end + + def symbol + render :symbol + end + end + + class TestRenderer < ActiveSupport::TestCase + def setup + @controller = ControllerRenderer.new + end + + def test_render_template + assert_equal "With Template", @controller.process(:template) + assert_equal "With Template", @controller.response_body + end + + def test_render_file + assert_equal "With File", @controller.process(:file) + assert_equal "With File", @controller.response_body + end + + def test_render_inline + assert_equal "With Inline", @controller.process(:inline) + assert_equal "With Inline", @controller.response_body + end + + def test_render_text + assert_equal "With Text", @controller.process(:text) + assert_equal "With Text", @controller.response_body + end + + def test_render_default + assert_equal "With Default", @controller.process(:default) + assert_equal "With Default", @controller.response_body + end + + def test_render_string + assert_equal "With String", @controller.process(:string) + assert_equal "With String", @controller.response_body + end + + def test_render_symbol + assert_equal "With Symbol", @controller.process(:symbol) + assert_equal "With Symbol", @controller.response_body + end + + def test_render_string_with_path + assert_equal "With String With Path", @controller.process(:string_with_path) + assert_equal "With String With Path", @controller.response_body + end + end + end +end diff --git a/actionview/test/actionpack/abstract/views/abstract_controller/testing/me3/formatted.html.erb b/actionview/test/actionpack/abstract/views/abstract_controller/testing/me3/formatted.html.erb new file mode 100644 index 0000000000..785bf69191 --- /dev/null +++ b/actionview/test/actionpack/abstract/views/abstract_controller/testing/me3/formatted.html.erb @@ -0,0 +1 @@ +Hello from me3/formatted.html.erb
\ No newline at end of file diff --git a/actionview/test/actionpack/abstract/views/abstract_controller/testing/me3/index.erb b/actionview/test/actionpack/abstract/views/abstract_controller/testing/me3/index.erb new file mode 100644 index 0000000000..f079ad8204 --- /dev/null +++ b/actionview/test/actionpack/abstract/views/abstract_controller/testing/me3/index.erb @@ -0,0 +1 @@ +Hello from me3/index.erb
\ No newline at end of file diff --git a/actionview/test/actionpack/abstract/views/abstract_controller/testing/me4/index.erb b/actionview/test/actionpack/abstract/views/abstract_controller/testing/me4/index.erb new file mode 100644 index 0000000000..89dce12bdc --- /dev/null +++ b/actionview/test/actionpack/abstract/views/abstract_controller/testing/me4/index.erb @@ -0,0 +1 @@ +Hello from me4/index.erb
\ No newline at end of file diff --git a/actionview/test/actionpack/abstract/views/action_with_ivars.erb b/actionview/test/actionpack/abstract/views/action_with_ivars.erb new file mode 100644 index 0000000000..8d8ae22fd7 --- /dev/null +++ b/actionview/test/actionpack/abstract/views/action_with_ivars.erb @@ -0,0 +1 @@ +<%= @my_ivar %> from index_with_ivars.erb
\ No newline at end of file diff --git a/actionview/test/actionpack/abstract/views/helper_test.erb b/actionview/test/actionpack/abstract/views/helper_test.erb new file mode 100644 index 0000000000..8ae45cc195 --- /dev/null +++ b/actionview/test/actionpack/abstract/views/helper_test.erb @@ -0,0 +1 @@ +Hello <%= helpery_test %> : <%= included_method %>
\ No newline at end of file diff --git a/actionview/test/actionpack/abstract/views/index.erb b/actionview/test/actionpack/abstract/views/index.erb new file mode 100644 index 0000000000..cc1a8b8c85 --- /dev/null +++ b/actionview/test/actionpack/abstract/views/index.erb @@ -0,0 +1 @@ +Hello from index.erb
\ No newline at end of file diff --git a/actionview/test/actionpack/abstract/views/layouts/abstract_controller/testing/me4.erb b/actionview/test/actionpack/abstract/views/layouts/abstract_controller/testing/me4.erb new file mode 100644 index 0000000000..172dd56569 --- /dev/null +++ b/actionview/test/actionpack/abstract/views/layouts/abstract_controller/testing/me4.erb @@ -0,0 +1 @@ +Me4 Enter : <%= yield %> : Exit
\ No newline at end of file diff --git a/actionview/test/actionpack/abstract/views/layouts/application.erb b/actionview/test/actionpack/abstract/views/layouts/application.erb new file mode 100644 index 0000000000..27317140ad --- /dev/null +++ b/actionview/test/actionpack/abstract/views/layouts/application.erb @@ -0,0 +1 @@ +Application Enter : <%= yield %> : Exit
\ No newline at end of file diff --git a/actionview/test/actionpack/abstract/views/naked_render.erb b/actionview/test/actionpack/abstract/views/naked_render.erb new file mode 100644 index 0000000000..1b3d03878b --- /dev/null +++ b/actionview/test/actionpack/abstract/views/naked_render.erb @@ -0,0 +1 @@ +Hello from naked_render.erb
\ No newline at end of file diff --git a/actionview/test/actionpack/controller/capture_test.rb b/actionview/test/actionpack/controller/capture_test.rb new file mode 100644 index 0000000000..d02125bafa --- /dev/null +++ b/actionview/test/actionpack/controller/capture_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/logger" + +class CaptureController < ActionController::Base + self.view_paths = [ File.expand_path("../../fixtures/actionpack", __dir__) ] + + def self.controller_name; "test"; end + def self.controller_path; "test"; end + + def content_for + @title = nil + render layout: "talk_from_action" + end + + def content_for_with_parameter + @title = nil + render layout: "talk_from_action" + end + + def content_for_concatenated + @title = nil + render layout: "talk_from_action" + end + + def non_erb_block_content_for + @title = nil + render layout: "talk_from_action" + end + + def proper_block_detection + @todo = "some todo" + end +end + +class CaptureTest < ActionController::TestCase + tests CaptureController + + with_routes do + get :content_for, to: "test#content_for" + get :capturing, to: "test#capturing" + get :proper_block_detection, to: "test#proper_block_detection" + get :non_erb_block_content_for, to: "test#non_erb_block_content_for" + get :content_for_concatenated, to: "test#content_for_concatenated" + get :content_for_with_parameter, to: "test#content_for_with_parameter" + end + + def setup + super + # enable a logger so that (e.g.) the benchmarking stuff runs, so we can get + # a more accurate simulation of what happens in "real life". + @controller.logger = ActiveSupport::Logger.new(nil) + + @request.host = "www.nextangle.com" + end + + def test_simple_capture + get :capturing + assert_equal "Dreamy days", @response.body.strip + end + + def test_content_for + get :content_for + assert_equal expected_content_for_output, @response.body + end + + def test_should_concatenate_content_for + get :content_for_concatenated + assert_equal expected_content_for_output, @response.body + end + + def test_should_set_content_for_with_parameter + get :content_for_with_parameter + assert_equal expected_content_for_output, @response.body + end + + def test_non_erb_block_content_for + get :non_erb_block_content_for + assert_equal expected_content_for_output, @response.body + end + + def test_proper_block_detection + get :proper_block_detection + assert_equal "some todo", @response.body + end + + private + def expected_content_for_output + "<title>Putting stuff in the title!</title>\nGreat stuff!" + end +end diff --git a/actionview/test/actionpack/controller/layout_test.rb b/actionview/test/actionpack/controller/layout_test.rb new file mode 100644 index 0000000000..6d5c97b7fd --- /dev/null +++ b/actionview/test/actionpack/controller/layout_test.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/core_ext/array/extract_options" + +# The view_paths array must be set on Base and not LayoutTest so that LayoutTest's inherited +# method has access to the view_paths array when looking for a layout to automatically assign. +old_load_paths = ActionController::Base.view_paths + +ActionController::Base.view_paths = [ File.expand_path("../../fixtures/actionpack/layout_tests", __dir__) ] + +class LayoutTest < ActionController::Base + def self.controller_path; "views" end + def self._implied_layout_name; to_s.underscore.gsub(/_controller$/, "") ; end + self.view_paths = ActionController::Base.view_paths.dup +end + +module TemplateHandlerHelper + def with_template_handler(*extensions, handler) + ActionView::Template.register_template_handler(*extensions, handler) + yield + ensure + ActionView::Template.unregister_template_handler(*extensions) + end +end + +# Restore view_paths to previous value +ActionController::Base.view_paths = old_load_paths + +class ProductController < LayoutTest +end + +class ItemController < LayoutTest +end + +class ThirdPartyTemplateLibraryController < LayoutTest +end + +module ControllerNameSpace +end + +class ControllerNameSpace::NestedController < LayoutTest +end + +class MultipleExtensions < LayoutTest +end + +class LayoutAutoDiscoveryTest < ActionController::TestCase + include TemplateHandlerHelper + + with_routes do + get :hello, to: "views#hello" + end + + def setup + super + @request.host = "www.nextangle.com" + end + + def test_application_layout_is_default_when_no_controller_match + @controller = ProductController.new + get :hello + assert_equal "layout_test.erb hello.erb", @response.body + end + + def test_controller_name_layout_name_match + @controller = ItemController.new + get :hello + assert_equal "item.erb hello.erb", @response.body + end + + def test_third_party_template_library_auto_discovers_layout + with_template_handler :mab, lambda { |template| template.source.inspect } do + @controller = ThirdPartyTemplateLibraryController.new + get :hello + assert_response :success + assert_equal "layouts/third_party_template_library.mab", @response.body + end + end + + def test_namespaced_controllers_auto_detect_layouts1 + @controller = ControllerNameSpace::NestedController.new + get :hello + assert_equal "controller_name_space/nested.erb hello.erb", @response.body + end + + def test_namespaced_controllers_auto_detect_layouts2 + @controller = MultipleExtensions.new + get :hello + assert_equal "multiple_extensions.html.erb hello.erb", @response.body.strip + end +end + +class DefaultLayoutController < LayoutTest +end + +class StreamingLayoutController < LayoutTest + def render(*args) + options = args.extract_options! + super(*args, options.merge(stream: true)) + end +end + +class AbsolutePathLayoutController < LayoutTest + layout File.expand_path("../../fixtures/actionpack/layout_tests/layouts/layout_test", __dir__) +end + +class HasOwnLayoutController < LayoutTest + layout "item" +end + +class HasNilLayoutSymbol < LayoutTest + layout :nilz + + def nilz + nil + end +end + +class HasNilLayoutProc < LayoutTest + layout proc { nil } +end + +class PrependsViewPathController < LayoutTest + def hello + prepend_view_path File.expand_path("../../fixtures/actionpack/layout_tests/alt", __dir__) + render layout: "alt" + end +end + +class OnlyLayoutController < LayoutTest + layout "item", only: "hello" +end + +class ExceptLayoutController < LayoutTest + layout "item", except: "goodbye" +end + +class SetsLayoutInRenderController < LayoutTest + def hello + render layout: "third_party_template_library" + end +end + +class RendersNoLayoutController < LayoutTest + def hello + render layout: false + end +end + +class LayoutSetInResponseTest < ActionController::TestCase + include ActionView::Template::Handlers + include TemplateHandlerHelper + + with_routes do + get :hello, to: "views#hello" + get :hello, to: "views#goodbye" + end + + def test_layout_set_when_using_default_layout + @controller = DefaultLayoutController.new + get :hello + assert_includes @response.body, "layout_test.erb" + end + + def test_layout_set_when_using_streaming_layout + @controller = StreamingLayoutController.new + get :hello + assert_includes @response.body, "layout_test.erb" + end + + def test_layout_set_when_set_in_controller + @controller = HasOwnLayoutController.new + get :hello + assert_includes @response.body, "item.erb" + end + + def test_layout_symbol_set_in_controller_returning_nil_falls_back_to_default + @controller = HasNilLayoutSymbol.new + get :hello + assert_includes @response.body, "layout_test.erb" + end + + def test_layout_proc_set_in_controller_returning_nil_falls_back_to_default + @controller = HasNilLayoutProc.new + get :hello + assert_includes @response.body, "layout_test.erb" + end + + def test_layout_only_exception_when_included + @controller = OnlyLayoutController.new + get :hello + assert_includes @response.body, "item.erb" + end + + def test_layout_only_exception_when_excepted + @controller = OnlyLayoutController.new + get :goodbye + assert_not_includes @response.body, "item.erb" + end + + def test_layout_except_exception_when_included + @controller = ExceptLayoutController.new + get :hello + assert_includes @response.body, "item.erb" + end + + def test_layout_except_exception_when_excepted + @controller = ExceptLayoutController.new + get :goodbye + assert_not_includes @response.body, "item.erb" + end + + def test_layout_set_when_using_render + with_template_handler :mab, lambda { |template| template.source.inspect } do + @controller = SetsLayoutInRenderController.new + get :hello + assert_includes @response.body, "layouts/third_party_template_library.mab" + end + end + + def test_layout_is_not_set_when_none_rendered + @controller = RendersNoLayoutController.new + get :hello + assert_equal "hello.erb", @response.body + end + + def test_layout_is_picked_from_the_controller_instances_view_path + @controller = PrependsViewPathController.new + get :hello + assert_includes @response.body, "alt.erb" + end + + def test_absolute_pathed_layout + @controller = AbsolutePathLayoutController.new + get :hello + assert_equal "layout_test.erb hello.erb", @response.body.strip + end +end + +class SetsNonExistentLayoutFile < LayoutTest + layout "nofile" +end + +class LayoutExceptionRaisedTest < ActionController::TestCase + with_routes do + get :hello, to: "views#hello" + end + + def test_exception_raised_when_layout_file_not_found + @controller = SetsNonExistentLayoutFile.new + assert_raise(ActionView::MissingTemplate) { get :hello } + end +end + +class LayoutStatusIsRendered < LayoutTest + def hello + render status: 401 + end +end + +class LayoutStatusIsRenderedTest < ActionController::TestCase + with_routes do + get :hello, to: "views#hello" + end + + def test_layout_status_is_rendered + @controller = LayoutStatusIsRendered.new + get :hello + assert_response 401 + end +end + +unless Gem.win_platform? + class LayoutSymlinkedTest < LayoutTest + layout "symlinked/symlinked_layout" + end + + class LayoutSymlinkedIsRenderedTest < ActionController::TestCase + with_routes do + get :hello, to: "views#hello" + end + + def test_symlinked_layout_is_rendered + @controller = LayoutSymlinkedTest.new + get :hello + assert_response 200 + assert_includes @response.body, "This is my layout" + end + end +end diff --git a/actionview/test/actionpack/controller/render_test.rb b/actionview/test/actionpack/controller/render_test.rb new file mode 100644 index 0000000000..204903c60c --- /dev/null +++ b/actionview/test/actionpack/controller/render_test.rb @@ -0,0 +1,1422 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_model" +require "controller/fake_models" + +module Quiz + # Models + Question = Struct.new(:name, :id) do + extend ActiveModel::Naming + include ActiveModel::Conversion + + def persisted? + id.present? + end + end + + # Controller + class QuestionsController < ActionController::Base + def new + render partial: Quiz::Question.new("Namespaced Partial") + end + end +end + +module Fun + class GamesController < ActionController::Base + def hello_world; end + + def nested_partial_with_form_builder + render partial: ActionView::Helpers::FormBuilder.new(:post, nil, view_context, {}) + end + end +end + +class TestController < ActionController::Base + protect_from_forgery + + before_action :set_variable_for_layout + + class LabellingFormBuilder < ActionView::Helpers::FormBuilder + end + + layout :determine_layout + + def name + nil + end + + private :name + helper_method :name + + def hello_world + end + + def hello_world_file + render file: File.expand_path("../../fixtures/actionpack/hello", __dir__), formats: [:html] + end + + # :ported: + def render_hello_world + render "test/hello_world" + end + + def render_hello_world_with_last_modified_set + response.last_modified = Date.new(2008, 10, 10).to_time + render "test/hello_world" + end + + # :ported: compatibility + def render_hello_world_with_forward_slash + render "/test/hello_world" + end + + # :ported: + def render_template_in_top_directory + render template: "shared" + end + + # :deprecated: + def render_template_in_top_directory_with_slash + render "/shared" + end + + # :ported: + def render_hello_world_from_variable + @person = "david" + render plain: "hello #{@person}" + end + + # :ported: + def render_action_hello_world + render action: "hello_world" + end + + def render_action_upcased_hello_world + render action: "Hello_world" + end + + def render_action_hello_world_as_string + render "hello_world" + end + + def render_action_hello_world_with_symbol + render action: :hello_world + end + + # :ported: + def render_text_hello_world + render plain: "hello world" + end + + # :ported: + def render_text_hello_world_with_layout + @variable_for_layout = ", I am here!" + render plain: "hello world", layout: true + end + + def hello_world_with_layout_false + render layout: false + end + + # :ported: + def render_file_with_instance_variables + @secret = "in the sauce" + path = File.expand_path("../../fixtures/test/render_file_with_ivar", __dir__) + render file: path + end + + # :ported: + def render_file_not_using_full_path + @secret = "in the sauce" + render file: "test/render_file_with_ivar" + end + + def render_file_not_using_full_path_with_dot_in_path + @secret = "in the sauce" + render file: "test/dot.directory/render_file_with_ivar" + end + + def render_file_using_pathname + @secret = "in the sauce" + render file: Pathname.new(__dir__).join("..", "..", "fixtures", "test", "dot.directory", "render_file_with_ivar") + end + + def render_file_from_template + @secret = "in the sauce" + @path = File.expand_path("../../fixtures/test/render_file_with_ivar", __dir__) + end + + def render_file_with_locals + path = File.expand_path("../../fixtures/test/render_file_with_locals", __dir__) + render file: path, locals: { secret: "in the sauce" } + end + + def render_file_as_string_with_locals + path = File.expand_path("../../fixtures/test/render_file_with_locals", __dir__) + render file: path, locals: { secret: "in the sauce" } + end + + def accessing_request_in_template + render inline: "Hello: <%= request.host %>" + end + + def accessing_logger_in_template + render inline: "<%= logger.class %>" + end + + def accessing_action_name_in_template + render inline: "<%= action_name %>" + end + + def accessing_controller_name_in_template + render inline: "<%= controller_name %>" + end + + # :ported: + def render_custom_code + render plain: "hello world", status: 404 + end + + # :ported: + def render_text_with_nil + render plain: nil + end + + # :ported: + def render_text_with_false + render plain: false + end + + def render_text_with_resource + render plain: Customer.new("David") + end + + # :ported: + def render_nothing_with_appendix + render plain: "appended" + end + + # This test is testing 3 things: + # render :file in AV :ported: + # render :template in AC :ported: + # setting content type + def render_xml_hello + @name = "David" + render template: "test/hello" + end + + def render_xml_hello_as_string_template + @name = "David" + render "test/hello" + end + + def render_line_offset + render inline: "<% raise %>", locals: { foo: "bar" } + end + + def heading + head :ok + end + + def greeting + # let's just rely on the template + end + + # :ported: + def blank_response + render plain: " " + end + + # :ported: + def layout_test + render action: "hello_world" + end + + # :ported: + def builder_layout_test + @name = nil + render action: "hello", layout: "layouts/builder" + end + + # :move: test this in Action View + def builder_partial_test + render action: "hello_world_container" + end + + # :ported: + def partials_list + @test_unchanged = "hello" + @customers = [ Customer.new("david"), Customer.new("mary") ] + render action: "list" + end + + def partial_only + render partial: true + end + + def hello_in_a_string + @customers = [ Customer.new("david"), Customer.new("mary") ] + render plain: "How's there? " + render_to_string(template: "test/list") + end + + def accessing_params_in_template + render inline: "Hello: <%= params[:name] %>" + end + + def accessing_local_assigns_in_inline_template + name = params[:local_name] + render inline: "<%= 'Goodbye, ' + local_name %>", + locals: { local_name: name } + end + + def render_implicit_html_template_from_xhr_request + end + + def render_implicit_js_template_without_layout + end + + def formatted_html_erb + end + + def formatted_xml_erb + end + + def render_to_string_test + @foo = render_to_string inline: "this is a test" + end + + def default_render + @alternate_default_render ||= nil + if @alternate_default_render + @alternate_default_render.call + else + super + end + end + + def render_action_hello_world_as_symbol + render action: :hello_world + end + + def layout_test_with_different_layout + render action: "hello_world", layout: "standard" + end + + def layout_test_with_different_layout_and_string_action + render "hello_world", layout: "standard" + end + + def layout_test_with_different_layout_and_symbol_action + render :hello_world, layout: "standard" + end + + def rendering_without_layout + render action: "hello_world", layout: false + end + + def layout_overriding_layout + render action: "hello_world", layout: "standard" + end + + def rendering_nothing_on_layout + head :ok + end + + def render_to_string_with_assigns + @before = "i'm before the render" + render_to_string plain: "foo" + @after = "i'm after the render" + render template: "test/hello_world" + end + + def render_to_string_with_exception + render_to_string file: "exception that will not be caught - this will certainly not work" + end + + def render_to_string_with_caught_exception + @before = "i'm before the render" + begin + render_to_string file: "exception that will be caught- hope my future instance vars still work!" + rescue + end + @after = "i'm after the render" + render template: "test/hello_world" + end + + def accessing_params_in_template_with_layout + render layout: true, inline: "Hello: <%= params[:name] %>" + end + + # :ported: + def render_with_explicit_template + render template: "test/hello_world" + end + + def render_with_explicit_unescaped_template + render template: "test/h*llo_world" + end + + def render_with_explicit_escaped_template + render template: "test/hello,world" + end + + def render_with_explicit_string_template + render "test/hello_world" + end + + # :ported: + def render_with_explicit_template_with_locals + render template: "test/render_file_with_locals", locals: { secret: "area51" } + end + + # :ported: + def double_render + render plain: "hello" + render plain: "world" + end + + def double_redirect + redirect_to action: "double_render" + redirect_to action: "double_render" + end + + def render_and_redirect + render plain: "hello" + redirect_to action: "double_render" + end + + def render_to_string_and_render + @stuff = render_to_string plain: "here is some cached stuff" + render plain: "Hi web users! #{@stuff}" + end + + def render_to_string_with_inline_and_render + render_to_string inline: "<%= 'dlrow olleh'.reverse %>" + render template: "test/hello_world" + end + + def rendering_with_conflicting_local_vars + @name = "David" + render action: "potential_conflicts" + end + + def hello_world_from_rxml_using_action + render action: "hello_world_from_rxml", handlers: [:builder] + end + + # :deprecated: + def hello_world_from_rxml_using_template + render template: "test/hello_world_from_rxml", handlers: [:builder] + end + + def action_talk_to_layout + # Action template sets variable that's picked up by layout + end + + # :addressed: + def render_text_with_assigns + @hello = "world" + render plain: "foo" + end + + def render_with_assigns_option + render inline: "<%= @hello %>", assigns: { hello: "world" } + end + + def yield_content_for + render action: "content_for", layout: "yield" + end + + def render_content_type_from_body + response.content_type = Mime[:rss] + render body: "hello world!" + end + + def render_using_layout_around_block + render action: "using_layout_around_block" + end + + def render_using_layout_around_block_in_main_layout_and_within_content_for_layout + render action: "using_layout_around_block", layout: "layouts/block_with_layout" + end + + def partial_formats_html + render partial: "partial", formats: [:html] + end + + def partial + render partial: "partial" + end + + def partial_html_erb + render partial: "partial_html_erb" + end + + def render_to_string_with_partial + @partial_only = render_to_string partial: "partial_only" + @partial_with_locals = render_to_string partial: "customer", locals: { customer: Customer.new("david") } + render template: "test/hello_world" + end + + def render_to_string_with_template_and_html_partial + @text = render_to_string template: "test/with_partial", formats: [:text] + @html = render_to_string template: "test/with_partial", formats: [:html] + render template: "test/with_html_partial" + end + + def render_to_string_and_render_with_different_formats + @html = render_to_string template: "test/with_partial", formats: [:html] + render template: "test/with_partial", formats: [:text] + end + + def render_template_within_a_template_with_other_format + render template: "test/with_xml_template", + formats: [:html], + layout: "with_html_partial" + end + + def partial_with_counter + render partial: "counter", locals: { counter_counter: 5 } + end + + def partial_with_locals + render partial: "customer", locals: { customer: Customer.new("david") } + end + + def partial_with_string_locals + render partial: "customer", locals: { "customer" => Customer.new("david") } + end + + def partial_with_form_builder + render partial: ActionView::Helpers::FormBuilder.new(:post, nil, view_context, {}) + end + + def partial_with_form_builder_subclass + render partial: LabellingFormBuilder.new(:post, nil, view_context, {}) + end + + def partial_collection + render partial: "customer", collection: [ Customer.new("david"), Customer.new("mary") ] + end + + def partial_collection_with_as + render partial: "customer_with_var", collection: [ Customer.new("david"), Customer.new("mary") ], as: :customer + end + + def partial_collection_with_iteration + render partial: "customer_iteration", collection: [ Customer.new("david"), Customer.new("mary"), Customer.new("christine") ] + end + + def partial_collection_with_as_and_iteration + render partial: "customer_iteration_with_as", collection: [ Customer.new("david"), Customer.new("mary"), Customer.new("christine") ], as: :client + end + + def partial_collection_with_counter + render partial: "customer_counter", collection: [ Customer.new("david"), Customer.new("mary") ] + end + + def partial_collection_with_as_and_counter + render partial: "customer_counter_with_as", collection: [ Customer.new("david"), Customer.new("mary") ], as: :client + end + + def partial_collection_with_locals + render partial: "customer_greeting", collection: [ Customer.new("david"), Customer.new("mary") ], locals: { greeting: "Bonjour" } + end + + def partial_collection_with_spacer + render partial: "customer", spacer_template: "partial_only", collection: [ Customer.new("david"), Customer.new("mary") ] + end + + def partial_collection_with_spacer_which_uses_render + render partial: "customer", spacer_template: "partial_with_partial", collection: [ Customer.new("david"), Customer.new("mary") ] + end + + def partial_collection_shorthand_with_locals + render partial: [ Customer.new("david"), Customer.new("mary") ], locals: { greeting: "Bonjour" } + end + + def partial_collection_shorthand_with_different_types_of_records + render partial: [ + BadCustomer.new("mark"), + GoodCustomer.new("craig"), + BadCustomer.new("john"), + GoodCustomer.new("zach"), + GoodCustomer.new("brandon"), + BadCustomer.new("dan") ], + locals: { greeting: "Bonjour" } + end + + def empty_partial_collection + render partial: "customer", collection: [] + end + + def partial_collection_shorthand_with_different_types_of_records_with_counter + partial_collection_shorthand_with_different_types_of_records + end + + def missing_partial + render partial: "thisFileIsntHere" + end + + def partial_with_hash_object + render partial: "hash_object", object: { first_name: "Sam" } + end + + def partial_with_nested_object + render partial: "quiz/questions/question", object: Quiz::Question.new("first") + end + + def partial_with_nested_object_shorthand + render Quiz::Question.new("first") + end + + def partial_hash_collection + render partial: "hash_object", collection: [ { first_name: "Pratik" }, { first_name: "Amy" } ] + end + + def partial_hash_collection_with_locals + render partial: "hash_greeting", collection: [ { first_name: "Pratik" }, { first_name: "Amy" } ], locals: { greeting: "Hola" } + end + + def partial_with_implicit_local_assignment + @customer = Customer.new("Marcel") + render partial: "customer" + end + + def render_call_to_partial_with_layout + render action: "calling_partial_with_layout" + end + + def render_call_to_partial_with_layout_in_main_layout_and_within_content_for_layout + render action: "calling_partial_with_layout", layout: "layouts/partial_with_layout" + end + + before_action only: :render_with_filters do + request.format = :xml + end + + # Ensure that the before filter is executed *before* self.formats is set. + def render_with_filters + render action: :formatted_xml_erb + end + + private + + def set_variable_for_layout + @variable_for_layout = nil + end + + def determine_layout + case action_name + when "hello_world", "layout_test", "rendering_without_layout", + "rendering_nothing_on_layout", "render_text_hello_world", + "render_text_hello_world_with_layout", + "hello_world_with_layout_false", + "partial_only", "accessing_params_in_template", + "accessing_params_in_template_with_layout", + "render_with_explicit_template", + "render_with_explicit_string_template", + "update_page", "update_page_with_instance_variables" + + "layouts/standard" + when "action_talk_to_layout", "layout_overriding_layout" + "layouts/talk_from_action" + when "render_implicit_html_template_from_xhr_request" + (request.xhr? ? "layouts/xhr" : "layouts/standard") + end + end +end + +class RenderTest < ActionController::TestCase + tests TestController + + with_routes do + get :"hyphen-ated", to: "test#hyphen-ated" + get :accessing_action_name_in_template, to: "test#accessing_action_name_in_template" + get :accessing_controller_name_in_template, to: "test#accessing_controller_name_in_template" + get :accessing_local_assigns_in_inline_template, to: "test#accessing_local_assigns_in_inline_template" + get :accessing_logger_in_template, to: "test#accessing_logger_in_template" + get :accessing_params_in_template, to: "test#accessing_params_in_template" + get :accessing_params_in_template_with_layout, to: "test#accessing_params_in_template_with_layout" + get :accessing_request_in_template, to: "test#accessing_request_in_template" + get :action_talk_to_layout, to: "test#action_talk_to_layout" + get :builder_layout_test, to: "test#builder_layout_test" + get :builder_partial_test, to: "test#builder_partial_test" + get :clone, to: "test#clone" + get :determine_layout, to: "test#determine_layout" + get :double_redirect, to: "test#double_redirect" + get :double_render, to: "test#double_render" + get :empty_partial_collection, to: "test#empty_partial_collection" + get :formatted_html_erb, to: "test#formatted_html_erb" + get :formatted_xml_erb, to: "test#formatted_xml_erb" + get :greeting, to: "test#greeting" + get :hello_in_a_string, to: "test#hello_in_a_string" + get :hello_world, to: "fun/games#hello_world" + get :hello_world, to: "test#hello_world" + get :hello_world_file, to: "test#hello_world_file" + get :hello_world_from_rxml_using_action, to: "test#hello_world_from_rxml_using_action" + get :hello_world_from_rxml_using_template, to: "test#hello_world_from_rxml_using_template" + get :hello_world_with_layout_false, to: "test#hello_world_with_layout_false" + get :layout_overriding_layout, to: "test#layout_overriding_layout" + get :layout_test, to: "test#layout_test" + get :layout_test_with_different_layout, to: "test#layout_test_with_different_layout" + get :layout_test_with_different_layout_and_string_action, to: "test#layout_test_with_different_layout_and_string_action" + get :layout_test_with_different_layout_and_symbol_action, to: "test#layout_test_with_different_layout_and_symbol_action" + get :missing_partial, to: "test#missing_partial" + get :nested_partial_with_form_builder, to: "fun/games#nested_partial_with_form_builder" + get :new, to: "quiz/questions#new" + get :partial, to: "test#partial" + get :partial_collection, to: "test#partial_collection" + get :partial_collection_shorthand_with_different_types_of_records, to: "test#partial_collection_shorthand_with_different_types_of_records" + get :partial_collection_shorthand_with_locals, to: "test#partial_collection_shorthand_with_locals" + get :partial_collection_with_as, to: "test#partial_collection_with_as" + get :partial_collection_with_as_and_counter, to: "test#partial_collection_with_as_and_counter" + get :partial_collection_with_as_and_iteration, to: "test#partial_collection_with_as_and_iteration" + get :partial_collection_with_counter, to: "test#partial_collection_with_counter" + get :partial_collection_with_iteration, to: "test#partial_collection_with_iteration" + get :partial_collection_with_locals, to: "test#partial_collection_with_locals" + get :partial_collection_with_spacer, to: "test#partial_collection_with_spacer" + get :partial_collection_with_spacer_which_uses_render, to: "test#partial_collection_with_spacer_which_uses_render" + get :partial_formats_html, to: "test#partial_formats_html" + get :partial_hash_collection, to: "test#partial_hash_collection" + get :partial_hash_collection_with_locals, to: "test#partial_hash_collection_with_locals" + get :partial_html_erb, to: "test#partial_html_erb" + get :partial_only, to: "test#partial_only" + get :partial_with_counter, to: "test#partial_with_counter" + get :partial_with_form_builder, to: "test#partial_with_form_builder" + get :partial_with_form_builder_subclass, to: "test#partial_with_form_builder_subclass" + get :partial_with_hash_object, to: "test#partial_with_hash_object" + get :partial_with_locals, to: "test#partial_with_locals" + get :partial_with_nested_object, to: "test#partial_with_nested_object" + get :partial_with_nested_object_shorthand, to: "test#partial_with_nested_object_shorthand" + get :partial_with_string_locals, to: "test#partial_with_string_locals" + get :partials_list, to: "test#partials_list" + get :render_action_hello_world, to: "test#render_action_hello_world" + get :render_action_hello_world_as_string, to: "test#render_action_hello_world_as_string" + get :render_action_hello_world_with_symbol, to: "test#render_action_hello_world_with_symbol" + get :render_action_upcased_hello_world, to: "test#render_action_upcased_hello_world" + get :render_and_redirect, to: "test#render_and_redirect" + get :render_call_to_partial_with_layout, to: "test#render_call_to_partial_with_layout" + get :render_call_to_partial_with_layout_in_main_layout_and_within_content_for_layout, to: "test#render_call_to_partial_with_layout_in_main_layout_and_within_content_for_layout" + get :render_custom_code, to: "test#render_custom_code" + get :render_file_as_string_with_locals, to: "test#render_file_as_string_with_locals" + get :render_file_from_template, to: "test#render_file_from_template" + get :render_file_not_using_full_path, to: "test#render_file_not_using_full_path" + get :render_file_not_using_full_path_with_dot_in_path, to: "test#render_file_not_using_full_path_with_dot_in_path" + get :render_file_using_pathname, to: "test#render_file_using_pathname" + get :render_file_with_instance_variables, to: "test#render_file_with_instance_variables" + get :render_file_with_locals, to: "test#render_file_with_locals" + get :render_hello_world, to: "test#render_hello_world" + get :render_hello_world_from_variable, to: "test#render_hello_world_from_variable" + get :render_hello_world_with_forward_slash, to: "test#render_hello_world_with_forward_slash" + get :render_implicit_html_template_from_xhr_request, to: "test#render_implicit_html_template_from_xhr_request" + get :render_implicit_js_template_without_layout, to: "test#render_implicit_js_template_without_layout" + get :render_line_offset, to: "test#render_line_offset" + get :render_nothing_with_appendix, to: "test#render_nothing_with_appendix" + get :render_template_in_top_directory, to: "test#render_template_in_top_directory" + get :render_template_in_top_directory_with_slash, to: "test#render_template_in_top_directory_with_slash" + get :render_template_within_a_template_with_other_format, to: "test#render_template_within_a_template_with_other_format" + get :render_text_hello_world, to: "test#render_text_hello_world" + get :render_text_hello_world_with_layout, to: "test#render_text_hello_world_with_layout" + get :render_text_with_assigns, to: "test#render_text_with_assigns" + get :render_text_with_false, to: "test#render_text_with_false" + get :render_text_with_nil, to: "test#render_text_with_nil" + get :render_text_with_resource, to: "test#render_text_with_resource" + get :render_to_string_and_render, to: "test#render_to_string_and_render" + get :render_to_string_and_render_with_different_formats, to: "test#render_to_string_and_render_with_different_formats" + get :render_to_string_test, to: "test#render_to_string_test" + get :render_to_string_with_assigns, to: "test#render_to_string_with_assigns" + get :render_to_string_with_caught_exception, to: "test#render_to_string_with_caught_exception" + get :render_to_string_with_exception, to: "test#render_to_string_with_exception" + get :render_to_string_with_inline_and_render, to: "test#render_to_string_with_inline_and_render" + get :render_to_string_with_partial, to: "test#render_to_string_with_partial" + get :render_to_string_with_template_and_html_partial, to: "test#render_to_string_with_template_and_html_partial" + get :render_using_layout_around_block, to: "test#render_using_layout_around_block" + get :render_using_layout_around_block_in_main_layout_and_within_content_for_layout, to: "test#render_using_layout_around_block_in_main_layout_and_within_content_for_layout" + get :render_with_assigns_option, to: "test#render_with_assigns_option" + get :render_with_explicit_escaped_template, to: "test#render_with_explicit_escaped_template" + get :render_with_explicit_string_template, to: "test#render_with_explicit_string_template" + get :render_with_explicit_template, to: "test#render_with_explicit_template" + get :render_with_explicit_template_with_locals, to: "test#render_with_explicit_template_with_locals" + get :render_with_explicit_unescaped_template, to: "test#render_with_explicit_unescaped_template" + get :render_with_filters, to: "test#render_with_filters" + get :render_xml_hello, to: "test#render_xml_hello" + get :render_xml_hello_as_string_template, to: "test#render_xml_hello_as_string_template" + get :rendering_nothing_on_layout, to: "test#rendering_nothing_on_layout" + get :rendering_with_conflicting_local_vars, to: "test#rendering_with_conflicting_local_vars" + get :rendering_without_layout, to: "test#rendering_without_layout" + get :yield_content_for, to: "test#yield_content_for" + end + + def setup + # enable a logger so that (e.g.) the benchmarking stuff runs, so we can get + # a more accurate simulation of what happens in "real life". + super + @controller.logger = ActiveSupport::Logger.new(nil) + ActionView::Base.logger = ActiveSupport::Logger.new(nil) + + @request.host = "www.nextangle.com" + + @old_view_paths = ActionController::Base.view_paths + ActionController::Base.view_paths = File.join(FIXTURE_LOAD_PATH, "actionpack") + end + + def teardown + ActionView::Base.logger = nil + + ActionController::Base.view_paths = @old_view_paths + end + + # :ported: + def test_simple_show + get :hello_world + assert_response 200 + assert_response :success + assert_equal "<html>Hello world!</html>", @response.body + end + + # :ported: + def test_renders_default_template_for_missing_action + get :'hyphen-ated' + assert_equal "hyphen-ated.erb", @response.body + end + + # :ported: + def test_render + get :render_hello_world + assert_equal "Hello world!", @response.body + end + + def test_line_offset + exc = assert_raises ActionView::Template::Error do + get :render_line_offset + end + line = exc.backtrace.first + assert(line =~ %r{:(\d+):}) + assert_equal "1", $1, + "The line offset is wrong, perhaps the wrong exception has been raised, exception was: #{exc.inspect}" + end + + # :ported: compatibility + def test_render_with_forward_slash + get :render_hello_world_with_forward_slash + assert_equal "Hello world!", @response.body + end + + # :ported: + def test_render_in_top_directory + get :render_template_in_top_directory + assert_equal "Elastica", @response.body + end + + # :ported: + def test_render_in_top_directory_with_slash + get :render_template_in_top_directory_with_slash + assert_equal "Elastica", @response.body + end + + # :ported: + def test_render_from_variable + get :render_hello_world_from_variable + assert_equal "hello david", @response.body + end + + # :ported: + def test_render_action + get :render_action_hello_world + assert_equal "Hello world!", @response.body + end + + def test_render_action_upcased + assert_raise ActionView::MissingTemplate do + get :render_action_upcased_hello_world + end + end + + # :ported: + def test_render_action_hello_world_as_string + get :render_action_hello_world_as_string + assert_equal "Hello world!", @response.body + end + + # :ported: + def test_render_action_with_symbol + get :render_action_hello_world_with_symbol + assert_equal "Hello world!", @response.body + end + + # :ported: + def test_render_text + get :render_text_hello_world + assert_equal "hello world", @response.body + end + + # :ported: + def test_do_with_render_text_and_layout + get :render_text_hello_world_with_layout + assert_equal "{{hello world, I am here!}}\n", @response.body + end + + # :ported: + def test_do_with_render_action_and_layout_false + get :hello_world_with_layout_false + assert_equal "Hello world!", @response.body + end + + # :ported: + def test_render_file_with_instance_variables + get :render_file_with_instance_variables + assert_equal "The secret is in the sauce\n", @response.body + end + + def test_render_file + get :hello_world_file + assert_equal "Hello world!", @response.body + end + + # :ported: + def test_render_file_not_using_full_path + get :render_file_not_using_full_path + assert_equal "The secret is in the sauce\n", @response.body + end + + # :ported: + def test_render_file_not_using_full_path_with_dot_in_path + get :render_file_not_using_full_path_with_dot_in_path + assert_equal "The secret is in the sauce\n", @response.body + end + + # :ported: + def test_render_file_using_pathname + get :render_file_using_pathname + assert_equal "The secret is in the sauce\n", @response.body + end + + # :ported: + def test_render_file_with_locals + get :render_file_with_locals + assert_equal "The secret is in the sauce\n", @response.body + end + + # :ported: + def test_render_file_as_string_with_locals + get :render_file_as_string_with_locals + assert_equal "The secret is in the sauce\n", @response.body + end + + # :assessed: + def test_render_file_from_template + get :render_file_from_template + assert_equal "The secret is in the sauce\n", @response.body + end + + # :ported: + def test_render_custom_code + get :render_custom_code + assert_response 404 + assert_response :missing + assert_equal "hello world", @response.body + end + + # :ported: + def test_render_text_with_nil + get :render_text_with_nil + assert_response 200 + assert_equal "", @response.body + end + + # :ported: + def test_render_text_with_false + get :render_text_with_false + assert_equal "false", @response.body + end + + # :ported: + def test_render_nothing_with_appendix + get :render_nothing_with_appendix + assert_response 200 + assert_equal "appended", @response.body + end + + def test_render_text_with_resource + get :render_text_with_resource + assert_equal 'name: "David"', @response.body + end + + # :ported: + def test_attempt_to_access_object_method + assert_raise(AbstractController::ActionNotFound) { get :clone } + end + + # :ported: + def test_private_methods + assert_raise(AbstractController::ActionNotFound) { get :determine_layout } + end + + # :ported: + def test_access_to_request_in_view + get :accessing_request_in_template + assert_equal "Hello: www.nextangle.com", @response.body + end + + def test_access_to_logger_in_view + get :accessing_logger_in_template + assert_equal "ActiveSupport::Logger", @response.body + end + + # :ported: + def test_access_to_action_name_in_view + get :accessing_action_name_in_template + assert_equal "accessing_action_name_in_template", @response.body + end + + # :ported: + def test_access_to_controller_name_in_view + get :accessing_controller_name_in_template + assert_equal "test", @response.body # name is explicitly set in the controller. + end + + # :ported: + def test_render_xml + get :render_xml_hello + assert_equal "<html>\n <p>Hello David</p>\n<p>This is grand!</p>\n</html>\n", @response.body + assert_equal "application/xml", @response.content_type + end + + # :ported: + def test_render_xml_as_string_template + get :render_xml_hello_as_string_template + assert_equal "<html>\n <p>Hello David</p>\n<p>This is grand!</p>\n</html>\n", @response.body + assert_equal "application/xml", @response.content_type + end + + # :ported: + def test_render_xml_with_default + get :greeting + assert_equal "<p>This is grand!</p>\n", @response.body + end + + # :move: test in AV + def test_render_xml_with_partial + get :builder_partial_test + assert_equal "<test>\n <hello/>\n</test>\n", @response.body + end + + # :ported: + def test_layout_rendering + get :layout_test + assert_equal "<html>Hello world!</html>", @response.body + end + + def test_render_xml_with_layouts + get :builder_layout_test + assert_equal "<wrapper>\n<html>\n <p>Hello </p>\n<p>This is grand!</p>\n</html>\n</wrapper>\n", @response.body + end + + def test_partials_list + get :partials_list + assert_equal "goodbyeHello: davidHello: marygoodbye\n", @response.body + end + + def test_render_to_string + get :hello_in_a_string + assert_equal "How's there? goodbyeHello: davidHello: marygoodbye\n", @response.body + end + + def test_render_to_string_resets_assigns + get :render_to_string_test + assert_equal "The value of foo is: ::this is a test::\n", @response.body + end + + def test_render_to_string_inline + get :render_to_string_with_inline_and_render + assert_equal "Hello world!", @response.body + end + + # :ported: + def test_nested_rendering + @controller = Fun::GamesController.new + get :hello_world + assert_equal "Living in a nested world", @response.body + end + + def test_accessing_params_in_template + get :accessing_params_in_template, params: { name: "David" } + assert_equal "Hello: David", @response.body + end + + def test_accessing_local_assigns_in_inline_template + get :accessing_local_assigns_in_inline_template, params: { local_name: "Local David" } + assert_equal "Goodbye, Local David", @response.body + assert_equal "text/html", @response.content_type + end + + def test_should_implicitly_render_html_template_from_xhr_request + get :render_implicit_html_template_from_xhr_request, xhr: true + assert_equal "XHR!\nHello HTML!", @response.body + end + + def test_should_implicitly_render_js_template_without_layout + get :render_implicit_js_template_without_layout, format: :js, xhr: true + assert_no_match %r{<html>}, @response.body + end + + def test_should_render_formatted_template + get :formatted_html_erb + assert_equal "formatted html erb", @response.body + end + + def test_should_render_formatted_html_erb_template + get :formatted_xml_erb + assert_equal "<test>passed formatted html erb</test>", @response.body + end + + def test_should_render_formatted_html_erb_template_with_bad_accepts_header + @request.env["HTTP_ACCEPT"] = "; a=dsf" + get :formatted_xml_erb + assert_equal "<test>passed formatted html erb</test>", @response.body + end + + def test_should_render_formatted_html_erb_template_with_faulty_accepts_header + @request.accept = "image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, application/x-shockwave-flash, */*" + get :formatted_xml_erb + assert_equal "<test>passed formatted html erb</test>", @response.body + end + + def test_layout_test_with_different_layout + get :layout_test_with_different_layout + assert_equal "<html>Hello world!</html>", @response.body + end + + def test_layout_test_with_different_layout_and_string_action + get :layout_test_with_different_layout_and_string_action + assert_equal "<html>Hello world!</html>", @response.body + end + + def test_layout_test_with_different_layout_and_symbol_action + get :layout_test_with_different_layout_and_symbol_action + assert_equal "<html>Hello world!</html>", @response.body + end + + def test_rendering_without_layout + get :rendering_without_layout + assert_equal "Hello world!", @response.body + end + + def test_layout_overriding_layout + get :layout_overriding_layout + assert_no_match %r{<title>}, @response.body + end + + def test_rendering_nothing_on_layout + get :rendering_nothing_on_layout + assert_equal "", @response.body + end + + def test_render_to_string_doesnt_break_assigns + get :render_to_string_with_assigns + assert_equal "i'm before the render", @controller.instance_variable_get(:@before) + assert_equal "i'm after the render", @controller.instance_variable_get(:@after) + end + + def test_bad_render_to_string_still_throws_exception + assert_raise(ActionView::MissingTemplate) { get :render_to_string_with_exception } + end + + def test_render_to_string_that_throws_caught_exception_doesnt_break_assigns + assert_nothing_raised { get :render_to_string_with_caught_exception } + assert_equal "i'm before the render", @controller.instance_variable_get(:@before) + assert_equal "i'm after the render", @controller.instance_variable_get(:@after) + end + + def test_accessing_params_in_template_with_layout + get :accessing_params_in_template_with_layout, params: { name: "David" } + assert_equal "<html>Hello: David</html>", @response.body + end + + def test_render_with_explicit_template + get :render_with_explicit_template + assert_response :success + end + + def test_render_with_explicit_unescaped_template + assert_raise(ActionView::MissingTemplate) { get :render_with_explicit_unescaped_template } + get :render_with_explicit_escaped_template + assert_equal "Hello w*rld!", @response.body + end + + def test_render_with_explicit_string_template + get :render_with_explicit_string_template + assert_equal "<html>Hello world!</html>", @response.body + end + + def test_render_with_filters + get :render_with_filters + assert_equal "<test>passed formatted xml erb</test>", @response.body + end + + # :ported: + def test_double_render + assert_raise(AbstractController::DoubleRenderError) { get :double_render } + end + + def test_double_redirect + assert_raise(AbstractController::DoubleRenderError) { get :double_redirect } + end + + def test_render_and_redirect + assert_raise(AbstractController::DoubleRenderError) { get :render_and_redirect } + end + + # specify the one exception to double render rule - render_to_string followed by render + def test_render_to_string_and_render + get :render_to_string_and_render + assert_equal("Hi web users! here is some cached stuff", @response.body) + end + + def test_rendering_with_conflicting_local_vars + get :rendering_with_conflicting_local_vars + assert_equal("First: David\nSecond: Stephan\nThird: David\nFourth: David\nFifth: ", @response.body) + end + + def test_action_talk_to_layout + get :action_talk_to_layout + assert_equal "<title>Talking to the layout</title>\nAction was here!", @response.body + end + + # :addressed: + def test_render_text_with_assigns + get :render_text_with_assigns + assert_equal "world", @controller.instance_variable_get(:@hello) + end + + def test_render_text_with_assigns_option + get :render_with_assigns_option + assert_equal "world", response.body + end + + # :ported: + def test_template_with_locals + get :render_with_explicit_template_with_locals + assert_equal "The secret is area51\n", @response.body + end + + def test_yield_content_for + get :yield_content_for + assert_equal "<title>Putting stuff in the title!</title>\nGreat stuff!\n", @response.body + end + + def test_overwriting_rendering_relative_file_with_extension + get :hello_world_from_rxml_using_template + assert_equal "<html>\n <p>Hello</p>\n</html>\n", @response.body + + get :hello_world_from_rxml_using_action + assert_equal "<html>\n <p>Hello</p>\n</html>\n", @response.body + end + + def test_using_layout_around_block + get :render_using_layout_around_block + assert_equal "Before (David)\nInside from block\nAfter", @response.body + end + + def test_using_layout_around_block_in_main_layout_and_within_content_for_layout + get :render_using_layout_around_block_in_main_layout_and_within_content_for_layout + assert_equal "Before (Anthony)\nInside from first block in layout\nAfter\nBefore (David)\nInside from block\nAfter\nBefore (Ramm)\nInside from second block in layout\nAfter\n", @response.body + end + + def test_partial_only + get :partial_only + assert_equal "only partial", @response.body + assert_equal "text/html", @response.content_type + end + + def test_should_render_html_formatted_partial + get :partial + assert_equal "partial html", @response.body + assert_equal "text/html", @response.content_type + end + + def test_render_html_formatted_partial_even_with_other_mime_time_in_accept + @request.accept = "text/javascript, text/html" + + get :partial_html_erb + + assert_equal "partial.html.erb", @response.body.strip + assert_equal "text/html", @response.content_type + end + + def test_should_render_html_partial_with_formats + get :partial_formats_html + assert_equal "partial html", @response.body + assert_equal "text/html", @response.content_type + end + + def test_render_to_string_partial + get :render_to_string_with_partial + assert_equal "only partial", @controller.instance_variable_get(:@partial_only) + assert_equal "Hello: david", @controller.instance_variable_get(:@partial_with_locals) + assert_equal "text/html", @response.content_type + end + + def test_render_to_string_with_template_and_html_partial + get :render_to_string_with_template_and_html_partial + assert_equal "**only partial**\n", @controller.instance_variable_get(:@text) + assert_equal "<strong>only partial</strong>\n", @controller.instance_variable_get(:@html) + assert_equal "<strong>only html partial</strong>\n", @response.body + assert_equal "text/html", @response.content_type + end + + def test_render_to_string_and_render_with_different_formats + get :render_to_string_and_render_with_different_formats + assert_equal "<strong>only partial</strong>\n", @controller.instance_variable_get(:@html) + assert_equal "**only partial**\n", @response.body + assert_equal "text/plain", @response.content_type + end + + def test_render_template_within_a_template_with_other_format + get :render_template_within_a_template_with_other_format + expected = "only html partial<p>This is grand!</p>" + assert_equal expected, @response.body.strip + assert_equal "text/html", @response.content_type + end + + def test_partial_with_counter + get :partial_with_counter + assert_equal "5", @response.body + end + + def test_partial_with_locals + get :partial_with_locals + assert_equal "Hello: david", @response.body + end + + def test_partial_with_string_locals + get :partial_with_string_locals + assert_equal "Hello: david", @response.body + end + + def test_partial_with_form_builder + get :partial_with_form_builder + assert_equal "<label for=\"post_title\">Title</label>\n", @response.body + end + + def test_partial_with_form_builder_subclass + get :partial_with_form_builder_subclass + assert_equal "<label for=\"post_title\">Title</label>\n", @response.body + end + + def test_nested_partial_with_form_builder + @controller = Fun::GamesController.new + get :nested_partial_with_form_builder + assert_equal "<label for=\"post_title\">Title</label>\n", @response.body + end + + def test_namespaced_object_partial + @controller = Quiz::QuestionsController.new + get :new + assert_equal "Namespaced Partial", @response.body + end + + def test_partial_collection + get :partial_collection + assert_equal "Hello: davidHello: mary", @response.body + end + + def test_partial_collection_with_as + get :partial_collection_with_as + assert_equal "david david davidmary mary mary", @response.body + end + + def test_partial_collection_with_iteration + get :partial_collection_with_iteration + assert_equal "3-0: david-first3-1: mary3-2: christine-last", @response.body + end + + def test_partial_collection_with_as_and_iteration + get :partial_collection_with_as_and_iteration + assert_equal "3-0: david-first3-1: mary3-2: christine-last", @response.body + end + + def test_partial_collection_with_counter + get :partial_collection_with_counter + assert_equal "david0mary1", @response.body + end + + def test_partial_collection_with_as_and_counter + get :partial_collection_with_as_and_counter + assert_equal "david0mary1", @response.body + end + + def test_partial_collection_with_locals + get :partial_collection_with_locals + assert_equal "Bonjour: davidBonjour: mary", @response.body + end + + def test_partial_collection_with_spacer + get :partial_collection_with_spacer + assert_equal "Hello: davidonly partialHello: mary", @response.body + end + + def test_partial_collection_with_spacer_which_uses_render + get :partial_collection_with_spacer_which_uses_render + assert_equal "Hello: davidpartial html\npartial with partial\nHello: mary", @response.body + end + + def test_partial_collection_shorthand_with_locals + get :partial_collection_shorthand_with_locals + assert_equal "Bonjour: davidBonjour: mary", @response.body + end + + def test_partial_collection_shorthand_with_different_types_of_records + get :partial_collection_shorthand_with_different_types_of_records + assert_equal "Bonjour bad customer: mark0Bonjour good customer: craig1Bonjour bad customer: john2Bonjour good customer: zach3Bonjour good customer: brandon4Bonjour bad customer: dan5", @response.body + end + + def test_empty_partial_collection + get :empty_partial_collection + assert_equal " ", @response.body + end + + def test_partial_with_hash_object + get :partial_with_hash_object + assert_equal "Sam\nmaS\n", @response.body + end + + def test_partial_with_nested_object + get :partial_with_nested_object + assert_equal "first", @response.body + end + + def test_partial_with_nested_object_shorthand + get :partial_with_nested_object_shorthand + assert_equal "first", @response.body + end + + def test_hash_partial_collection + get :partial_hash_collection + assert_equal "Pratik\nkitarP\nAmy\nymA\n", @response.body + end + + def test_partial_hash_collection_with_locals + get :partial_hash_collection_with_locals + assert_equal "Hola: PratikHola: Amy", @response.body + end + + def test_render_missing_partial_template + assert_raise(ActionView::MissingTemplate) do + get :missing_partial + end + end + + def test_render_call_to_partial_with_layout + get :render_call_to_partial_with_layout + assert_equal "Before (David)\nInside from partial (David)\nAfter", @response.body + end + + def test_render_call_to_partial_with_layout_in_main_layout_and_within_content_for_layout + get :render_call_to_partial_with_layout_in_main_layout_and_within_content_for_layout + assert_equal "Before (Anthony)\nInside from partial (Anthony)\nAfter\nBefore (David)\nInside from partial (David)\nAfter\nBefore (Ramm)\nInside from partial (Ramm)\nAfter", @response.body + end +end diff --git a/actionview/test/actionpack/controller/view_paths_test.rb b/actionview/test/actionpack/controller/view_paths_test.rb new file mode 100644 index 0000000000..7f3fe0fa08 --- /dev/null +++ b/actionview/test/actionpack/controller/view_paths_test.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class ViewLoadPathsTest < ActionController::TestCase + class TestController < ActionController::Base + def self.controller_path() "test" end + + before_action :add_view_path, only: :hello_world_at_request_time + + def hello_world() end + def hello_world_at_request_time() render(action: "hello_world") end + + private + def add_view_path + prepend_view_path "#{FIXTURE_LOAD_PATH}/override" + end + end + + module Test + class SubController < ActionController::Base + layout "test/sub" + def hello_world; render(template: "test/hello_world"); end + end + end + + with_routes do + get :hello_world, to: "test#hello_world" + get :hello_world_at_request_time, to: "test#hello_world_at_request_time" + end + + def setup + @controller = TestController.new + @request = ActionController::TestRequest.create(@controller.class) + @response = ActionDispatch::TestResponse.new + @paths = TestController.view_paths + super + end + + def teardown + TestController.view_paths = @paths + end + + def expand(array) + array.map { |x| File.expand_path(x.to_s) } + end + + def assert_paths(*paths) + controller = paths.first.is_a?(Class) ? paths.shift : @controller + assert_equal expand(paths), controller.view_paths.map(&:to_s) + end + + def test_template_load_path_was_set_correctly + assert_paths FIXTURE_LOAD_PATH + end + + def test_controller_appends_view_path_correctly + @controller.append_view_path "foo" + assert_paths(FIXTURE_LOAD_PATH, "foo") + + @controller.append_view_path(%w(bar baz)) + assert_paths(FIXTURE_LOAD_PATH, "foo", "bar", "baz") + + @controller.append_view_path(FIXTURE_LOAD_PATH) + assert_paths(FIXTURE_LOAD_PATH, "foo", "bar", "baz", FIXTURE_LOAD_PATH) + end + + def test_controller_prepends_view_path_correctly + @controller.prepend_view_path "baz" + assert_paths("baz", FIXTURE_LOAD_PATH) + + @controller.prepend_view_path(%w(foo bar)) + assert_paths "foo", "bar", "baz", FIXTURE_LOAD_PATH + + @controller.prepend_view_path(FIXTURE_LOAD_PATH) + assert_paths FIXTURE_LOAD_PATH, "foo", "bar", "baz", FIXTURE_LOAD_PATH + end + + def test_template_appends_view_path_correctly + @controller.instance_variable_set :@template, ActionView::Base.new(TestController.view_paths, {}, @controller) + class_view_paths = TestController.view_paths + + @controller.append_view_path "foo" + assert_paths FIXTURE_LOAD_PATH, "foo" + + @controller.append_view_path(%w(bar baz)) + assert_paths FIXTURE_LOAD_PATH, "foo", "bar", "baz" + assert_paths TestController, *class_view_paths + end + + def test_template_prepends_view_path_correctly + @controller.instance_variable_set :@template, ActionView::Base.new(TestController.view_paths, {}, @controller) + class_view_paths = TestController.view_paths + + @controller.prepend_view_path "baz" + assert_paths "baz", FIXTURE_LOAD_PATH + + @controller.prepend_view_path(%w(foo bar)) + assert_paths "foo", "bar", "baz", FIXTURE_LOAD_PATH + assert_paths TestController, *class_view_paths + end + + def test_view_paths + get :hello_world + assert_response :success + assert_equal "Hello world!", @response.body + end + + def test_view_paths_override + TestController.prepend_view_path "#{FIXTURE_LOAD_PATH}/override" + get :hello_world + assert_response :success + assert_equal "Hello overridden world!", @response.body + end + + def test_view_paths_override_for_layouts_in_controllers_with_a_module + @controller = Test::SubController.new + with_routes do + get :hello_world, to: "view_load_paths_test/test/sub#hello_world" + end + + Test::SubController.view_paths = [ "#{FIXTURE_LOAD_PATH}/override", FIXTURE_LOAD_PATH, "#{FIXTURE_LOAD_PATH}/override2" ] + get :hello_world + assert_response :success + assert_equal "layout: Hello overridden world!", @response.body + end + + def test_view_paths_override_at_request_time + get :hello_world_at_request_time + assert_response :success + assert_equal "Hello overridden world!", @response.body + end + + def test_decorate_view_paths_with_custom_resolver + decorator_class = Class.new(ActionView::PathResolver) do + def initialize(path_set) + @path_set = path_set + end + + def find_all(*args) + @path_set.find_all(*args).collect do |template| + ::ActionView::Template.new( + "Decorated body", + template.identifier, + template.handler, + virtual_path: template.virtual_path, + format: template.formats + ) + end + end + end + + decorator = decorator_class.new(TestController.view_paths) + TestController.view_paths = ActionView::PathSet.new.push(decorator) + + get :hello_world + assert_response :success + assert_equal "Decorated body", @response.body + end + + def test_inheritance + original_load_paths = ActionController::Base.view_paths + + self.class.class_eval %{ + class A < ActionController::Base; end + class B < A; end + class C < ActionController::Base; end + } + + A.view_paths = ["a/path"] + + assert_paths A, "a/path" + assert_paths A, *B.view_paths + assert_paths C, *original_load_paths + + C.view_paths = [] + assert_nothing_raised { C.append_view_path "c/path" } + assert_paths C, "c/path" + end + + def test_lookup_context_accessor + assert_equal ["test"], TestController.new.lookup_context.prefixes + end +end diff --git a/actionview/test/active_record_unit.rb b/actionview/test/active_record_unit.rb new file mode 100644 index 0000000000..e4ea6a426d --- /dev/null +++ b/actionview/test/active_record_unit.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "abstract_unit" + +# Define the essentials +class ActiveRecordTestConnector + cattr_accessor :able_to_connect + cattr_accessor :connected + + # Set our defaults + self.connected = false + self.able_to_connect = true +end + +# Try to grab AR +unless defined?(ActiveRecord) && defined?(FixtureSet) + begin + PATH_TO_AR = File.expand_path("../../activerecord/lib", __dir__) + raise LoadError, "#{PATH_TO_AR} doesn't exist" unless File.directory?(PATH_TO_AR) + $LOAD_PATH.unshift PATH_TO_AR + require "active_record" + rescue LoadError => e + $stderr.print "Failed to load Active Record. Skipping Active Record assertion tests: #{e}" + ActiveRecordTestConnector.able_to_connect = false + end +end +$stderr.flush + +# Define the rest of the connector +class ActiveRecordTestConnector + class << self + def setup + unless connected || !able_to_connect + setup_connection + load_schema + require_fixture_models + self.connected = true + end + rescue Exception => e # errors from ActiveRecord setup + $stderr.puts "\nSkipping ActiveRecord assertion tests: #{e}" + # $stderr.puts " #{e.backtrace.join("\n ")}\n" + self.able_to_connect = false + end + + private + def setup_connection + if Object.const_defined?(:ActiveRecord) + defaults = { database: ":memory:" } + adapter = defined?(JRUBY_VERSION) ? "jdbcsqlite3" : "sqlite3" + options = defaults.merge adapter: adapter, timeout: 500 + ActiveRecord::Base.establish_connection(options) + ActiveRecord::Base.configurations = { "sqlite3_ar_integration" => options } + ActiveRecord::Base.connection + + Object.send(:const_set, :QUOTED_TYPE, ActiveRecord::Base.connection.quote_column_name("type")) unless Object.const_defined?(:QUOTED_TYPE) + else + raise "Can't setup connection since ActiveRecord isn't loaded." + end + end + + # Load actionpack sqlite3 tables + def load_schema + File.read(File.expand_path("fixtures/db_definitions/sqlite.sql", __dir__)).split(";").each do |sql| + ActiveRecord::Base.connection.execute(sql) unless sql.blank? + end + end + + def require_fixture_models + Dir.glob(File.expand_path("fixtures/*.rb", __dir__)).each { |f| require f } + end + end +end + +class ActiveRecordTestCase < ActionController::TestCase + include ActiveRecord::TestFixtures + + def self.tests(controller) + super + if defined? controller::ROUTES + include Module.new { + define_method(:setup) do + super() + @routes = controller::ROUTES + end + } + end + end + + # Set our fixture path + if ActiveRecordTestConnector.able_to_connect + self.fixture_path = [FIXTURE_LOAD_PATH] + self.use_transactional_tests = false + end + + def self.fixtures(*args) + super if ActiveRecordTestConnector.connected + end + + def run(*args) + super if ActiveRecordTestConnector.connected + end +end + +ActiveRecordTestConnector.setup diff --git a/actionview/test/activerecord/controller_runtime_test.rb b/actionview/test/activerecord/controller_runtime_test.rb new file mode 100644 index 0000000000..563044f11e --- /dev/null +++ b/actionview/test/activerecord/controller_runtime_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "active_record_unit" +require "active_record/railties/controller_runtime" +require "fixtures/project" +require "active_support/log_subscriber/test_helper" +require "action_controller/log_subscriber" + +ActionController::Base.include(ActiveRecord::Railties::ControllerRuntime) + +class ControllerRuntimeLogSubscriberTest < ActionController::TestCase + class LogSubscriberController < ActionController::Base + def show + render inline: "<%= Project.all %>" + end + + def zero + render inline: "Zero DB runtime" + end + + def create + ActiveRecord::LogSubscriber.runtime += 100 + Project.last + redirect_to "/" + end + + def redirect + Project.all + redirect_to action: "show" + end + + def db_after_render + render inline: "Hello world" + Project.all + ActiveRecord::LogSubscriber.runtime += 100 + end + end + + include ActiveSupport::LogSubscriber::TestHelper + tests LogSubscriberController + + with_routes do + get :show, to: "#{LogSubscriberController.controller_path}#show" + get :zero, to: "#{LogSubscriberController.controller_path}#zero" + get :db_after_render, to: "#{LogSubscriberController.controller_path}#db_after_render" + get :redirect, to: "#{LogSubscriberController.controller_path}#redirect" + post :create, to: "#{LogSubscriberController.controller_path}#create" + end + + def setup + @old_logger = ActionController::Base.logger + super + ActionController::LogSubscriber.attach_to :action_controller + end + + def teardown + super + ActiveSupport::LogSubscriber.log_subscribers.clear + ActionController::Base.logger = @old_logger + end + + def set_logger(logger) + ActionController::Base.logger = logger + end + + def test_log_with_active_record + get :show + wait + + assert_equal 2, @logger.logged(:info).size + assert_match(/\(Views: [\d.]+ms \| ActiveRecord: [\d.]+ms \| Allocations: [\d.]+\)/, @logger.logged(:info)[1]) + end + + def test_runtime_reset_before_requests + ActiveRecord::LogSubscriber.runtime += 12345 + get :zero + wait + + assert_equal 2, @logger.logged(:info).size + assert_match(/\(Views: [\d.]+ms \| ActiveRecord: [\d.]+ms \| Allocations: [\d.]+\)/, @logger.logged(:info)[1]) + end + + def test_log_with_active_record_when_post + post :create + wait + assert_match(/ActiveRecord: ([1-9][\d.]+)ms \| Allocations: [\d.]+\)/, @logger.logged(:info)[2]) + end + + def test_log_with_active_record_when_redirecting + get :redirect + wait + assert_equal 3, @logger.logged(:info).size + assert_match(/\(ActiveRecord: [\d.]+ms \| Allocations: [\d.]+\)/, @logger.logged(:info)[2]) + end + + def test_include_time_query_time_after_rendering + get :db_after_render + wait + + assert_equal 2, @logger.logged(:info).size + assert_match(/\(Views: [\d.]+ms \| ActiveRecord: ([1-9][\d.]+)ms \| Allocations: [\d.]+\)/, @logger.logged(:info)[1]) + end +end diff --git a/actionview/test/activerecord/debug_helper_test.rb b/actionview/test/activerecord/debug_helper_test.rb new file mode 100644 index 0000000000..87a1791573 --- /dev/null +++ b/actionview/test/activerecord/debug_helper_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "active_record_unit" +require "nokogiri" + +class DebugHelperTest < ActionView::TestCase + def test_debug + company = Company.new(name: "firebase") + output = debug(company) + assert_match "name: name", output + assert_match "value_before_type_cast: firebase", output + assert_match "active_record_yaml_version: 2", output + end + + def test_debug_with_marshal_error + obj = -> { } + assert_match obj.inspect, Nokogiri.XML(debug(obj)).content + end +end diff --git a/actionview/test/activerecord/form_helper_activerecord_test.rb b/actionview/test/activerecord/form_helper_activerecord_test.rb new file mode 100644 index 0000000000..34655bfe23 --- /dev/null +++ b/actionview/test/activerecord/form_helper_activerecord_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "active_record_unit" +require "fixtures/project" +require "fixtures/developer" + +class FormHelperActiveRecordTest < ActionView::TestCase + tests ActionView::Helpers::FormHelper + + def form_for(*) + @output_buffer = super + end + + def setup + @developer = Developer.new + @developer.id = 123 + @developer.name = "developer #123" + + @project = Project.new + @project.id = 321 + @project.name = "project #321" + @project.save + + @developer.projects << @project + @developer.save + super + @controller.singleton_class.include Routes.url_helpers + end + + def teardown + super + Project.delete(321) + Developer.delete(123) + end + + Routes = ActionDispatch::Routing::RouteSet.new + Routes.draw do + resources :developers do + resources :projects + end + end + + include Routes.url_helpers + + def test_nested_fields_for_with_child_index_option_override_on_a_nested_attributes_collection_association + form_for(@developer) do |f| + concat f.fields_for(:projects, @developer.projects.first, child_index: "abc") { |cf| + concat cf.text_field(:name) + } + end + + expected = whole_form("/developers/123", "edit_developer_123", "edit_developer", method: "patch") do + '<input id="developer_projects_attributes_abc_name" name="developer[projects_attributes][abc][name]" type="text" value="project #321" />' \ + '<input id="developer_projects_attributes_abc_id" name="developer[projects_attributes][abc][id]" type="hidden" value="321" />' + end + + assert_dom_equal expected, output_buffer + end + + private + + def hidden_fields(method = nil) + txt = +%{<input name="utf8" type="hidden" value="✓" />} + + 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, remote = nil, multipart = nil, method = nil) + txt = +%{<form accept-charset="UTF-8" action="#{action}"} + txt << %{ enctype="multipart/form-data"} if multipart + txt << %{ data-remote="true"} if remote + 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, options = nil) + contents = block_given? ? yield : "" + + if options.is_a?(Hash) + method, remote, multipart = options.values_at(:method, :remote, :multipart) + else + method = options + end + + form_text(action, id, html_class, remote, multipart, method) + hidden_fields(method) + contents + "</form>" + end +end diff --git a/actionview/test/activerecord/multifetch_cache_test.rb b/actionview/test/activerecord/multifetch_cache_test.rb new file mode 100644 index 0000000000..12be069e69 --- /dev/null +++ b/actionview/test/activerecord/multifetch_cache_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "active_record_unit" +require "active_record/railties/collection_cache_association_loading" + +ActionView::PartialRenderer.prepend(ActiveRecord::Railties::CollectionCacheAssociationLoading) + +class MultifetchCacheTest < ActiveRecordTestCase + fixtures :topics, :replies + + def setup + view_paths = ActionController::Base.view_paths + + @view = Class.new(ActionView::Base) do + def view_cache_dependencies + [] + end + + def combined_fragment_cache_key(key) + [ :views, key ] + end + end.new(view_paths, {}) + end + + def test_only_preloading_for_records_that_miss_the_cache + @view.render partial: "test/partial", collection: [topics(:rails)], cached: true + + @topics = Topic.preload(:replies) + + @view.render partial: "test/partial", collection: @topics, cached: true + + assert_not @topics.detect { |topic| topic.id == topics(:rails).id }.replies.loaded? + assert @topics.detect { |topic| topic.id != topics(:rails).id }.replies.loaded? + end +end diff --git a/actionview/test/activerecord/polymorphic_routes_test.rb b/actionview/test/activerecord/polymorphic_routes_test.rb new file mode 100644 index 0000000000..724129a7d9 --- /dev/null +++ b/actionview/test/activerecord/polymorphic_routes_test.rb @@ -0,0 +1,786 @@ +# frozen_string_literal: true + +require "active_record_unit" +require "fixtures/project" + +class Task < ActiveRecord::Base + self.table_name = "projects" +end + +class Step < ActiveRecord::Base + self.table_name = "projects" +end + +class Bid < ActiveRecord::Base + self.table_name = "projects" +end + +class Tax < ActiveRecord::Base + self.table_name = "projects" +end + +class Fax < ActiveRecord::Base + self.table_name = "projects" +end + +class Series < ActiveRecord::Base + self.table_name = "projects" +end + +class ModelDelegator + def to_model + ModelDelegate.new + end +end + +class ModelDelegate + def persisted? + true + end + + def model_name + ActiveModel::Name.new(self.class) + end + + def to_param + "overridden" + end +end + +module Weblog + class Post < ActiveRecord::Base + self.table_name = "projects" + end + + class Blog < ActiveRecord::Base + self.table_name = "projects" + end + + def self.use_relative_model_naming? + true + end +end + +class PolymorphicRoutesTest < ActionController::TestCase + Routes = ActionDispatch::Routing::RouteSet.new + Routes.draw { } + include Routes.url_helpers + + default_url_options[:host] = "example.com" + + def setup + super + @project = Project.new + @task = Task.new + @step = Step.new + @bid = Bid.new + @tax = Tax.new + @fax = Fax.new + @delegator = ModelDelegator.new + @series = Series.new + @blog_post = Weblog::Post.new + @blog_blog = Weblog::Blog.new + end + + def assert_url(url, args) + host = self.class.default_url_options[:host] + + assert_equal url.sub(/http:\/\/#{host}/, ""), polymorphic_path(args) + assert_equal url, polymorphic_url(args) + assert_equal url, url_for(args) + end + + def test_string + with_test_routes do + assert_equal "/projects", polymorphic_path("projects") + assert_equal "http://example.com/projects", polymorphic_url("projects") + assert_equal "projects", url_for("projects") + end + end + + def test_string_with_options + with_test_routes do + assert_equal "http://example.com/projects?id=10", polymorphic_url("projects", id: 10) + end + end + + def test_symbol + with_test_routes do + assert_url "http://example.com/projects", :projects + end + end + + def test_symbol_with_options + with_test_routes do + assert_equal "http://example.com/projects?id=10", polymorphic_url(:projects, id: 10) + end + end + + def test_passing_routes_proxy + with_namespaced_routes(:blog) do + proxy = ActionDispatch::Routing::RoutesProxy.new(_routes, self, _routes.url_helpers) + @blog_post.save + assert_url "http://example.com/posts/#{@blog_post.id}", [proxy, @blog_post] + end + end + + def test_namespaced_model + with_namespaced_routes(:blog) do + @blog_post.save + assert_url "http://example.com/posts/#{@blog_post.id}", @blog_post + end + end + + def test_namespaced_model_with_name_the_same_as_namespace + with_namespaced_routes(:blog) do + @blog_blog.save + assert_url "http://example.com/blogs/#{@blog_blog.id}", @blog_blog + end + end + + def test_polymorphic_url_with_2_objects + with_namespaced_routes(:blog) do + @blog_blog.save + @blog_post.save + assert_equal "http://example.com/blogs/#{@blog_blog.id}/posts/#{@blog_post.id}", polymorphic_url([@blog_blog, @blog_post]) + end + end + + def test_polymorphic_url_with_3_objects + with_namespaced_routes(:blog) do + @blog_blog.save + @blog_post.save + @fax.save + assert_equal "http://example.com/blogs/#{@blog_blog.id}/posts/#{@blog_post.id}/faxes/#{@fax.id}", polymorphic_url([@blog_blog, @blog_post, @fax]) + end + end + + def test_namespaced_model_with_nested_resources + with_namespaced_routes(:blog) do + @blog_post.save + @blog_blog.save + assert_url "http://example.com/blogs/#{@blog_blog.id}/posts/#{@blog_post.id}", [@blog_blog, @blog_post] + end + end + + def test_with_nil + with_test_routes do + exception = assert_raise ArgumentError do + polymorphic_url(nil) + end + assert_equal "Nil location provided. Can't build URI.", exception.message + end + end + + def test_with_empty_list + with_test_routes do + exception = assert_raise ArgumentError do + polymorphic_url([]) + end + assert_equal "Nil location provided. Can't build URI.", exception.message + end + end + + def test_with_nil_id + with_test_routes do + exception = assert_raise ArgumentError do + polymorphic_url(id: nil) + end + assert_equal "Nil location provided. Can't build URI.", exception.message + end + end + + def test_with_entirely_nil_list + with_test_routes do + exception = assert_raise ArgumentError do + @series.save + polymorphic_url([nil, nil]) + end + assert_equal "Nil location provided. Can't build URI.", exception.message + end + end + + def test_with_nil_in_list_for_resource_that_could_be_top_level_or_nested + with_top_level_and_nested_routes do + @blog_post.save + assert_equal "http://example.com/posts/#{@blog_post.id}", polymorphic_url([nil, @blog_post]) + end + end + + def test_with_nil_in_list_does_not_generate_invalid_link + with_top_level_and_nested_routes do + exception = assert_raise NoMethodError do + @series.save + polymorphic_url([nil, @series]) + end + assert_match(/undefined method `series_url'/, exception.message) + end + end + + def test_with_record + with_test_routes do + @project.save + assert_url "http://example.com/projects/#{@project.id}", @project + end + end + + def test_with_class + with_test_routes do + assert_url "http://example.com/projects", @project.class + end + end + + def test_with_class_list_of_one + with_test_routes do + assert_url "http://example.com/projects", [@project.class] + end + end + + def test_class_with_options + with_test_routes do + assert_equal "http://example.com/projects?foo=bar", polymorphic_url(@project.class, foo: :bar) + assert_equal "/projects?foo=bar", polymorphic_path(@project.class, foo: :bar) + end + end + + def test_with_new_record + with_test_routes do + assert_url "http://example.com/projects", @project + end + end + + def test_new_record_arguments + params = nil + + with_test_routes do + extend Module.new { + define_method("projects_url") { |*args| + params = args + super(*args) + } + + define_method("projects_path") { |*args| + params = args + super(*args) + } + } + + assert_url "http://example.com/projects", @project + assert_equal [], params + end + end + + def test_with_destroyed_record + with_test_routes do + @project.destroy + assert_url "http://example.com/projects", @project + end + end + + def test_with_record_and_action + with_test_routes do + assert_equal "http://example.com/projects/new", polymorphic_url(@project, action: "new") + end + end + + def test_url_helper_prefixed_with_new + with_test_routes do + assert_equal "http://example.com/projects/new", new_polymorphic_url(@project) + end + end + + def test_regression_path_helper_prefixed_with_new_and_edit + with_test_routes do + assert_equal "/projects/new", new_polymorphic_path(@project) + + @project.save + assert_equal "/projects/#{@project.id}/edit", edit_polymorphic_path(@project) + end + end + + def test_url_helper_prefixed_with_edit + with_test_routes do + @project.save + assert_equal "http://example.com/projects/#{@project.id}/edit", edit_polymorphic_url(@project) + end + end + + def test_url_helper_prefixed_with_edit_with_url_options + with_test_routes do + @project.save + assert_equal "http://example.com/projects/#{@project.id}/edit?param1=10", edit_polymorphic_url(@project, param1: "10") + end + end + + def test_url_helper_with_url_options + with_test_routes do + @project.save + assert_equal "http://example.com/projects/#{@project.id}?param1=10", polymorphic_url(@project, param1: "10") + end + end + + def test_format_option + with_test_routes do + @project.save + assert_equal "http://example.com/projects/#{@project.id}.pdf", polymorphic_url(@project, format: :pdf) + end + end + + def test_format_option_with_url_options + with_test_routes do + @project.save + assert_equal "http://example.com/projects/#{@project.id}.pdf?param1=10", polymorphic_url(@project, format: :pdf, param1: "10") + end + end + + def test_id_and_format_option + with_test_routes do + @project.save + assert_equal "http://example.com/projects/#{@project.id}.pdf", polymorphic_url(id: @project, format: :pdf) + end + end + + def test_with_nested + with_test_routes do + @project.save + @task.save + assert_url "http://example.com/projects/#{@project.id}/tasks/#{@task.id}", [@project, @task] + end + end + + def test_with_nested_unsaved + with_test_routes do + @project.save + assert_url "http://example.com/projects/#{@project.id}/tasks", [@project, @task] + end + end + + def test_with_nested_destroyed + with_test_routes do + @project.save + @task.destroy + assert_url "http://example.com/projects/#{@project.id}/tasks", [@project, @task] + end + end + + def test_with_nested_class + with_test_routes do + @project.save + assert_url "http://example.com/projects/#{@project.id}/tasks", [@project, @task.class] + end + end + + def test_class_with_array_and_namespace + with_admin_test_routes do + assert_url "http://example.com/admin/projects", [:admin, @project.class] + end + end + + def test_new_with_array_and_namespace + with_admin_test_routes do + assert_equal "http://example.com/admin/projects/new", polymorphic_url([:admin, @project], action: "new") + end + end + + def test_unsaved_with_array_and_namespace + with_admin_test_routes do + assert_url "http://example.com/admin/projects", [:admin, @project] + end + end + + def test_nested_unsaved_with_array_and_namespace + with_admin_test_routes do + @project.save + assert_url "http://example.com/admin/projects/#{@project.id}/tasks", [:admin, @project, @task] + end + end + + def test_nested_with_array_and_namespace + with_admin_test_routes do + @project.save + @task.save + assert_url "http://example.com/admin/projects/#{@project.id}/tasks/#{@task.id}", [:admin, @project, @task] + end + end + + def test_ordering_of_nesting_and_namespace + with_admin_and_site_test_routes do + @project.save + @task.save + @step.save + assert_url "http://example.com/admin/projects/#{@project.id}/site/tasks/#{@task.id}/steps/#{@step.id}", [:admin, @project, :site, @task, @step] + end + end + + def test_nesting_with_array_ending_in_singleton_resource + with_test_routes do + @project.save + assert_url "http://example.com/projects/#{@project.id}/bid", [@project, :bid] + end + end + + def test_nesting_with_array_containing_singleton_resource + with_test_routes do + @project.save + @task.save + assert_url "http://example.com/projects/#{@project.id}/bid/tasks/#{@task.id}", [@project, :bid, @task] + end + end + + def test_nesting_with_array_containing_singleton_resource_and_format + with_test_routes do + @project.save + @task.save + assert_equal "http://example.com/projects/#{@project.id}/bid/tasks/#{@task.id}.pdf", polymorphic_url([@project, :bid, @task], format: :pdf) + end + end + + def test_nesting_with_array_containing_namespace_and_singleton_resource + with_admin_test_routes do + @project.save + @task.save + assert_url "http://example.com/admin/projects/#{@project.id}/bid/tasks/#{@task.id}", [:admin, @project, :bid, @task] + end + end + + def test_nesting_with_array + with_test_routes do + @project.save + assert_url "http://example.com/projects/#{@project.id}/bid", [@project, :bid] + end + end + + def test_with_array_containing_single_object + with_test_routes do + @project.save + assert_url "http://example.com/projects/#{@project.id}", [@project] + end + end + + def test_with_array_containing_single_name + with_test_routes do + @project.save + assert_url "http://example.com/projects", [:projects] + end + end + + def test_with_array_containing_single_string_name + with_test_routes do + assert_url "http://example.com/projects", ["projects"] + end + end + + def test_with_array_containing_symbols + with_test_routes do + assert_url "http://example.com/series/new", [:new, :series] + end + end + + def test_with_hash + with_test_routes do + @project.save + assert_equal "http://example.com/projects/#{@project.id}", polymorphic_url(id: @project) + end + end + + def test_polymorphic_path_accepts_options + with_test_routes do + assert_equal "/projects/new", polymorphic_path(@project, action: "new") + end + end + + def test_polymorphic_path_does_not_modify_arguments + with_admin_test_routes do + @project.save + @task.save + + options = {} + object_array = [:admin, @project, @task] + original_args = [object_array.dup, options.dup] + + assert_no_difference("object_array.size") { polymorphic_path(object_array, options) } + assert_equal original_args, [object_array, options] + end + end + + # Tests for names where .plural.singular doesn't round-trip + def test_with_irregular_plural_record + with_test_routes do + @tax.save + assert_url "http://example.com/taxes/#{@tax.id}", @tax + end + end + + def test_with_irregular_plural_class + with_test_routes do + assert_url "http://example.com/taxes", @tax.class + end + end + + def test_with_irregular_plural_new_record + with_test_routes do + assert_url "http://example.com/taxes", @tax + end + end + + def test_with_irregular_plural_destroyed_record + with_test_routes do + @tax.destroy + assert_url "http://example.com/taxes", @tax + end + end + + def test_with_irregular_plural_record_and_action + with_test_routes do + assert_equal "http://example.com/taxes/new", polymorphic_url(@tax, action: "new") + end + end + + def test_irregular_plural_url_helper_prefixed_with_new + with_test_routes do + assert_equal "http://example.com/taxes/new", new_polymorphic_url(@tax) + end + end + + def test_irregular_plural_url_helper_prefixed_with_edit + with_test_routes do + @tax.save + assert_equal "http://example.com/taxes/#{@tax.id}/edit", edit_polymorphic_url(@tax) + end + end + + def test_with_nested_irregular_plurals + with_test_routes do + @tax.save + @fax.save + assert_equal "http://example.com/taxes/#{@tax.id}/faxes/#{@fax.id}", polymorphic_url([@tax, @fax]) + end + end + + def test_with_nested_unsaved_irregular_plurals + with_test_routes do + @tax.save + assert_url "http://example.com/taxes/#{@tax.id}/faxes", [@tax, @fax] + end + end + + def test_new_with_irregular_plural_array_and_namespace + with_admin_test_routes do + assert_equal "http://example.com/admin/taxes/new", polymorphic_url([:admin, @tax], action: "new") + end + end + + def test_class_with_irregular_plural_array_and_namespace + with_admin_test_routes do + assert_url "http://example.com/admin/taxes", [:admin, @tax.class] + end + end + + def test_unsaved_with_irregular_plural_array_and_namespace + with_admin_test_routes do + assert_url "http://example.com/admin/taxes", [:admin, @tax] + end + end + + def test_nesting_with_irregular_plurals_and_array_ending_in_singleton_resource + with_test_routes do + @tax.save + assert_url "http://example.com/taxes/#{@tax.id}/bid", [@tax, :bid] + end + end + + def test_with_array_containing_single_irregular_plural_object + with_test_routes do + @tax.save + assert_url "http://example.com/taxes/#{@tax.id}", [@tax] + end + end + + def test_with_array_containing_single_name_irregular_plural + with_test_routes do + @tax.save + assert_url "http://example.com/taxes", [:taxes] + end + end + + # Tests for uncountable names + def test_uncountable_resource + with_test_routes do + @series.save + assert_url "http://example.com/series/#{@series.id}", @series + assert_url "http://example.com/series", Series.new + end + end + + def test_routing_to_a_model_delegate + with_test_routes do + assert_url "http://example.com/model_delegates/overridden", @delegator + end + end + + def test_nested_routing_to_a_model_delegate + with_test_routes do + assert_url "http://example.com/foo/model_delegates/overridden", [:foo, @delegator] + end + end + + def with_namespaced_routes(name) + with_routing do |set| + set.draw do + scope(module: name) do + resources :blogs do + resources :posts do + resources :faxes + end + end + resources :posts + end + end + + extend @routes.url_helpers + yield + end + end + + def with_test_routes(options = {}) + with_routing do |set| + set.draw do + resources :projects do + resources :tasks + resource :bid do + resources :tasks + end + end + resources :taxes do + resources :faxes + resource :bid + end + resources :series + resources :model_delegates + namespace :foo do + resources :model_delegates + end + end + + extend @routes.url_helpers + yield + end + end + + def with_top_level_and_nested_routes(options = {}) + with_routing do |set| + set.draw do + resources :blogs do + resources :posts + resources :series + end + resources :posts + end + + extend @routes.url_helpers + yield + end + end + + def with_admin_test_routes(options = {}) + with_routing do |set| + set.draw do + namespace :admin do + resources :projects do + resources :tasks + resource :bid do + resources :tasks + end + end + resources :taxes do + resources :faxes + end + resources :series + end + end + + extend @routes.url_helpers + yield + end + end + + def with_admin_and_site_test_routes(options = {}) + with_routing do |set| + set.draw do + namespace :admin do + resources :projects do + namespace :site do + resources :tasks do + resources :steps + end + end + end + end + end + + extend @routes.url_helpers + yield + end + end +end + +class PolymorphicPathRoutesTest < PolymorphicRoutesTest + include ActionView::RoutingUrlFor + include ActionView::Context + + attr_accessor :controller + + def assert_url(url, args) + host = self.class.default_url_options[:host] + + assert_equal url.sub(/http:\/\/#{host}/, ""), url_for(args) + end +end + +class DirectRoutesTest < ActionView::TestCase + class Linkable + attr_reader :id + + def self.name + super.demodulize + end + + def initialize(id) + @id = id + end + + def linkable_type + self.class.name.underscore + end + end + + class Category < Linkable; end + class Collection < Linkable; end + class Product < Linkable; end + + Routes = ActionDispatch::Routing::RouteSet.new + Routes.draw do + resources :categories, :collections, :products + direct(:linkable) { |linkable| [:"#{linkable.linkable_type}", { id: linkable.id }] } + end + + include Routes.url_helpers + + def setup + super + @category = Category.new("1") + @collection = Collection.new("2") + @product = Product.new("3") + @controller.singleton_class.include Routes.url_helpers + end + + def test_direct_routes + assert_equal "/categories/1", linkable_path(@category) + assert_equal "/collections/2", linkable_path(@collection) + assert_equal "/products/3", linkable_path(@product) + + assert_equal "http://test.host/categories/1", linkable_url(@category) + assert_equal "http://test.host/collections/2", linkable_url(@collection) + assert_equal "http://test.host/products/3", linkable_url(@product) + end +end diff --git a/actionview/test/activerecord/relation_cache_test.rb b/actionview/test/activerecord/relation_cache_test.rb new file mode 100644 index 0000000000..a6befc3ee5 --- /dev/null +++ b/actionview/test/activerecord/relation_cache_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "active_record_unit" + +class RelationCacheTest < ActionView::TestCase + tests ActionView::Helpers::CacheHelper + + def setup + super + view_paths = ActionController::Base.view_paths + lookup_context = ActionView::LookupContext.new(view_paths, {}, ["test"]) + @view_renderer = ActionView::Renderer.new(lookup_context) + @virtual_path = "path" + + controller.cache_store = ActiveSupport::Cache::MemoryStore.new + end + + def test_cache_relation_other + cache(Project.all) { concat("Hello World") } + assert_equal "Hello World", controller.cache_store.read("views/path/projects-#{Project.count}") + end + + def view_cache_dependencies; []; end +end diff --git a/actionview/test/activerecord/render_partial_with_record_identification_test.rb b/actionview/test/activerecord/render_partial_with_record_identification_test.rb new file mode 100644 index 0000000000..2bb3cfeb5b --- /dev/null +++ b/actionview/test/activerecord/render_partial_with_record_identification_test.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require "active_record_unit" + +class RenderPartialWithRecordIdentificationController < ActionController::Base + ROUTES = test_routes do + get :render_with_record_collection, to: "render_partial_with_record_identification#render_with_record_collection" + get :render_with_scope, to: "render_partial_with_record_identification#render_with_scope" + get :render_with_record, to: "render_partial_with_record_identification#render_with_record" + get :render_with_has_many_association, to: "render_partial_with_record_identification#render_with_has_many_association" + get :render_with_has_many_and_belongs_to_association, to: "render_partial_with_record_identification#render_with_has_many_and_belongs_to_association" + get :render_with_has_one_association, to: "render_partial_with_record_identification#render_with_has_one_association" + get :render_with_record_collection_and_spacer_template, to: "render_partial_with_record_identification#render_with_record_collection_and_spacer_template" + end + + def render_with_has_many_and_belongs_to_association + @developer = Developer.find(1) + render partial: @developer.projects + end + + def render_with_has_many_association + @topic = Topic.find(1) + render partial: @topic.replies + end + + def render_with_scope + render partial: Reply.base + end + + def render_with_has_one_association + @company = Company.find(1) + render partial: @company.mascot + end + + def render_with_record + @developer = Developer.first + render partial: @developer + end + + def render_with_record_collection + @developers = Developer.all + render partial: @developers + end + + def render_with_record_collection_and_spacer_template + @developer = Developer.find(1) + render partial: @developer.projects, spacer_template: "test/partial_only" + end +end + +class RenderPartialWithRecordIdentificationTest < ActiveRecordTestCase + tests RenderPartialWithRecordIdentificationController + fixtures :developers, :projects, :developers_projects, :topics, :replies, :companies, :mascots + + def test_rendering_partial_with_has_many_and_belongs_to_association + get :render_with_has_many_and_belongs_to_association + assert_equal Developer.find(1).projects.map(&:name).join, @response.body + end + + def test_rendering_partial_with_has_many_association + get :render_with_has_many_association + assert_equal "Birdman is better!", @response.body + end + + def test_rendering_partial_with_scope + get :render_with_scope + assert_equal "Birdman is better!Nuh uh!", @response.body + end + + def test_render_with_record + get :render_with_record + assert_equal "David", @response.body + end + + def test_render_with_record_collection + get :render_with_record_collection + assert_equal "DavidJamisfixture_3fixture_4fixture_5fixture_6fixture_7fixture_8fixture_9fixture_10Jamis", @response.body + end + + def test_render_with_record_collection_and_spacer_template + get :render_with_record_collection_and_spacer_template + assert_equal Developer.find(1).projects.map(&:name).join("only partial"), @response.body + end + + def test_rendering_partial_with_has_one_association + mascot = Company.find(1).mascot + get :render_with_has_one_association + assert_equal mascot.name, @response.body + end +end + +Game = Struct.new(:name, :id) do + extend ActiveModel::Naming + include ActiveModel::Conversion + def to_param + id.to_s + end +end + +module Fun + class NestedController < ActionController::Base + ROUTES = test_routes do + get :render_with_record_in_nested_controller, to: "fun/nested#render_with_record_in_nested_controller" + get :render_with_record_collection_in_nested_controller, to: "fun/nested#render_with_record_collection_in_nested_controller" + end + + def render_with_record_in_nested_controller + render partial: Game.new("Pong") + end + + def render_with_record_collection_in_nested_controller + render partial: [ Game.new("Pong"), Game.new("Tank") ] + end + end + + module Serious + class NestedDeeperController < ActionController::Base + ROUTES = test_routes do + get :render_with_record_in_deeper_nested_controller, to: "fun/serious/nested_deeper#render_with_record_in_deeper_nested_controller" + get :render_with_record_collection_in_deeper_nested_controller, to: "fun/serious/nested_deeper#render_with_record_collection_in_deeper_nested_controller" + end + + def render_with_record_in_deeper_nested_controller + render partial: Game.new("Chess") + end + + def render_with_record_collection_in_deeper_nested_controller + render partial: [ Game.new("Chess"), Game.new("Sudoku"), Game.new("Solitaire") ] + end + end + end +end + +class RenderPartialWithRecordIdentificationAndNestedControllersTest < ActiveRecordTestCase + tests Fun::NestedController + + def test_render_with_record_in_nested_controller + get :render_with_record_in_nested_controller + assert_equal "Fun Pong\n", @response.body + end + + def test_render_with_record_collection_in_nested_controller + get :render_with_record_collection_in_nested_controller + assert_equal "Fun Pong\nFun Tank\n", @response.body + end +end + +class RenderPartialWithRecordIdentificationAndNestedControllersWithoutPrefixTest < ActiveRecordTestCase + tests Fun::NestedController + + def test_render_with_record_in_nested_controller + old_config = ActionView::Base.prefix_partial_path_with_controller_namespace + ActionView::Base.prefix_partial_path_with_controller_namespace = false + + get :render_with_record_in_nested_controller + assert_equal "Just Pong\n", @response.body + ensure + ActionView::Base.prefix_partial_path_with_controller_namespace = old_config + end + + def test_render_with_record_collection_in_nested_controller + old_config = ActionView::Base.prefix_partial_path_with_controller_namespace + ActionView::Base.prefix_partial_path_with_controller_namespace = false + + get :render_with_record_collection_in_nested_controller + assert_equal "Just Pong\nJust Tank\n", @response.body + ensure + ActionView::Base.prefix_partial_path_with_controller_namespace = old_config + end +end + +class RenderPartialWithRecordIdentificationAndNestedDeeperControllersTest < ActiveRecordTestCase + tests Fun::Serious::NestedDeeperController + + def test_render_with_record_in_deeper_nested_controller + get :render_with_record_in_deeper_nested_controller + assert_equal "Serious Chess\n", @response.body + end + + def test_render_with_record_collection_in_deeper_nested_controller + get :render_with_record_collection_in_deeper_nested_controller + assert_equal "Serious Chess\nSerious Sudoku\nSerious Solitaire\n", @response.body + end +end + +class RenderPartialWithRecordIdentificationAndNestedDeeperControllersWithoutPrefixTest < ActiveRecordTestCase + tests Fun::Serious::NestedDeeperController + + def test_render_with_record_in_deeper_nested_controller + old_config = ActionView::Base.prefix_partial_path_with_controller_namespace + ActionView::Base.prefix_partial_path_with_controller_namespace = false + + get :render_with_record_in_deeper_nested_controller + assert_equal "Just Chess\n", @response.body + ensure + ActionView::Base.prefix_partial_path_with_controller_namespace = old_config + end + + def test_render_with_record_collection_in_deeper_nested_controller + old_config = ActionView::Base.prefix_partial_path_with_controller_namespace + ActionView::Base.prefix_partial_path_with_controller_namespace = false + + get :render_with_record_collection_in_deeper_nested_controller + assert_equal "Just Chess\nJust Sudoku\nJust Solitaire\n", @response.body + ensure + ActionView::Base.prefix_partial_path_with_controller_namespace = old_config + end +end diff --git a/actionview/test/fixtures/_top_level_partial.html.erb b/actionview/test/fixtures/_top_level_partial.html.erb new file mode 100644 index 0000000000..0b1c2e46e0 --- /dev/null +++ b/actionview/test/fixtures/_top_level_partial.html.erb @@ -0,0 +1 @@ +top level partial html
\ No newline at end of file diff --git a/actionview/test/fixtures/_top_level_partial_only.erb b/actionview/test/fixtures/_top_level_partial_only.erb new file mode 100644 index 0000000000..44f25b61d0 --- /dev/null +++ b/actionview/test/fixtures/_top_level_partial_only.erb @@ -0,0 +1 @@ +top level partial
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/bad_customers/_bad_customer.html.erb b/actionview/test/fixtures/actionpack/bad_customers/_bad_customer.html.erb new file mode 100644 index 0000000000..d22af431ec --- /dev/null +++ b/actionview/test/fixtures/actionpack/bad_customers/_bad_customer.html.erb @@ -0,0 +1 @@ +<%= greeting %> bad customer: <%= bad_customer.name %><%= bad_customer_counter %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/customers/_customer.html.erb b/actionview/test/fixtures/actionpack/customers/_customer.html.erb new file mode 100644 index 0000000000..483571e22a --- /dev/null +++ b/actionview/test/fixtures/actionpack/customers/_customer.html.erb @@ -0,0 +1 @@ +<%= greeting %>: <%= customer.name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/fun/games/_form.erb b/actionview/test/fixtures/actionpack/fun/games/_form.erb new file mode 100644 index 0000000000..01107f1cb2 --- /dev/null +++ b/actionview/test/fixtures/actionpack/fun/games/_form.erb @@ -0,0 +1 @@ +<%= form.label :title %> diff --git a/actionview/test/fixtures/actionpack/fun/games/hello_world.erb b/actionview/test/fixtures/actionpack/fun/games/hello_world.erb new file mode 100644 index 0000000000..1ebfbe2539 --- /dev/null +++ b/actionview/test/fixtures/actionpack/fun/games/hello_world.erb @@ -0,0 +1 @@ +Living in a nested world
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/good_customers/_good_customer.html.erb b/actionview/test/fixtures/actionpack/good_customers/_good_customer.html.erb new file mode 100644 index 0000000000..a2d97ebc6d --- /dev/null +++ b/actionview/test/fixtures/actionpack/good_customers/_good_customer.html.erb @@ -0,0 +1 @@ +<%= greeting %> good customer: <%= good_customer.name %><%= good_customer_counter %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/hello.html b/actionview/test/fixtures/actionpack/hello.html new file mode 100644 index 0000000000..6769dd60bd --- /dev/null +++ b/actionview/test/fixtures/actionpack/hello.html @@ -0,0 +1 @@ +Hello world!
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layout_tests/alt/layouts/alt.erb b/actionview/test/fixtures/actionpack/layout_tests/alt/layouts/alt.erb new file mode 100644 index 0000000000..60b81525b5 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layout_tests/alt/layouts/alt.erb @@ -0,0 +1 @@ +alt.erb diff --git a/actionview/test/fixtures/actionpack/layout_tests/layouts/controller_name_space/nested.erb b/actionview/test/fixtures/actionpack/layout_tests/layouts/controller_name_space/nested.erb new file mode 100644 index 0000000000..121bc079a1 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layout_tests/layouts/controller_name_space/nested.erb @@ -0,0 +1 @@ +controller_name_space/nested.erb <%= yield %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layout_tests/layouts/item.erb b/actionview/test/fixtures/actionpack/layout_tests/layouts/item.erb new file mode 100644 index 0000000000..60f04d77d5 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layout_tests/layouts/item.erb @@ -0,0 +1 @@ +item.erb <%= yield %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layout_tests/layouts/layout_test.erb b/actionview/test/fixtures/actionpack/layout_tests/layouts/layout_test.erb new file mode 100644 index 0000000000..b74ac0840d --- /dev/null +++ b/actionview/test/fixtures/actionpack/layout_tests/layouts/layout_test.erb @@ -0,0 +1 @@ +layout_test.erb <%= yield %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layout_tests/layouts/multiple_extensions.html.erb b/actionview/test/fixtures/actionpack/layout_tests/layouts/multiple_extensions.html.erb new file mode 100644 index 0000000000..3b65e54f9c --- /dev/null +++ b/actionview/test/fixtures/actionpack/layout_tests/layouts/multiple_extensions.html.erb @@ -0,0 +1 @@ +multiple_extensions.html.erb <%= yield %> diff --git a/actionview/test/fixtures/actionpack/layout_tests/layouts/symlinked/symlinked_layout.erb b/actionview/test/fixtures/actionpack/layout_tests/layouts/symlinked/symlinked_layout.erb new file mode 100644 index 0000000000..bda57d0fae --- /dev/null +++ b/actionview/test/fixtures/actionpack/layout_tests/layouts/symlinked/symlinked_layout.erb @@ -0,0 +1,5 @@ +This is my layout + +<%= yield %> + +End. diff --git a/actionview/test/fixtures/actionpack/layout_tests/layouts/third_party_template_library.mab b/actionview/test/fixtures/actionpack/layout_tests/layouts/third_party_template_library.mab new file mode 100644 index 0000000000..fcee620d82 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layout_tests/layouts/third_party_template_library.mab @@ -0,0 +1 @@ +layouts/third_party_template_library.mab
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layout_tests/views/goodbye.erb b/actionview/test/fixtures/actionpack/layout_tests/views/goodbye.erb new file mode 100644 index 0000000000..4ee911188e --- /dev/null +++ b/actionview/test/fixtures/actionpack/layout_tests/views/goodbye.erb @@ -0,0 +1 @@ +hello.erb
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layout_tests/views/hello.erb b/actionview/test/fixtures/actionpack/layout_tests/views/hello.erb new file mode 100644 index 0000000000..4ee911188e --- /dev/null +++ b/actionview/test/fixtures/actionpack/layout_tests/views/hello.erb @@ -0,0 +1 @@ +hello.erb
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layouts/_column.html.erb b/actionview/test/fixtures/actionpack/layouts/_column.html.erb new file mode 100644 index 0000000000..96db002b8a --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/_column.html.erb @@ -0,0 +1,2 @@ +<div id="column"><%= yield :column %></div> +<div id="content"><%= yield %></div>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layouts/_customers.erb b/actionview/test/fixtures/actionpack/layouts/_customers.erb new file mode 100644 index 0000000000..ae63f13cd3 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/_customers.erb @@ -0,0 +1 @@ +<title><%= yield Struct.new(:name).new("David") %></title>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layouts/_partial_and_yield.erb b/actionview/test/fixtures/actionpack/layouts/_partial_and_yield.erb new file mode 100644 index 0000000000..74cc428ffa --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/_partial_and_yield.erb @@ -0,0 +1,2 @@ +<%= render :partial => 'test/partial' %> +<%= yield %> diff --git a/actionview/test/fixtures/actionpack/layouts/_yield_only.erb b/actionview/test/fixtures/actionpack/layouts/_yield_only.erb new file mode 100644 index 0000000000..37f0bddbd7 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/_yield_only.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/actionview/test/fixtures/actionpack/layouts/_yield_with_params.erb b/actionview/test/fixtures/actionpack/layouts/_yield_with_params.erb new file mode 100644 index 0000000000..68e6557fb8 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/_yield_with_params.erb @@ -0,0 +1 @@ +<%= yield 'Yield!' %> diff --git a/actionview/test/fixtures/actionpack/layouts/block_with_layout.erb b/actionview/test/fixtures/actionpack/layouts/block_with_layout.erb new file mode 100644 index 0000000000..73ac833e52 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/block_with_layout.erb @@ -0,0 +1,3 @@ +<%= render(:layout => "layout_for_partial", :locals => { :name => "Anthony" }) do %>Inside from first block in layout<% "Return value should be discarded" %><% end %> +<%= yield %> +<%= render(:layout => "layout_for_partial", :locals => { :name => "Ramm" }) do %>Inside from second block in layout<% end %> diff --git a/actionview/test/fixtures/actionpack/layouts/builder.builder b/actionview/test/fixtures/actionpack/layouts/builder.builder new file mode 100644 index 0000000000..c55488edd0 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/builder.builder @@ -0,0 +1,3 @@ +xml.wrapper do + xml << yield +end diff --git a/actionview/test/fixtures/actionpack/layouts/partial_with_layout.erb b/actionview/test/fixtures/actionpack/layouts/partial_with_layout.erb new file mode 100644 index 0000000000..a0349d731e --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/partial_with_layout.erb @@ -0,0 +1,3 @@ +<%= render( :layout => "layout_for_partial", :partial => "partial_for_use_in_layout", :locals => {:name => 'Anthony' } ) %> +<%= yield %> +<%= render( :layout => "layout_for_partial", :partial => "partial_for_use_in_layout", :locals => {:name => 'Ramm' } ) %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layouts/standard.html.erb b/actionview/test/fixtures/actionpack/layouts/standard.html.erb new file mode 100644 index 0000000000..5e6c24fe39 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/standard.html.erb @@ -0,0 +1 @@ +<html><%= yield %><%= @variable_for_layout %></html>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layouts/standard.text.erb b/actionview/test/fixtures/actionpack/layouts/standard.text.erb new file mode 100644 index 0000000000..a58afb1aa2 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/standard.text.erb @@ -0,0 +1 @@ +{{<%= yield %><%= @variable_for_layout %>}} diff --git a/actionview/test/fixtures/actionpack/layouts/streaming.erb b/actionview/test/fixtures/actionpack/layouts/streaming.erb new file mode 100644 index 0000000000..d3f896a6ca --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/streaming.erb @@ -0,0 +1,4 @@ +<%= yield :header -%> +<%= yield -%> +<%= yield :footer -%> +<%= yield(:unknown).presence || "." -%>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layouts/talk_from_action.erb b/actionview/test/fixtures/actionpack/layouts/talk_from_action.erb new file mode 100644 index 0000000000..bf53fdb785 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/talk_from_action.erb @@ -0,0 +1,2 @@ +<title><%= @title || yield(:title) %></title> +<%= yield -%>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layouts/with_html_partial.html.erb b/actionview/test/fixtures/actionpack/layouts/with_html_partial.html.erb new file mode 100644 index 0000000000..fd2896aeaa --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/with_html_partial.html.erb @@ -0,0 +1 @@ +<%= render :partial => "partial_only_html" %><%= yield %> diff --git a/actionview/test/fixtures/actionpack/layouts/xhr.html.erb b/actionview/test/fixtures/actionpack/layouts/xhr.html.erb new file mode 100644 index 0000000000..85285324ec --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/xhr.html.erb @@ -0,0 +1,2 @@ +XHR! +<%= yield %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/layouts/yield.erb b/actionview/test/fixtures/actionpack/layouts/yield.erb new file mode 100644 index 0000000000..482dc9022e --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/yield.erb @@ -0,0 +1,2 @@ +<title><%= yield :title %></title> +<%= yield %> diff --git a/actionview/test/fixtures/actionpack/layouts/yield_with_render_inline_inside.erb b/actionview/test/fixtures/actionpack/layouts/yield_with_render_inline_inside.erb new file mode 100644 index 0000000000..7298d79690 --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/yield_with_render_inline_inside.erb @@ -0,0 +1,2 @@ +<%= render :inline => 'welcome' %> +<%= yield %> diff --git a/actionview/test/fixtures/actionpack/layouts/yield_with_render_partial_inside.erb b/actionview/test/fixtures/actionpack/layouts/yield_with_render_partial_inside.erb new file mode 100644 index 0000000000..74cc428ffa --- /dev/null +++ b/actionview/test/fixtures/actionpack/layouts/yield_with_render_partial_inside.erb @@ -0,0 +1,2 @@ +<%= render :partial => 'test/partial' %> +<%= yield %> diff --git a/actionview/test/fixtures/actionpack/quiz/questions/_question.html.erb b/actionview/test/fixtures/actionpack/quiz/questions/_question.html.erb new file mode 100644 index 0000000000..fb4dcfee64 --- /dev/null +++ b/actionview/test/fixtures/actionpack/quiz/questions/_question.html.erb @@ -0,0 +1 @@ +<%= question.name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/shared.html.erb b/actionview/test/fixtures/actionpack/shared.html.erb new file mode 100644 index 0000000000..af262fc9f8 --- /dev/null +++ b/actionview/test/fixtures/actionpack/shared.html.erb @@ -0,0 +1 @@ +Elastica
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_changing_priority.html.erb b/actionview/test/fixtures/actionpack/test/_changing_priority.html.erb new file mode 100644 index 0000000000..3225efc49a --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_changing_priority.html.erb @@ -0,0 +1 @@ +HTML
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_changing_priority.json.erb b/actionview/test/fixtures/actionpack/test/_changing_priority.json.erb new file mode 100644 index 0000000000..7fa41dce66 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_changing_priority.json.erb @@ -0,0 +1 @@ +JSON
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_counter.html.erb b/actionview/test/fixtures/actionpack/test/_counter.html.erb new file mode 100644 index 0000000000..fd245bfc70 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_counter.html.erb @@ -0,0 +1 @@ +<%= counter_counter %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_customer.erb b/actionview/test/fixtures/actionpack/test/_customer.erb new file mode 100644 index 0000000000..d8220afeda --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_customer.erb @@ -0,0 +1 @@ +Hello: <%= customer.name rescue "Anonymous" %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_customer_counter.erb b/actionview/test/fixtures/actionpack/test/_customer_counter.erb new file mode 100644 index 0000000000..3435979dba --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_customer_counter.erb @@ -0,0 +1 @@ +<%= customer_counter.name %><%= customer_counter_counter %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_customer_counter_with_as.erb b/actionview/test/fixtures/actionpack/test/_customer_counter_with_as.erb new file mode 100644 index 0000000000..1241eb604d --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_customer_counter_with_as.erb @@ -0,0 +1 @@ +<%= client.name %><%= client_counter %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_customer_greeting.erb b/actionview/test/fixtures/actionpack/test/_customer_greeting.erb new file mode 100644 index 0000000000..6acbcb20c4 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_customer_greeting.erb @@ -0,0 +1 @@ +<%= greeting %>: <%= customer_greeting.name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_customer_iteration.erb b/actionview/test/fixtures/actionpack/test/_customer_iteration.erb new file mode 100644 index 0000000000..fb530b04a7 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_customer_iteration.erb @@ -0,0 +1 @@ +<%= customer_iteration_iteration.size %>-<%= customer_iteration_iteration.index %>: <%= customer_iteration.name %><%= '-first' if customer_iteration_iteration.first? %><%= '-last' if customer_iteration_iteration.last? %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_customer_iteration_with_as.erb b/actionview/test/fixtures/actionpack/test/_customer_iteration_with_as.erb new file mode 100644 index 0000000000..57297d0564 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_customer_iteration_with_as.erb @@ -0,0 +1 @@ +<%= client_iteration.size %>-<%= client_iteration.index %>: <%= client.name %><%= '-first' if client_iteration.first? %><%= '-last' if client_iteration.last? %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_customer_with_var.erb b/actionview/test/fixtures/actionpack/test/_customer_with_var.erb new file mode 100644 index 0000000000..00047dd20e --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_customer_with_var.erb @@ -0,0 +1 @@ +<%= customer.name %> <%= customer.name %> <%= customer.name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_directory/_partial_with_locales.html.erb b/actionview/test/fixtures/actionpack/test/_directory/_partial_with_locales.html.erb new file mode 100644 index 0000000000..1cc8d41475 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_directory/_partial_with_locales.html.erb @@ -0,0 +1 @@ +Hello <%= name %> diff --git a/actionview/test/fixtures/actionpack/test/_first_json_partial.json.erb b/actionview/test/fixtures/actionpack/test/_first_json_partial.json.erb new file mode 100644 index 0000000000..790ee896db --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_first_json_partial.json.erb @@ -0,0 +1 @@ +<%= render :partial => "test/second_json_partial" %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_form.erb b/actionview/test/fixtures/actionpack/test/_form.erb new file mode 100644 index 0000000000..01107f1cb2 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_form.erb @@ -0,0 +1 @@ +<%= form.label :title %> diff --git a/actionview/test/fixtures/actionpack/test/_hash_greeting.erb b/actionview/test/fixtures/actionpack/test/_hash_greeting.erb new file mode 100644 index 0000000000..fc54a36f2a --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_hash_greeting.erb @@ -0,0 +1 @@ +<%= greeting %>: <%= hash_greeting[:first_name] %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_hash_object.erb b/actionview/test/fixtures/actionpack/test/_hash_object.erb new file mode 100644 index 0000000000..34a92c6a56 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_hash_object.erb @@ -0,0 +1,2 @@ +<%= hash_object[:first_name] %> +<%= hash_object[:first_name].reverse %> diff --git a/actionview/test/fixtures/actionpack/test/_hello.builder b/actionview/test/fixtures/actionpack/test/_hello.builder new file mode 100644 index 0000000000..fc72df16d0 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_hello.builder @@ -0,0 +1 @@ +xm.hello diff --git a/actionview/test/fixtures/actionpack/test/_json_change_priority.json.erb b/actionview/test/fixtures/actionpack/test/_json_change_priority.json.erb new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_json_change_priority.json.erb diff --git a/actionview/test/fixtures/actionpack/test/_labelling_form.erb b/actionview/test/fixtures/actionpack/test/_labelling_form.erb new file mode 100644 index 0000000000..1b95763165 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_labelling_form.erb @@ -0,0 +1 @@ +<%= labelling_form.label :title %> diff --git a/actionview/test/fixtures/actionpack/test/_layout_for_partial.html.erb b/actionview/test/fixtures/actionpack/test/_layout_for_partial.html.erb new file mode 100644 index 0000000000..666efadbb6 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_layout_for_partial.html.erb @@ -0,0 +1,3 @@ +Before (<%= name %>) +<%= yield %> +After
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_partial.erb b/actionview/test/fixtures/actionpack/test/_partial.erb new file mode 100644 index 0000000000..e466dcbd8e --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_partial.erb @@ -0,0 +1 @@ +invalid
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_partial.html.erb b/actionview/test/fixtures/actionpack/test/_partial.html.erb new file mode 100644 index 0000000000..e39f6c9827 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_partial.html.erb @@ -0,0 +1 @@ +partial html
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_partial.js.erb b/actionview/test/fixtures/actionpack/test/_partial.js.erb new file mode 100644 index 0000000000..b350cdd7ef --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_partial.js.erb @@ -0,0 +1 @@ +partial js
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_partial_for_use_in_layout.html.erb b/actionview/test/fixtures/actionpack/test/_partial_for_use_in_layout.html.erb new file mode 100644 index 0000000000..3a03a64e31 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_partial_for_use_in_layout.html.erb @@ -0,0 +1 @@ +Inside from partial (<%= name %>)
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_partial_html_erb.html.erb b/actionview/test/fixtures/actionpack/test/_partial_html_erb.html.erb new file mode 100644 index 0000000000..4b54875782 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_partial_html_erb.html.erb @@ -0,0 +1 @@ +<%= "partial.html.erb" %> diff --git a/actionview/test/fixtures/actionpack/test/_partial_name_local_variable.erb b/actionview/test/fixtures/actionpack/test/_partial_name_local_variable.erb new file mode 100644 index 0000000000..cc3a91c89f --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_partial_name_local_variable.erb @@ -0,0 +1 @@ +<%= partial_name_local_variable %> diff --git a/actionview/test/fixtures/actionpack/test/_partial_only.erb b/actionview/test/fixtures/actionpack/test/_partial_only.erb new file mode 100644 index 0000000000..a44b3eed40 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_partial_only.erb @@ -0,0 +1 @@ +only partial
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_partial_only_html.html b/actionview/test/fixtures/actionpack/test/_partial_only_html.html new file mode 100644 index 0000000000..d2d630bd40 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_partial_only_html.html @@ -0,0 +1 @@ +only html partial
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_partial_with_partial.erb b/actionview/test/fixtures/actionpack/test/_partial_with_partial.erb new file mode 100644 index 0000000000..ee0d5037b6 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_partial_with_partial.erb @@ -0,0 +1,2 @@ +<%= render 'test/partial' %> +partial with partial diff --git a/actionview/test/fixtures/actionpack/test/_person.erb b/actionview/test/fixtures/actionpack/test/_person.erb new file mode 100644 index 0000000000..b2e5688956 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_person.erb @@ -0,0 +1,2 @@ +Second: <%= name %> +Third: <%= @name %> diff --git a/actionview/test/fixtures/actionpack/test/_raise_indentation.html.erb b/actionview/test/fixtures/actionpack/test/_raise_indentation.html.erb new file mode 100644 index 0000000000..f9a93728fe --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_raise_indentation.html.erb @@ -0,0 +1,13 @@ +<p>First paragraph</p> +<p>Second paragraph</p> +<p>Third paragraph</p> +<p>Fourth paragraph</p> +<p>Fifth paragraph</p> +<p>Sixth paragraph</p> +<p>Seventh paragraph</p> +<p>Eight paragraph</p> +<p>Ninth paragraph</p> +<p>Tenth paragraph</p> +<%= raise "error here!" %> +<p>Eleventh paragraph</p> +<p>Twelfth paragraph</p>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/_second_json_partial.json.erb b/actionview/test/fixtures/actionpack/test/_second_json_partial.json.erb new file mode 100644 index 0000000000..5ebb7f1afd --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/_second_json_partial.json.erb @@ -0,0 +1 @@ +Third level
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/action_talk_to_layout.erb b/actionview/test/fixtures/actionpack/test/action_talk_to_layout.erb new file mode 100644 index 0000000000..36e896daa8 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/action_talk_to_layout.erb @@ -0,0 +1,2 @@ +<% @title = "Talking to the layout" -%> +Action was here!
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/calling_partial_with_layout.html.erb b/actionview/test/fixtures/actionpack/test/calling_partial_with_layout.html.erb new file mode 100644 index 0000000000..ac44bc0d81 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/calling_partial_with_layout.html.erb @@ -0,0 +1 @@ +<%= render(:layout => "layout_for_partial", :partial => "partial_for_use_in_layout", :locals => { :name => "David" }) %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/capturing.erb b/actionview/test/fixtures/actionpack/test/capturing.erb new file mode 100644 index 0000000000..1addaa40d9 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/capturing.erb @@ -0,0 +1,4 @@ +<% days = capture do %> + Dreamy days +<% end %> +<%= days %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/change_priority.html.erb b/actionview/test/fixtures/actionpack/test/change_priority.html.erb new file mode 100644 index 0000000000..5618977d05 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/change_priority.html.erb @@ -0,0 +1,2 @@ +<%= render :partial => "test/json_change_priority", formats: :json %> +HTML Template, but <%= render :partial => "test/changing_priority" %> partial
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/content_for.erb b/actionview/test/fixtures/actionpack/test/content_for.erb new file mode 100644 index 0000000000..1fb829f54c --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/content_for.erb @@ -0,0 +1 @@ +<% content_for :title do -%>Putting stuff in the title!<% end -%>Great stuff!
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/content_for_concatenated.erb b/actionview/test/fixtures/actionpack/test/content_for_concatenated.erb new file mode 100644 index 0000000000..e65f629574 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/content_for_concatenated.erb @@ -0,0 +1,3 @@ +<% content_for :title, "Putting stuff " + content_for :title, "in the title!" -%> +Great stuff!
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/content_for_with_parameter.erb b/actionview/test/fixtures/actionpack/test/content_for_with_parameter.erb new file mode 100644 index 0000000000..aeb6f73ce0 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/content_for_with_parameter.erb @@ -0,0 +1,2 @@ +<% content_for :title, "Putting stuff in the title!" -%> +Great stuff!
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/dot.directory/render_file_with_ivar.erb b/actionview/test/fixtures/actionpack/test/dot.directory/render_file_with_ivar.erb new file mode 100644 index 0000000000..8b8a449236 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/dot.directory/render_file_with_ivar.erb @@ -0,0 +1 @@ +The secret is <%= @secret %> diff --git a/actionview/test/fixtures/actionpack/test/formatted_html_erb.html.erb b/actionview/test/fixtures/actionpack/test/formatted_html_erb.html.erb new file mode 100644 index 0000000000..1c64efabd8 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/formatted_html_erb.html.erb @@ -0,0 +1 @@ +formatted html erb
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/formatted_xml_erb.builder b/actionview/test/fixtures/actionpack/test/formatted_xml_erb.builder new file mode 100644 index 0000000000..f98aaa34a5 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/formatted_xml_erb.builder @@ -0,0 +1 @@ +xml.test "failed" diff --git a/actionview/test/fixtures/actionpack/test/formatted_xml_erb.html.erb b/actionview/test/fixtures/actionpack/test/formatted_xml_erb.html.erb new file mode 100644 index 0000000000..0c855a604b --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/formatted_xml_erb.html.erb @@ -0,0 +1 @@ +<test>passed formatted html erb</test>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/formatted_xml_erb.xml.erb b/actionview/test/fixtures/actionpack/test/formatted_xml_erb.xml.erb new file mode 100644 index 0000000000..6ca09d5304 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/formatted_xml_erb.xml.erb @@ -0,0 +1 @@ +<test>passed formatted xml erb</test>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/greeting.html.erb b/actionview/test/fixtures/actionpack/test/greeting.html.erb new file mode 100644 index 0000000000..62fb0293f0 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/greeting.html.erb @@ -0,0 +1 @@ +<p>This is grand!</p> diff --git a/actionview/test/fixtures/actionpack/test/greeting.xml.erb b/actionview/test/fixtures/actionpack/test/greeting.xml.erb new file mode 100644 index 0000000000..62fb0293f0 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/greeting.xml.erb @@ -0,0 +1 @@ +<p>This is grand!</p> diff --git a/actionview/test/fixtures/actionpack/test/hello,world.erb b/actionview/test/fixtures/actionpack/test/hello,world.erb new file mode 100644 index 0000000000..bc8fa5e0ca --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/hello,world.erb @@ -0,0 +1 @@ +Hello w*rld!
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/hello.builder b/actionview/test/fixtures/actionpack/test/hello.builder new file mode 100644 index 0000000000..b8ab17ad5b --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/hello.builder @@ -0,0 +1,4 @@ +xml.html do + xml.p "Hello #{@name}" + xml << render(file: "test/greeting") +end diff --git a/actionview/test/fixtures/actionpack/test/hello/hello.erb b/actionview/test/fixtures/actionpack/test/hello/hello.erb new file mode 100644 index 0000000000..6769dd60bd --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/hello/hello.erb @@ -0,0 +1 @@ +Hello world!
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/hello_world.erb b/actionview/test/fixtures/actionpack/test/hello_world.erb new file mode 100644 index 0000000000..6769dd60bd --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/hello_world.erb @@ -0,0 +1 @@ +Hello world!
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/hello_world_container.builder b/actionview/test/fixtures/actionpack/test/hello_world_container.builder new file mode 100644 index 0000000000..24032ab5e0 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/hello_world_container.builder @@ -0,0 +1,3 @@ +xml.test do + render partial: "hello", locals: { xm: xml } +end diff --git a/actionview/test/fixtures/actionpack/test/hello_world_from_rxml.builder b/actionview/test/fixtures/actionpack/test/hello_world_from_rxml.builder new file mode 100644 index 0000000000..619a97ba96 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/hello_world_from_rxml.builder @@ -0,0 +1,3 @@ +xml.html do + xml.p "Hello" +end diff --git a/actionview/test/fixtures/actionpack/test/hello_world_with_layout_false.erb b/actionview/test/fixtures/actionpack/test/hello_world_with_layout_false.erb new file mode 100644 index 0000000000..6769dd60bd --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/hello_world_with_layout_false.erb @@ -0,0 +1 @@ +Hello world!
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/hello_world_with_partial.html.erb b/actionview/test/fixtures/actionpack/test/hello_world_with_partial.html.erb new file mode 100644 index 0000000000..ec31545356 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/hello_world_with_partial.html.erb @@ -0,0 +1,2 @@ +Hello world! +<%= render '/test/partial' %> diff --git a/actionview/test/fixtures/actionpack/test/hello_xml_world.builder b/actionview/test/fixtures/actionpack/test/hello_xml_world.builder new file mode 100644 index 0000000000..d16bb6b5cb --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/hello_xml_world.builder @@ -0,0 +1,11 @@ +xml.html do + xml.head do + xml.title "Hello World" + end + + xml.body do + xml.p "abes" + xml.p "monks" + xml.p "wiseguys" + end +end diff --git a/actionview/test/fixtures/actionpack/test/html_template.html.erb b/actionview/test/fixtures/actionpack/test/html_template.html.erb new file mode 100644 index 0000000000..1bbc2b7f09 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/html_template.html.erb @@ -0,0 +1 @@ +<%= render :partial => "test/first_json_partial", formats: :json %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/hyphen-ated.erb b/actionview/test/fixtures/actionpack/test/hyphen-ated.erb new file mode 100644 index 0000000000..28dbe94ee1 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/hyphen-ated.erb @@ -0,0 +1 @@ +hyphen-ated.erb
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/implicit_content_type.atom.builder b/actionview/test/fixtures/actionpack/test/implicit_content_type.atom.builder new file mode 100644 index 0000000000..2fcb32d247 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/implicit_content_type.atom.builder @@ -0,0 +1,2 @@ +xml.atom do +end diff --git a/actionview/test/fixtures/actionpack/test/list.erb b/actionview/test/fixtures/actionpack/test/list.erb new file mode 100644 index 0000000000..0a4bda58ee --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/list.erb @@ -0,0 +1 @@ +<%= @test_unchanged = 'goodbye' %><%= render :partial => 'customer', :collection => @customers %><%= @test_unchanged %> diff --git a/actionview/test/fixtures/actionpack/test/non_erb_block_content_for.builder b/actionview/test/fixtures/actionpack/test/non_erb_block_content_for.builder new file mode 100644 index 0000000000..cd65da751b --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/non_erb_block_content_for.builder @@ -0,0 +1,4 @@ +content_for :title do + "Putting stuff in the title!" +end +xml << "Great stuff!" diff --git a/actionview/test/fixtures/actionpack/test/potential_conflicts.erb b/actionview/test/fixtures/actionpack/test/potential_conflicts.erb new file mode 100644 index 0000000000..a5e964e359 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/potential_conflicts.erb @@ -0,0 +1,4 @@ +First: <%= @name %> +<%= render :partial => "person", :locals => { :name => "Stephan" } -%> +Fourth: <%= @name %> +Fifth: <%= name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/proper_block_detection.erb b/actionview/test/fixtures/actionpack/test/proper_block_detection.erb new file mode 100644 index 0000000000..b55efbb25d --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/proper_block_detection.erb @@ -0,0 +1 @@ +<%= @todo %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/render_file_from_template.html.erb b/actionview/test/fixtures/actionpack/test/render_file_from_template.html.erb new file mode 100644 index 0000000000..fde9f4bb64 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/render_file_from_template.html.erb @@ -0,0 +1 @@ +<%= render :file => @path %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/render_file_with_ivar.erb b/actionview/test/fixtures/actionpack/test/render_file_with_ivar.erb new file mode 100644 index 0000000000..8b8a449236 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/render_file_with_ivar.erb @@ -0,0 +1 @@ +The secret is <%= @secret %> diff --git a/actionview/test/fixtures/actionpack/test/render_file_with_locals.erb b/actionview/test/fixtures/actionpack/test/render_file_with_locals.erb new file mode 100644 index 0000000000..ebe09faee6 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/render_file_with_locals.erb @@ -0,0 +1 @@ +The secret is <%= secret %> diff --git a/actionview/test/fixtures/actionpack/test/render_file_with_locals_and_default.erb b/actionview/test/fixtures/actionpack/test/render_file_with_locals_and_default.erb new file mode 100644 index 0000000000..9b4900acc5 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/render_file_with_locals_and_default.erb @@ -0,0 +1 @@ +<%= secret ||= 'one' %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/render_implicit_html_template_from_xhr_request.da.html.erb b/actionview/test/fixtures/actionpack/test/render_implicit_html_template_from_xhr_request.da.html.erb new file mode 100644 index 0000000000..0740b2d07c --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/render_implicit_html_template_from_xhr_request.da.html.erb @@ -0,0 +1 @@ +Hey HTML! diff --git a/actionview/test/fixtures/actionpack/test/render_implicit_html_template_from_xhr_request.html.erb b/actionview/test/fixtures/actionpack/test/render_implicit_html_template_from_xhr_request.html.erb new file mode 100644 index 0000000000..4a11845cfe --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/render_implicit_html_template_from_xhr_request.html.erb @@ -0,0 +1 @@ +Hello HTML!
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/render_implicit_js_template_without_layout.js.erb b/actionview/test/fixtures/actionpack/test/render_implicit_js_template_without_layout.js.erb new file mode 100644 index 0000000000..892ae5eca2 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/render_implicit_js_template_without_layout.js.erb @@ -0,0 +1 @@ +alert('hello');
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/render_partial_inside_directory.html.erb b/actionview/test/fixtures/actionpack/test/render_partial_inside_directory.html.erb new file mode 100644 index 0000000000..1461b95186 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/render_partial_inside_directory.html.erb @@ -0,0 +1 @@ +<%= render partial: 'test/_directory/partial_with_locales', locals: {'name' => 'Jane'} %> diff --git a/actionview/test/fixtures/actionpack/test/render_to_string_test.erb b/actionview/test/fixtures/actionpack/test/render_to_string_test.erb new file mode 100644 index 0000000000..6e267e8634 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/render_to_string_test.erb @@ -0,0 +1 @@ +The value of foo is: ::<%= @foo %>:: diff --git a/actionview/test/fixtures/actionpack/test/render_two_partials.html.erb b/actionview/test/fixtures/actionpack/test/render_two_partials.html.erb new file mode 100644 index 0000000000..3db6025860 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/render_two_partials.html.erb @@ -0,0 +1,2 @@ +<%= render :partial => 'partial', :locals => {'first' => '1'} %> +<%= render :partial => 'partial', :locals => {'second' => '2'} %> diff --git a/actionview/test/fixtures/actionpack/test/using_layout_around_block.html.erb b/actionview/test/fixtures/actionpack/test/using_layout_around_block.html.erb new file mode 100644 index 0000000000..3d6661df9a --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/using_layout_around_block.html.erb @@ -0,0 +1 @@ +<%= render(:layout => "layout_for_partial", :locals => { :name => "David" }) do %>Inside from block<% end %>
\ No newline at end of file diff --git a/actionview/test/fixtures/actionpack/test/with_html_partial.html.erb b/actionview/test/fixtures/actionpack/test/with_html_partial.html.erb new file mode 100644 index 0000000000..d84d909d64 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/with_html_partial.html.erb @@ -0,0 +1 @@ +<strong><%= render :partial => "partial_only_html" %></strong> diff --git a/actionview/test/fixtures/actionpack/test/with_partial.html.erb b/actionview/test/fixtures/actionpack/test/with_partial.html.erb new file mode 100644 index 0000000000..7502364cf5 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/with_partial.html.erb @@ -0,0 +1 @@ +<strong><%= render :partial => "partial_only" %></strong> diff --git a/actionview/test/fixtures/actionpack/test/with_partial.text.erb b/actionview/test/fixtures/actionpack/test/with_partial.text.erb new file mode 100644 index 0000000000..5f068ebf27 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/with_partial.text.erb @@ -0,0 +1 @@ +**<%= render :partial => "partial_only" %>** diff --git a/actionview/test/fixtures/actionpack/test/with_xml_template.html.erb b/actionview/test/fixtures/actionpack/test/with_xml_template.html.erb new file mode 100644 index 0000000000..e54a7cd001 --- /dev/null +++ b/actionview/test/fixtures/actionpack/test/with_xml_template.html.erb @@ -0,0 +1 @@ +<%= render :template => "test/greeting", :formats => :xml %> diff --git a/actionview/test/fixtures/comments/empty.de.html.erb b/actionview/test/fixtures/comments/empty.de.html.erb new file mode 100644 index 0000000000..cffd90dd26 --- /dev/null +++ b/actionview/test/fixtures/comments/empty.de.html.erb @@ -0,0 +1 @@ +<h1>Kein Kommentar</h1>
\ No newline at end of file diff --git a/actionview/test/fixtures/comments/empty.html+grid.erb b/actionview/test/fixtures/comments/empty.html+grid.erb new file mode 100644 index 0000000000..dc3fa32a81 --- /dev/null +++ b/actionview/test/fixtures/comments/empty.html+grid.erb @@ -0,0 +1 @@ +<h1>No Comment</h1> diff --git a/actionview/test/fixtures/comments/empty.html.builder b/actionview/test/fixtures/comments/empty.html.builder new file mode 100644 index 0000000000..12d6fdd9a5 --- /dev/null +++ b/actionview/test/fixtures/comments/empty.html.builder @@ -0,0 +1 @@ +xml.h1 "No Comment" diff --git a/actionview/test/fixtures/comments/empty.html.erb b/actionview/test/fixtures/comments/empty.html.erb new file mode 100644 index 0000000000..827f3861de --- /dev/null +++ b/actionview/test/fixtures/comments/empty.html.erb @@ -0,0 +1 @@ +<h1>No Comment</h1>
\ No newline at end of file diff --git a/actionview/test/fixtures/comments/empty.xml.erb b/actionview/test/fixtures/comments/empty.xml.erb new file mode 100644 index 0000000000..db1027cd7d --- /dev/null +++ b/actionview/test/fixtures/comments/empty.xml.erb @@ -0,0 +1 @@ +<error>No Comment</error>
\ No newline at end of file diff --git a/actionview/test/fixtures/companies.yml b/actionview/test/fixtures/companies.yml new file mode 100644 index 0000000000..ed2992e0b1 --- /dev/null +++ b/actionview/test/fixtures/companies.yml @@ -0,0 +1,24 @@ +thirty_seven_signals: + id: 1 + name: 37Signals + rating: 4 + +TextDrive: + id: 2 + name: TextDrive + rating: 4 + +PlanetArgon: + id: 3 + name: Planet Argon + rating: 4 + +Google: + id: 4 + name: Google + rating: 4 + +Ionist: + id: 5 + name: Ioni.st + rating: 4
\ No newline at end of file diff --git a/actionview/test/fixtures/company.rb b/actionview/test/fixtures/company.rb new file mode 100644 index 0000000000..93afdd5472 --- /dev/null +++ b/actionview/test/fixtures/company.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Company < ActiveRecord::Base + has_one :mascot + self.sequence_name = :companies_nonstd_seq + + validates_presence_of :name + def validate + errors.add("rating", "rating should not be 2") if rating == 2 + end +end diff --git a/actionview/test/fixtures/custom_pattern/another.html.erb b/actionview/test/fixtures/custom_pattern/another.html.erb new file mode 100644 index 0000000000..6d7f3bafbb --- /dev/null +++ b/actionview/test/fixtures/custom_pattern/another.html.erb @@ -0,0 +1 @@ +Hello custom patterns!
\ No newline at end of file diff --git a/actionview/test/fixtures/custom_pattern/html/another.erb b/actionview/test/fixtures/custom_pattern/html/another.erb new file mode 100644 index 0000000000..dbd7e96ab6 --- /dev/null +++ b/actionview/test/fixtures/custom_pattern/html/another.erb @@ -0,0 +1 @@ +Another template!
\ No newline at end of file diff --git a/actionview/test/fixtures/custom_pattern/html/path.erb b/actionview/test/fixtures/custom_pattern/html/path.erb new file mode 100644 index 0000000000..6d7f3bafbb --- /dev/null +++ b/actionview/test/fixtures/custom_pattern/html/path.erb @@ -0,0 +1 @@ +Hello custom patterns!
\ No newline at end of file diff --git a/actionview/test/fixtures/customers/_customer.html.erb b/actionview/test/fixtures/customers/_customer.html.erb new file mode 100644 index 0000000000..483571e22a --- /dev/null +++ b/actionview/test/fixtures/customers/_customer.html.erb @@ -0,0 +1 @@ +<%= greeting %>: <%= customer.name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/customers/_customer.xml.erb b/actionview/test/fixtures/customers/_customer.xml.erb new file mode 100644 index 0000000000..d3f1e0768f --- /dev/null +++ b/actionview/test/fixtures/customers/_customer.xml.erb @@ -0,0 +1 @@ +<greeting><%= greeting %></greeting><name><%= customer.name %></name>
\ No newline at end of file diff --git a/actionview/test/fixtures/db_definitions/sqlite.sql b/actionview/test/fixtures/db_definitions/sqlite.sql new file mode 100644 index 0000000000..99df4b3e61 --- /dev/null +++ b/actionview/test/fixtures/db_definitions/sqlite.sql @@ -0,0 +1,49 @@ +CREATE TABLE 'companies' ( + 'id' INTEGER PRIMARY KEY NOT NULL, + 'name' TEXT DEFAULT NULL, + 'rating' INTEGER DEFAULT 1 +); + +CREATE TABLE 'replies' ( + 'id' INTEGER PRIMARY KEY NOT NULL, + 'content' text, + 'created_at' datetime, + 'updated_at' datetime, + 'topic_id' integer, + 'developer_id' integer +); + +CREATE TABLE 'topics' ( + 'id' INTEGER PRIMARY KEY NOT NULL, + 'title' varchar(255), + 'subtitle' varchar(255), + 'content' text, + 'created_at' datetime, + 'updated_at' datetime +); + +CREATE TABLE 'developers' ( + 'id' INTEGER PRIMARY KEY NOT NULL, + 'name' TEXT DEFAULT NULL, + 'salary' INTEGER DEFAULT 70000, + 'created_at' DATETIME DEFAULT NULL, + 'updated_at' DATETIME DEFAULT NULL +); + +CREATE TABLE 'projects' ( + 'id' INTEGER PRIMARY KEY NOT NULL, + 'name' TEXT DEFAULT NULL +); + +CREATE TABLE 'developers_projects' ( + 'developer_id' INTEGER NOT NULL, + 'project_id' INTEGER NOT NULL, + 'joined_on' DATE DEFAULT NULL, + 'access_level' INTEGER DEFAULT 1 +); + +CREATE TABLE 'mascots' ( + 'id' INTEGER PRIMARY KEY NOT NULL, + 'company_id' INTEGER NOT NULL, + 'name' TEXT DEFAULT NULL +);
\ No newline at end of file diff --git a/actionview/test/fixtures/developer.rb b/actionview/test/fixtures/developer.rb new file mode 100644 index 0000000000..cb7ee49eed --- /dev/null +++ b/actionview/test/fixtures/developer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Developer < ActiveRecord::Base + has_and_belongs_to_many :projects + has_many :replies + has_many :topics, through: :replies + accepts_nested_attributes_for :projects +end diff --git a/actionview/test/fixtures/developers.yml b/actionview/test/fixtures/developers.yml new file mode 100644 index 0000000000..3656564f63 --- /dev/null +++ b/actionview/test/fixtures/developers.yml @@ -0,0 +1,21 @@ +david: + id: 1 + name: David + salary: 80000 + +jamis: + id: 2 + name: Jamis + salary: 150000 + +<% (3..10).each do |digit| %> +dev_<%= digit %>: + id: <%= digit %> + name: fixture_<%= digit %> + salary: 100000 +<% end %> + +poor_jamis: + id: 11 + name: Jamis + salary: 9000
\ No newline at end of file diff --git a/actionview/test/fixtures/developers/_developer.erb b/actionview/test/fixtures/developers/_developer.erb new file mode 100644 index 0000000000..904a3137e7 --- /dev/null +++ b/actionview/test/fixtures/developers/_developer.erb @@ -0,0 +1 @@ +<%= developer.name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/developers_projects.yml b/actionview/test/fixtures/developers_projects.yml new file mode 100644 index 0000000000..cee359c7cf --- /dev/null +++ b/actionview/test/fixtures/developers_projects.yml @@ -0,0 +1,13 @@ +david_action_controller: + developer_id: 1 + project_id: 2 + joined_on: 2004-10-10 + +david_active_record: + developer_id: 1 + project_id: 1 + joined_on: 2004-10-10 + +jamis_active_record: + developer_id: 2 + project_id: 1
\ No newline at end of file diff --git a/actionview/test/fixtures/digestor/api/comments/_comment.json.erb b/actionview/test/fixtures/digestor/api/comments/_comment.json.erb new file mode 100644 index 0000000000..696eb13917 --- /dev/null +++ b/actionview/test/fixtures/digestor/api/comments/_comment.json.erb @@ -0,0 +1 @@ +{"content": "Great story!"} diff --git a/actionview/test/fixtures/digestor/api/comments/_comments.json.erb b/actionview/test/fixtures/digestor/api/comments/_comments.json.erb new file mode 100644 index 0000000000..c28646a283 --- /dev/null +++ b/actionview/test/fixtures/digestor/api/comments/_comments.json.erb @@ -0,0 +1 @@ +<%= render partial: "comments/comment", collection: commentable.comments %> diff --git a/actionview/test/fixtures/digestor/comments/_comment.html.erb b/actionview/test/fixtures/digestor/comments/_comment.html.erb new file mode 100644 index 0000000000..a8fa21f644 --- /dev/null +++ b/actionview/test/fixtures/digestor/comments/_comment.html.erb @@ -0,0 +1 @@ +Great story! diff --git a/actionview/test/fixtures/digestor/comments/_comments.html.erb b/actionview/test/fixtures/digestor/comments/_comments.html.erb new file mode 100644 index 0000000000..c28646a283 --- /dev/null +++ b/actionview/test/fixtures/digestor/comments/_comments.html.erb @@ -0,0 +1 @@ +<%= render partial: "comments/comment", collection: commentable.comments %> diff --git a/actionview/test/fixtures/digestor/comments/show.js.erb b/actionview/test/fixtures/digestor/comments/show.js.erb new file mode 100644 index 0000000000..38b37dfa2b --- /dev/null +++ b/actionview/test/fixtures/digestor/comments/show.js.erb @@ -0,0 +1 @@ +alert("<%=j render("comments/comment") %>") diff --git a/actionview/test/fixtures/digestor/events/_completed.html.erb b/actionview/test/fixtures/digestor/events/_completed.html.erb new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/actionview/test/fixtures/digestor/events/_completed.html.erb diff --git a/actionview/test/fixtures/digestor/events/_event.html.erb b/actionview/test/fixtures/digestor/events/_event.html.erb new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/actionview/test/fixtures/digestor/events/_event.html.erb diff --git a/actionview/test/fixtures/digestor/events/index.html.erb b/actionview/test/fixtures/digestor/events/index.html.erb new file mode 100644 index 0000000000..bc45e41bcb --- /dev/null +++ b/actionview/test/fixtures/digestor/events/index.html.erb @@ -0,0 +1 @@ +<% # Template Dependency: events/* %>
\ No newline at end of file diff --git a/actionview/test/fixtures/digestor/level/_recursion.html.erb b/actionview/test/fixtures/digestor/level/_recursion.html.erb new file mode 100644 index 0000000000..ee5aaf09c3 --- /dev/null +++ b/actionview/test/fixtures/digestor/level/_recursion.html.erb @@ -0,0 +1 @@ +<%= render 'recursion' %> diff --git a/actionview/test/fixtures/digestor/level/below/_header.html.erb b/actionview/test/fixtures/digestor/level/below/_header.html.erb new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/actionview/test/fixtures/digestor/level/below/_header.html.erb diff --git a/actionview/test/fixtures/digestor/level/below/index.html.erb b/actionview/test/fixtures/digestor/level/below/index.html.erb new file mode 100644 index 0000000000..b92f49a8f8 --- /dev/null +++ b/actionview/test/fixtures/digestor/level/below/index.html.erb @@ -0,0 +1 @@ +<%= render partial: "header" %> diff --git a/actionview/test/fixtures/digestor/level/recursion.html.erb b/actionview/test/fixtures/digestor/level/recursion.html.erb new file mode 100644 index 0000000000..ee5aaf09c3 --- /dev/null +++ b/actionview/test/fixtures/digestor/level/recursion.html.erb @@ -0,0 +1 @@ +<%= render 'recursion' %> diff --git a/actionview/test/fixtures/digestor/messages/_form.html.erb b/actionview/test/fixtures/digestor/messages/_form.html.erb new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/actionview/test/fixtures/digestor/messages/_form.html.erb diff --git a/actionview/test/fixtures/digestor/messages/_header.html.erb b/actionview/test/fixtures/digestor/messages/_header.html.erb new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/actionview/test/fixtures/digestor/messages/_header.html.erb diff --git a/actionview/test/fixtures/digestor/messages/_message.html.erb b/actionview/test/fixtures/digestor/messages/_message.html.erb new file mode 100644 index 0000000000..406a0fb848 --- /dev/null +++ b/actionview/test/fixtures/digestor/messages/_message.html.erb @@ -0,0 +1 @@ +THIS BE WHERE THEM MESSAGE GO, YO!
\ No newline at end of file diff --git a/actionview/test/fixtures/digestor/messages/actions/_move.html.erb b/actionview/test/fixtures/digestor/messages/actions/_move.html.erb new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/actionview/test/fixtures/digestor/messages/actions/_move.html.erb diff --git a/actionview/test/fixtures/digestor/messages/edit.html.erb b/actionview/test/fixtures/digestor/messages/edit.html.erb new file mode 100644 index 0000000000..a9e0a88e32 --- /dev/null +++ b/actionview/test/fixtures/digestor/messages/edit.html.erb @@ -0,0 +1,5 @@ +<%= render "header" %> +<%= render partial: "form" %> +<%= render @message %> +<%= render ( @message.events ) %> +<%= render :partial => "comments/comment", :collection => @message.comments %> diff --git a/actionview/test/fixtures/digestor/messages/index.html.erb b/actionview/test/fixtures/digestor/messages/index.html.erb new file mode 100644 index 0000000000..1937b652e4 --- /dev/null +++ b/actionview/test/fixtures/digestor/messages/index.html.erb @@ -0,0 +1,2 @@ +<%= render @messages %> +<%= render @events %> diff --git a/actionview/test/fixtures/digestor/messages/new.html+iphone.erb b/actionview/test/fixtures/digestor/messages/new.html+iphone.erb new file mode 100644 index 0000000000..791e1d36b4 --- /dev/null +++ b/actionview/test/fixtures/digestor/messages/new.html+iphone.erb @@ -0,0 +1,15 @@ +<%# Template Dependency: messages/message %> + +<%= render "header" %> +<%= render "comments/comments" %> + +<%= render "messages/actions/move" %> + +<%= render @message.history.events %> + +<%# render "something_missing" %> +<%# render "something_missing_1" %> + +<% + # Template Dependency: messages/form +%>
\ No newline at end of file diff --git a/actionview/test/fixtures/digestor/messages/peek.html.erb b/actionview/test/fixtures/digestor/messages/peek.html.erb new file mode 100644 index 0000000000..84885ab0bc --- /dev/null +++ b/actionview/test/fixtures/digestor/messages/peek.html.erb @@ -0,0 +1,2 @@ +<%# Template Dependency: messages/message %> +<%= render "comments/comments" %> diff --git a/actionview/test/fixtures/digestor/messages/show.html.erb b/actionview/test/fixtures/digestor/messages/show.html.erb new file mode 100644 index 0000000000..42aa2363dd --- /dev/null +++ b/actionview/test/fixtures/digestor/messages/show.html.erb @@ -0,0 +1,14 @@ +<%# Template Dependency: messages/message %> +<%= render "header" %> +<%= render "comments/comments" %> + +<%= render "messages/actions/move" %> + +<%= render @message.history.events %> + +<%# render "something_missing" %> +<%# render "something_missing_1" %> + +<% + # Template Dependency: messages/form +%>
\ No newline at end of file diff --git a/actionview/test/fixtures/digestor/messages/thread.json.erb b/actionview/test/fixtures/digestor/messages/thread.json.erb new file mode 100644 index 0000000000..e4c1ba97cd --- /dev/null +++ b/actionview/test/fixtures/digestor/messages/thread.json.erb @@ -0,0 +1 @@ +<%= render "comments/comments" %> diff --git a/actionview/test/fixtures/fun/games/_game.erb b/actionview/test/fixtures/fun/games/_game.erb new file mode 100644 index 0000000000..f0f542ff92 --- /dev/null +++ b/actionview/test/fixtures/fun/games/_game.erb @@ -0,0 +1 @@ +Fun <%= game.name %> diff --git a/actionview/test/fixtures/fun/games/hello_world.erb b/actionview/test/fixtures/fun/games/hello_world.erb new file mode 100644 index 0000000000..1ebfbe2539 --- /dev/null +++ b/actionview/test/fixtures/fun/games/hello_world.erb @@ -0,0 +1 @@ +Living in a nested world
\ No newline at end of file diff --git a/actionview/test/fixtures/fun/serious/games/_game.erb b/actionview/test/fixtures/fun/serious/games/_game.erb new file mode 100644 index 0000000000..523bc55bd7 --- /dev/null +++ b/actionview/test/fixtures/fun/serious/games/_game.erb @@ -0,0 +1 @@ +Serious <%= game.name %> diff --git a/actionview/test/fixtures/games/_game.erb b/actionview/test/fixtures/games/_game.erb new file mode 100644 index 0000000000..1aeb81fcba --- /dev/null +++ b/actionview/test/fixtures/games/_game.erb @@ -0,0 +1 @@ +Just <%= game.name %> diff --git a/actionview/test/fixtures/good_customers/_good_customer.html.erb b/actionview/test/fixtures/good_customers/_good_customer.html.erb new file mode 100644 index 0000000000..a2d97ebc6d --- /dev/null +++ b/actionview/test/fixtures/good_customers/_good_customer.html.erb @@ -0,0 +1 @@ +<%= greeting %> good customer: <%= good_customer.name %><%= good_customer_counter %>
\ No newline at end of file diff --git a/actionview/test/fixtures/helpers/abc_helper.rb b/actionview/test/fixtures/helpers/abc_helper.rb new file mode 100644 index 0000000000..999b9b5c6e --- /dev/null +++ b/actionview/test/fixtures/helpers/abc_helper.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module AbcHelper + def bare_a() end +end diff --git a/actionview/test/fixtures/helpers/helpery_test_helper.rb b/actionview/test/fixtures/helpers/helpery_test_helper.rb new file mode 100644 index 0000000000..9836143848 --- /dev/null +++ b/actionview/test/fixtures/helpers/helpery_test_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module HelperyTestHelper + def helpery_test + "Default" + end +end diff --git a/actionview/test/fixtures/helpers_missing/invalid_require_helper.rb b/actionview/test/fixtures/helpers_missing/invalid_require_helper.rb new file mode 100644 index 0000000000..c77121046d --- /dev/null +++ b/actionview/test/fixtures/helpers_missing/invalid_require_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "very_invalid_file_name" + +module InvalidRequireHelper +end diff --git a/actionview/test/fixtures/layout_tests/alt/hello.erb b/actionview/test/fixtures/layout_tests/alt/hello.erb new file mode 100644 index 0000000000..1055c36659 --- /dev/null +++ b/actionview/test/fixtures/layout_tests/alt/hello.erb @@ -0,0 +1 @@ +alt/hello.erb diff --git a/actionview/test/fixtures/layouts/_column.html.erb b/actionview/test/fixtures/layouts/_column.html.erb new file mode 100644 index 0000000000..96db002b8a --- /dev/null +++ b/actionview/test/fixtures/layouts/_column.html.erb @@ -0,0 +1,2 @@ +<div id="column"><%= yield :column %></div> +<div id="content"><%= yield %></div>
\ No newline at end of file diff --git a/actionview/test/fixtures/layouts/_customers.erb b/actionview/test/fixtures/layouts/_customers.erb new file mode 100644 index 0000000000..ae63f13cd3 --- /dev/null +++ b/actionview/test/fixtures/layouts/_customers.erb @@ -0,0 +1 @@ +<title><%= yield Struct.new(:name).new("David") %></title>
\ No newline at end of file diff --git a/actionview/test/fixtures/layouts/_partial_and_yield.erb b/actionview/test/fixtures/layouts/_partial_and_yield.erb new file mode 100644 index 0000000000..74cc428ffa --- /dev/null +++ b/actionview/test/fixtures/layouts/_partial_and_yield.erb @@ -0,0 +1,2 @@ +<%= render :partial => 'test/partial' %> +<%= yield %> diff --git a/actionview/test/fixtures/layouts/_yield_only.erb b/actionview/test/fixtures/layouts/_yield_only.erb new file mode 100644 index 0000000000..37f0bddbd7 --- /dev/null +++ b/actionview/test/fixtures/layouts/_yield_only.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/actionview/test/fixtures/layouts/_yield_with_params.erb b/actionview/test/fixtures/layouts/_yield_with_params.erb new file mode 100644 index 0000000000..68e6557fb8 --- /dev/null +++ b/actionview/test/fixtures/layouts/_yield_with_params.erb @@ -0,0 +1 @@ +<%= yield 'Yield!' %> diff --git a/actionview/test/fixtures/layouts/render_partial_html.erb b/actionview/test/fixtures/layouts/render_partial_html.erb new file mode 100644 index 0000000000..d4dbb6c76c --- /dev/null +++ b/actionview/test/fixtures/layouts/render_partial_html.erb @@ -0,0 +1,2 @@ +<%= render :partial => 'test/partialhtml' %> +<%= yield %> diff --git a/actionview/test/fixtures/layouts/streaming.erb b/actionview/test/fixtures/layouts/streaming.erb new file mode 100644 index 0000000000..d3f896a6ca --- /dev/null +++ b/actionview/test/fixtures/layouts/streaming.erb @@ -0,0 +1,4 @@ +<%= yield :header -%> +<%= yield -%> +<%= yield :footer -%> +<%= yield(:unknown).presence || "." -%>
\ No newline at end of file diff --git a/actionview/test/fixtures/layouts/streaming_with_capture.erb b/actionview/test/fixtures/layouts/streaming_with_capture.erb new file mode 100644 index 0000000000..538c19ce3a --- /dev/null +++ b/actionview/test/fixtures/layouts/streaming_with_capture.erb @@ -0,0 +1,6 @@ +<%= yield :header -%> +<%= capture do %> + this works +<% end %> +<%= yield :footer -%> +<%= yield(:unknown).presence || "." -%> diff --git a/actionview/test/fixtures/layouts/streaming_with_locale.erb b/actionview/test/fixtures/layouts/streaming_with_locale.erb new file mode 100644 index 0000000000..e1fdad2073 --- /dev/null +++ b/actionview/test/fixtures/layouts/streaming_with_locale.erb @@ -0,0 +1,2 @@ +layout.locale: <%= I18n.locale %> +<%= yield %> diff --git a/actionview/test/fixtures/layouts/yield.erb b/actionview/test/fixtures/layouts/yield.erb new file mode 100644 index 0000000000..482dc9022e --- /dev/null +++ b/actionview/test/fixtures/layouts/yield.erb @@ -0,0 +1,2 @@ +<title><%= yield :title %></title> +<%= yield %> diff --git a/actionview/test/fixtures/layouts/yield_with_render_inline_inside.erb b/actionview/test/fixtures/layouts/yield_with_render_inline_inside.erb new file mode 100644 index 0000000000..7298d79690 --- /dev/null +++ b/actionview/test/fixtures/layouts/yield_with_render_inline_inside.erb @@ -0,0 +1,2 @@ +<%= render :inline => 'welcome' %> +<%= yield %> diff --git a/actionview/test/fixtures/layouts/yield_with_render_partial_inside.erb b/actionview/test/fixtures/layouts/yield_with_render_partial_inside.erb new file mode 100644 index 0000000000..74cc428ffa --- /dev/null +++ b/actionview/test/fixtures/layouts/yield_with_render_partial_inside.erb @@ -0,0 +1,2 @@ +<%= render :partial => 'test/partial' %> +<%= yield %> diff --git a/actionview/test/fixtures/mascot.rb b/actionview/test/fixtures/mascot.rb new file mode 100644 index 0000000000..26a2c7bbe1 --- /dev/null +++ b/actionview/test/fixtures/mascot.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Mascot < ActiveRecord::Base + belongs_to :company +end diff --git a/actionview/test/fixtures/mascots.yml b/actionview/test/fixtures/mascots.yml new file mode 100644 index 0000000000..17b7dff454 --- /dev/null +++ b/actionview/test/fixtures/mascots.yml @@ -0,0 +1,4 @@ +upload_bird: + id: 1 + company_id: 1 + name: The Upload Bird
\ No newline at end of file diff --git a/actionview/test/fixtures/mascots/_mascot.html.erb b/actionview/test/fixtures/mascots/_mascot.html.erb new file mode 100644 index 0000000000..432773a1da --- /dev/null +++ b/actionview/test/fixtures/mascots/_mascot.html.erb @@ -0,0 +1 @@ +<%= mascot.name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/override/test/hello_world.erb b/actionview/test/fixtures/override/test/hello_world.erb new file mode 100644 index 0000000000..3e308d3d86 --- /dev/null +++ b/actionview/test/fixtures/override/test/hello_world.erb @@ -0,0 +1 @@ +Hello overridden world!
\ No newline at end of file diff --git a/actionview/test/fixtures/override2/layouts/test/sub.erb b/actionview/test/fixtures/override2/layouts/test/sub.erb new file mode 100644 index 0000000000..3863d5a8ef --- /dev/null +++ b/actionview/test/fixtures/override2/layouts/test/sub.erb @@ -0,0 +1 @@ +layout: <%= yield %>
\ No newline at end of file diff --git a/actionview/test/fixtures/plain_text.raw b/actionview/test/fixtures/plain_text.raw new file mode 100644 index 0000000000..b13985337f --- /dev/null +++ b/actionview/test/fixtures/plain_text.raw @@ -0,0 +1 @@ +<%= hello_world %> diff --git a/actionview/test/fixtures/plain_text_with_characters.raw b/actionview/test/fixtures/plain_text_with_characters.raw new file mode 100644 index 0000000000..1e86e44fb4 --- /dev/null +++ b/actionview/test/fixtures/plain_text_with_characters.raw @@ -0,0 +1 @@ +Here are some characters: !@#$%^&*()-="'}{` diff --git a/actionview/test/fixtures/project.rb b/actionview/test/fixtures/project.rb new file mode 100644 index 0000000000..019ddb7aef --- /dev/null +++ b/actionview/test/fixtures/project.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Project < ActiveRecord::Base + has_and_belongs_to_many :developers, -> { uniq } + + def self.collection_cache_key(collection = all, timestamp_column = :updated_at) + "projects-#{collection.count}" + end +end diff --git a/actionview/test/fixtures/projects.yml b/actionview/test/fixtures/projects.yml new file mode 100644 index 0000000000..02800c7824 --- /dev/null +++ b/actionview/test/fixtures/projects.yml @@ -0,0 +1,7 @@ +action_controller: + id: 2 + name: Active Controller + +active_record: + id: 1 + name: Active Record diff --git a/actionview/test/fixtures/projects/_project.erb b/actionview/test/fixtures/projects/_project.erb new file mode 100644 index 0000000000..480c4c2af3 --- /dev/null +++ b/actionview/test/fixtures/projects/_project.erb @@ -0,0 +1 @@ +<%= project.name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/public/elsewhere/cools.js b/actionview/test/fixtures/public/elsewhere/cools.js new file mode 100644 index 0000000000..6e12fe29c4 --- /dev/null +++ b/actionview/test/fixtures/public/elsewhere/cools.js @@ -0,0 +1 @@ +// cools.js
\ No newline at end of file diff --git a/actionview/test/fixtures/public/elsewhere/file.css b/actionview/test/fixtures/public/elsewhere/file.css new file mode 100644 index 0000000000..6aea0733b1 --- /dev/null +++ b/actionview/test/fixtures/public/elsewhere/file.css @@ -0,0 +1 @@ +/*file.css*/
\ No newline at end of file diff --git a/actionview/test/fixtures/public/foo/baz.css b/actionview/test/fixtures/public/foo/baz.css new file mode 100644 index 0000000000..b5173fbef2 --- /dev/null +++ b/actionview/test/fixtures/public/foo/baz.css @@ -0,0 +1,3 @@ +body { +background: #000; +} diff --git a/actionview/test/fixtures/public/javascripts/application.js b/actionview/test/fixtures/public/javascripts/application.js new file mode 100644 index 0000000000..9702692980 --- /dev/null +++ b/actionview/test/fixtures/public/javascripts/application.js @@ -0,0 +1 @@ +// application js
\ No newline at end of file diff --git a/actionview/test/fixtures/public/javascripts/bank.js b/actionview/test/fixtures/public/javascripts/bank.js new file mode 100644 index 0000000000..4a1bee7182 --- /dev/null +++ b/actionview/test/fixtures/public/javascripts/bank.js @@ -0,0 +1 @@ +// bank js
\ No newline at end of file diff --git a/actionview/test/fixtures/public/javascripts/common.javascript b/actionview/test/fixtures/public/javascripts/common.javascript new file mode 100644 index 0000000000..2ae1929056 --- /dev/null +++ b/actionview/test/fixtures/public/javascripts/common.javascript @@ -0,0 +1 @@ +// common.javascript
\ No newline at end of file diff --git a/actionview/test/fixtures/public/javascripts/controls.js b/actionview/test/fixtures/public/javascripts/controls.js new file mode 100644 index 0000000000..88168d9f13 --- /dev/null +++ b/actionview/test/fixtures/public/javascripts/controls.js @@ -0,0 +1 @@ +// controls js
\ No newline at end of file diff --git a/actionview/test/fixtures/public/javascripts/dragdrop.js b/actionview/test/fixtures/public/javascripts/dragdrop.js new file mode 100644 index 0000000000..c07061ac0c --- /dev/null +++ b/actionview/test/fixtures/public/javascripts/dragdrop.js @@ -0,0 +1 @@ +// dragdrop js
\ No newline at end of file diff --git a/actionview/test/fixtures/public/javascripts/effects.js b/actionview/test/fixtures/public/javascripts/effects.js new file mode 100644 index 0000000000..b555d63034 --- /dev/null +++ b/actionview/test/fixtures/public/javascripts/effects.js @@ -0,0 +1 @@ +// effects js
\ No newline at end of file diff --git a/actionview/test/fixtures/public/javascripts/prototype.js b/actionview/test/fixtures/public/javascripts/prototype.js new file mode 100644 index 0000000000..9780064a0e --- /dev/null +++ b/actionview/test/fixtures/public/javascripts/prototype.js @@ -0,0 +1 @@ +// prototype js
\ No newline at end of file diff --git a/actionview/test/fixtures/public/javascripts/robber.js b/actionview/test/fixtures/public/javascripts/robber.js new file mode 100644 index 0000000000..eb82fcbdf4 --- /dev/null +++ b/actionview/test/fixtures/public/javascripts/robber.js @@ -0,0 +1 @@ +// robber js
\ No newline at end of file diff --git a/actionview/test/fixtures/public/javascripts/subdir/subdir.js b/actionview/test/fixtures/public/javascripts/subdir/subdir.js new file mode 100644 index 0000000000..9d23a67aa1 --- /dev/null +++ b/actionview/test/fixtures/public/javascripts/subdir/subdir.js @@ -0,0 +1 @@ +// subdir js diff --git a/actionview/test/fixtures/public/javascripts/version.1.0.js b/actionview/test/fixtures/public/javascripts/version.1.0.js new file mode 100644 index 0000000000..cfd5fce70e --- /dev/null +++ b/actionview/test/fixtures/public/javascripts/version.1.0.js @@ -0,0 +1 @@ +// version.1.0 js
\ No newline at end of file diff --git a/actionview/test/fixtures/public/stylesheets/bank.css b/actionview/test/fixtures/public/stylesheets/bank.css new file mode 100644 index 0000000000..ea161b12b2 --- /dev/null +++ b/actionview/test/fixtures/public/stylesheets/bank.css @@ -0,0 +1 @@ +/* bank.css */
\ No newline at end of file diff --git a/actionview/test/fixtures/public/stylesheets/random.styles b/actionview/test/fixtures/public/stylesheets/random.styles new file mode 100644 index 0000000000..d4eeead95c --- /dev/null +++ b/actionview/test/fixtures/public/stylesheets/random.styles @@ -0,0 +1 @@ +/* random.styles */
\ No newline at end of file diff --git a/actionview/test/fixtures/public/stylesheets/robber.css b/actionview/test/fixtures/public/stylesheets/robber.css new file mode 100644 index 0000000000..0fdd00a6a5 --- /dev/null +++ b/actionview/test/fixtures/public/stylesheets/robber.css @@ -0,0 +1 @@ +/* robber.css */
\ No newline at end of file diff --git a/actionview/test/fixtures/public/stylesheets/subdir/subdir.css b/actionview/test/fixtures/public/stylesheets/subdir/subdir.css new file mode 100644 index 0000000000..241152a905 --- /dev/null +++ b/actionview/test/fixtures/public/stylesheets/subdir/subdir.css @@ -0,0 +1 @@ +/* subdir.css */ diff --git a/actionview/test/fixtures/public/stylesheets/version.1.0.css b/actionview/test/fixtures/public/stylesheets/version.1.0.css new file mode 100644 index 0000000000..30f5f9ba6e --- /dev/null +++ b/actionview/test/fixtures/public/stylesheets/version.1.0.css @@ -0,0 +1 @@ +/* version.1.0.css */
\ No newline at end of file diff --git a/actionview/test/fixtures/replies.yml b/actionview/test/fixtures/replies.yml new file mode 100644 index 0000000000..2a3454b8bf --- /dev/null +++ b/actionview/test/fixtures/replies.yml @@ -0,0 +1,15 @@ +witty_retort: + id: 1 + topic_id: 1 + developer_id: 1 + content: Birdman is better! + created_at: <%= 6.hours.ago.to_s(:db) %> + updated_at: nil + +another: + id: 2 + topic_id: 2 + developer_id: 1 + content: Nuh uh! + created_at: <%= 1.hour.ago.to_s(:db) %> + updated_at: nil diff --git a/actionview/test/fixtures/replies/_reply.erb b/actionview/test/fixtures/replies/_reply.erb new file mode 100644 index 0000000000..68baf548d8 --- /dev/null +++ b/actionview/test/fixtures/replies/_reply.erb @@ -0,0 +1 @@ +<%= reply.content %>
\ No newline at end of file diff --git a/actionview/test/fixtures/reply.rb b/actionview/test/fixtures/reply.rb new file mode 100644 index 0000000000..b2b662e1b5 --- /dev/null +++ b/actionview/test/fixtures/reply.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Reply < ActiveRecord::Base + scope :base, -> { all } + belongs_to :topic, -> { includes(:replies) } + belongs_to :developer + + validates_presence_of :content +end diff --git a/actionview/test/fixtures/respond_to/using_defaults_with_all.html.erb b/actionview/test/fixtures/respond_to/using_defaults_with_all.html.erb new file mode 100644 index 0000000000..9f1f855269 --- /dev/null +++ b/actionview/test/fixtures/respond_to/using_defaults_with_all.html.erb @@ -0,0 +1 @@ +HTML! diff --git a/actionview/test/fixtures/ruby_template.ruby b/actionview/test/fixtures/ruby_template.ruby new file mode 100644 index 0000000000..3e0bc445a2 --- /dev/null +++ b/actionview/test/fixtures/ruby_template.ruby @@ -0,0 +1,2 @@ +body = +"" +body << ["Hello", "from", "Ruby", "code"].join(" ") diff --git a/actionview/test/fixtures/shared.html.erb b/actionview/test/fixtures/shared.html.erb new file mode 100644 index 0000000000..af262fc9f8 --- /dev/null +++ b/actionview/test/fixtures/shared.html.erb @@ -0,0 +1 @@ +Elastica
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_200.html.erb b/actionview/test/fixtures/test/_200.html.erb new file mode 100644 index 0000000000..c9f45675dc --- /dev/null +++ b/actionview/test/fixtures/test/_200.html.erb @@ -0,0 +1 @@ +<h1>Invalid partial</h1> diff --git a/actionview/test/fixtures/test/_FooBar.html.erb b/actionview/test/fixtures/test/_FooBar.html.erb new file mode 100644 index 0000000000..4bbe59410a --- /dev/null +++ b/actionview/test/fixtures/test/_FooBar.html.erb @@ -0,0 +1 @@ +🍣 diff --git a/actionview/test/fixtures/test/_a-in.html.erb b/actionview/test/fixtures/test/_a-in.html.erb new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/actionview/test/fixtures/test/_a-in.html.erb diff --git a/actionview/test/fixtures/test/_b_layout_for_partial.html.erb b/actionview/test/fixtures/test/_b_layout_for_partial.html.erb new file mode 100644 index 0000000000..e918ba8f83 --- /dev/null +++ b/actionview/test/fixtures/test/_b_layout_for_partial.html.erb @@ -0,0 +1 @@ +<b><%= yield %></b>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_b_layout_for_partial_with_object.html.erb b/actionview/test/fixtures/test/_b_layout_for_partial_with_object.html.erb new file mode 100644 index 0000000000..bdd53014cd --- /dev/null +++ b/actionview/test/fixtures/test/_b_layout_for_partial_with_object.html.erb @@ -0,0 +1 @@ +<b class="<%= customer.name.downcase %>"><%= yield %></b>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_b_layout_for_partial_with_object_counter.html.erb b/actionview/test/fixtures/test/_b_layout_for_partial_with_object_counter.html.erb new file mode 100644 index 0000000000..44d6121297 --- /dev/null +++ b/actionview/test/fixtures/test/_b_layout_for_partial_with_object_counter.html.erb @@ -0,0 +1 @@ +<b data-counter="<%= customer_counter %>"><%= yield %></b>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_builder_tag_nested_in_content_tag.erb b/actionview/test/fixtures/test/_builder_tag_nested_in_content_tag.erb new file mode 100644 index 0000000000..ddad7ec3ac --- /dev/null +++ b/actionview/test/fixtures/test/_builder_tag_nested_in_content_tag.erb @@ -0,0 +1,3 @@ +<%= tag.p do %> + <%= tag.b 'Hello' %> +<% end %> diff --git a/actionview/test/fixtures/test/_cached_customer.erb b/actionview/test/fixtures/test/_cached_customer.erb new file mode 100644 index 0000000000..52f35a3497 --- /dev/null +++ b/actionview/test/fixtures/test/_cached_customer.erb @@ -0,0 +1,3 @@ +<% cache cached_customer do %> + Hello: <%= cached_customer.name %> +<% end %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_cached_customer_as.erb b/actionview/test/fixtures/test/_cached_customer_as.erb new file mode 100644 index 0000000000..fca8d19e34 --- /dev/null +++ b/actionview/test/fixtures/test/_cached_customer_as.erb @@ -0,0 +1,3 @@ +<% cache buyer do %> + <%= greeting %>: <%= customer.name %> +<% end %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_cached_nested_cached_customer.erb b/actionview/test/fixtures/test/_cached_nested_cached_customer.erb new file mode 100644 index 0000000000..01bf025cd3 --- /dev/null +++ b/actionview/test/fixtures/test/_cached_nested_cached_customer.erb @@ -0,0 +1,3 @@ +<% cache cached_customer do %> + <%= render partial: "test/cached_customer", locals: { cached_customer: cached_customer } %> +<% end %> diff --git a/actionview/test/fixtures/test/_changing_priority.html.erb b/actionview/test/fixtures/test/_changing_priority.html.erb new file mode 100644 index 0000000000..3225efc49a --- /dev/null +++ b/actionview/test/fixtures/test/_changing_priority.html.erb @@ -0,0 +1 @@ +HTML
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_changing_priority.json.erb b/actionview/test/fixtures/test/_changing_priority.json.erb new file mode 100644 index 0000000000..7fa41dce66 --- /dev/null +++ b/actionview/test/fixtures/test/_changing_priority.json.erb @@ -0,0 +1 @@ +JSON
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_content_tag_nested_in_content_tag.erb b/actionview/test/fixtures/test/_content_tag_nested_in_content_tag.erb new file mode 100644 index 0000000000..2f21a75dd9 --- /dev/null +++ b/actionview/test/fixtures/test/_content_tag_nested_in_content_tag.erb @@ -0,0 +1,3 @@ +<%= content_tag 'p' do %> + <%= content_tag 'b', 'Hello' %> +<% end %> diff --git a/actionview/test/fixtures/test/_counter.html.erb b/actionview/test/fixtures/test/_counter.html.erb new file mode 100644 index 0000000000..fd245bfc70 --- /dev/null +++ b/actionview/test/fixtures/test/_counter.html.erb @@ -0,0 +1 @@ +<%= counter_counter %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_customer.erb b/actionview/test/fixtures/test/_customer.erb new file mode 100644 index 0000000000..d8220afeda --- /dev/null +++ b/actionview/test/fixtures/test/_customer.erb @@ -0,0 +1 @@ +Hello: <%= customer.name rescue "Anonymous" %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_customer.mobile.erb b/actionview/test/fixtures/test/_customer.mobile.erb new file mode 100644 index 0000000000..d8220afeda --- /dev/null +++ b/actionview/test/fixtures/test/_customer.mobile.erb @@ -0,0 +1 @@ +Hello: <%= customer.name rescue "Anonymous" %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_customer_greeting.erb b/actionview/test/fixtures/test/_customer_greeting.erb new file mode 100644 index 0000000000..6acbcb20c4 --- /dev/null +++ b/actionview/test/fixtures/test/_customer_greeting.erb @@ -0,0 +1 @@ +<%= greeting %>: <%= customer_greeting.name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_customer_with_var.erb b/actionview/test/fixtures/test/_customer_with_var.erb new file mode 100644 index 0000000000..00047dd20e --- /dev/null +++ b/actionview/test/fixtures/test/_customer_with_var.erb @@ -0,0 +1 @@ +<%= customer.name %> <%= customer.name %> <%= customer.name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_directory/_partial_with_locales.html.erb b/actionview/test/fixtures/test/_directory/_partial_with_locales.html.erb new file mode 100644 index 0000000000..1cc8d41475 --- /dev/null +++ b/actionview/test/fixtures/test/_directory/_partial_with_locales.html.erb @@ -0,0 +1 @@ +Hello <%= name %> diff --git a/actionview/test/fixtures/test/_first_json_partial.json.erb b/actionview/test/fixtures/test/_first_json_partial.json.erb new file mode 100644 index 0000000000..790ee896db --- /dev/null +++ b/actionview/test/fixtures/test/_first_json_partial.json.erb @@ -0,0 +1 @@ +<%= render :partial => "test/second_json_partial" %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_from_helper.erb b/actionview/test/fixtures/test/_from_helper.erb new file mode 100644 index 0000000000..16de7c0f8a --- /dev/null +++ b/actionview/test/fixtures/test/_from_helper.erb @@ -0,0 +1 @@ +<%= render_from_helper %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_json_change_priority.json.erb b/actionview/test/fixtures/test/_json_change_priority.json.erb new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/actionview/test/fixtures/test/_json_change_priority.json.erb diff --git a/actionview/test/fixtures/test/_klass.erb b/actionview/test/fixtures/test/_klass.erb new file mode 100644 index 0000000000..9936f86001 --- /dev/null +++ b/actionview/test/fixtures/test/_klass.erb @@ -0,0 +1 @@ +<%= klass.class.name %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_label_with_block.erb b/actionview/test/fixtures/test/_label_with_block.erb new file mode 100644 index 0000000000..94089ea93d --- /dev/null +++ b/actionview/test/fixtures/test/_label_with_block.erb @@ -0,0 +1,4 @@ +<%= label('post', 'message')do %> + Message + <%= text_field 'post', 'message' %> +<% end %> diff --git a/actionview/test/fixtures/test/_layout_for_block_with_args.html.erb b/actionview/test/fixtures/test/_layout_for_block_with_args.html.erb new file mode 100644 index 0000000000..307533208d --- /dev/null +++ b/actionview/test/fixtures/test/_layout_for_block_with_args.html.erb @@ -0,0 +1,3 @@ +Before +<%= yield 'arg1', 'arg2' %> +After
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_layout_for_partial.html.erb b/actionview/test/fixtures/test/_layout_for_partial.html.erb new file mode 100644 index 0000000000..666efadbb6 --- /dev/null +++ b/actionview/test/fixtures/test/_layout_for_partial.html.erb @@ -0,0 +1,3 @@ +Before (<%= name %>) +<%= yield %> +After
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_layout_with_partial_and_yield.html.erb b/actionview/test/fixtures/test/_layout_with_partial_and_yield.html.erb new file mode 100644 index 0000000000..820e7db789 --- /dev/null +++ b/actionview/test/fixtures/test/_layout_with_partial_and_yield.html.erb @@ -0,0 +1,4 @@ +Before +<%= render :partial => "test/partial" %> +<%= yield %> +After diff --git a/actionview/test/fixtures/test/_local_inspector.html.erb b/actionview/test/fixtures/test/_local_inspector.html.erb new file mode 100644 index 0000000000..e6765c0882 --- /dev/null +++ b/actionview/test/fixtures/test/_local_inspector.html.erb @@ -0,0 +1 @@ +<%= local_assigns.keys.map {|k| k.to_s }.sort.join(",") -%>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_nested_cached_customer.erb b/actionview/test/fixtures/test/_nested_cached_customer.erb new file mode 100644 index 0000000000..f43adc94c9 --- /dev/null +++ b/actionview/test/fixtures/test/_nested_cached_customer.erb @@ -0,0 +1 @@ +<%= render partial: "test/cached_customer", locals: { cached_customer: cached_customer } %> diff --git a/actionview/test/fixtures/test/_object_inspector.erb b/actionview/test/fixtures/test/_object_inspector.erb new file mode 100644 index 0000000000..53af593821 --- /dev/null +++ b/actionview/test/fixtures/test/_object_inspector.erb @@ -0,0 +1 @@ +<%= object_inspector.inspect -%>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_one.html.erb b/actionview/test/fixtures/test/_one.html.erb new file mode 100644 index 0000000000..f796291cb4 --- /dev/null +++ b/actionview/test/fixtures/test/_one.html.erb @@ -0,0 +1 @@ +<%= render :partial => "two" %> world diff --git a/actionview/test/fixtures/test/_partial.erb b/actionview/test/fixtures/test/_partial.erb new file mode 100644 index 0000000000..e466dcbd8e --- /dev/null +++ b/actionview/test/fixtures/test/_partial.erb @@ -0,0 +1 @@ +invalid
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_partial.html.erb b/actionview/test/fixtures/test/_partial.html.erb new file mode 100644 index 0000000000..e39f6c9827 --- /dev/null +++ b/actionview/test/fixtures/test/_partial.html.erb @@ -0,0 +1 @@ +partial html
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_partial.js.erb b/actionview/test/fixtures/test/_partial.js.erb new file mode 100644 index 0000000000..b350cdd7ef --- /dev/null +++ b/actionview/test/fixtures/test/_partial.js.erb @@ -0,0 +1 @@ +partial js
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_partial_for_use_in_layout.html.erb b/actionview/test/fixtures/test/_partial_for_use_in_layout.html.erb new file mode 100644 index 0000000000..3a03a64e31 --- /dev/null +++ b/actionview/test/fixtures/test/_partial_for_use_in_layout.html.erb @@ -0,0 +1 @@ +Inside from partial (<%= name %>)
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_partial_iteration_1.erb b/actionview/test/fixtures/test/_partial_iteration_1.erb new file mode 100644 index 0000000000..c0fdd4c22a --- /dev/null +++ b/actionview/test/fixtures/test/_partial_iteration_1.erb @@ -0,0 +1 @@ +<%= defined?(partial_iteration_1_iteration) %> diff --git a/actionview/test/fixtures/test/_partial_iteration_2.erb b/actionview/test/fixtures/test/_partial_iteration_2.erb new file mode 100644 index 0000000000..50dd11db27 --- /dev/null +++ b/actionview/test/fixtures/test/_partial_iteration_2.erb @@ -0,0 +1 @@ +<%= defined?(partial_iteration_2_iteration) -%> diff --git a/actionview/test/fixtures/test/_partial_name_in_local_assigns.erb b/actionview/test/fixtures/test/_partial_name_in_local_assigns.erb new file mode 100644 index 0000000000..28ee9f41c5 --- /dev/null +++ b/actionview/test/fixtures/test/_partial_name_in_local_assigns.erb @@ -0,0 +1 @@ +<%= local_assigns.has_key?(:partial_name_in_local_assigns) %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_partial_name_local_variable.erb b/actionview/test/fixtures/test/_partial_name_local_variable.erb new file mode 100644 index 0000000000..cc3a91c89f --- /dev/null +++ b/actionview/test/fixtures/test/_partial_name_local_variable.erb @@ -0,0 +1 @@ +<%= partial_name_local_variable %> diff --git a/actionview/test/fixtures/test/_partial_only.erb b/actionview/test/fixtures/test/_partial_only.erb new file mode 100644 index 0000000000..a44b3eed40 --- /dev/null +++ b/actionview/test/fixtures/test/_partial_only.erb @@ -0,0 +1 @@ +only partial
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_partial_shortcut_with_block_content.html.erb b/actionview/test/fixtures/test/_partial_shortcut_with_block_content.html.erb new file mode 100644 index 0000000000..352128f3ba --- /dev/null +++ b/actionview/test/fixtures/test/_partial_shortcut_with_block_content.html.erb @@ -0,0 +1,3 @@ +<%= render "test/layout_for_block_with_args" do |arg_1, arg_2| %> + Yielded: <%= arg_1 %>/<%= arg_2 %> +<% end %> diff --git a/actionview/test/fixtures/test/_partial_with_layout.erb b/actionview/test/fixtures/test/_partial_with_layout.erb new file mode 100644 index 0000000000..2a50c834fe --- /dev/null +++ b/actionview/test/fixtures/test/_partial_with_layout.erb @@ -0,0 +1,2 @@ +<%= render :partial => 'test/partial', :layout => 'test/layout_for_partial', :locals => { :name => 'Bar!' } %> +partial with layout diff --git a/actionview/test/fixtures/test/_partial_with_layout_block_content.erb b/actionview/test/fixtures/test/_partial_with_layout_block_content.erb new file mode 100644 index 0000000000..65dafd93a8 --- /dev/null +++ b/actionview/test/fixtures/test/_partial_with_layout_block_content.erb @@ -0,0 +1,4 @@ +<%= render :layout => 'test/layout_for_partial', :locals => { :name => 'Bar!' } do %> + Content from inside layout! +<% end %> +partial with layout diff --git a/actionview/test/fixtures/test/_partial_with_layout_block_partial.erb b/actionview/test/fixtures/test/_partial_with_layout_block_partial.erb new file mode 100644 index 0000000000..444197a7d0 --- /dev/null +++ b/actionview/test/fixtures/test/_partial_with_layout_block_partial.erb @@ -0,0 +1,4 @@ +<%= render :layout => 'test/layout_for_partial', :locals => { :name => 'Bar!' } do %> + <%= render 'test/partial' %> +<% end %> +partial with layout diff --git a/actionview/test/fixtures/test/_partial_with_only_html_version.html.erb b/actionview/test/fixtures/test/_partial_with_only_html_version.html.erb new file mode 100644 index 0000000000..00e6b6d6da --- /dev/null +++ b/actionview/test/fixtures/test/_partial_with_only_html_version.html.erb @@ -0,0 +1 @@ +partial with only html version
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_partial_with_partial.erb b/actionview/test/fixtures/test/_partial_with_partial.erb new file mode 100644 index 0000000000..ee0d5037b6 --- /dev/null +++ b/actionview/test/fixtures/test/_partial_with_partial.erb @@ -0,0 +1,2 @@ +<%= render 'test/partial' %> +partial with partial diff --git a/actionview/test/fixtures/test/_partial_with_variants.html+grid.erb b/actionview/test/fixtures/test/_partial_with_variants.html+grid.erb new file mode 100644 index 0000000000..225363c8c3 --- /dev/null +++ b/actionview/test/fixtures/test/_partial_with_variants.html+grid.erb @@ -0,0 +1 @@ +<h1>Partial with variants</h1> diff --git a/actionview/test/fixtures/test/_partialhtml.html b/actionview/test/fixtures/test/_partialhtml.html new file mode 100644 index 0000000000..afe39b730a --- /dev/null +++ b/actionview/test/fixtures/test/_partialhtml.html @@ -0,0 +1 @@ +<h1>partial html</h1>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_raise.html.erb b/actionview/test/fixtures/test/_raise.html.erb new file mode 100644 index 0000000000..68b08181d3 --- /dev/null +++ b/actionview/test/fixtures/test/_raise.html.erb @@ -0,0 +1 @@ +<%= doesnt_exist %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_raise_indentation.html.erb b/actionview/test/fixtures/test/_raise_indentation.html.erb new file mode 100644 index 0000000000..f9a93728fe --- /dev/null +++ b/actionview/test/fixtures/test/_raise_indentation.html.erb @@ -0,0 +1,13 @@ +<p>First paragraph</p> +<p>Second paragraph</p> +<p>Third paragraph</p> +<p>Fourth paragraph</p> +<p>Fifth paragraph</p> +<p>Sixth paragraph</p> +<p>Seventh paragraph</p> +<p>Eight paragraph</p> +<p>Ninth paragraph</p> +<p>Tenth paragraph</p> +<%= raise "error here!" %> +<p>Eleventh paragraph</p> +<p>Twelfth paragraph</p>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_second_json_partial.json.erb b/actionview/test/fixtures/test/_second_json_partial.json.erb new file mode 100644 index 0000000000..5ebb7f1afd --- /dev/null +++ b/actionview/test/fixtures/test/_second_json_partial.json.erb @@ -0,0 +1 @@ +Third level
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_two.html.erb b/actionview/test/fixtures/test/_two.html.erb new file mode 100644 index 0000000000..5ab2f8a432 --- /dev/null +++ b/actionview/test/fixtures/test/_two.html.erb @@ -0,0 +1 @@ +Hello
\ No newline at end of file diff --git a/actionview/test/fixtures/test/_utf8_partial.html.erb b/actionview/test/fixtures/test/_utf8_partial.html.erb new file mode 100644 index 0000000000..8d717fd427 --- /dev/null +++ b/actionview/test/fixtures/test/_utf8_partial.html.erb @@ -0,0 +1 @@ +<%= "текст" %> diff --git a/actionview/test/fixtures/test/_utf8_partial_magic.html.erb b/actionview/test/fixtures/test/_utf8_partial_magic.html.erb new file mode 100644 index 0000000000..4e2224610a --- /dev/null +++ b/actionview/test/fixtures/test/_utf8_partial_magic.html.erb @@ -0,0 +1,2 @@ +<%# encoding: utf-8 -%> +<%= "текст" %> diff --git a/actionview/test/fixtures/test/_🍣.erb b/actionview/test/fixtures/test/_🍣.erb new file mode 100644 index 0000000000..4bbe59410a --- /dev/null +++ b/actionview/test/fixtures/test/_🍣.erb @@ -0,0 +1 @@ +🍣 diff --git a/actionview/test/fixtures/test/basic.html.erb b/actionview/test/fixtures/test/basic.html.erb new file mode 100644 index 0000000000..ea696d7e01 --- /dev/null +++ b/actionview/test/fixtures/test/basic.html.erb @@ -0,0 +1 @@ +Hello from basic.html.erb
\ No newline at end of file diff --git a/actionview/test/fixtures/test/calling_partial_with_layout.html.erb b/actionview/test/fixtures/test/calling_partial_with_layout.html.erb new file mode 100644 index 0000000000..ac44bc0d81 --- /dev/null +++ b/actionview/test/fixtures/test/calling_partial_with_layout.html.erb @@ -0,0 +1 @@ +<%= render(:layout => "layout_for_partial", :partial => "partial_for_use_in_layout", :locals => { :name => "David" }) %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/change_priority.html.erb b/actionview/test/fixtures/test/change_priority.html.erb new file mode 100644 index 0000000000..5618977d05 --- /dev/null +++ b/actionview/test/fixtures/test/change_priority.html.erb @@ -0,0 +1,2 @@ +<%= render :partial => "test/json_change_priority", formats: :json %> +HTML Template, but <%= render :partial => "test/changing_priority" %> partial
\ No newline at end of file diff --git a/actionview/test/fixtures/test/dont_pick_me b/actionview/test/fixtures/test/dont_pick_me new file mode 100644 index 0000000000..0157c9e503 --- /dev/null +++ b/actionview/test/fixtures/test/dont_pick_me @@ -0,0 +1 @@ +non-template file
\ No newline at end of file diff --git a/actionview/test/fixtures/test/dot.directory/render_file_with_ivar.erb b/actionview/test/fixtures/test/dot.directory/render_file_with_ivar.erb new file mode 100644 index 0000000000..8b8a449236 --- /dev/null +++ b/actionview/test/fixtures/test/dot.directory/render_file_with_ivar.erb @@ -0,0 +1 @@ +The secret is <%= @secret %> diff --git a/actionview/test/fixtures/test/greeting.xml.erb b/actionview/test/fixtures/test/greeting.xml.erb new file mode 100644 index 0000000000..62fb0293f0 --- /dev/null +++ b/actionview/test/fixtures/test/greeting.xml.erb @@ -0,0 +1 @@ +<p>This is grand!</p> diff --git a/actionview/test/fixtures/test/hello.builder b/actionview/test/fixtures/test/hello.builder new file mode 100644 index 0000000000..b8ab17ad5b --- /dev/null +++ b/actionview/test/fixtures/test/hello.builder @@ -0,0 +1,4 @@ +xml.html do + xml.p "Hello #{@name}" + xml << render(file: "test/greeting") +end diff --git a/actionview/test/fixtures/test/hello/hello.erb b/actionview/test/fixtures/test/hello/hello.erb new file mode 100644 index 0000000000..6769dd60bd --- /dev/null +++ b/actionview/test/fixtures/test/hello/hello.erb @@ -0,0 +1 @@ +Hello world!
\ No newline at end of file diff --git a/actionview/test/fixtures/test/hello_world.da.html.erb b/actionview/test/fixtures/test/hello_world.da.html.erb new file mode 100644 index 0000000000..10ec443291 --- /dev/null +++ b/actionview/test/fixtures/test/hello_world.da.html.erb @@ -0,0 +1 @@ +Hey verden
\ No newline at end of file diff --git a/actionview/test/fixtures/test/hello_world.erb b/actionview/test/fixtures/test/hello_world.erb new file mode 100644 index 0000000000..6769dd60bd --- /dev/null +++ b/actionview/test/fixtures/test/hello_world.erb @@ -0,0 +1 @@ +Hello world!
\ No newline at end of file diff --git a/actionview/test/fixtures/test/hello_world.erb~ b/actionview/test/fixtures/test/hello_world.erb~ new file mode 100644 index 0000000000..21934a1c95 --- /dev/null +++ b/actionview/test/fixtures/test/hello_world.erb~ @@ -0,0 +1 @@ +Don't pick me!
\ No newline at end of file diff --git a/actionview/test/fixtures/test/hello_world.html+phone.erb b/actionview/test/fixtures/test/hello_world.html+phone.erb new file mode 100644 index 0000000000..b4f236f878 --- /dev/null +++ b/actionview/test/fixtures/test/hello_world.html+phone.erb @@ -0,0 +1 @@ +Hello phone!
\ No newline at end of file diff --git a/actionview/test/fixtures/test/hello_world.pt-BR.html.erb b/actionview/test/fixtures/test/hello_world.pt-BR.html.erb new file mode 100644 index 0000000000..773b3c8c6e --- /dev/null +++ b/actionview/test/fixtures/test/hello_world.pt-BR.html.erb @@ -0,0 +1 @@ +Ola mundo
\ No newline at end of file diff --git a/actionview/test/fixtures/test/hello_world.text+phone.erb b/actionview/test/fixtures/test/hello_world.text+phone.erb new file mode 100644 index 0000000000..611e2ee442 --- /dev/null +++ b/actionview/test/fixtures/test/hello_world.text+phone.erb @@ -0,0 +1 @@ +Hello texty phone!
\ No newline at end of file diff --git a/actionview/test/fixtures/test/hello_world_with_partial.html.erb b/actionview/test/fixtures/test/hello_world_with_partial.html.erb new file mode 100644 index 0000000000..ec31545356 --- /dev/null +++ b/actionview/test/fixtures/test/hello_world_with_partial.html.erb @@ -0,0 +1,2 @@ +Hello world! +<%= render '/test/partial' %> diff --git a/actionview/test/fixtures/test/html_template.html.erb b/actionview/test/fixtures/test/html_template.html.erb new file mode 100644 index 0000000000..1bbc2b7f09 --- /dev/null +++ b/actionview/test/fixtures/test/html_template.html.erb @@ -0,0 +1 @@ +<%= render :partial => "test/first_json_partial", formats: :json %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/layout_render_file.erb b/actionview/test/fixtures/test/layout_render_file.erb new file mode 100644 index 0000000000..2f8e921c5f --- /dev/null +++ b/actionview/test/fixtures/test/layout_render_file.erb @@ -0,0 +1,2 @@ +<% content_for :title do %>title<% end -%> +<%= render :file => 'layouts/yield' -%>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/layout_render_object.erb b/actionview/test/fixtures/test/layout_render_object.erb new file mode 100644 index 0000000000..acc4453c08 --- /dev/null +++ b/actionview/test/fixtures/test/layout_render_object.erb @@ -0,0 +1 @@ +<%= render :layout => "layouts/customers" do |customer| %><%= customer.name %><% end %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/list.erb b/actionview/test/fixtures/test/list.erb new file mode 100644 index 0000000000..0a4bda58ee --- /dev/null +++ b/actionview/test/fixtures/test/list.erb @@ -0,0 +1 @@ +<%= @test_unchanged = 'goodbye' %><%= render :partial => 'customer', :collection => @customers %><%= @test_unchanged %> diff --git a/actionview/test/fixtures/test/malformed/malformed.en.html.erb~ b/actionview/test/fixtures/test/malformed/malformed.en.html.erb~ new file mode 100644 index 0000000000..d009950384 --- /dev/null +++ b/actionview/test/fixtures/test/malformed/malformed.en.html.erb~ @@ -0,0 +1 @@ +Don't render me!
\ No newline at end of file diff --git a/actionview/test/fixtures/test/malformed/malformed.erb~ b/actionview/test/fixtures/test/malformed/malformed.erb~ new file mode 100644 index 0000000000..d009950384 --- /dev/null +++ b/actionview/test/fixtures/test/malformed/malformed.erb~ @@ -0,0 +1 @@ +Don't render me!
\ No newline at end of file diff --git a/actionview/test/fixtures/test/malformed/malformed.html.erb~ b/actionview/test/fixtures/test/malformed/malformed.html.erb~ new file mode 100644 index 0000000000..d009950384 --- /dev/null +++ b/actionview/test/fixtures/test/malformed/malformed.html.erb~ @@ -0,0 +1 @@ +Don't render me!
\ No newline at end of file diff --git a/actionview/test/fixtures/test/malformed/malformed~ b/actionview/test/fixtures/test/malformed/malformed~ new file mode 100644 index 0000000000..d009950384 --- /dev/null +++ b/actionview/test/fixtures/test/malformed/malformed~ @@ -0,0 +1 @@ +Don't render me!
\ No newline at end of file diff --git a/actionview/test/fixtures/test/nested_layout.erb b/actionview/test/fixtures/test/nested_layout.erb new file mode 100644 index 0000000000..6078f74b4c --- /dev/null +++ b/actionview/test/fixtures/test/nested_layout.erb @@ -0,0 +1,3 @@ +<% content_for :title, "title" -%> +<% content_for :column do -%>column<% end -%> +<%= render :layout => 'layouts/column' do -%>content<% end -%>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/nested_streaming.erb b/actionview/test/fixtures/test/nested_streaming.erb new file mode 100644 index 0000000000..55525e0c92 --- /dev/null +++ b/actionview/test/fixtures/test/nested_streaming.erb @@ -0,0 +1,3 @@ +<%- content_for :header do -%>?<%- end -%> +<%= render :template => "test/streaming" %> +?
\ No newline at end of file diff --git a/actionview/test/fixtures/test/nil_return.erb b/actionview/test/fixtures/test/nil_return.erb new file mode 100644 index 0000000000..90ce3881f6 --- /dev/null +++ b/actionview/test/fixtures/test/nil_return.erb @@ -0,0 +1 @@ +This is nil: <%== nil %> diff --git a/actionview/test/fixtures/test/one.html.erb b/actionview/test/fixtures/test/one.html.erb new file mode 100644 index 0000000000..0151874809 --- /dev/null +++ b/actionview/test/fixtures/test/one.html.erb @@ -0,0 +1 @@ +<%= render :partial => "test/two" %> world
\ No newline at end of file diff --git a/actionview/test/fixtures/test/render_file_inspect_local_assigns.erb b/actionview/test/fixtures/test/render_file_inspect_local_assigns.erb new file mode 100644 index 0000000000..aea5c351c5 --- /dev/null +++ b/actionview/test/fixtures/test/render_file_inspect_local_assigns.erb @@ -0,0 +1 @@ +<%= local_assigns.inspect.html_safe %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/render_file_instance_variable.erb b/actionview/test/fixtures/test/render_file_instance_variable.erb new file mode 100644 index 0000000000..5344ac8a66 --- /dev/null +++ b/actionview/test/fixtures/test/render_file_instance_variable.erb @@ -0,0 +1 @@ +<%= @foo %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/render_file_unicode_local.erb b/actionview/test/fixtures/test/render_file_unicode_local.erb new file mode 100644 index 0000000000..cbfd040a76 --- /dev/null +++ b/actionview/test/fixtures/test/render_file_unicode_local.erb @@ -0,0 +1 @@ +<%= 🎃 %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/render_file_with_ivar.erb b/actionview/test/fixtures/test/render_file_with_ivar.erb new file mode 100644 index 0000000000..8b8a449236 --- /dev/null +++ b/actionview/test/fixtures/test/render_file_with_ivar.erb @@ -0,0 +1 @@ +The secret is <%= @secret %> diff --git a/actionview/test/fixtures/test/render_file_with_locals.erb b/actionview/test/fixtures/test/render_file_with_locals.erb new file mode 100644 index 0000000000..ebe09faee6 --- /dev/null +++ b/actionview/test/fixtures/test/render_file_with_locals.erb @@ -0,0 +1 @@ +The secret is <%= secret %> diff --git a/actionview/test/fixtures/test/render_file_with_locals_and_default.erb b/actionview/test/fixtures/test/render_file_with_locals_and_default.erb new file mode 100644 index 0000000000..9b4900acc5 --- /dev/null +++ b/actionview/test/fixtures/test/render_file_with_locals_and_default.erb @@ -0,0 +1 @@ +<%= secret ||= 'one' %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/render_file_with_ruby_keyword_locals.erb b/actionview/test/fixtures/test/render_file_with_ruby_keyword_locals.erb new file mode 100644 index 0000000000..7e3fe6c6d9 --- /dev/null +++ b/actionview/test/fixtures/test/render_file_with_ruby_keyword_locals.erb @@ -0,0 +1 @@ +The class is <%= local_assigns[:class] %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/render_partial_inside_directory.html.erb b/actionview/test/fixtures/test/render_partial_inside_directory.html.erb new file mode 100644 index 0000000000..1461b95186 --- /dev/null +++ b/actionview/test/fixtures/test/render_partial_inside_directory.html.erb @@ -0,0 +1 @@ +<%= render partial: 'test/_directory/partial_with_locales', locals: {'name' => 'Jane'} %> diff --git a/actionview/test/fixtures/test/render_two_partials.html.erb b/actionview/test/fixtures/test/render_two_partials.html.erb new file mode 100644 index 0000000000..3db6025860 --- /dev/null +++ b/actionview/test/fixtures/test/render_two_partials.html.erb @@ -0,0 +1,2 @@ +<%= render :partial => 'partial', :locals => {'first' => '1'} %> +<%= render :partial => 'partial', :locals => {'second' => '2'} %> diff --git a/actionview/test/fixtures/test/streaming.erb b/actionview/test/fixtures/test/streaming.erb new file mode 100644 index 0000000000..fb9b8b1ade --- /dev/null +++ b/actionview/test/fixtures/test/streaming.erb @@ -0,0 +1,3 @@ +<%- provide :header do -%>Yes, <%- end -%> +this works +<%- content_for :footer, " like a charm" -%> diff --git a/actionview/test/fixtures/test/streaming_buster.erb b/actionview/test/fixtures/test/streaming_buster.erb new file mode 100644 index 0000000000..4221d56dcc --- /dev/null +++ b/actionview/test/fixtures/test/streaming_buster.erb @@ -0,0 +1,3 @@ +<%= yield :foo -%> +This won't look +<% provide :unknown, " good." -%> diff --git a/actionview/test/fixtures/test/streaming_with_locale.erb b/actionview/test/fixtures/test/streaming_with_locale.erb new file mode 100644 index 0000000000..b0f2b2f7e9 --- /dev/null +++ b/actionview/test/fixtures/test/streaming_with_locale.erb @@ -0,0 +1 @@ +view.locale: <%= I18n.locale %> diff --git a/actionview/test/fixtures/test/sub_template_raise.html.erb b/actionview/test/fixtures/test/sub_template_raise.html.erb new file mode 100644 index 0000000000..f38c0bda90 --- /dev/null +++ b/actionview/test/fixtures/test/sub_template_raise.html.erb @@ -0,0 +1 @@ +<%= render :partial => "test/raise" %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/template.erb b/actionview/test/fixtures/test/template.erb new file mode 100644 index 0000000000..785afa8f6a --- /dev/null +++ b/actionview/test/fixtures/test/template.erb @@ -0,0 +1 @@ +<%= template.path %>
\ No newline at end of file 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/fixtures/test/update_element_with_capture.erb b/actionview/test/fixtures/test/update_element_with_capture.erb new file mode 100644 index 0000000000..fa3ef200f9 --- /dev/null +++ b/actionview/test/fixtures/test/update_element_with_capture.erb @@ -0,0 +1,9 @@ +<% replacement_function = update_element_function("products", :action => :update) do %> + <p>Product 1</p> + <p>Product 2</p> +<% end %> +<%= javascript_tag(replacement_function) %> + +<% update_element_function("status", :action => :update, :binding => binding) do %> + <b>You bought something!</b> +<% end %> diff --git a/actionview/test/fixtures/test/utf8.html.erb b/actionview/test/fixtures/test/utf8.html.erb new file mode 100644 index 0000000000..ac98c2f012 --- /dev/null +++ b/actionview/test/fixtures/test/utf8.html.erb @@ -0,0 +1,4 @@ +Русский <%= render :partial => 'test/utf8_partial' %> +<%= "日".encoding %> +<%= @output_buffer.encoding %> +<%= __ENCODING__ %> diff --git a/actionview/test/fixtures/test/utf8_magic.html.erb b/actionview/test/fixtures/test/utf8_magic.html.erb new file mode 100644 index 0000000000..257279c29f --- /dev/null +++ b/actionview/test/fixtures/test/utf8_magic.html.erb @@ -0,0 +1,5 @@ +<%# encoding: utf-8 -%> +Русский <%= render :partial => 'test/utf8_partial_magic' %> +<%= "日".encoding %> +<%= @output_buffer.encoding %> +<%= __ENCODING__ %> diff --git a/actionview/test/fixtures/test/utf8_magic_with_bare_partial.html.erb b/actionview/test/fixtures/test/utf8_magic_with_bare_partial.html.erb new file mode 100644 index 0000000000..cb22692f9a --- /dev/null +++ b/actionview/test/fixtures/test/utf8_magic_with_bare_partial.html.erb @@ -0,0 +1,5 @@ +<%# encoding: utf-8 -%> +Русский <%= render :partial => 'test/utf8_partial' %> +<%= "日".encoding %> +<%= @output_buffer.encoding %> +<%= __ENCODING__ %> diff --git a/actionview/test/fixtures/topic.rb b/actionview/test/fixtures/topic.rb new file mode 100644 index 0000000000..ff194ce567 --- /dev/null +++ b/actionview/test/fixtures/topic.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Topic < ActiveRecord::Base + has_many :replies, dependent: :destroy +end diff --git a/actionview/test/fixtures/topics.yml b/actionview/test/fixtures/topics.yml new file mode 100644 index 0000000000..7fdd49d54e --- /dev/null +++ b/actionview/test/fixtures/topics.yml @@ -0,0 +1,22 @@ +futurama: + id: 1 + title: Isnt futurama awesome? + subtitle: It really is, isnt it. + content: I like futurama + created_at: <%= 1.day.ago.to_s(:db) %> + updated_at: + +harvey_birdman: + id: 2 + title: Harvey Birdman is the king of all men + subtitle: yup + content: It really is + created_at: <%= 2.hours.ago.to_s(:db) %> + updated_at: + +rails: + id: 3 + title: Rails is nice + subtitle: It makes me happy + content: except when I have to hack internals to fix pagination. even then really. + created_at: <%= 20.minutes.ago.to_s(:db) %> diff --git a/actionview/test/fixtures/topics/_topic.html.erb b/actionview/test/fixtures/topics/_topic.html.erb new file mode 100644 index 0000000000..98659ca098 --- /dev/null +++ b/actionview/test/fixtures/topics/_topic.html.erb @@ -0,0 +1 @@ +<%= topic.title %>
\ No newline at end of file diff --git a/actionview/test/fixtures/translations/templates/array.erb b/actionview/test/fixtures/translations/templates/array.erb new file mode 100644 index 0000000000..d86045a172 --- /dev/null +++ b/actionview/test/fixtures/translations/templates/array.erb @@ -0,0 +1 @@ +<%= t('.foo.bar') %> diff --git a/actionview/test/fixtures/translations/templates/default.erb b/actionview/test/fixtures/translations/templates/default.erb new file mode 100644 index 0000000000..8b70031071 --- /dev/null +++ b/actionview/test/fixtures/translations/templates/default.erb @@ -0,0 +1 @@ +<%= t('.missing', :default => :'.foo') %> diff --git a/actionview/test/fixtures/translations/templates/found.erb b/actionview/test/fixtures/translations/templates/found.erb new file mode 100644 index 0000000000..080c9c0aee --- /dev/null +++ b/actionview/test/fixtures/translations/templates/found.erb @@ -0,0 +1 @@ +<%= t('.foo') %> diff --git a/actionview/test/fixtures/translations/templates/missing.erb b/actionview/test/fixtures/translations/templates/missing.erb new file mode 100644 index 0000000000..0f3f17f8ef --- /dev/null +++ b/actionview/test/fixtures/translations/templates/missing.erb @@ -0,0 +1 @@ +<%= t('.missing') %> diff --git a/actionview/test/fixtures/with_format.json.erb b/actionview/test/fixtures/with_format.json.erb new file mode 100644 index 0000000000..a7f480ab1d --- /dev/null +++ b/actionview/test/fixtures/with_format.json.erb @@ -0,0 +1 @@ +<%= render :partial => 'missing', :formats => [:json] %> diff --git a/actionview/test/lib/controller/fake_models.rb b/actionview/test/lib/controller/fake_models.rb new file mode 100644 index 0000000000..f8b7ddaecc --- /dev/null +++ b/actionview/test/lib/controller/fake_models.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require "active_model" + +Customer = Struct.new(:name, :id) do + extend ActiveModel::Naming + include ActiveModel::Conversion + + undef_method :to_json + + def to_xml(options = {}) + if options[:builder] + options[:builder].name name + else + "<name>#{name}</name>" + end + end + + def to_js(options = {}) + "name: #{name.inspect}" + end + alias :to_text :to_js + + def errors + [] + end + + def persisted? + id.present? + end + + def cache_key + name.to_s + end +end + +class BadCustomer < Customer; end +class GoodCustomer < Customer; end + +Post = Struct.new(:title, :author_name, :body, :secret, :persisted, :written_on, :cost) do + extend ActiveModel::Naming + include ActiveModel::Conversion + extend ActiveModel::Translation + + alias_method :secret?, :secret + alias_method :persisted?, :persisted + + def initialize(*args) + super + @persisted = false + end + + attr_accessor :author + def author_attributes=(attributes); end + + attr_accessor :comments, :comment_ids + def comments_attributes=(attributes); end + + attr_accessor :tags + def tags_attributes=(attributes); end +end + +class PostDelegator < Post + def to_model + PostDelegate.new + end +end + +class PostDelegate < Post + def self.human_attribute_name(attribute) + "Delegate #{super}" + end + + def model_name + ActiveModel::Name.new(self.class) + end +end + +class Comment + extend ActiveModel::Naming + include ActiveModel::Conversion + + attr_reader :id + attr_reader :post_id + def initialize(id = nil, post_id = nil); @id, @post_id = id, post_id end + def to_key; id ? [id] : nil end + def save; @id = 1; @post_id = 1 end + def persisted?; @id.present? end + def to_param; @id && @id.to_s; end + def name + @id.nil? ? "new #{self.class.name.downcase}" : "#{self.class.name.downcase} ##{@id}" + end + + attr_accessor :relevances + def relevances_attributes=(attributes); end + + attr_accessor :body +end + +class Tag + extend ActiveModel::Naming + include ActiveModel::Conversion + + attr_reader :id + attr_reader :post_id + def initialize(id = nil, post_id = nil); @id, @post_id = id, post_id end + def to_key; id ? [id] : nil end + def save; @id = 1; @post_id = 1 end + def persisted?; @id.present? end + def to_param; @id && @id.to_s; end + def value + @id.nil? ? "new #{self.class.name.downcase}" : "#{self.class.name.downcase} ##{@id}" + end + + attr_accessor :relevances + def relevances_attributes=(attributes); end +end + +class CommentRelevance + extend ActiveModel::Naming + include ActiveModel::Conversion + + attr_reader :id + attr_reader :comment_id + def initialize(id = nil, comment_id = nil); @id, @comment_id = id, comment_id end + def to_key; id ? [id] : nil end + def save; @id = 1; @comment_id = 1 end + def persisted?; @id.present? end + def to_param; @id && @id.to_s; end + def value + @id.nil? ? "new #{self.class.name.downcase}" : "#{self.class.name.downcase} ##{@id}" + end +end + +class TagRelevance + extend ActiveModel::Naming + include ActiveModel::Conversion + + attr_reader :id + attr_reader :tag_id + def initialize(id = nil, tag_id = nil); @id, @tag_id = id, tag_id end + def to_key; id ? [id] : nil end + def save; @id = 1; @tag_id = 1 end + def persisted?; @id.present? end + def to_param; @id && @id.to_s; end + def value + @id.nil? ? "new #{self.class.name.downcase}" : "#{self.class.name.downcase} ##{@id}" + end +end + +class Author < Comment + attr_accessor :post + def post_attributes=(attributes); end +end + +class HashBackedAuthor < Hash + extend ActiveModel::Naming + include ActiveModel::Conversion + + def persisted?; false; end + + def name + "hash backed author" + end +end + +module Blog + def self.use_relative_model_naming? + true + end + + Post = Struct.new(:title, :id) do + extend ActiveModel::Naming + include ActiveModel::Conversion + + def persisted? + id.present? + end + end +end + +class ArelLike + def to_ary + true + end + def each + a = Array.new(2) { |id| Comment.new(id + 1) } + a.each { |i| yield i } + end +end + +Car = Struct.new(:color) + +class Plane + attr_reader :to_key + + def model_name + OpenStruct.new param_key: "airplane" + end + + def save + @to_key = [1] + end +end diff --git a/actionview/test/template/active_model_helper_test.rb b/actionview/test/template/active_model_helper_test.rb new file mode 100644 index 0000000000..36afed6dd6 --- /dev/null +++ b/actionview/test/template/active_model_helper_test.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class ActiveModelHelperTest < ActionView::TestCase + tests ActionView::Helpers::ActiveModelHelper + + silence_warnings do + Post = Struct.new(:author_name, :body, :category, :published, :updated_at) do + include ActiveModel::Conversion + include ActiveModel::Validations + + def persisted? + false + end + end + end + + def setup + super + + @post = Post.new + @post.errors[:author_name] << "can't be empty" + @post.errors[:body] << "foo" + @post.errors[:category] << "must exist" + @post.errors[:published] << "must be accepted" + @post.errors[:updated_at] << "bar" + + @post.author_name = "" + @post.body = "Back to the hill and over it again!" + @post.category = "rails" + @post.published = false + @post.updated_at = Date.new(2004, 6, 15) + end + + def test_text_area_with_errors + assert_dom_equal( + %(<div class="field_with_errors"><textarea id="post_body" name="post[body]">\nBack to the hill and over it again!</textarea></div>), + text_area("post", "body") + ) + end + + def test_text_field_with_errors + assert_dom_equal( + %(<div class="field_with_errors"><input id="post_author_name" name="post[author_name]" type="text" value="" /></div>), + text_field("post", "author_name") + ) + end + + def test_select_with_errors + assert_dom_equal( + %(<div class="field_with_errors"><select name="post[author_name]" id="post_author_name"><option value="a">a</option>\n<option value="b">b</option></select></div>), + select("post", "author_name", [:a, :b]) + ) + end + + def test_select_with_errors_and_blank_option + expected_dom = %(<div class="field_with_errors"><select name="post[author_name]" id="post_author_name"><option value="">Choose one...</option>\n<option value="a">a</option>\n<option value="b">b</option></select></div>) + assert_dom_equal(expected_dom, select("post", "author_name", [:a, :b], include_blank: "Choose one...")) + assert_dom_equal(expected_dom, select("post", "author_name", [:a, :b], prompt: "Choose one...")) + end + + def test_select_grouped_options_with_errors + grouped_options = [ + ["A", [["A1"], ["A2"]]], + ["B", [["B1"], ["B2"]]], + ] + + assert_dom_equal( + %(<div class="field_with_errors"><select name="post[category]" id="post_category"><optgroup label="A"><option value="A1">A1</option>\n<option value="A2">A2</option></optgroup><optgroup label="B"><option value="B1">B1</option>\n<option value="B2">B2</option></optgroup></select></div>), + select("post", "category", grouped_options) + ) + end + + def test_collection_select_with_errors + assert_dom_equal( + %(<div class="field_with_errors"><select name="post[author_name]" id="post_author_name"><option value="a">a</option>\n<option value="b">b</option></select></div>), + collection_select("post", "author_name", [:a, :b], :to_s, :to_s) + ) + end + + def test_date_select_with_errors + assert_dom_equal( + %(<div class="field_with_errors"><select id="post_updated_at_1i" name="post[updated_at(1i)]">\n<option selected="selected" value="2004">2004</option>\n<option value="2005">2005</option>\n</select>\n<input id="post_updated_at_2i" name="post[updated_at(2i)]" type="hidden" value="6" />\n<input id="post_updated_at_3i" name="post[updated_at(3i)]" type="hidden" value="1" />\n</div>), + date_select("post", "updated_at", discard_month: true, discard_day: true, start_year: 2004, end_year: 2005) + ) + end + + def test_datetime_select_with_errors + assert_dom_equal( + %(<div class="field_with_errors"><input id="post_updated_at_1i" name="post[updated_at(1i)]" type="hidden" value="2004" />\n<input id="post_updated_at_2i" name="post[updated_at(2i)]" type="hidden" value="6" />\n<input id="post_updated_at_3i" name="post[updated_at(3i)]" type="hidden" value="1" />\n<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n<option selected="selected" value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n</select>\n : <select id="post_updated_at_5i" name="post[updated_at(5i)]">\n<option selected="selected" value="00">00</option>\n</select>\n</div>), + datetime_select("post", "updated_at", discard_year: true, discard_month: true, discard_day: true, minute_step: 60) + ) + end + + def test_time_select_with_errors + assert_dom_equal( + %(<div class="field_with_errors"><input id="post_updated_at_1i" name="post[updated_at(1i)]" type="hidden" value="2004" />\n<input id="post_updated_at_2i" name="post[updated_at(2i)]" type="hidden" value="6" />\n<input id="post_updated_at_3i" name="post[updated_at(3i)]" type="hidden" value="15" />\n<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n<option selected="selected" value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n</select>\n : <select id="post_updated_at_5i" name="post[updated_at(5i)]">\n<option selected="selected" value="00">00</option>\n</select>\n</div>), + time_select("post", "updated_at", minute_step: 60) + ) + end + + def test_label_with_errors + assert_dom_equal( + %(<div class="field_with_errors"><label for="post_body">Body</label></div>), + label("post", "body") + ) + end + + def test_check_box_with_errors + assert_dom_equal( + %(<input name="post[published]" type="hidden" value="0" /><div class="field_with_errors"><input type="checkbox" value="1" name="post[published]" id="post_published" /></div>), + check_box("post", "published") + ) + end + + def test_check_boxes_with_errors + assert_dom_equal( + %(<input name="post[published]" type="hidden" value="0" /><div class="field_with_errors"><input type="checkbox" value="1" name="post[published]" id="post_published" /></div><input name="post[published]" type="hidden" value="0" /><div class="field_with_errors"><input type="checkbox" value="1" name="post[published]" id="post_published" /></div>), + check_box("post", "published") + check_box("post", "published") + ) + end + + def test_radio_button_with_errors + assert_dom_equal( + %(<div class="field_with_errors"><input type="radio" value="rails" checked="checked" name="post[category]" id="post_category_rails" /></div>), + radio_button("post", "category", "rails") + ) + end + + def test_radio_buttons_with_errors + assert_dom_equal( + %(<div class="field_with_errors"><input type="radio" value="rails" checked="checked" name="post[category]" id="post_category_rails" /></div><div class="field_with_errors"><input type="radio" value="java" name="post[category]" id="post_category_java" /></div>), + radio_button("post", "category", "rails") + radio_button("post", "category", "java") + ) + end + + def test_collection_check_boxes_with_errors + assert_dom_equal( + %(<input type="hidden" name="post[category][]" value="" /><div class="field_with_errors"><input type="checkbox" value="ruby" name="post[category][]" id="post_category_ruby" /></div><label for="post_category_ruby">ruby</label><div class="field_with_errors"><input type="checkbox" value="java" name="post[category][]" id="post_category_java" /></div><label for="post_category_java">java</label>), + collection_check_boxes("post", "category", [:ruby, :java], :to_s, :to_s) + ) + end + + def test_collection_radio_buttons_with_errors + assert_dom_equal( + %(<input type="hidden" name="post[category]" value="" /><div class="field_with_errors"><input type="radio" value="ruby" name="post[category]" id="post_category_ruby" /></div><label for="post_category_ruby">ruby</label><div class="field_with_errors"><input type="radio" value="java" name="post[category]" id="post_category_java" /></div><label for="post_category_java">java</label>), + collection_radio_buttons("post", "category", [:ruby, :java], :to_s, :to_s) + ) + end + + def test_hidden_field_does_not_render_errors + assert_dom_equal( + %(<input id="post_author_name" name="post[author_name]" type="hidden" value="" />), + hidden_field("post", "author_name") + ) + end + + def test_field_error_proc + old_proc = ActionView::Base.field_error_proc + ActionView::Base.field_error_proc = Proc.new do |html_tag, instance| + raw(%(<div class=\"field_with_errors\">#{html_tag} <span class="error">#{[instance.error_message].join(', ')}</span></div>)) + end + + assert_dom_equal( + %(<div class="field_with_errors"><input id="post_author_name" name="post[author_name]" type="text" value="" /> <span class="error">can't be empty</span></div>), + text_field("post", "author_name") + ) + ensure + ActionView::Base.field_error_proc = old_proc if old_proc + end +end diff --git a/actionview/test/template/asset_tag_helper_test.rb b/actionview/test/template/asset_tag_helper_test.rb new file mode 100644 index 0000000000..e68f03d1f4 --- /dev/null +++ b/actionview/test/template/asset_tag_helper_test.rb @@ -0,0 +1,913 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/ordered_options" + +class AssetTagHelperTest < ActionView::TestCase + tests ActionView::Helpers::AssetTagHelper + + attr_reader :request + + def setup + super + + @controller = BasicController.new + + @request = Class.new do + attr_accessor :script_name + def protocol() "http://" end + def ssl?() false end + def host_with_port() "localhost" end + def base_url() "http://www.example.com" end + def send_early_hints(links) end + end.new + + @controller.request = @request + end + + def url_for(*args) + "http://www.example.com" + end + + def content_security_policy_nonce + "iyhD0Yc0W+c=" + end + + AssetPathToTag = { + %(asset_path("")) => %(), + %(asset_path(" ")) => %(), + %(asset_path("foo")) => %(/foo), + %(asset_path("style.css")) => %(/style.css), + %(asset_path("xmlhr.js")) => %(/xmlhr.js), + %(asset_path("xml.png")) => %(/xml.png), + %(asset_path("dir/xml.png")) => %(/dir/xml.png), + %(asset_path("/dir/xml.png")) => %(/dir/xml.png), + + %(asset_path("script.min")) => %(/script.min), + %(asset_path("script.min.js")) => %(/script.min.js), + %(asset_path("style.min")) => %(/style.min), + %(asset_path("style.min.css")) => %(/style.min.css), + + %(asset_path("http://www.outside.com/image.jpg")) => %(http://www.outside.com/image.jpg), + %(asset_path("HTTP://www.outside.com/image.jpg")) => %(HTTP://www.outside.com/image.jpg), + + %(asset_path("style", type: :stylesheet)) => %(/stylesheets/style.css), + %(asset_path("xmlhr", type: :javascript)) => %(/javascripts/xmlhr.js), + %(asset_path("xml.png", type: :image)) => %(/images/xml.png) + } + + AutoDiscoveryToTag = { + %(auto_discovery_link_tag) => %(<link href="http://www.example.com" rel="alternate" title="RSS" type="application/rss+xml" />), + %(auto_discovery_link_tag(:rss)) => %(<link href="http://www.example.com" rel="alternate" title="RSS" type="application/rss+xml" />), + %(auto_discovery_link_tag(:atom)) => %(<link href="http://www.example.com" rel="alternate" title="ATOM" type="application/atom+xml" />), + %(auto_discovery_link_tag(:json)) => %(<link href="http://www.example.com" rel="alternate" title="JSON" type="application/json" />), + %(auto_discovery_link_tag(:rss, :action => "feed")) => %(<link href="http://www.example.com" rel="alternate" title="RSS" type="application/rss+xml" />), + %(auto_discovery_link_tag(:rss, "http://localhost/feed")) => %(<link href="http://localhost/feed" rel="alternate" title="RSS" type="application/rss+xml" />), + %(auto_discovery_link_tag(:rss, "//localhost/feed")) => %(<link href="//localhost/feed" rel="alternate" title="RSS" type="application/rss+xml" />), + %(auto_discovery_link_tag(:rss, {:action => "feed"}, {:title => "My RSS"})) => %(<link href="http://www.example.com" rel="alternate" title="My RSS" type="application/rss+xml" />), + %(auto_discovery_link_tag(:rss, {}, {:title => "My RSS"})) => %(<link href="http://www.example.com" rel="alternate" title="My RSS" type="application/rss+xml" />), + %(auto_discovery_link_tag(nil, {}, {:type => "text/html"})) => %(<link href="http://www.example.com" rel="alternate" title="" type="text/html" />), + %(auto_discovery_link_tag(nil, {}, {:title => "No stream.. really", :type => "text/html"})) => %(<link href="http://www.example.com" rel="alternate" title="No stream.. really" type="text/html" />), + %(auto_discovery_link_tag(:rss, {}, {:title => "My RSS", :type => "text/html"})) => %(<link href="http://www.example.com" rel="alternate" title="My RSS" type="text/html" />), + %(auto_discovery_link_tag(:atom, {}, {:rel => "Not so alternate"})) => %(<link href="http://www.example.com" rel="Not so alternate" title="ATOM" type="application/atom+xml" />), + } + + JavascriptPathToTag = { + %(javascript_path("xmlhr")) => %(/javascripts/xmlhr.js), + %(javascript_path("super/xmlhr")) => %(/javascripts/super/xmlhr.js), + %(javascript_path("/super/xmlhr.js")) => %(/super/xmlhr.js), + %(javascript_path("xmlhr.min")) => %(/javascripts/xmlhr.min.js), + %(javascript_path("xmlhr.min.js")) => %(/javascripts/xmlhr.min.js), + + %(javascript_path("xmlhr.js?123")) => %(/javascripts/xmlhr.js?123), + %(javascript_path("xmlhr.js?body=1")) => %(/javascripts/xmlhr.js?body=1), + %(javascript_path("xmlhr.js#hash")) => %(/javascripts/xmlhr.js#hash), + %(javascript_path("xmlhr.js?123#hash")) => %(/javascripts/xmlhr.js?123#hash) + } + + PathToJavascriptToTag = { + %(path_to_javascript("xmlhr")) => %(/javascripts/xmlhr.js), + %(path_to_javascript("super/xmlhr")) => %(/javascripts/super/xmlhr.js), + %(path_to_javascript("/super/xmlhr.js")) => %(/super/xmlhr.js) + } + + JavascriptUrlToTag = { + %(javascript_url("xmlhr")) => %(http://www.example.com/javascripts/xmlhr.js), + %(javascript_url("super/xmlhr")) => %(http://www.example.com/javascripts/super/xmlhr.js), + %(javascript_url("/super/xmlhr.js")) => %(http://www.example.com/super/xmlhr.js) + } + + UrlToJavascriptToTag = { + %(url_to_javascript("xmlhr")) => %(http://www.example.com/javascripts/xmlhr.js), + %(url_to_javascript("super/xmlhr")) => %(http://www.example.com/javascripts/super/xmlhr.js), + %(url_to_javascript("/super/xmlhr.js")) => %(http://www.example.com/super/xmlhr.js) + } + + JavascriptIncludeToTag = { + %(javascript_include_tag("bank")) => %(<script src="/javascripts/bank.js" ></script>), + %(javascript_include_tag("bank.js")) => %(<script src="/javascripts/bank.js" ></script>), + %(javascript_include_tag("bank", :lang => "vbscript")) => %(<script lang="vbscript" src="/javascripts/bank.js" ></script>), + %(javascript_include_tag("bank", :host => "assets.example.com")) => %(<script src="http://assets.example.com/javascripts/bank.js"></script>), + + %(javascript_include_tag("http://example.com/all")) => %(<script src="http://example.com/all"></script>), + %(javascript_include_tag("http://example.com/all.js")) => %(<script src="http://example.com/all.js"></script>), + %(javascript_include_tag("//example.com/all.js")) => %(<script src="//example.com/all.js"></script>), + } + + StylePathToTag = { + %(stylesheet_path("bank")) => %(/stylesheets/bank.css), + %(stylesheet_path("bank.css")) => %(/stylesheets/bank.css), + %(stylesheet_path('subdir/subdir')) => %(/stylesheets/subdir/subdir.css), + %(stylesheet_path('/subdir/subdir.css')) => %(/subdir/subdir.css), + %(stylesheet_path("style.min")) => %(/stylesheets/style.min.css), + %(stylesheet_path("style.min.css")) => %(/stylesheets/style.min.css) + } + + PathToStyleToTag = { + %(path_to_stylesheet("style")) => %(/stylesheets/style.css), + %(path_to_stylesheet("style.css")) => %(/stylesheets/style.css), + %(path_to_stylesheet('dir/file')) => %(/stylesheets/dir/file.css), + %(path_to_stylesheet('/dir/file.rcss', :extname => false)) => %(/dir/file.rcss), + %(path_to_stylesheet('/dir/file', :extname => '.rcss')) => %(/dir/file.rcss) + } + + StyleUrlToTag = { + %(stylesheet_url("bank")) => %(http://www.example.com/stylesheets/bank.css), + %(stylesheet_url("bank.css")) => %(http://www.example.com/stylesheets/bank.css), + %(stylesheet_url('subdir/subdir')) => %(http://www.example.com/stylesheets/subdir/subdir.css), + %(stylesheet_url('/subdir/subdir.css')) => %(http://www.example.com/subdir/subdir.css) + } + + UrlToStyleToTag = { + %(url_to_stylesheet("style")) => %(http://www.example.com/stylesheets/style.css), + %(url_to_stylesheet("style.css")) => %(http://www.example.com/stylesheets/style.css), + %(url_to_stylesheet('dir/file')) => %(http://www.example.com/stylesheets/dir/file.css), + %(url_to_stylesheet('/dir/file.rcss', :extname => false)) => %(http://www.example.com/dir/file.rcss), + %(url_to_stylesheet('/dir/file', :extname => '.rcss')) => %(http://www.example.com/dir/file.rcss) + } + + StyleLinkToTag = { + %(stylesheet_link_tag("bank")) => %(<link href="/stylesheets/bank.css" media="screen" rel="stylesheet" />), + %(stylesheet_link_tag("bank.css")) => %(<link href="/stylesheets/bank.css" media="screen" rel="stylesheet" />), + %(stylesheet_link_tag("/elsewhere/file")) => %(<link href="/elsewhere/file.css" media="screen" rel="stylesheet" />), + %(stylesheet_link_tag("subdir/subdir")) => %(<link href="/stylesheets/subdir/subdir.css" media="screen" rel="stylesheet" />), + %(stylesheet_link_tag("bank", :media => "all")) => %(<link href="/stylesheets/bank.css" media="all" rel="stylesheet" />), + %(stylesheet_link_tag("bank", :host => "assets.example.com")) => %(<link href="http://assets.example.com/stylesheets/bank.css" media="screen" rel="stylesheet" />), + + %(stylesheet_link_tag("http://www.example.com/styles/style")) => %(<link href="http://www.example.com/styles/style" media="screen" rel="stylesheet" />), + %(stylesheet_link_tag("http://www.example.com/styles/style.css")) => %(<link href="http://www.example.com/styles/style.css" media="screen" rel="stylesheet" />), + %(stylesheet_link_tag("//www.example.com/styles/style.css")) => %(<link href="//www.example.com/styles/style.css" media="screen" rel="stylesheet" />), + } + + ImagePathToTag = { + %(image_path("xml")) => %(/images/xml), + %(image_path("xml.png")) => %(/images/xml.png), + %(image_path("dir/xml.png")) => %(/images/dir/xml.png), + %(image_path("/dir/xml.png")) => %(/dir/xml.png) + } + + PathToImageToTag = { + %(path_to_image("xml")) => %(/images/xml), + %(path_to_image("xml.png")) => %(/images/xml.png), + %(path_to_image("dir/xml.png")) => %(/images/dir/xml.png), + %(path_to_image("/dir/xml.png")) => %(/dir/xml.png) + } + + ImageUrlToTag = { + %(image_url("xml")) => %(http://www.example.com/images/xml), + %(image_url("xml.png")) => %(http://www.example.com/images/xml.png), + %(image_url("dir/xml.png")) => %(http://www.example.com/images/dir/xml.png), + %(image_url("/dir/xml.png")) => %(http://www.example.com/dir/xml.png) + } + + UrlToImageToTag = { + %(url_to_image("xml")) => %(http://www.example.com/images/xml), + %(url_to_image("xml.png")) => %(http://www.example.com/images/xml.png), + %(url_to_image("dir/xml.png")) => %(http://www.example.com/images/dir/xml.png), + %(url_to_image("/dir/xml.png")) => %(http://www.example.com/dir/xml.png) + } + + ImageLinkToTag = { + %(image_tag("xml.png")) => %(<img src="/images/xml.png" />), + %(image_tag("rss.gif", :alt => "rss syndication")) => %(<img alt="rss syndication" src="/images/rss.gif" />), + %(image_tag("gold.png", :size => "20")) => %(<img height="20" src="/images/gold.png" width="20" />), + %(image_tag("gold.png", :size => 20)) => %(<img height="20" src="/images/gold.png" width="20" />), + %(image_tag("gold.png", :size => "45x70")) => %(<img height="70" src="/images/gold.png" width="45" />), + %(image_tag("gold.png", "size" => "45x70")) => %(<img height="70" src="/images/gold.png" width="45" />), + %(image_tag("error.png", "size" => "45 x 70")) => %(<img src="/images/error.png" />), + %(image_tag("error.png", "size" => "x")) => %(<img src="/images/error.png" />), + %(image_tag("google.com.png")) => %(<img src="/images/google.com.png" />), + %(image_tag("slash..png")) => %(<img src="/images/slash..png" />), + %(image_tag(".pdf.png")) => %(<img src="/images/.pdf.png" />), + %(image_tag("http://www.rubyonrails.com/images/rails.png")) => %(<img src="http://www.rubyonrails.com/images/rails.png" />), + %(image_tag("//www.rubyonrails.com/images/rails.png")) => %(<img src="//www.rubyonrails.com/images/rails.png" />), + %(image_tag("mouse.png", :alt => nil)) => %(<img src="/images/mouse.png" />), + %(image_tag("data:image/gif;base64,R0lGODlhAQABAID/AMDAwAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==", :alt => nil)) => %(<img src="data:image/gif;base64,R0lGODlhAQABAID/AMDAwAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" />), + %(image_tag("")) => %(<img src="" />), + %(image_tag("gold.png", data: { title: 'Rails Application' })) => %(<img data-title="Rails Application" src="/images/gold.png" />), + %(image_tag("rss.gif", srcset: "/assets/pic_640.jpg 640w, /assets/pic_1024.jpg 1024w")) => %(<img srcset="/assets/pic_640.jpg 640w, /assets/pic_1024.jpg 1024w" src="/images/rss.gif" />), + %(image_tag("rss.gif", srcset: { "pic_640.jpg" => "640w", "pic_1024.jpg" => "1024w" })) => %(<img srcset="/images/pic_640.jpg 640w, /images/pic_1024.jpg 1024w" src="/images/rss.gif" />), + %(image_tag("rss.gif", srcset: [["pic_640.jpg", "640w"], ["pic_1024.jpg", "1024w"]])) => %(<img srcset="/images/pic_640.jpg 640w, /images/pic_1024.jpg 1024w" src="/images/rss.gif" />) + } + + FaviconLinkToTag = { + %(favicon_link_tag) => %(<link href="/images/favicon.ico" rel="shortcut icon" type="image/x-icon" />), + %(favicon_link_tag 'favicon.ico') => %(<link href="/images/favicon.ico" rel="shortcut icon" type="image/x-icon" />), + %(favicon_link_tag 'favicon.ico', :rel => 'foo') => %(<link href="/images/favicon.ico" rel="foo" type="image/x-icon" />), + %(favicon_link_tag 'favicon.ico', :rel => 'foo', :type => 'bar') => %(<link href="/images/favicon.ico" rel="foo" type="bar" />), + %(favicon_link_tag 'mb-icon.png', :rel => 'apple-touch-icon', :type => 'image/png') => %(<link href="/images/mb-icon.png" rel="apple-touch-icon" type="image/png" />) + } + + PreloadLinkToTag = { + %(preload_link_tag '/styles/custom_theme.css') => %(<link rel="preload" href="/styles/custom_theme.css" as="style" type="text/css" />), + %(preload_link_tag '/videos/video.webm') => %(<link rel="preload" href="/videos/video.webm" as="video" type="video/webm" />), + %(preload_link_tag '/posts.json', as: 'fetch') => %(<link rel="preload" href="/posts.json" as="fetch" type="application/json" />), + %(preload_link_tag '/users', as: 'fetch', type: 'application/json') => %(<link rel="preload" href="/users" as="fetch" type="application/json" />), + %(preload_link_tag '//example.com/map?callback=initMap', as: 'fetch', type: 'application/javascript') => %(<link rel="preload" href="//example.com/map?callback=initMap" as="fetch" type="application/javascript" />), + %(preload_link_tag '//example.com/font.woff2') => %(<link rel="preload" href="//example.com/font.woff2" as="font" type="font/woff2" crossorigin="anonymous"/>), + %(preload_link_tag '//example.com/font.woff2', crossorigin: 'use-credentials') => %(<link rel="preload" href="//example.com/font.woff2" as="font" type="font/woff2" crossorigin="use-credentials" />), + %(preload_link_tag '/media/audio.ogg', nopush: true) => %(<link rel="preload" href="/media/audio.ogg" as="audio" type="audio/ogg" />) + } + + VideoPathToTag = { + %(video_path("xml")) => %(/videos/xml), + %(video_path("xml.ogg")) => %(/videos/xml.ogg), + %(video_path("dir/xml.ogg")) => %(/videos/dir/xml.ogg), + %(video_path("/dir/xml.ogg")) => %(/dir/xml.ogg) + } + + PathToVideoToTag = { + %(path_to_video("xml")) => %(/videos/xml), + %(path_to_video("xml.ogg")) => %(/videos/xml.ogg), + %(path_to_video("dir/xml.ogg")) => %(/videos/dir/xml.ogg), + %(path_to_video("/dir/xml.ogg")) => %(/dir/xml.ogg) + } + + VideoUrlToTag = { + %(video_url("xml")) => %(http://www.example.com/videos/xml), + %(video_url("xml.ogg")) => %(http://www.example.com/videos/xml.ogg), + %(video_url("dir/xml.ogg")) => %(http://www.example.com/videos/dir/xml.ogg), + %(video_url("/dir/xml.ogg")) => %(http://www.example.com/dir/xml.ogg) + } + + UrlToVideoToTag = { + %(url_to_video("xml")) => %(http://www.example.com/videos/xml), + %(url_to_video("xml.ogg")) => %(http://www.example.com/videos/xml.ogg), + %(url_to_video("dir/xml.ogg")) => %(http://www.example.com/videos/dir/xml.ogg), + %(url_to_video("/dir/xml.ogg")) => %(http://www.example.com/dir/xml.ogg) + } + + VideoLinkToTag = { + %(video_tag("xml.ogg")) => %(<video src="/videos/xml.ogg"></video>), + %(video_tag("rss.m4v", :autoplay => true, :controls => true)) => %(<video autoplay="autoplay" controls="controls" src="/videos/rss.m4v"></video>), + %(video_tag("rss.m4v", :preload => 'none')) => %(<video preload="none" src="/videos/rss.m4v"></video>), + %(video_tag("gold.m4v", :size => "160x120")) => %(<video height="120" src="/videos/gold.m4v" width="160"></video>), + %(video_tag("gold.m4v", "size" => "320x240")) => %(<video height="240" src="/videos/gold.m4v" width="320"></video>), + %(video_tag("trailer.ogg", :poster => "screenshot.png")) => %(<video poster="/images/screenshot.png" src="/videos/trailer.ogg"></video>), + %(video_tag("error.avi", "size" => "100")) => %(<video height="100" src="/videos/error.avi" width="100"></video>), + %(video_tag("error.avi", "size" => 100)) => %(<video height="100" src="/videos/error.avi" width="100"></video>), + %(video_tag("error.avi", "size" => "100 x 100")) => %(<video src="/videos/error.avi"></video>), + %(video_tag("error.avi", "size" => "x")) => %(<video src="/videos/error.avi"></video>), + %(video_tag("http://media.rubyonrails.org/video/rails_blog_2.mov")) => %(<video src="http://media.rubyonrails.org/video/rails_blog_2.mov"></video>), + %(video_tag("//media.rubyonrails.org/video/rails_blog_2.mov")) => %(<video src="//media.rubyonrails.org/video/rails_blog_2.mov"></video>), + %(video_tag("multiple.ogg", "multiple.avi")) => %(<video><source src="/videos/multiple.ogg" /><source src="/videos/multiple.avi" /></video>), + %(video_tag(["multiple.ogg", "multiple.avi"])) => %(<video><source src="/videos/multiple.ogg" /><source src="/videos/multiple.avi" /></video>), + %(video_tag(["multiple.ogg", "multiple.avi"], :size => "160x120", :controls => true)) => %(<video controls="controls" height="120" width="160"><source src="/videos/multiple.ogg" /><source src="/videos/multiple.avi" /></video>) + } + + AudioPathToTag = { + %(audio_path("xml")) => %(/audios/xml), + %(audio_path("xml.wav")) => %(/audios/xml.wav), + %(audio_path("dir/xml.wav")) => %(/audios/dir/xml.wav), + %(audio_path("/dir/xml.wav")) => %(/dir/xml.wav) + } + + PathToAudioToTag = { + %(path_to_audio("xml")) => %(/audios/xml), + %(path_to_audio("xml.wav")) => %(/audios/xml.wav), + %(path_to_audio("dir/xml.wav")) => %(/audios/dir/xml.wav), + %(path_to_audio("/dir/xml.wav")) => %(/dir/xml.wav) + } + + AudioUrlToTag = { + %(audio_url("xml")) => %(http://www.example.com/audios/xml), + %(audio_url("xml.wav")) => %(http://www.example.com/audios/xml.wav), + %(audio_url("dir/xml.wav")) => %(http://www.example.com/audios/dir/xml.wav), + %(audio_url("/dir/xml.wav")) => %(http://www.example.com/dir/xml.wav) + } + + UrlToAudioToTag = { + %(url_to_audio("xml")) => %(http://www.example.com/audios/xml), + %(url_to_audio("xml.wav")) => %(http://www.example.com/audios/xml.wav), + %(url_to_audio("dir/xml.wav")) => %(http://www.example.com/audios/dir/xml.wav), + %(url_to_audio("/dir/xml.wav")) => %(http://www.example.com/dir/xml.wav) + } + + AudioLinkToTag = { + %(audio_tag("xml.wav")) => %(<audio src="/audios/xml.wav"></audio>), + %(audio_tag("rss.wav", :autoplay => true, :controls => true)) => %(<audio autoplay="autoplay" controls="controls" src="/audios/rss.wav"></audio>), + %(audio_tag("http://media.rubyonrails.org/audio/rails_blog_2.mov")) => %(<audio src="http://media.rubyonrails.org/audio/rails_blog_2.mov"></audio>), + %(audio_tag("//media.rubyonrails.org/audio/rails_blog_2.mov")) => %(<audio src="//media.rubyonrails.org/audio/rails_blog_2.mov"></audio>), + %(audio_tag("audio.mp3", "audio.ogg")) => %(<audio><source src="/audios/audio.mp3" /><source src="/audios/audio.ogg" /></audio>), + %(audio_tag(["audio.mp3", "audio.ogg"])) => %(<audio><source src="/audios/audio.mp3" /><source src="/audios/audio.ogg" /></audio>), + %(audio_tag(["audio.mp3", "audio.ogg"], :preload => 'none', :controls => true)) => %(<audio preload="none" controls="controls"><source src="/audios/audio.mp3" /><source src="/audios/audio.ogg" /></audio>) + } + + FontPathToTag = { + %(font_path("font.eot")) => %(/fonts/font.eot), + %(font_path("font.eot#iefix")) => %(/fonts/font.eot#iefix), + %(font_path("font.woff")) => %(/fonts/font.woff), + %(font_path("font.ttf")) => %(/fonts/font.ttf), + %(font_path("font.ttf?123")) => %(/fonts/font.ttf?123) + } + + FontUrlToTag = { + %(font_url("font.eot")) => %(http://www.example.com/fonts/font.eot), + %(font_url("font.eot#iefix")) => %(http://www.example.com/fonts/font.eot#iefix), + %(font_url("font.woff")) => %(http://www.example.com/fonts/font.woff), + %(font_url("font.ttf")) => %(http://www.example.com/fonts/font.ttf), + %(font_url("font.ttf?123")) => %(http://www.example.com/fonts/font.ttf?123), + %(font_url("font.ttf", host: "http://assets.example.com")) => %(http://assets.example.com/fonts/font.ttf) + } + + UrlToFontToTag = { + %(url_to_font("font.eot")) => %(http://www.example.com/fonts/font.eot), + %(url_to_font("font.eot#iefix")) => %(http://www.example.com/fonts/font.eot#iefix), + %(url_to_font("font.woff")) => %(http://www.example.com/fonts/font.woff), + %(url_to_font("font.ttf")) => %(http://www.example.com/fonts/font.ttf), + %(url_to_font("font.ttf?123")) => %(http://www.example.com/fonts/font.ttf?123), + %(url_to_font("font.ttf", host: "http://assets.example.com")) => %(http://assets.example.com/fonts/font.ttf) + } + + def test_autodiscovery_link_tag_with_unknown_type_but_not_pass_type_option_key + assert_raise(ArgumentError) do + auto_discovery_link_tag(:xml) + end + end + + def test_autodiscovery_link_tag_with_unknown_type + result = auto_discovery_link_tag(:xml, "/feed.xml", type: "application/xml") + expected = %(<link href="/feed.xml" rel="alternate" title="XML" type="application/xml" />) + assert_dom_equal expected, result + end + + def test_asset_path_tag + AssetPathToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_asset_path_tag_raises_an_error_for_nil_source + e = assert_raise(ArgumentError) { asset_path(nil) } + assert_equal("nil is not a valid asset source", e.message) + end + + def test_asset_path_tag_to_not_create_duplicate_slashes + @controller.config.asset_host = "host/" + assert_dom_equal("http://host/foo", asset_path("foo")) + + @controller.config.relative_url_root = "/some/root/" + assert_dom_equal("http://host/some/root/foo", asset_path("foo")) + end + + def test_compute_asset_public_path + assert_equal "/robots.txt", compute_asset_path("robots.txt") + assert_equal "/robots.txt", compute_asset_path("/robots.txt") + assert_equal "/javascripts/foo.js", compute_asset_path("foo.js", type: :javascript) + assert_equal "/javascripts/foo.js", compute_asset_path("/foo.js", type: :javascript) + assert_equal "/stylesheets/foo.css", compute_asset_path("foo.css", type: :stylesheet) + end + + def test_auto_discovery_link_tag + AutoDiscoveryToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_javascript_path + JavascriptPathToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_path_to_javascript_alias_for_javascript_path + PathToJavascriptToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_javascript_url + JavascriptUrlToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_url_to_javascript_alias_for_javascript_url + UrlToJavascriptToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_javascript_include_tag + JavascriptIncludeToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_javascript_include_tag_with_missing_source + assert_nothing_raised { + javascript_include_tag("missing_security_guard") + } + + assert_nothing_raised { + javascript_include_tag("http://example.com/css/missing_security_guard") + } + end + + def test_javascript_include_tag_is_html_safe + assert_predicate javascript_include_tag("prototype"), :html_safe? + end + + def test_javascript_include_tag_relative_protocol + @controller.config.asset_host = "assets.example.com" + assert_dom_equal %(<script src="//assets.example.com/javascripts/prototype.js"></script>), javascript_include_tag("prototype", protocol: :relative) + end + + def test_javascript_include_tag_default_protocol + @controller.config.asset_host = "assets.example.com" + @controller.config.default_asset_host_protocol = :relative + assert_dom_equal %(<script src="//assets.example.com/javascripts/prototype.js"></script>), javascript_include_tag("prototype") + end + + def test_javascript_include_tag_nonce + assert_dom_equal %(<script src="/javascripts/bank.js" nonce="iyhD0Yc0W+c="></script>), javascript_include_tag("bank", nonce: true) + end + + def test_stylesheet_path + StylePathToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_path_to_stylesheet_alias_for_stylesheet_path + PathToStyleToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_stylesheet_url + StyleUrlToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_url_to_stylesheet_alias_for_stylesheet_url + UrlToStyleToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_stylesheet_link_tag + StyleLinkToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_stylesheet_link_tag_with_missing_source + assert_nothing_raised { + stylesheet_link_tag("missing_security_guard") + } + + assert_nothing_raised { + stylesheet_link_tag("http://example.com/css/missing_security_guard") + } + end + + def test_stylesheet_link_tag_without_request + @request = nil + assert_dom_equal( + %(<link rel="stylesheet" media="screen" href="/stylesheets/foo.css" />), + stylesheet_link_tag("foo.css") + ) + end + + def test_stylesheet_link_tag_is_html_safe + assert_predicate stylesheet_link_tag("dir/file"), :html_safe? + assert_predicate stylesheet_link_tag("dir/other/file", "dir/file2"), :html_safe? + end + + def test_stylesheet_link_tag_escapes_options + assert_dom_equal %(<link href="/file.css" media="<script>" rel="stylesheet" />), stylesheet_link_tag("/file", media: "<script>") + end + + def test_stylesheet_link_tag_should_not_output_the_same_asset_twice + assert_dom_equal %(<link href="/stylesheets/wellington.css" media="screen" rel="stylesheet" />\n<link href="/stylesheets/amsterdam.css" media="screen" rel="stylesheet" />), stylesheet_link_tag("wellington", "wellington", "amsterdam") + end + + def test_stylesheet_link_tag_with_relative_protocol + @controller.config.asset_host = "assets.example.com" + assert_dom_equal %(<link href="//assets.example.com/stylesheets/wellington.css" media="screen" rel="stylesheet" />), stylesheet_link_tag("wellington", protocol: :relative) + end + + def test_stylesheet_link_tag_with_default_protocol + @controller.config.asset_host = "assets.example.com" + @controller.config.default_asset_host_protocol = :relative + assert_dom_equal %(<link href="//assets.example.com/stylesheets/wellington.css" media="screen" rel="stylesheet" />), stylesheet_link_tag("wellington") + end + + def test_javascript_include_tag_without_request + @request = nil + assert_dom_equal %(<script src="/javascripts/foo.js"></script>), javascript_include_tag("foo.js") + end + + def test_image_path + ImagePathToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_path_to_image_alias_for_image_path + PathToImageToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_image_url + ImageUrlToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_url_to_image_alias_for_image_url + UrlToImageToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_image_alt + [nil, "/", "/foo/bar/", "foo/bar/"].each do |prefix| + assert_deprecated do + assert_equal "Rails", image_alt("#{prefix}rails.png") + end + assert_deprecated do + assert_equal "Rails", image_alt("#{prefix}rails-9c0a079bdd7701d7e729bd956823d153.png") + end + assert_deprecated do + assert_equal "Rails", image_alt("#{prefix}rails-f56ef62bc41b040664e801a38f068082a75d506d9048307e8096737463503d0b.png") + end + assert_deprecated do + assert_equal "Long file name with hyphens", image_alt("#{prefix}long-file-name-with-hyphens.png") + end + assert_deprecated do + assert_equal "Long file name with underscores", image_alt("#{prefix}long_file_name_with_underscores.png") + end + end + end + + def test_image_tag + ImageLinkToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_image_tag_does_not_modify_options + options = { size: "16x10" } + image_tag("icon", options) + assert_equal({ size: "16x10" }, options) + end + + def test_image_tag_raises_an_error_for_competing_size_arguments + exception = assert_raise(ArgumentError) do + image_tag("gold.png", height: "100", width: "200", size: "45x70") + end + + assert_equal("Cannot pass a :size option with a :height or :width option", exception.message) + end + + def test_favicon_link_tag + FaviconLinkToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_preload_link_tag + PreloadLinkToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_video_path + VideoPathToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_path_to_video_alias_for_video_path + PathToVideoToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_video_url + VideoUrlToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_url_to_video_alias_for_video_url + UrlToVideoToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_video_tag + VideoLinkToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_audio_path + AudioPathToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_path_to_audio_alias_for_audio_path + PathToAudioToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_audio_url + AudioUrlToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_url_to_audio_alias_for_audio_url + UrlToAudioToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_audio_tag + AudioLinkToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_font_path + FontPathToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_font_url + FontUrlToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_url_to_font_alias_for_font_url + UrlToFontToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } + end + + def test_video_audio_tag_does_not_modify_options + options = { autoplay: true } + video_tag("video", options) + assert_equal({ autoplay: true }, options) + audio_tag("audio", options) + assert_equal({ autoplay: true }, options) + end + + def test_image_tag_interpreting_email_cid_correctly + # An inline image has no need for an alt tag to be automatically generated from the cid: + assert_equal '<img src="cid:thi%25%25sis@acontentid" />', image_tag("cid:thi%25%25sis@acontentid") + end + + def test_image_tag_interpreting_email_adding_optional_alt_tag + assert_equal '<img alt="Image" src="cid:thi%25%25sis@acontentid" />', image_tag("cid:thi%25%25sis@acontentid", alt: "Image") + end + + def test_should_not_modify_source_string + source = "/images/rails.png" + copy = source.dup + image_tag(source) + assert_equal copy, source + end + + class PlaceholderImage + def blank?; true; end + def to_s; "no-image-yet.png"; end + end + def test_image_path_with_blank_placeholder + assert_equal "/images/no-image-yet.png", image_path(PlaceholderImage.new) + end + + def test_image_path_with_asset_host_proc_returning_nil + @controller.config.asset_host = Proc.new do |source| + unless source.end_with?("tiff") + "cdn.example.com" + end + end + + assert_equal "/images/file.tiff", image_path("file.tiff") + assert_equal "http://cdn.example.com/images/file.png", image_path("file.png") + end + + def test_image_url_with_asset_host_proc_returning_nil + @controller.config.asset_host = Proc.new { nil } + @controller.request = Struct.new(:base_url, :script_name).new("http://www.example.com", nil) + + assert_equal "/images/rails.png", image_path("rails.png") + assert_equal "http://www.example.com/images/rails.png", image_url("rails.png") + end + + def test_caching_image_path_with_caching_and_proc_asset_host_using_request + @controller.config.asset_host = Proc.new do |source, request| + if request.ssl? + "#{request.protocol}#{request.host_with_port}" + else + "#{request.protocol}assets#{source.length}.example.com" + end + end + + @controller.request.stub(:ssl?, false) do + assert_equal "http://assets15.example.com/images/xml.png", image_path("xml.png") + end + + @controller.request.stub(:ssl?, true) do + assert_equal "http://localhost/images/xml.png", image_path("xml.png") + end + end +end + +class AssetTagHelperNonVhostTest < ActionView::TestCase + tests ActionView::Helpers::AssetTagHelper + + attr_reader :request + + def setup + super + @controller = BasicController.new + @controller.config.relative_url_root = "/collaboration/hieraki" + + @request = Struct.new(:protocol, :base_url) do + def send_early_hints(links); end + end.new("gopher://", "gopher://www.example.com") + @controller.request = @request + end + + def url_for(options) + "http://www.example.com/collaboration/hieraki" + end + + def test_should_compute_proper_path + assert_dom_equal(%(<link href="http://www.example.com/collaboration/hieraki" rel="alternate" title="RSS" type="application/rss+xml" />), auto_discovery_link_tag) + assert_dom_equal(%(/collaboration/hieraki/javascripts/xmlhr.js), javascript_path("xmlhr")) + assert_dom_equal(%(/collaboration/hieraki/stylesheets/style.css), stylesheet_path("style")) + assert_dom_equal(%(/collaboration/hieraki/images/xml.png), image_path("xml.png")) + end + + def test_should_return_nothing_if_asset_host_isnt_configured + assert_nil compute_asset_host("foo") + end + + def test_should_current_request_host_is_always_returned_for_request + assert_equal "gopher://www.example.com", compute_asset_host("foo", protocol: :request) + end + + def test_should_return_custom_host_if_passed_in_options + assert_equal "http://custom.example.com", compute_asset_host("foo", host: "http://custom.example.com") + end + + def test_should_ignore_relative_root_path_on_complete_url + assert_dom_equal(%(http://www.example.com/images/xml.png), image_path("http://www.example.com/images/xml.png")) + end + + def test_should_return_simple_string_asset_host + @controller.config.asset_host = "assets.example.com" + assert_equal "gopher://assets.example.com", compute_asset_host("foo") + end + + def test_should_return_relative_asset_host + @controller.config.asset_host = "assets.example.com" + assert_equal "//assets.example.com", compute_asset_host("foo", protocol: :relative) + end + + def test_should_return_custom_protocol_asset_host + @controller.config.asset_host = "assets.example.com" + assert_equal "ftp://assets.example.com", compute_asset_host("foo", protocol: "ftp") + end + + def test_should_compute_proper_path_with_asset_host + @controller.config.asset_host = "assets.example.com" + assert_dom_equal(%(<link href="http://www.example.com/collaboration/hieraki" rel="alternate" title="RSS" type="application/rss+xml" />), auto_discovery_link_tag) + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/javascripts/xmlhr.js), javascript_path("xmlhr")) + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/stylesheets/style.css), stylesheet_path("style")) + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/images/xml.png), image_path("xml.png")) + end + + def test_should_compute_proper_path_with_asset_host_and_default_protocol + @controller.config.asset_host = "assets.example.com" + @controller.config.default_asset_host_protocol = :request + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/javascripts/xmlhr.js), javascript_path("xmlhr")) + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/stylesheets/style.css), stylesheet_path("style")) + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/images/xml.png), image_path("xml.png")) + end + + def test_should_compute_proper_url_with_asset_host + @controller.config.asset_host = "assets.example.com" + assert_dom_equal(%(<link href="http://www.example.com/collaboration/hieraki" rel="alternate" title="RSS" type="application/rss+xml" />), auto_discovery_link_tag) + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/javascripts/xmlhr.js), javascript_url("xmlhr")) + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/stylesheets/style.css), stylesheet_url("style")) + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/images/xml.png), image_url("xml.png")) + end + + def test_should_compute_proper_url_with_asset_host_and_default_protocol + @controller.config.asset_host = "assets.example.com" + @controller.config.default_asset_host_protocol = :request + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/javascripts/xmlhr.js), javascript_url("xmlhr")) + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/stylesheets/style.css), stylesheet_url("style")) + assert_dom_equal(%(gopher://assets.example.com/collaboration/hieraki/images/xml.png), image_url("xml.png")) + end + + def test_should_return_asset_host_with_protocol + @controller.config.asset_host = "http://assets.example.com" + assert_equal "http://assets.example.com", compute_asset_host("foo") + end + + def test_should_ignore_asset_host_on_complete_url + @controller.config.asset_host = "http://assets.example.com" + assert_dom_equal(%(<link href="http://bar.example.com/stylesheets/style.css" media="screen" rel="stylesheet" />), stylesheet_link_tag("http://bar.example.com/stylesheets/style.css")) + end + + def test_should_ignore_asset_host_on_scheme_relative_url + @controller.config.asset_host = "http://assets.example.com" + assert_dom_equal(%(<link href="//bar.example.com/stylesheets/style.css" media="screen" rel="stylesheet" />), stylesheet_link_tag("//bar.example.com/stylesheets/style.css")) + end + + def test_should_wildcard_asset_host + @controller.config.asset_host = "http://a%d.example.com" + assert_match(%r(http://a[0123]\.example\.com), compute_asset_host("foo")) + end + + def test_should_wildcard_asset_host_between_zero_and_four + @controller.config.asset_host = "http://a%d.example.com" + assert_match(%r(http://a[0123]\.example\.com/collaboration/hieraki/images/xml\.png), image_path("xml.png")) + assert_match(%r(http://a[0123]\.example\.com/collaboration/hieraki/images/xml\.png), image_url("xml.png")) + end + + def test_asset_host_without_protocol_should_be_protocol_relative + @controller.config.asset_host = "a.example.com" + assert_equal "gopher://a.example.com/collaboration/hieraki/images/xml.png", image_path("xml.png") + assert_equal "gopher://a.example.com/collaboration/hieraki/images/xml.png", image_url("xml.png") + end + + def test_asset_host_without_protocol_should_be_protocol_relative_even_if_path_present + @controller.config.asset_host = "a.example.com/files/go/here" + assert_equal "gopher://a.example.com/files/go/here/collaboration/hieraki/images/xml.png", image_path("xml.png") + assert_equal "gopher://a.example.com/files/go/here/collaboration/hieraki/images/xml.png", image_url("xml.png") + end + + def test_assert_css_and_js_of_the_same_name_return_correct_extension + assert_dom_equal(%(/collaboration/hieraki/javascripts/foo.js), javascript_path("foo")) + assert_dom_equal(%(/collaboration/hieraki/stylesheets/foo.css), stylesheet_path("foo")) + end +end + +class AssetTagHelperWithoutRequestTest < ActionView::TestCase + tests ActionView::Helpers::AssetTagHelper + + undef :request + + def test_stylesheet_link_tag_without_request + assert_dom_equal( + %(<link rel="stylesheet" media="screen" href="/stylesheets/foo.css" />), + stylesheet_link_tag("foo.css") + ) + end + + def test_javascript_include_tag_without_request + assert_dom_equal %(<script src="/javascripts/foo.js"></script>), javascript_include_tag("foo.js") + end +end + +class AssetUrlHelperControllerTest < ActionView::TestCase + tests ActionView::Helpers::AssetUrlHelper + + def setup + super + + @controller = BasicController.new + @controller.extend ActionView::Helpers::AssetUrlHelper + + @request = Class.new do + attr_accessor :script_name + def protocol() "http://" end + def ssl?() false end + def host_with_port() "www.example.com" end + def base_url() "http://www.example.com" end + end.new + + @controller.request = @request + end + + def test_asset_path + assert_equal "/foo", @controller.asset_path("foo") + end + + def test_asset_url + assert_equal "http://www.example.com/foo", @controller.asset_url("foo") + end +end + +class AssetUrlHelperEmptyModuleTest < ActionView::TestCase + tests ActionView::Helpers::AssetUrlHelper + + def setup + super + + @module = Module.new + @module.extend ActionView::Helpers::AssetUrlHelper + end + + def test_asset_path + assert_equal "/foo", @module.asset_path("foo") + end + + def test_asset_url + assert_equal "/foo", @module.asset_url("foo") + end + + def test_asset_url_with_request + @module.instance_eval do + def request + Struct.new(:base_url, :script_name).new("http://www.example.com", nil) + end + end + + assert @module.request + assert_equal "http://www.example.com/foo", @module.asset_url("foo") + end + + def test_asset_url_with_config_asset_host + @module.instance_eval do + def config + Struct.new(:asset_host).new("http://www.example.com") + end + end + + assert @module.config.asset_host + assert_equal "http://www.example.com/foo", @module.asset_url("foo") + end + + def test_asset_url_with_custom_asset_host + @module.instance_eval do + def config + Struct.new(:asset_host).new("http://www.example.com") + end + end + + assert @module.config.asset_host + assert_equal "http://custom.example.com/foo", @module.asset_url("foo", host: "http://custom.example.com") + end +end diff --git a/actionview/test/template/atom_feed_helper_test.rb b/actionview/test/template/atom_feed_helper_test.rb new file mode 100644 index 0000000000..8e683cb48a --- /dev/null +++ b/actionview/test/template/atom_feed_helper_test.rb @@ -0,0 +1,375 @@ +# frozen_string_literal: true + +require "abstract_unit" + +Scroll = Struct.new(:id, :to_param, :title, :body, :updated_at, :created_at) do + extend ActiveModel::Naming + include ActiveModel::Conversion + + def persisted? + false + end +end + +class ScrollsController < ActionController::Base + FEEDS = {} + FEEDS["defaults"] = <<-EOT + atom_feed(:schema_date => '2008') do |feed| + feed.title("My great blog!") + feed.updated(@scrolls.first.created_at) + + @scrolls.each do |scroll| + feed.entry(scroll) do |entry| + entry.title(scroll.title) + entry.content(scroll.body, :type => 'html') + + entry.author do |author| + author.name("DHH") + end + end + end + end + EOT + FEEDS["entry_options"] = <<-EOT + atom_feed do |feed| + feed.title("My great blog!") + feed.updated(@scrolls.first.created_at) + + @scrolls.each do |scroll| + feed.entry(scroll, :url => "/otherstuff/" + scroll.to_param.to_s, :updated => Time.utc(2007, 1, scroll.id)) do |entry| + entry.title(scroll.title) + entry.content(scroll.body, :type => 'html') + + entry.author do |author| + author.name("DHH") + end + end + end + end + EOT + FEEDS["entry_type_options"] = <<-EOT + atom_feed(:schema_date => '2008') do |feed| + feed.title("My great blog!") + feed.updated(@scrolls.first.created_at) + + @scrolls.each do |scroll| + feed.entry(scroll, :type => 'text/xml') do |entry| + entry.title(scroll.title) + entry.content(scroll.body, :type => 'html') + + entry.author do |author| + author.name("DHH") + end + end + end + end + EOT + FEEDS["entry_url_false_option"] = <<-EOT + atom_feed do |feed| + feed.title("My great blog!") + feed.updated(@scrolls.first.created_at) + + @scrolls.each do |scroll| + feed.entry(scroll, :url => false) do |entry| + entry.title(scroll.title) + entry.content(scroll.body, :type => 'html') + + entry.author do |author| + author.name("DHH") + end + end + end + end + EOT + FEEDS["xml_block"] = <<-EOT + atom_feed do |feed| + feed.title("My great blog!") + feed.updated(@scrolls.first.created_at) + + feed.author do |author| + author.name("DHH") + end + + @scrolls.each do |scroll| + feed.entry(scroll, :url => "/otherstuff/" + scroll.to_param.to_s, :updated => Time.utc(2007, 1, scroll.id)) do |entry| + entry.title(scroll.title) + entry.content(scroll.body, :type => 'html') + end + end + end + EOT + FEEDS["feed_with_atomPub_namespace"] = <<-EOT + atom_feed({'xmlns:app' => 'http://www.w3.org/2007/app', + 'xmlns:openSearch' => 'http://a9.com/-/spec/opensearch/1.1/'}) do |feed| + feed.title("My great blog!") + feed.updated(@scrolls.first.created_at) + + @scrolls.each do |scroll| + feed.entry(scroll) do |entry| + entry.title(scroll.title) + entry.content(scroll.body, :type => 'html') + entry.tag!('app:edited', Time.now) + + entry.author do |author| + author.name("DHH") + end + end + end + end + EOT + FEEDS["feed_with_overridden_ids"] = <<-EOT + atom_feed({:id => 'tag:test.rubyonrails.org,2008:test/'}) do |feed| + feed.title("My great blog!") + feed.updated(@scrolls.first.created_at) + + @scrolls.each do |scroll| + feed.entry(scroll, :id => "tag:test.rubyonrails.org,2008:"+scroll.id.to_s) do |entry| + entry.title(scroll.title) + entry.content(scroll.body, :type => 'html') + entry.tag!('app:edited', Time.now) + + entry.author do |author| + author.name("DHH") + end + end + end + end + EOT + FEEDS["feed_with_xml_processing_instructions"] = <<-EOT + atom_feed(:schema_date => '2008', + :instruct => {'xml-stylesheet' => { :href=> 't.css', :type => 'text/css' }}) do |feed| + feed.title("My great blog!") + feed.updated(@scrolls.first.created_at) + + @scrolls.each do |scroll| + feed.entry(scroll) do |entry| + entry.title(scroll.title) + entry.content(scroll.body, :type => 'html') + + entry.author do |author| + author.name("DHH") + end + end + end + end + EOT + FEEDS["feed_with_xml_processing_instructions_duplicate_targets"] = <<-EOT + atom_feed(:schema_date => '2008', + :instruct => {'target1' => [{ :a => '1', :b => '2' }, { :c => '3', :d => '4' }]}) do |feed| + feed.title("My great blog!") + feed.updated(@scrolls.first.created_at) + + @scrolls.each do |scroll| + feed.entry(scroll) do |entry| + entry.title(scroll.title) + entry.content(scroll.body, :type => 'html') + + entry.author do |author| + author.name("DHH") + end + end + end + end + EOT + FEEDS["feed_with_xhtml_content"] = <<-'EOT' + atom_feed do |feed| + feed.title("My great blog!") + feed.updated(@scrolls.first.created_at) + + @scrolls.each do |scroll| + feed.entry(scroll) do |entry| + entry.title(scroll.title) + entry.summary(:type => 'xhtml') do |xhtml| + xhtml.p "before #{scroll.id}" + xhtml.p {xhtml << scroll.body} + xhtml.p "after #{scroll.id}" + end + entry.tag!('app:edited', Time.now) + + entry.author do |author| + author.name("DHH") + end + end + end + end + EOT + FEEDS["provide_builder"] = <<-'EOT' + # we pass in the new_xml to the helper so it doesn't + # call anything on the original builder + new_xml = Builder::XmlMarkup.new(:target=>''.dup) + atom_feed(:xml => new_xml) do |feed| + feed.title("My great blog!") + feed.updated(@scrolls.first.created_at) + + @scrolls.each do |scroll| + feed.entry(scroll) do |entry| + entry.title(scroll.title) + entry.content(scroll.body, :type => 'html') + + entry.author do |author| + author.name("DHH") + end + end + end + end + EOT + def index + @scrolls = [ + Scroll.new(1, "1", "Hello One", "Something <i>COOL!</i>", Time.utc(2007, 12, 12, 15), Time.utc(2007, 12, 12, 15)), + Scroll.new(2, "2", "Hello Two", "Something Boring", Time.utc(2007, 12, 12, 15)), + ] + + render inline: FEEDS[params[:id]], type: :builder + end +end + +class AtomFeedTest < ActionController::TestCase + tests ScrollsController + + def setup + super + @request.host = "www.nextangle.com" + end + + def test_feed_should_use_default_language_if_none_is_given + with_restful_routing(:scrolls) do + get :index, params: { id: "defaults" } + assert_match(%r{xml:lang="en-US"}, @response.body) + end + end + + def test_feed_should_include_two_entries + with_restful_routing(:scrolls) do + get :index, params: { id: "defaults" } + assert_select "entry", 2 + end + end + + def test_entry_should_only_use_published_if_created_at_is_present + with_restful_routing(:scrolls) do + get :index, params: { id: "defaults" } + assert_select "published", 1 + end + end + + def test_providing_builder_to_atom_feed + with_restful_routing(:scrolls) do + get :index, params: { id: "provide_builder" } + # because we pass in the non-default builder, the content generated by the + # helper should go 'nowhere'. Leaving the response body blank. + assert_predicate @response.body, :blank? + end + end + + def test_entry_with_prefilled_options_should_use_those_instead_of_querying_the_record + with_restful_routing(:scrolls) do + get :index, params: { id: "entry_options" } + + assert_select "updated", Time.utc(2007, 1, 1).xmlschema + assert_select "updated", Time.utc(2007, 1, 2).xmlschema + end + end + + def test_self_url_should_default_to_current_request_url + with_restful_routing(:scrolls) do + get :index, params: { id: "defaults" } + assert_select "link[rel=self][href=\"http://www.nextangle.com/scrolls?id=defaults\"]" + end + end + + def test_feed_id_should_be_a_valid_tag + with_restful_routing(:scrolls) do + get :index, params: { id: "defaults" } + assert_select "id", text: "tag:www.nextangle.com,2008:/scrolls?id=defaults" + end + end + + def test_entry_id_should_be_a_valid_tag + with_restful_routing(:scrolls) do + get :index, params: { id: "defaults" } + assert_select "entry id", text: "tag:www.nextangle.com,2008:Scroll/1" + assert_select "entry id", text: "tag:www.nextangle.com,2008:Scroll/2" + end + end + + def test_feed_should_allow_nested_xml_blocks + with_restful_routing(:scrolls) do + get :index, params: { id: "xml_block" } + assert_select "author name", text: "DHH" + end + end + + def test_feed_should_include_atomPub_namespace + with_restful_routing(:scrolls) do + get :index, params: { id: "feed_with_atomPub_namespace" } + assert_match %r{xml:lang="en-US"}, @response.body + assert_match %r{xmlns="http://www\.w3\.org/2005/Atom"}, @response.body + assert_match %r{xmlns:app="http://www\.w3\.org/2007/app"}, @response.body + end + end + + def test_feed_should_allow_overriding_ids + with_restful_routing(:scrolls) do + get :index, params: { id: "feed_with_overridden_ids" } + assert_select "id", text: "tag:test.rubyonrails.org,2008:test/" + assert_select "entry id", text: "tag:test.rubyonrails.org,2008:1" + assert_select "entry id", text: "tag:test.rubyonrails.org,2008:2" + end + end + + def test_feed_xml_processing_instructions + with_restful_routing(:scrolls) do + get :index, params: { id: "feed_with_xml_processing_instructions" } + assert_match %r{<\?xml-stylesheet [^\?]*type="text/css"}, @response.body + assert_match %r{<\?xml-stylesheet [^\?]*href="t\.css"}, @response.body + end + end + + def test_feed_xml_processing_instructions_duplicate_targets + with_restful_routing(:scrolls) do + get :index, params: { id: "feed_with_xml_processing_instructions_duplicate_targets" } + assert_match %r{<\?target1 (a="1" b="2"|b="2" a="1")\?>}, @response.body + assert_match %r{<\?target1 (c="3" d="4"|d="4" c="3")\?>}, @response.body + end + end + + def test_feed_xhtml + with_restful_routing(:scrolls) do + get :index, params: { id: "feed_with_xhtml_content" } + assert_match %r{xmlns="http://www\.w3\.org/1999/xhtml"}, @response.body + assert_select "summary", text: /Something Boring/ + assert_select "summary", text: /after 2/ + end + end + + def test_feed_entry_type_option_default_to_text_html + with_restful_routing(:scrolls) do + get :index, params: { id: "defaults" } + assert_select "entry link[rel=alternate][type=\"text/html\"]" + end + end + + def test_feed_entry_type_option_specified + with_restful_routing(:scrolls) do + get :index, params: { id: "entry_type_options" } + assert_select "entry link[rel=alternate][type=\"text/xml\"]" + end + end + + def test_feed_entry_url_false_option_adds_no_link + with_restful_routing(:scrolls) do + get :index, params: { id: "entry_url_false_option" } + assert_select "entry link", false + end + end + + private + def with_restful_routing(resources) + with_routing do |set| + set.draw do + resources(resources) + end + yield + end + end +end diff --git a/actionview/test/template/capture_helper_test.rb b/actionview/test/template/capture_helper_test.rb new file mode 100644 index 0000000000..e172497c88 --- /dev/null +++ b/actionview/test/template/capture_helper_test.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class CaptureHelperTest < ActionView::TestCase + def setup + super + @av = ActionView::Base.new + @view_flow = ActionView::OutputFlow.new + end + + def test_capture_captures_the_temporary_output_buffer_in_its_block + assert_nil @av.output_buffer + string = @av.capture do + @av.output_buffer << "foo" + @av.output_buffer << "bar" + end + assert_nil @av.output_buffer + assert_equal "foobar", string + end + + def test_capture_captures_the_value_returned_by_the_block_if_the_temporary_buffer_is_blank + string = @av.capture("foo", "bar") do |a, b| + a + b + end + assert_equal "foobar", string + end + + def test_capture_returns_nil_if_the_returned_value_is_not_a_string + assert_nil @av.capture { 1 } + end + + def test_capture_escapes_html + string = @av.capture { "<em>bar</em>" } + assert_equal "<em>bar</em>", string + end + + def test_capture_doesnt_escape_twice + string = @av.capture { raw("<em>bar</em>") } + assert_equal "<em>bar</em>", string + end + + def test_content_for_used_for_read + content_for :foo, "foo" + assert_equal "foo", content_for(:foo) + + content_for(:bar) { "bar" } + assert_equal "bar", content_for(:bar) + end + + def test_content_for_with_multiple_calls + assert_not content_for?(:title) + content_for :title, "foo" + content_for :title, "bar" + assert_equal "foobar", content_for(:title) + end + + def test_content_for_with_multiple_calls_and_flush + assert_not content_for?(:title) + content_for :title, "foo" + content_for :title, "bar", flush: true + assert_equal "bar", content_for(:title) + end + + def test_content_for_with_block + assert_not content_for?(:title) + content_for :title do + output_buffer << "foo" + output_buffer << "bar" + nil + end + assert_equal "foobar", content_for(:title) + end + + def test_content_for_with_block_and_multiple_calls_with_flush + assert_not content_for?(:title) + content_for :title do + "foo" + end + content_for :title, flush: true do + "bar" + end + assert_equal "bar", content_for(:title) + end + + def test_content_for_with_block_and_multiple_calls_with_flush_nil_content + assert_not content_for?(:title) + content_for :title do + "foo" + end + content_for :title, nil, flush: true do + "bar" + end + assert_equal "bar", content_for(:title) + end + + def test_content_for_with_block_and_multiple_calls_without_flush + assert_not content_for?(:title) + content_for :title do + "foo" + end + content_for :title, flush: false do + "bar" + end + assert_equal "foobar", content_for(:title) + end + + def test_content_for_with_whitespace_block + assert_not content_for?(:title) + content_for :title, "foo" + content_for :title do + output_buffer << " \n " + nil + end + content_for :title, "bar" + assert_equal "foobar", content_for(:title) + end + + def test_content_for_with_whitespace_block_and_flush + assert_not content_for?(:title) + content_for :title, "foo" + content_for :title, flush: true do + output_buffer << " \n " + nil + end + content_for :title, "bar", flush: true + assert_equal "bar", content_for(:title) + end + + def test_content_for_returns_nil_when_writing + assert_not content_for?(:title) + assert_nil content_for(:title, "foo") + assert_nil content_for(:title) { output_buffer << "bar"; nil } + assert_nil content_for(:title) { output_buffer << " \n "; nil } + assert_equal "foobar", content_for(:title) + assert_nil content_for(:title, "foo", flush: true) + assert_nil content_for(:title, flush: true) { output_buffer << "bar"; nil } + assert_nil content_for(:title, flush: true) { output_buffer << " \n "; nil } + assert_equal "bar", content_for(:title) + end + + def test_content_for_returns_nil_when_content_missing + assert_nil content_for(:some_missing_key) + end + + def test_content_for_question_mark + assert_not content_for?(:title) + content_for :title, "title" + assert content_for?(:title) + assert_not content_for?(:something_else) + end + + def test_content_for_should_be_html_safe_after_flush_empty + assert_not content_for?(:title) + content_for :title do + content_tag(:p, "title") + end + assert_predicate content_for(:title), :html_safe? + content_for :title, "", flush: true + content_for(:title) do + content_tag(:p, "title") + end + assert_predicate content_for(:title), :html_safe? + end + + def test_provide + assert_not content_for?(:title) + provide :title, "hi" + assert content_for?(:title) + assert_equal "hi", content_for(:title) + provide :title, "<p>title</p>" + assert_equal "hi<p>title</p>", content_for(:title) + + @view_flow = ActionView::OutputFlow.new + provide :title, "hi" + provide :title, raw("<p>title</p>") + assert_equal "hi<p>title</p>", content_for(:title) + end + + def test_with_output_buffer_swaps_the_output_buffer_given_no_argument + assert_nil @av.output_buffer + buffer = @av.with_output_buffer do + @av.output_buffer << "." + end + assert_equal ".", buffer + assert_nil @av.output_buffer + end + + def test_with_output_buffer_swaps_the_output_buffer_with_an_argument + assert_nil @av.output_buffer + buffer = ActionView::OutputBuffer.new(".") + @av.with_output_buffer(buffer) do + @av.output_buffer << "." + end + assert_equal "..", buffer + assert_nil @av.output_buffer + end + + def test_with_output_buffer_restores_the_output_buffer + buffer = ActionView::OutputBuffer.new + @av.output_buffer = buffer + @av.with_output_buffer do + @av.output_buffer << "." + end + assert buffer.equal?(@av.output_buffer) + end + + def test_with_output_buffer_sets_proper_encoding + @av.output_buffer = ActionView::OutputBuffer.new + + # Ensure we set the output buffer to an encoding different than the default one. + alt_encoding = alt_encoding(@av.output_buffer) + @av.output_buffer.force_encoding(alt_encoding) + + @av.with_output_buffer do + assert_equal alt_encoding, @av.output_buffer.encoding + end + end + + def test_with_output_buffer_does_not_assume_there_is_an_output_buffer + assert_nil @av.output_buffer + assert_equal "", @av.with_output_buffer { } + end + + def alt_encoding(output_buffer) + output_buffer.encoding == Encoding::US_ASCII ? Encoding::UTF_8 : Encoding::US_ASCII + end +end diff --git a/actionview/test/template/compiled_templates_test.rb b/actionview/test/template/compiled_templates_test.rb new file mode 100644 index 0000000000..3cd6448e38 --- /dev/null +++ b/actionview/test/template/compiled_templates_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class CompiledTemplatesTest < ActiveSupport::TestCase + teardown do + ActionView::LookupContext::DetailsKey.clear + end + + def test_template_with_nil_erb_return + assert_equal "This is nil: \n", render(template: "test/nil_return") + end + + def test_template_with_ruby_keyword_locals + assert_equal "The class is foo", + render(file: "test/render_file_with_ruby_keyword_locals", locals: { class: "foo" }) + end + + def test_template_with_invalid_identifier_locals + locals = { + foo: "bar", + Foo: "bar", + "d-a-s-h-e-s": "", + "white space": "", + } + 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 + + def test_template_with_instance_variable_identifier + assert_equal "bar", render(file: "test/render_file_instance_variable", locals: { "@foo": "bar" }) + end + + def test_template_gets_recompiled_when_using_different_keys_in_local_assigns + assert_equal "one", render(file: "test/render_file_with_locals_and_default") + assert_equal "two", render(file: "test/render_file_with_locals_and_default", locals: { secret: "two" }) + end + + def test_template_changes_are_not_reflected_with_cached_templates + assert_equal "Hello world!", render(file: "test/hello_world") + modify_template "test/hello_world.erb", "Goodbye world!" do + assert_equal "Hello world!", render(file: "test/hello_world") + end + assert_equal "Hello world!", render(file: "test/hello_world") + end + + def test_template_changes_are_reflected_with_uncached_templates + assert_equal "Hello world!", render_without_cache(file: "test/hello_world") + modify_template "test/hello_world.erb", "Goodbye world!" do + assert_equal "Goodbye world!", render_without_cache(file: "test/hello_world") + end + assert_equal "Hello world!", render_without_cache(file: "test/hello_world") + end + + private + def render(*args) + render_with_cache(*args) + end + + def render_with_cache(*args) + view_paths = ActionController::Base.view_paths + ActionView::Base.new(view_paths, {}).render(*args) + end + + def render_without_cache(*args) + path = ActionView::FileSystemResolver.new(FIXTURE_LOAD_PATH) + view_paths = ActionView::PathSet.new([path]) + ActionView::Base.new(view_paths, {}).render(*args) + end + + def modify_template(template, content) + filename = "#{FIXTURE_LOAD_PATH}/#{template}" + old_content = File.read(filename) + begin + File.open(filename, "wb+") { |f| f.write(content) } + yield + ensure + File.open(filename, "wb+") { |f| f.write(old_content) } + end + end +end diff --git a/actionview/test/template/controller_helper_test.rb b/actionview/test/template/controller_helper_test.rb new file mode 100644 index 0000000000..46d20c188c --- /dev/null +++ b/actionview/test/template/controller_helper_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class ControllerHelperTest < ActionView::TestCase + tests ActionView::Helpers::ControllerHelper + + class SpecializedFormBuilder < ActionView::Helpers::FormBuilder ; end + + def test_assign_controller_sets_default_form_builder + @controller = OpenStruct.new(default_form_builder: SpecializedFormBuilder) + assign_controller(@controller) + + assert_equal SpecializedFormBuilder, default_form_builder + end + + def test_assign_controller_skips_default_form_builder + @controller = OpenStruct.new + assign_controller(@controller) + + assert_nil default_form_builder + end + + def test_respond_to + @controller = OpenStruct.new + assign_controller(@controller) + assert_not respond_to?(:params) + assert respond_to?(:assign_controller) + + @controller.params = {} + assert respond_to?(:params) + assert respond_to?(:assign_controller) + end +end diff --git a/actionview/test/template/date_helper_i18n_test.rb b/actionview/test/template/date_helper_i18n_test.rb new file mode 100644 index 0000000000..60303b4c91 --- /dev/null +++ b/actionview/test/template/date_helper_i18n_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class DateHelperDistanceOfTimeInWordsI18nTests < ActiveSupport::TestCase + include ActionView::Helpers::DateHelper + attr_reader :request + + def setup + @from = Time.utc(2004, 6, 6, 21, 45, 0) + end + + # distance_of_time_in_words + + def test_distance_of_time_in_words_calls_i18n + { # with include_seconds + [2.seconds, { include_seconds: true }] => [:'less_than_x_seconds', 5], + [9.seconds, { include_seconds: true }] => [:'less_than_x_seconds', 10], + [19.seconds, { include_seconds: true }] => [:'less_than_x_seconds', 20], + [30.seconds, { include_seconds: true }] => [:'half_a_minute', nil], + [59.seconds, { include_seconds: true }] => [:'less_than_x_minutes', 1], + [60.seconds, { include_seconds: true }] => [:'x_minutes', 1], + + # without include_seconds + [29.seconds, { include_seconds: false }] => [:'less_than_x_minutes', 1], + [60.seconds, { include_seconds: false }] => [:'x_minutes', 1], + [44.minutes, { include_seconds: false }] => [:'x_minutes', 44], + [61.minutes, { include_seconds: false }] => [:'about_x_hours', 1], + [24.hours, { include_seconds: false }] => [:'x_days', 1], + [30.days, { include_seconds: false }] => [:'about_x_months', 1], + [60.days, { include_seconds: false }] => [:'x_months', 2], + [1.year, { include_seconds: false }] => [:'about_x_years', 1], + [3.years + 6.months, { include_seconds: false }] => [:'over_x_years', 3], + [3.years + 10.months, { include_seconds: false }] => [:'almost_x_years', 4] + + }.each do |passed, expected| + assert_distance_of_time_in_words_translates_key passed, expected + end + end + + def test_distance_of_time_in_words_calls_i18n_with_custom_scope + { + [30.days, { scope: :'datetime.distance_in_words_ago' }] => [:'about_x_months', 1], + [60.days, { scope: :'datetime.distance_in_words_ago' }] => [:'x_months', 2], + }.each do |passed, expected| + assert_distance_of_time_in_words_translates_key(passed, expected, scope: :'datetime.distance_in_words_ago') + end + end + + def test_time_ago_in_words_passes_locale + assert_called_with(I18n, :t, [:less_than_x_minutes, scope: :'datetime.distance_in_words', count: 1, locale: "ru"]) do + time_ago_in_words(15.seconds.ago, locale: "ru") + end + end + + def test_distance_of_time_pluralizations + { [:'less_than_x_seconds', 1] => "less than 1 second", + [:'less_than_x_seconds', 2] => "less than 2 seconds", + [:'less_than_x_minutes', 1] => "less than a minute", + [:'less_than_x_minutes', 2] => "less than 2 minutes", + [:'x_minutes', 1] => "1 minute", + [:'x_minutes', 2] => "2 minutes", + [:'about_x_hours', 1] => "about 1 hour", + [:'about_x_hours', 2] => "about 2 hours", + [:'x_days', 1] => "1 day", + [:'x_days', 2] => "2 days", + [:'about_x_years', 1] => "about 1 year", + [:'about_x_years', 2] => "about 2 years", + [:'over_x_years', 1] => "over 1 year", + [:'over_x_years', 2] => "over 2 years" + + }.each do |args, expected| + key, count = *args + assert_equal expected, I18n.t(key, count: count, scope: "datetime.distance_in_words") + end + end + + def assert_distance_of_time_in_words_translates_key(passed, expected, expected_options = {}) + diff, passed_options = *passed + key, count = *expected + to = @from + diff + + options = { locale: "en", scope: :'datetime.distance_in_words' }.merge!(expected_options) + options[:count] = count if count + + assert_called_with(I18n, :t, [key, options]) do + distance_of_time_in_words(@from, to, passed_options.merge(locale: "en")) + end + end +end + +class DateHelperSelectTagsI18nTests < ActiveSupport::TestCase + include ActionView::Helpers::DateHelper + attr_reader :request + + # select_month + + def test_select_month_given_use_month_names_option_does_not_translate_monthnames + assert_not_called(I18n, :translate) do + select_month(8, locale: "en", use_month_names: Date::MONTHNAMES) + end + end + + def test_select_month_translates_monthnames + assert_called_with(I18n, :translate, [:'date.month_names', locale: "en"], returns: Date::MONTHNAMES) do + select_month(8, locale: "en") + end + end + + def test_select_month_given_use_short_month_option_translates_abbr_monthnames + assert_called_with(I18n, :translate, [:'date.abbr_month_names', locale: "en"], returns: Date::ABBR_MONTHNAMES) do + select_month(8, locale: "en", use_short_month: true) + end + end + + def test_date_or_time_select_translates_prompts + prompt_defaults = { year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", second: "Seconds" } + defaults = { [:'date.order', locale: "en", default: []] => %w(year month day) } + + prompt_defaults.each do |key, prompt| + defaults[[("datetime.prompts." + key.to_s).to_sym, locale: "en"]] = prompt + end + + prompts_check = -> (prompt, x) do + @prompt_called ||= 0 + + return_value = defaults[[prompt, x]] + @prompt_called += 1 if return_value.present? + + return_value + end + + I18n.stub(:translate, prompts_check) do + datetime_select("post", "updated_at", locale: "en", include_seconds: true, prompt: true, use_month_names: Date::MONTHNAMES) + end + assert_equal defaults.count, @prompt_called + end + + # date_or_time_select + + def test_date_or_time_select_given_an_order_options_does_not_translate_order + assert_not_called(I18n, :translate) do + datetime_select("post", "updated_at", order: [:year, :month, :day], locale: "en", use_month_names: Date::MONTHNAMES) + end + end + + def test_date_or_time_select_given_no_order_options_translates_order + assert_called_with(I18n, :translate, [ [:'date.order', locale: "en", default: []], [:"date.month_names", { locale: "en" }] ], returns: %w(year month day)) do + datetime_select("post", "updated_at", locale: "en") + end + end + + def test_date_or_time_select_given_invalid_order + assert_called_with(I18n, :translate, [:'date.order', locale: "en", default: []], returns: %w(invalid month day)) do + assert_raise StandardError do + datetime_select("post", "updated_at", locale: "en") + end + end + end + + def test_date_or_time_select_given_symbol_keys + assert_called_with(I18n, :translate, [ [:'date.order', locale: "en", default: []], [:"date.month_names", { locale: "en" }] ], returns: [:year, :month, :day]) do + datetime_select("post", "updated_at", locale: "en") + end + end +end diff --git a/actionview/test/template/date_helper_test.rb b/actionview/test/template/date_helper_test.rb new file mode 100644 index 0000000000..0a294ec674 --- /dev/null +++ b/actionview/test/template/date_helper_test.rb @@ -0,0 +1,3649 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class DateHelperTest < ActionView::TestCase + tests ActionView::Helpers::DateHelper + + silence_warnings do + Post = Struct.new("Post", :id, :written_on, :updated_at) + Post.class_eval do + def id + 123 + end + def id_before_type_cast + 123 + end + def to_param + "123" + end + end + end + + def assert_distance_of_time_in_words(from, to = nil) + to ||= from + + # 0..1 minute with :include_seconds => true + assert_equal "less than 5 seconds", distance_of_time_in_words(from, to + 0.seconds, include_seconds: true) + assert_equal "less than 5 seconds", distance_of_time_in_words(from, to + 4.seconds, include_seconds: true) + assert_equal "less than 10 seconds", distance_of_time_in_words(from, to + 5.seconds, include_seconds: true) + assert_equal "less than 10 seconds", distance_of_time_in_words(from, to + 9.seconds, include_seconds: true) + assert_equal "less than 20 seconds", distance_of_time_in_words(from, to + 10.seconds, include_seconds: true) + assert_equal "less than 20 seconds", distance_of_time_in_words(from, to + 19.seconds, include_seconds: true) + assert_equal "half a minute", distance_of_time_in_words(from, to + 20.seconds, include_seconds: true) + assert_equal "half a minute", distance_of_time_in_words(from, to + 39.seconds, include_seconds: true) + assert_equal "less than a minute", distance_of_time_in_words(from, to + 40.seconds, include_seconds: true) + assert_equal "less than a minute", distance_of_time_in_words(from, to + 59.seconds, include_seconds: true) + assert_equal "1 minute", distance_of_time_in_words(from, to + 60.seconds, include_seconds: true) + assert_equal "1 minute", distance_of_time_in_words(from, to + 89.seconds, include_seconds: true) + + # 0..1 minute with :include_seconds => false + assert_equal "less than a minute", distance_of_time_in_words(from, to + 0.seconds, include_seconds: false) + assert_equal "less than a minute", distance_of_time_in_words(from, to + 4.seconds, include_seconds: false) + assert_equal "less than a minute", distance_of_time_in_words(from, to + 5.seconds, include_seconds: false) + assert_equal "less than a minute", distance_of_time_in_words(from, to + 9.seconds, include_seconds: false) + assert_equal "less than a minute", distance_of_time_in_words(from, to + 10.seconds, include_seconds: false) + assert_equal "less than a minute", distance_of_time_in_words(from, to + 19.seconds, include_seconds: false) + assert_equal "less than a minute", distance_of_time_in_words(from, to + 20.seconds, include_seconds: false) + assert_equal "1 minute", distance_of_time_in_words(from, to + 39.seconds, include_seconds: false) + assert_equal "1 minute", distance_of_time_in_words(from, to + 40.seconds, include_seconds: false) + assert_equal "1 minute", distance_of_time_in_words(from, to + 59.seconds, include_seconds: false) + assert_equal "1 minute", distance_of_time_in_words(from, to + 60.seconds, include_seconds: false) + assert_equal "1 minute", distance_of_time_in_words(from, to + 89.seconds, include_seconds: false) + + # Note that we are including a 30-second boundary around the interval we + # want to test. For instance, "1 minute" is actually 30s to 1m29s. The + # reason for doing this is simple -- in `distance_of_time_to_words`, when we + # take the distance between our two Time objects in seconds and convert it + # to minutes, we round the number. So 29s gets rounded down to 0m, 30s gets + # rounded up to 1m, and 1m29s gets rounded down to 1m. A similar thing + # happens with the other cases. + + # First case 0..1 minute + assert_equal "less than a minute", distance_of_time_in_words(from, to + 0.seconds) + assert_equal "less than a minute", distance_of_time_in_words(from, to + 29.seconds) + assert_equal "1 minute", distance_of_time_in_words(from, to + 30.seconds) + assert_equal "1 minute", distance_of_time_in_words(from, to + 1.minutes + 29.seconds) + + # 2 minutes up to 45 minutes + assert_equal "2 minutes", distance_of_time_in_words(from, to + 1.minutes + 30.seconds) + assert_equal "44 minutes", distance_of_time_in_words(from, to + 44.minutes + 29.seconds) + + # 45 minutes up to 90 minutes + assert_equal "about 1 hour", distance_of_time_in_words(from, to + 44.minutes + 30.seconds) + assert_equal "about 1 hour", distance_of_time_in_words(from, to + 89.minutes + 29.seconds) + + # 90 minutes up to 24 hours + assert_equal "about 2 hours", distance_of_time_in_words(from, to + 89.minutes + 30.seconds) + assert_equal "about 24 hours", distance_of_time_in_words(from, to + 23.hours + 59.minutes + 29.seconds) + + # 24 hours up to 42 hours + assert_equal "1 day", distance_of_time_in_words(from, to + 23.hours + 59.minutes + 30.seconds) + assert_equal "1 day", distance_of_time_in_words(from, to + 41.hours + 59.minutes + 29.seconds) + + # 42 hours up to 30 days + assert_equal "2 days", distance_of_time_in_words(from, to + 41.hours + 59.minutes + 30.seconds) + assert_equal "3 days", distance_of_time_in_words(from, to + 2.days + 12.hours) + assert_equal "30 days", distance_of_time_in_words(from, to + 29.days + 23.hours + 59.minutes + 29.seconds) + + # 30 days up to 60 days + assert_equal "about 1 month", distance_of_time_in_words(from, to + 29.days + 23.hours + 59.minutes + 30.seconds) + assert_equal "about 1 month", distance_of_time_in_words(from, to + 44.days + 23.hours + 59.minutes + 29.seconds) + assert_equal "about 2 months", distance_of_time_in_words(from, to + 44.days + 23.hours + 59.minutes + 30.seconds) + assert_equal "about 2 months", distance_of_time_in_words(from, to + 59.days + 23.hours + 59.minutes + 29.seconds) + + # 60 days up to 365 days + assert_equal "2 months", distance_of_time_in_words(from, to + 59.days + 23.hours + 59.minutes + 30.seconds) + assert_equal "12 months", distance_of_time_in_words(from, to + 1.years - 31.seconds) + + # >= 365 days + assert_equal "about 1 year", distance_of_time_in_words(from, to + 1.years - 30.seconds) + assert_equal "about 1 year", distance_of_time_in_words(from, to + 1.years + 3.months - 1.day) + assert_equal "over 1 year", distance_of_time_in_words(from, to + 1.years + 6.months) + + assert_equal "almost 2 years", distance_of_time_in_words(from, to + 2.years - 3.months + 1.day) + assert_equal "about 2 years", distance_of_time_in_words(from, to + 2.years + 3.months - 1.day) + assert_equal "over 2 years", distance_of_time_in_words(from, to + 2.years + 3.months + 1.day) + assert_equal "over 2 years", distance_of_time_in_words(from, to + 2.years + 9.months - 1.day) + assert_equal "almost 3 years", distance_of_time_in_words(from, to + 2.years + 9.months + 1.day) + + assert_equal "almost 5 years", distance_of_time_in_words(from, to + 5.years - 3.months + 1.day) + assert_equal "about 5 years", distance_of_time_in_words(from, to + 5.years + 3.months - 1.day) + assert_equal "over 5 years", distance_of_time_in_words(from, to + 5.years + 3.months + 1.day) + assert_equal "over 5 years", distance_of_time_in_words(from, to + 5.years + 9.months - 1.day) + assert_equal "almost 6 years", distance_of_time_in_words(from, to + 5.years + 9.months + 1.day) + + assert_equal "almost 10 years", distance_of_time_in_words(from, to + 10.years - 3.months + 1.day) + assert_equal "about 10 years", distance_of_time_in_words(from, to + 10.years + 3.months - 1.day) + assert_equal "over 10 years", distance_of_time_in_words(from, to + 10.years + 3.months + 1.day) + assert_equal "over 10 years", distance_of_time_in_words(from, to + 10.years + 9.months - 1.day) + assert_equal "almost 11 years", distance_of_time_in_words(from, to + 10.years + 9.months + 1.day) + + # test to < from + assert_equal "about 4 hours", distance_of_time_in_words(from + 4.hours, to) + assert_equal "less than 20 seconds", distance_of_time_in_words(from + 19.seconds, to, include_seconds: true) + assert_equal "less than a minute", distance_of_time_in_words(from + 19.seconds, to, include_seconds: false) + end + + def test_distance_in_words + from = Time.utc(2004, 6, 6, 21, 45, 0) + assert_distance_of_time_in_words(from) + end + + def test_distance_in_words_with_nil_input + assert_raises(ArgumentError) { distance_of_time_in_words(nil) } + assert_raises(ArgumentError) { distance_of_time_in_words(0, nil) } + end + + def test_distance_in_words_with_mixed_argument_types + assert_equal "1 minute", distance_of_time_in_words(0, Time.at(60)) + assert_equal "10 minutes", distance_of_time_in_words(Time.at(600), 0) + end + + def test_distance_in_words_doesnt_use_the_quotient_operator + rubinius_skip "Date is written in Ruby and relies on Integer#/" + jruby_skip "Date is written in Ruby and relies on Integer#/" + + # Make sure that we avoid Integer#/ (redefined by mathn) + Integer.send :private, :/ + + from = Time.utc(2004, 6, 6, 21, 45, 0) + assert_distance_of_time_in_words(from) + ensure + Integer.send :public, :/ + end + + def test_time_ago_in_words_passes_include_seconds + assert_equal "less than 20 seconds", time_ago_in_words(15.seconds.ago, include_seconds: true) + assert_equal "less than a minute", time_ago_in_words(15.seconds.ago, include_seconds: false) + end + + def test_distance_in_words_with_time_zones + from = Time.mktime(2004, 6, 6, 21, 45, 0) + assert_distance_of_time_in_words(from.in_time_zone("Alaska")) + assert_distance_of_time_in_words(from.in_time_zone("Hawaii")) + end + + def test_distance_in_words_with_different_time_zones + from = Time.mktime(2004, 6, 6, 21, 45, 0) + assert_distance_of_time_in_words( + from.in_time_zone("Alaska"), + from.in_time_zone("Hawaii") + ) + end + + def test_distance_in_words_with_dates + start_date = Date.new 1975, 1, 31 + end_date = Date.new 1977, 1, 31 + assert_equal("about 2 years", distance_of_time_in_words(start_date, end_date)) + + start_date = Date.new 1982, 12, 3 + end_date = Date.new 2010, 11, 30 + assert_equal("almost 28 years", distance_of_time_in_words(start_date, end_date)) + assert_equal("almost 28 years", distance_of_time_in_words(end_date, start_date)) + end + + def test_distance_in_words_with_integers + assert_equal "1 minute", distance_of_time_in_words(59) + assert_equal "about 1 hour", distance_of_time_in_words(60 * 60) + assert_equal "1 minute", distance_of_time_in_words(0, 59) + assert_equal "about 1 hour", distance_of_time_in_words(60 * 60, 0) + assert_equal "about 3 years", distance_of_time_in_words(10**8) + assert_equal "about 3 years", distance_of_time_in_words(0, 10**8) + end + + def test_distance_in_words_with_times + assert_equal "1 minute", distance_of_time_in_words(30.seconds) + assert_equal "1 minute", distance_of_time_in_words(59.seconds) + assert_equal "2 minutes", distance_of_time_in_words(119.seconds) + assert_equal "2 minutes", distance_of_time_in_words(1.minute + 59.seconds) + assert_equal "3 minutes", distance_of_time_in_words(2.minute + 30.seconds) + assert_equal "44 minutes", distance_of_time_in_words(44.minutes + 29.seconds) + assert_equal "about 1 hour", distance_of_time_in_words(44.minutes + 30.seconds) + assert_equal "about 1 hour", distance_of_time_in_words(60.minutes) + + # include seconds + assert_equal "half a minute", distance_of_time_in_words(39.seconds, 0, include_seconds: true) + assert_equal "less than a minute", distance_of_time_in_words(40.seconds, 0, include_seconds: true) + assert_equal "less than a minute", distance_of_time_in_words(59.seconds, 0, include_seconds: true) + assert_equal "1 minute", distance_of_time_in_words(60.seconds, 0, include_seconds: true) + end + + def test_time_ago_in_words + assert_equal "about 1 year", time_ago_in_words(1.year.ago - 1.day) + end + + def test_select_day + expected = +%(<select id="date_day" name="date[day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_day(Time.mktime(2003, 8, 16)) + assert_dom_equal expected, select_day(16) + end + + def test_select_day_with_blank + expected = +%(<select id="date_day" name="date[day]">\n) + expected << %(<option value=""></option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_day(Time.mktime(2003, 8, 16), include_blank: true) + assert_dom_equal expected, select_day(16, include_blank: true) + end + + def test_select_day_nil_with_blank + expected = +%(<select id="date_day" name="date[day]">\n) + expected << %(<option value=""></option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_day(nil, include_blank: true) + end + + def test_select_day_with_two_digit_numbers + expected = +%(<select id="date_day" name="date[day]">\n) + expected << %(<option value="1">01</option>\n<option selected="selected" value="2">02</option>\n<option value="3">03</option>\n<option value="4">04</option>\n<option value="5">05</option>\n<option value="6">06</option>\n<option value="7">07</option>\n<option value="8">08</option>\n<option value="9">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_day(Time.mktime(2011, 8, 2), use_two_digit_numbers: true) + assert_dom_equal expected, select_day(2, use_two_digit_numbers: true) + end + + def test_select_day_with_html_options + expected = +%(<select id="date_day" name="date[day]" class="selector">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_day(Time.mktime(2003, 8, 16), {}, { class: "selector" }) + assert_dom_equal expected, select_day(16, {}, { class: "selector" }) + end + + def test_select_day_with_default_prompt + expected = +%(<select id="date_day" name="date[day]">\n) + expected << %(<option value="">Day</option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_day(16, prompt: true) + end + + def test_select_day_with_custom_prompt + expected = +%(<select id="date_day" name="date[day]">\n) + expected << %(<option value="">Choose day</option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_day(16, prompt: "Choose day") + end + + def test_select_day_with_generic_with_css_classes + expected = +%(<select id="date_day" name="date[day]" class="day">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_day(16, with_css_classes: true) + end + + def test_select_day_with_custom_with_css_classes + expected = +%(<select id="date_day" name="date[day]" class="my-day">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_day(16, with_css_classes: { day: "my-day" }) + end + + def test_select_month + expected = +%(<select id="date_month" name="date[month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16)) + assert_dom_equal expected, select_month(8) + end + + def test_select_month_with_two_digit_numbers + expected = +%(<select id="date_month" name="date[month]">\n) + expected << %(<option value="1">01</option>\n<option value="2">02</option>\n<option value="3">03</option>\n<option value="4">04</option>\n<option value="5">05</option>\n<option value="6">06</option>\n<option value="7">07</option>\n<option value="8" selected="selected">08</option>\n<option value="9">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2011, 8, 16), use_two_digit_numbers: true) + assert_dom_equal expected, select_month(8, use_two_digit_numbers: true) + end + + def test_select_month_with_disabled + expected = +%(<select id="date_month" name="date[month]" disabled="disabled">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16), disabled: true) + assert_dom_equal expected, select_month(8, disabled: true) + end + + def test_select_month_with_field_name_override + expected = +%(<select id="date_mois" name="date[mois]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16), field_name: "mois") + assert_dom_equal expected, select_month(8, field_name: "mois") + end + + def test_select_month_with_blank + expected = +%(<select id="date_month" name="date[month]">\n) + expected << %(<option value=""></option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16), include_blank: true) + assert_dom_equal expected, select_month(8, include_blank: true) + end + + def test_select_month_nil_with_blank + expected = +%(<select id="date_month" name="date[month]">\n) + expected << %(<option value=""></option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(nil, include_blank: true) + end + + def test_select_month_with_numbers + expected = +%(<select id="date_month" name="date[month]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8" selected="selected">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16), use_month_numbers: true) + assert_dom_equal expected, select_month(8, use_month_numbers: true) + end + + def test_select_month_with_numbers_and_names + expected = +%(<select id="date_month" name="date[month]">\n) + expected << %(<option value="1">1 - January</option>\n<option value="2">2 - February</option>\n<option value="3">3 - March</option>\n<option value="4">4 - April</option>\n<option value="5">5 - May</option>\n<option value="6">6 - June</option>\n<option value="7">7 - July</option>\n<option value="8" selected="selected">8 - August</option>\n<option value="9">9 - September</option>\n<option value="10">10 - October</option>\n<option value="11">11 - November</option>\n<option value="12">12 - December</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16), add_month_numbers: true) + assert_dom_equal expected, select_month(8, add_month_numbers: true) + end + + def test_select_month_with_format_string + expected = +%(<select id="date_month" name="date[month]">\n) + expected << %(<option value="1">January (01)</option>\n<option value="2">February (02)</option>\n<option value="3">March (03)</option>\n<option value="4">April (04)</option>\n<option value="5">May (05)</option>\n<option value="6">June (06)</option>\n<option value="7">July (07)</option>\n<option value="8" selected="selected">August (08)</option>\n<option value="9">September (09)</option>\n<option value="10">October (10)</option>\n<option value="11">November (11)</option>\n<option value="12">December (12)</option>\n) + expected << "</select>\n" + + format_string = "%{name} (%<number>02d)" + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16), month_format_string: format_string) + assert_dom_equal expected, select_month(8, month_format_string: format_string) + end + + def test_select_month_with_numbers_and_names_with_abbv + expected = +%(<select id="date_month" name="date[month]">\n) + expected << %(<option value="1">1 - Jan</option>\n<option value="2">2 - Feb</option>\n<option value="3">3 - Mar</option>\n<option value="4">4 - Apr</option>\n<option value="5">5 - May</option>\n<option value="6">6 - Jun</option>\n<option value="7">7 - Jul</option>\n<option value="8" selected="selected">8 - Aug</option>\n<option value="9">9 - Sep</option>\n<option value="10">10 - Oct</option>\n<option value="11">11 - Nov</option>\n<option value="12">12 - Dec</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16), add_month_numbers: true, use_short_month: true) + assert_dom_equal expected, select_month(8, add_month_numbers: true, use_short_month: true) + end + + def test_select_month_with_abbv + expected = +%(<select id="date_month" name="date[month]">\n) + expected << %(<option value="1">Jan</option>\n<option value="2">Feb</option>\n<option value="3">Mar</option>\n<option value="4">Apr</option>\n<option value="5">May</option>\n<option value="6">Jun</option>\n<option value="7">Jul</option>\n<option value="8" selected="selected">Aug</option>\n<option value="9">Sep</option>\n<option value="10">Oct</option>\n<option value="11">Nov</option>\n<option value="12">Dec</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16), use_short_month: true) + assert_dom_equal expected, select_month(8, use_short_month: true) + end + + def test_select_month_with_custom_names + month_names = %w(nil Januar Februar Marts April Maj Juni Juli August September Oktober November December) + + expected = +%(<select id="date_month" name="date[month]">\n) + 1.upto(12) { |month| expected << %(<option value="#{month}"#{' selected="selected"' if month == 8}>#{month_names[month]}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16), use_month_names: month_names) + assert_dom_equal expected, select_month(8, use_month_names: month_names) + end + + def test_select_month_with_zero_indexed_custom_names + month_names = %w(Januar Februar Marts April Maj Juni Juli August September Oktober November December) + + expected = +%(<select id="date_month" name="date[month]">\n) + 1.upto(12) { |month| expected << %(<option value="#{month}"#{' selected="selected"' if month == 8}>#{month_names[month - 1]}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16), use_month_names: month_names) + assert_dom_equal expected, select_month(8, use_month_names: month_names) + end + + def test_select_month_with_hidden + assert_dom_equal "<input type=\"hidden\" id=\"date_month\" name=\"date[month]\" value=\"8\" />\n", select_month(8, use_hidden: true) + end + + def test_select_month_with_hidden_and_field_name + assert_dom_equal "<input type=\"hidden\" id=\"date_mois\" name=\"date[mois]\" value=\"8\" />\n", select_month(8, use_hidden: true, field_name: "mois") + end + + def test_select_month_with_html_options + expected = +%(<select id="date_month" name="date[month]" class="selector" accesskey="M">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(Time.mktime(2003, 8, 16), {}, { class: "selector", accesskey: "M" }) + end + + def test_select_month_with_default_prompt + expected = +%(<select id="date_month" name="date[month]">\n) + expected << %(<option value="">Month</option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(8, prompt: true) + end + + def test_select_month_with_custom_prompt + expected = +%(<select id="date_month" name="date[month]">\n) + expected << %(<option value="">Choose month</option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(8, prompt: "Choose month") + end + + def test_select_month_with_generic_with_css_classes + expected = +%(<select id="date_month" name="date[month]" class="month">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(8, with_css_classes: true) + end + + def test_select_month_with_custom_with_css_classes + expected = +%(<select id="date_month" name="date[month]" class="my-month">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_month(8, with_css_classes: { month: "my-month" }) + end + + def test_select_year + expected = +%(<select id="date_year" name="date[year]">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_year(Time.mktime(2003, 8, 16), start_year: 2003, end_year: 2005) + assert_dom_equal expected, select_year(2003, start_year: 2003, end_year: 2005) + end + + def test_select_year_with_disabled + expected = +%(<select id="date_year" name="date[year]" disabled="disabled">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_year(Time.mktime(2003, 8, 16), disabled: true, start_year: 2003, end_year: 2005) + assert_dom_equal expected, select_year(2003, disabled: true, start_year: 2003, end_year: 2005) + end + + def test_select_year_with_field_name_override + expected = +%(<select id="date_annee" name="date[annee]">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_year(Time.mktime(2003, 8, 16), start_year: 2003, end_year: 2005, field_name: "annee") + assert_dom_equal expected, select_year(2003, start_year: 2003, end_year: 2005, field_name: "annee") + end + + def test_select_year_with_type_discarding + expected = +%(<select id="date_year" name="date_year">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_year( + Time.mktime(2003, 8, 16), prefix: "date_year", discard_type: true, start_year: 2003, end_year: 2005) + assert_dom_equal expected, select_year( + 2003, prefix: "date_year", discard_type: true, start_year: 2003, end_year: 2005) + end + + def test_select_year_descending + expected = +%(<select id="date_year" name="date[year]">\n) + expected << %(<option value="2005" selected="selected">2005</option>\n<option value="2004">2004</option>\n<option value="2003">2003</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_year(Time.mktime(2005, 8, 16), start_year: 2005, end_year: 2003) + assert_dom_equal expected, select_year(2005, start_year: 2005, end_year: 2003) + end + + def test_select_year_with_hidden + assert_dom_equal "<input type=\"hidden\" id=\"date_year\" name=\"date[year]\" value=\"2007\" />\n", select_year(2007, use_hidden: true) + end + + def test_select_year_with_hidden_and_field_name + assert_dom_equal "<input type=\"hidden\" id=\"date_anno\" name=\"date[anno]\" value=\"2007\" />\n", select_year(2007, use_hidden: true, field_name: "anno") + end + + def test_select_year_with_html_options + expected = +%(<select id="date_year" name="date[year]" class="selector" accesskey="M">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_year(Time.mktime(2003, 8, 16), { start_year: 2003, end_year: 2005 }, { class: "selector", accesskey: "M" }) + end + + def test_select_year_with_default_prompt + expected = +%(<select id="date_year" name="date[year]">\n) + expected << %(<option value="">Year</option>\n<option value="2003">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_year(nil, start_year: 2003, end_year: 2005, prompt: true) + end + + def test_select_year_with_custom_prompt + expected = +%(<select id="date_year" name="date[year]">\n) + expected << %(<option value="">Choose year</option>\n<option value="2003">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_year(nil, start_year: 2003, end_year: 2005, prompt: "Choose year") + end + + def test_select_year_with_generic_with_css_classes + expected = +%(<select id="date_year" name="date[year]" class="year">\n) + expected << %(<option value="2003">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_year(nil, start_year: 2003, end_year: 2005, with_css_classes: true) + end + + def test_select_year_with_custom_with_css_classes + expected = +%(<select id="date_year" name="date[year]" class="my-year">\n) + expected << %(<option value="2003">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_year(nil, start_year: 2003, end_year: 2005, with_css_classes: { year: "my-year" }) + end + + def test_select_year_with_position + expected = +%(<select id="date_year_1i" name="date[year(1i)]">\n) + expected << %(<option value="2003">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + assert_dom_equal expected, select_year(Date.current, include_position: true, start_year: 2003, end_year: 2005) + end + + def test_select_year_with_custom_names + year_format_lambda = ->year { "Heisei #{ year - 1988 }" } + expected = %(<select id="date_year" name="date[year]">\n).dup + expected << %(<option value="2003">Heisei 15</option>\n<option value="2004">Heisei 16</option>\n<option value="2005">Heisei 17</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_year(nil, start_year: 2003, end_year: 2005, year_format: year_format_lambda) + end + + def test_select_hour + expected = +%(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_hour(Time.mktime(2003, 8, 16, 8, 4, 18)) + end + + def test_select_hour_with_ampm + expected = +%(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value="00">12 AM</option>\n<option value="01">01 AM</option>\n<option value="02">02 AM</option>\n<option value="03">03 AM</option>\n<option value="04">04 AM</option>\n<option value="05">05 AM</option>\n<option value="06">06 AM</option>\n<option value="07">07 AM</option>\n<option value="08" selected="selected">08 AM</option>\n<option value="09">09 AM</option>\n<option value="10">10 AM</option>\n<option value="11">11 AM</option>\n<option value="12">12 PM</option>\n<option value="13">01 PM</option>\n<option value="14">02 PM</option>\n<option value="15">03 PM</option>\n<option value="16">04 PM</option>\n<option value="17">05 PM</option>\n<option value="18">06 PM</option>\n<option value="19">07 PM</option>\n<option value="20">08 PM</option>\n<option value="21">09 PM</option>\n<option value="22">10 PM</option>\n<option value="23">11 PM</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_hour(Time.mktime(2003, 8, 16, 8, 4, 18), ampm: true) + end + + def test_select_hour_with_disabled + expected = +%(<select id="date_hour" name="date[hour]" disabled="disabled">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_hour(Time.mktime(2003, 8, 16, 8, 4, 18), disabled: true) + end + + def test_select_hour_with_field_name_override + expected = +%(<select id="date_heure" name="date[heure]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_hour(Time.mktime(2003, 8, 16, 8, 4, 18), field_name: "heure") + end + + def test_select_hour_with_blank + expected = +%(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value=""></option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_hour(Time.mktime(2003, 8, 16, 8, 4, 18), include_blank: true) + end + + def test_select_hour_nil_with_blank + expected = +%(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value=""></option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_hour(nil, include_blank: true) + end + + def test_select_hour_with_html_options + expected = +%(<select id="date_hour" name="date[hour]" class="selector" accesskey="M">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_hour(Time.mktime(2003, 8, 16, 8, 4, 18), {}, { class: "selector", accesskey: "M" }) + end + + def test_select_hour_with_default_prompt + expected = +%(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value="">Hour</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_hour(Time.mktime(2003, 8, 16, 8, 4, 18), prompt: true) + end + + def test_select_hour_with_custom_prompt + expected = +%(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value="">Choose hour</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_hour(Time.mktime(2003, 8, 16, 8, 4, 18), prompt: "Choose hour") + end + + def test_select_hour_with_generic_with_css_classes + expected = +%(<select id="date_hour" name="date[hour]" class="hour">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_hour(Time.mktime(2003, 8, 16, 8, 4, 18), with_css_classes: true) + end + + def test_select_hour_with_custom_with_css_classes + expected = +%(<select id="date_hour" name="date[hour]" class="my-hour">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_hour(Time.mktime(2003, 8, 16, 8, 4, 18), with_css_classes: { hour: "my-hour" }) + end + + def test_select_minute + expected = +%(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(Time.mktime(2003, 8, 16, 8, 4, 18)) + end + + def test_select_minute_with_disabled + expected = +%(<select id="date_minute" name="date[minute]" disabled="disabled">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(Time.mktime(2003, 8, 16, 8, 4, 18), disabled: true) + end + + def test_select_minute_with_field_name_override + expected = +%(<select id="date_minuto" name="date[minuto]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(Time.mktime(2003, 8, 16, 8, 4, 18), field_name: "minuto") + end + + def test_select_minute_with_blank + expected = +%(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value=""></option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(Time.mktime(2003, 8, 16, 8, 4, 18), include_blank: true) + end + + def test_select_minute_with_blank_and_step + expected = +%(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value=""></option>\n<option value="00">00</option>\n<option value="15">15</option>\n<option value="30">30</option>\n<option value="45">45</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(Time.mktime(2003, 8, 16, 8, 4, 18), include_blank: true, minute_step: 15) + end + + def test_select_minute_nil_with_blank + expected = +%(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value=""></option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(nil, include_blank: true) + end + + def test_select_minute_nil_with_blank_and_step + expected = +%(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value=""></option>\n<option value="00">00</option>\n<option value="15">15</option>\n<option value="30">30</option>\n<option value="45">45</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(nil, include_blank: true, minute_step: 15) + end + + def test_select_minute_with_hidden + assert_dom_equal "<input type=\"hidden\" id=\"date_minute\" name=\"date[minute]\" value=\"8\" />\n", select_minute(8, use_hidden: true) + end + + def test_select_minute_with_hidden_and_field_name + assert_dom_equal "<input type=\"hidden\" id=\"date_minuto\" name=\"date[minuto]\" value=\"8\" />\n", select_minute(8, use_hidden: true, field_name: "minuto") + end + + def test_select_minute_with_html_options + expected = +%(<select id="date_minute" name="date[minute]" class="selector" accesskey="M">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(Time.mktime(2003, 8, 16, 8, 4, 18), {}, { class: "selector", accesskey: "M" }) + end + + def test_select_minute_with_default_prompt + expected = +%(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value="">Minute</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(Time.mktime(2003, 8, 16, 8, 4, 18), prompt: true) + end + + def test_select_minute_with_custom_prompt + expected = +%(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value="">Choose minute</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(Time.mktime(2003, 8, 16, 8, 4, 18), prompt: "Choose minute") + end + + def test_select_minute_with_generic_with_css_classes + expected = +%(<select id="date_minute" name="date[minute]" class="minute">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(Time.mktime(2003, 8, 16, 8, 4, 18), with_css_classes: true) + end + + def test_select_minute_with_custom_with_css_classes + expected = +%(<select id="date_minute" name="date[minute]" class="my-minute">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_minute(Time.mktime(2003, 8, 16, 8, 4, 18), with_css_classes: { minute: "my-minute" }) + end + + def test_select_second + expected = +%(<select id="date_second" name="date[second]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_second(Time.mktime(2003, 8, 16, 8, 4, 18)) + end + + def test_select_second_with_disabled + expected = +%(<select id="date_second" name="date[second]" disabled="disabled">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_second(Time.mktime(2003, 8, 16, 8, 4, 18), disabled: true) + end + + def test_select_second_with_field_name_override + expected = +%(<select id="date_segundo" name="date[segundo]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_second(Time.mktime(2003, 8, 16, 8, 4, 18), field_name: "segundo") + end + + def test_select_second_with_blank + expected = +%(<select id="date_second" name="date[second]">\n) + expected << %(<option value=""></option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_second(Time.mktime(2003, 8, 16, 8, 4, 18), include_blank: true) + end + + def test_select_second_nil_with_blank + expected = +%(<select id="date_second" name="date[second]">\n) + expected << %(<option value=""></option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_second(nil, include_blank: true) + end + + def test_select_second_with_html_options + expected = +%(<select id="date_second" name="date[second]" class="selector" accesskey="M">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_second(Time.mktime(2003, 8, 16, 8, 4, 18), {}, { class: "selector", accesskey: "M" }) + end + + def test_select_second_with_default_prompt + expected = +%(<select id="date_second" name="date[second]">\n) + expected << %(<option value="">Seconds</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_second(Time.mktime(2003, 8, 16, 8, 4, 18), prompt: true) + end + + def test_select_second_with_custom_prompt + expected = +%(<select id="date_second" name="date[second]">\n) + expected << %(<option value="">Choose seconds</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_second(Time.mktime(2003, 8, 16, 8, 4, 18), prompt: "Choose seconds") + end + + def test_select_second_with_generic_with_css_classes + expected = +%(<select id="date_second" name="date[second]" class="second">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_second(Time.mktime(2003, 8, 16, 8, 4, 18), with_css_classes: true) + end + + def test_select_second_with_custom_with_css_classes + expected = +%(<select id="date_second" name="date[second]" class="my-second">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_second(Time.mktime(2003, 8, 16, 8, 4, 18), with_css_classes: { second: "my-second" }) + end + + def test_select_date + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), start_year: 2003, end_year: 2005, prefix: "date[first]") + end + + def test_select_date_with_too_big_range_between_start_year_and_end_year + assert_raise(ArgumentError) { select_date(Time.mktime(2003, 8, 16), start_year: 2000, end_year: 20000, prefix: "date[first]", order: [:month, :day, :year]) } + assert_raise(ArgumentError) { select_date(Time.mktime(2003, 8, 16), start_year: 100, end_year: 2000, prefix: "date[first]", order: [:month, :day, :year]) } + end + + def test_select_date_can_have_more_then_1000_years_interval_if_forced_via_parameter + assert_nothing_raised { select_date(Time.mktime(2003, 8, 16), start_year: 2000, end_year: 3100, max_years_allowed: 2000) } + end + + def test_select_date_with_order + expected = +%(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), start_year: 2003, end_year: 2005, prefix: "date[first]", order: [:month, :day, :year]) + end + + def test_select_date_with_incomplete_order + # Since the order is incomplete nothing will be shown + expected = +%(<input id="date_first_year" name="date[first][year]" type="hidden" value="2003" />\n) + expected << %(<input id="date_first_month" name="date[first][month]" type="hidden" value="8" />\n) + expected << %(<input id="date_first_day" name="date[first][day]" type="hidden" value="1" />\n) + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), start_year: 2003, end_year: 2005, prefix: "date[first]", order: [:day]) + end + + def test_select_date_with_disabled + expected = +%(<select id="date_first_year" name="date[first][year]" disabled="disabled">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]" disabled="disabled">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]" disabled="disabled">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), start_year: 2003, end_year: 2005, prefix: "date[first]", disabled: true) + end + + def test_select_date_with_no_start_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + (Date.today.year - 5).upto(Date.today.year + 1) do |y| + if y == Date.today.year + expected << %(<option value="#{y}" selected="selected">#{y}</option>\n) + else + expected << %(<option value="#{y}">#{y}</option>\n) + end + end + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date( + Time.mktime(Date.today.year, 8, 16), end_year: Date.today.year + 1, prefix: "date[first]" + ) + end + + def test_select_date_with_no_end_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + 2003.upto(2008) do |y| + if y == 2003 + expected << %(<option value="#{y}" selected="selected">#{y}</option>\n) + else + expected << %(<option value="#{y}">#{y}</option>\n) + end + end + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date( + Time.mktime(2003, 8, 16), start_year: 2003, prefix: "date[first]" + ) + end + + def test_select_date_with_no_start_or_end_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + (Date.today.year - 5).upto(Date.today.year + 5) do |y| + if y == Date.today.year + expected << %(<option value="#{y}" selected="selected">#{y}</option>\n) + else + expected << %(<option value="#{y}">#{y}</option>\n) + end + end + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date( + Time.mktime(Date.today.year, 8, 16), prefix: "date[first]" + ) + end + + def test_select_date_with_zero_value + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="2003">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(0, start_year: 2003, end_year: 2005, prefix: "date[first]") + end + + def test_select_date_with_zero_value_and_no_start_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + (Date.today.year - 5).upto(Date.today.year + 1) { |y| expected << %(<option value="#{y}">#{y}</option>\n) } + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(0, end_year: Date.today.year + 1, prefix: "date[first]") + end + + def test_select_date_with_zero_value_and_no_end_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + last_year = Time.now.year + 5 + 2003.upto(last_year) { |y| expected << %(<option value="#{y}">#{y}</option>\n) } + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(0, start_year: 2003, prefix: "date[first]") + end + + def test_select_date_with_zero_value_and_no_start_and_end_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + (Date.today.year - 5).upto(Date.today.year + 5) { |y| expected << %(<option value="#{y}">#{y}</option>\n) } + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(0, prefix: "date[first]") + end + + def test_select_date_with_nil_value_and_no_start_and_end_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + (Date.today.year - 5).upto(Date.today.year + 5) { |y| expected << %(<option value="#{y}">#{y}</option>\n) } + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(nil, prefix: "date[first]") + end + + def test_select_date_with_html_options + expected = +%(<select id="date_first_year" name="date[first][year]" class="selector">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]" class="selector">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]" class="selector">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), { start_year: 2003, end_year: 2005, prefix: "date[first]" }, { class: "selector" }) + end + + def test_select_date_with_separator + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << " / " + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << " / " + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), date_separator: " / ", start_year: 2003, end_year: 2005, prefix: "date[first]") + end + + def test_select_date_with_separator_and_discard_day + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << " / " + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<input type="hidden" id="date_first_day" name="date[first][day]" value="1" />\n) + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), date_separator: " / ", discard_day: true, start_year: 2003, end_year: 2005, prefix: "date[first]") + end + + def test_select_date_with_separator_discard_month_and_day + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<input type="hidden" id="date_first_month" name="date[first][month]" value="8" />\n) + expected << %(<input type="hidden" id="date_first_day" name="date[first][day]" value="1" />\n) + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), date_separator: " / ", discard_month: true, discard_day: true, start_year: 2003, end_year: 2005, prefix: "date[first]") + end + + def test_select_date_with_hidden + expected = +%(<input id="date_first_year" name="date[first][year]" type="hidden" value="2003"/>\n) + expected << %(<input id="date_first_month" name="date[first][month]" type="hidden" value="8" />\n) + expected << %(<input id="date_first_day" name="date[first][day]" type="hidden" value="16" />\n) + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), prefix: "date[first]", use_hidden: true) + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), date_separator: " / ", prefix: "date[first]", use_hidden: true) + end + + def test_select_date_with_css_classes_option + expected = +%(<select id="date_first_year" name="date[first][year]" class="year">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]" class="month">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]" class="day">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), start_year: 2003, end_year: 2005, prefix: "date[first]", with_css_classes: true) + end + + def test_select_date_with_custom_with_css_classes + expected = +%(<select id="date_year" name="date[year]" class="my-year">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_month" name="date[month]" class="my-month">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_day" name="date[day]" class="my-day">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), start_year: 2003, end_year: 2005, with_css_classes: { year: "my-year", month: "my-month", day: "my-day" }) + end + + def test_select_date_with_css_classes_option_and_html_class_option + expected = +%(<select id="date_first_year" name="date[first][year]" class="datetime optional year">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]" class="datetime optional month">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]" class="datetime optional day">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), { start_year: 2003, end_year: 2005, prefix: "date[first]", with_css_classes: true }, { class: "datetime optional" }) + end + + def test_select_date_with_custom_with_css_classes_and_html_class_option + expected = +%(<select id="date_year" name="date[year]" class="date optional my-year">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_month" name="date[month]" class="date optional my-month">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_day" name="date[day]" class="date optional my-day">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), { start_year: 2003, end_year: 2005, with_css_classes: { year: "my-year", month: "my-month", day: "my-day" } }, { class: "date optional" }) + end + + def test_select_date_with_partial_with_css_classes_and_html_class_option + expected = +%(<select id="date_year" name="date[year]" class="date optional">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_month" name="date[month]" class="date optional my-month custom-grid">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_day" name="date[day]" class="date optional">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), { start_year: 2003, end_year: 2005, with_css_classes: { month: "my-month custom-grid" } }, { class: "date optional" }) + end + + def test_select_date_with_html_class_option + expected = +%(<select id="date_year" name="date[year]" class="date optional custom-grid">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_month" name="date[month]" class="date optional custom-grid">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_day" name="date[day]" class="date optional custom-grid">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(Time.mktime(2003, 8, 16), { start_year: 2003, end_year: 2005 }, { class: "date optional custom-grid" }) + end + + def test_select_datetime + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << " — " + + expected << %(<select id="date_first_hour" name="date[first][hour]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_first_minute" name="date[first][minute]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), start_year: 2003, end_year: 2005, prefix: "date[first]") + end + + def test_select_datetime_with_ampm + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << " — " + + expected << %(<select id="date_first_hour" name="date[first][hour]">\n) + expected << %(<option value="00">12 AM</option>\n<option value="01">01 AM</option>\n<option value="02">02 AM</option>\n<option value="03">03 AM</option>\n<option value="04">04 AM</option>\n<option value="05">05 AM</option>\n<option value="06">06 AM</option>\n<option value="07">07 AM</option>\n<option value="08" selected="selected">08 AM</option>\n<option value="09">09 AM</option>\n<option value="10">10 AM</option>\n<option value="11">11 AM</option>\n<option value="12">12 PM</option>\n<option value="13">01 PM</option>\n<option value="14">02 PM</option>\n<option value="15">03 PM</option>\n<option value="16">04 PM</option>\n<option value="17">05 PM</option>\n<option value="18">06 PM</option>\n<option value="19">07 PM</option>\n<option value="20">08 PM</option>\n<option value="21">09 PM</option>\n<option value="22">10 PM</option>\n<option value="23">11 PM</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_first_minute" name="date[first][minute]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), start_year: 2003, end_year: 2005, prefix: "date[first]", ampm: true) + end + + def test_select_datetime_with_separators + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << " — " + + expected << %(<select id="date_first_hour" name="date[first][hour]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_first_minute" name="date[first][minute]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), start_year: 2003, end_year: 2005, prefix: "date[first]", datetime_separator: " — ", time_separator: " : ") + end + + def test_select_datetime_with_nil_value_and_no_start_and_end_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + (Date.today.year - 5).upto(Date.today.year + 5) { |y| expected << %(<option value="#{y}">#{y}</option>\n) } + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << " — " + + expected << %(<select id="date_first_hour" name="date[first][hour]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_first_minute" name="date[first][minute]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(nil, prefix: "date[first]") + end + + def test_select_datetime_with_html_options + expected = +%(<select id="date_first_year" name="date[first][year]" class="selector">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]" class="selector">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]" class="selector">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << " — " + + expected << %(<select id="date_first_hour" name="date[first][hour]" class="selector">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_first_minute" name="date[first][minute]" class="selector">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), { start_year: 2003, end_year: 2005, prefix: "date[first]" }, { class: "selector" }) + end + + def test_select_datetime_with_all_separators + expected = +%(<select id="date_first_year" name="date[first][year]" class="selector">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << "/" + + expected << %(<select id="date_first_month" name="date[first][month]" class="selector">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << "/" + + expected << %(<select id="date_first_day" name="date[first][day]" class="selector">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << "—" + + expected << %(<select id="date_first_hour" name="date[first][hour]" class="selector">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << ":" + + expected << %(<select id="date_first_minute" name="date[first][minute]" class="selector">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), { datetime_separator: "—", date_separator: "/", time_separator: ":", start_year: 2003, end_year: 2005, prefix: "date[first]" }, { class: "selector" }) + end + + def test_select_datetime_should_work_with_date + assert_nothing_raised { select_datetime(Date.today) } + end + + def test_select_datetime_with_default_prompt + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="">Year</option>\n<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="">Month</option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="">Day</option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << " — " + + expected << %(<select id="date_first_hour" name="date[first][hour]">\n) + expected << %(<option value="">Hour</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_first_minute" name="date[first][minute]">\n) + expected << %(<option value="">Minute</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), start_year: 2003, end_year: 2005, + prefix: "date[first]", prompt: true) + end + + def test_select_datetime_with_custom_prompt + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="">Choose year</option>\n<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="">Choose month</option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="">Choose day</option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << " — " + + expected << %(<select id="date_first_hour" name="date[first][hour]">\n) + expected << %(<option value="">Choose hour</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_first_minute" name="date[first][minute]">\n) + expected << %(<option value="">Choose minute</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), start_year: 2003, end_year: 2005, prefix: "date[first]", + prompt: { day: "Choose day", month: "Choose month", year: "Choose year", hour: "Choose hour", minute: "Choose minute" }) + end + + def test_select_datetime_with_generic_with_css_classes + expected = +%(<select id="date_year" name="date[year]" class="year">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_month" name="date[month]" class="month">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_day" name="date[day]" class="day">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << " — " + + expected << %(<select id="date_hour" name="date[hour]" class="hour">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]" class="minute">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), start_year: 2003, end_year: 2005, with_css_classes: true) + end + + def test_select_datetime_with_custom_with_css_classes + expected = +%(<select id="date_year" name="date[year]" class="my-year">\n) + expected << %(<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_month" name="date[month]" class="my-month">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_day" name="date[day]" class="my-day">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << " — " + + expected << %(<select id="date_hour" name="date[hour]" class="my-hour">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]" class="my-minute">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), start_year: 2003, end_year: 2005, with_css_classes: { day: "my-day", month: "my-month", year: "my-year", hour: "my-hour", minute: "my-minute" }) + end + + def test_select_datetime_with_custom_hours + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + expected << %(<option value="">Choose year</option>\n<option value="2003" selected="selected">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="">Choose month</option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8" selected="selected">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="">Choose day</option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << " — " + + expected << %(<select id="date_first_hour" name="date[first][hour]">\n) + expected << %(<option value="">Choose hour</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_first_minute" name="date[first][minute]">\n) + expected << %(<option value="">Choose minute</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), start_year: 2003, end_year: 2005, start_hour: 1, end_hour: 9, prefix: "date[first]", + prompt: { day: "Choose day", month: "Choose month", year: "Choose year", hour: "Choose hour", minute: "Choose minute" }) + end + + def test_select_datetime_with_hidden + expected = +%(<input id="date_first_year" name="date[first][year]" type="hidden" value="2003" />\n) + expected << %(<input id="date_first_month" name="date[first][month]" type="hidden" value="8" />\n) + expected << %(<input id="date_first_day" name="date[first][day]" type="hidden" value="16" />\n) + expected << %(<input id="date_first_hour" name="date[first][hour]" type="hidden" value="8" />\n) + expected << %(<input id="date_first_minute" name="date[first][minute]" type="hidden" value="4" />\n) + + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), prefix: "date[first]", use_hidden: true) + assert_dom_equal expected, select_datetime(Time.mktime(2003, 8, 16, 8, 4, 18), datetime_separator: "—", date_separator: "/", + time_separator: ":", prefix: "date[first]", use_hidden: true) + end + + def test_select_time + expected = +%(<input name="date[year]" id="date_year" value="2003" type="hidden" />\n) + expected << %(<input name="date[month]" id="date_month" value="8" type="hidden" />\n) + expected << %(<input name="date[day]" id="date_day" value="16" type="hidden" />\n) + + expected << %(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18)) + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), include_seconds: false) + end + + def test_select_time_with_ampm + expected = +%(<input name="date[year]" id="date_year" value="2003" type="hidden" />\n) + expected << %(<input name="date[month]" id="date_month" value="8" type="hidden" />\n) + expected << %(<input name="date[day]" id="date_day" value="16" type="hidden" />\n) + + expected << %(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value="00">12 AM</option>\n<option value="01">01 AM</option>\n<option value="02">02 AM</option>\n<option value="03">03 AM</option>\n<option value="04">04 AM</option>\n<option value="05">05 AM</option>\n<option value="06">06 AM</option>\n<option value="07">07 AM</option>\n<option value="08" selected="selected">08 AM</option>\n<option value="09">09 AM</option>\n<option value="10">10 AM</option>\n<option value="11">11 AM</option>\n<option value="12">12 PM</option>\n<option value="13">01 PM</option>\n<option value="14">02 PM</option>\n<option value="15">03 PM</option>\n<option value="16">04 PM</option>\n<option value="17">05 PM</option>\n<option value="18">06 PM</option>\n<option value="19">07 PM</option>\n<option value="20">08 PM</option>\n<option value="21">09 PM</option>\n<option value="22">10 PM</option>\n<option value="23">11 PM</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), include_seconds: false, ampm: true) + end + + def test_select_time_with_separator + expected = +%(<input name="date[year]" id="date_year" value="2003" type="hidden" />\n) + expected << %(<input name="date[month]" id="date_month" value="8" type="hidden" />\n) + expected << %(<input name="date[day]" id="date_day" value="16" type="hidden" />\n) + expected << %(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), time_separator: " : ") + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), time_separator: " : ", include_seconds: false) + end + + def test_select_time_with_seconds + expected = +%(<input name="date[year]" id="date_year" value="2003" type="hidden" />\n) + expected << %(<input name="date[month]" id="date_month" value="8" type="hidden" />\n) + expected << %(<input name="date[day]" id="date_day" value="16" type="hidden" />\n) + + expected << %(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_second" name="date[second]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), include_seconds: true) + end + + def test_select_time_with_seconds_and_separator + expected = +%(<input name="date[year]" id="date_year" value="2003" type="hidden" />\n) + expected << %(<input name="date[month]" id="date_month" value="8" type="hidden" />\n) + expected << %(<input name="date[day]" id="date_day" value="16" type="hidden" />\n) + + expected << %(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_second" name="date[second]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), include_seconds: true, time_separator: " : ") + end + + def test_select_time_with_html_options + expected = +%(<input name="date[year]" id="date_year" value="2003" type="hidden" />\n) + expected << %(<input name="date[month]" id="date_month" value="8" type="hidden" />\n) + expected << %(<input name="date[day]" id="date_day" value="16" type="hidden" />\n) + + expected << %(<select id="date_hour" name="date[hour]" class="selector">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]" class="selector">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), {}, { class: "selector" }) + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), { include_seconds: false }, { class: "selector" }) + end + + def test_select_time_should_work_with_date + assert_nothing_raised { select_time(Date.today) } + end + + def test_select_time_with_default_prompt + expected = +%(<input name="date[year]" id="date_year" value="2003" type="hidden" />\n) + expected << %(<input name="date[month]" id="date_month" value="8" type="hidden" />\n) + expected << %(<input name="date[day]" id="date_day" value="16" type="hidden" />\n) + + expected << %(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value="">Hour</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value="">Minute</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_second" name="date[second]">\n) + expected << %(<option value="">Seconds</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), include_seconds: true, prompt: true) + end + + def test_select_time_with_custom_prompt + expected = +%(<input name="date[year]" id="date_year" value="2003" type="hidden" />\n) + expected << %(<input name="date[month]" id="date_month" value="8" type="hidden" />\n) + expected << %(<input name="date[day]" id="date_day" value="16" type="hidden" />\n) + + expected << %(<select id="date_hour" name="date[hour]">\n) + expected << %(<option value="">Choose hour</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]">\n) + expected << %(<option value="">Choose minute</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_second" name="date[second]">\n) + expected << %(<option value="">Choose seconds</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), include_seconds: true, + prompt: { hour: "Choose hour", minute: "Choose minute", second: "Choose seconds" }) + end + + def test_select_time_with_generic_with_css_classes + expected = +%(<input name="date[year]" id="date_year" value="2003" type="hidden" />\n) + expected << %(<input name="date[month]" id="date_month" value="8" type="hidden" />\n) + expected << %(<input name="date[day]" id="date_day" value="16" type="hidden" />\n) + + expected << %(<select id="date_hour" name="date[hour]" class="hour">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]" class="minute">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_second" name="date[second]" class="second">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), include_seconds: true, with_css_classes: true) + end + + def test_select_time_with_custom_with_css_classes + expected = +%(<input name="date[year]" id="date_year" value="2003" type="hidden" />\n) + expected << %(<input name="date[month]" id="date_month" value="8" type="hidden" />\n) + expected << %(<input name="date[day]" id="date_day" value="16" type="hidden" />\n) + + expected << %(<select id="date_hour" name="date[hour]" class="my-hour">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08" selected="selected">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_minute" name="date[minute]" class="my-minute">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04" selected="selected">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_second" name="date[second]" class="my-second">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18" selected="selected">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), include_seconds: true, with_css_classes: { hour: "my-hour", minute: "my-minute", second: "my-second" }) + end + + def test_select_time_with_hidden + expected = +%(<input id="date_first_year" name="date[first][year]" type="hidden" value="2003" />\n) + expected << %(<input id="date_first_month" name="date[first][month]" type="hidden" value="8" />\n) + expected << %(<input id="date_first_day" name="date[first][day]" type="hidden" value="16" />\n) + expected << %(<input id="date_first_hour" name="date[first][hour]" type="hidden" value="8" />\n) + expected << %(<input id="date_first_minute" name="date[first][minute]" type="hidden" value="4" />\n) + + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), prefix: "date[first]", use_hidden: true) + assert_dom_equal expected, select_time(Time.mktime(2003, 8, 16, 8, 4, 18), time_separator: ":", prefix: "date[first]", use_hidden: true) + end + + def test_date_select + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on") + end + + def test_date_select_with_selected + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option selected="selected" value="2004">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7" selected="selected">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10" selected="selected">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", selected: Date.new(2004, 07, 10)) + end + + def test_date_select_with_selected_in_hash + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option selected="selected" value="2004">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7" selected="selected">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10" selected="selected">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", selected: { day: 10, month: 07, year: 2004 }) + end + + def test_date_select_with_selected_nil + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = '<input id="post_written_on_1i" name="post[written_on(1i)]" type="hidden" value="1"/>' + "\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value=""></option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << %{<option value=""></option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", include_blank: true, discard_year: true, selected: nil) + end + + def test_date_select_without_day + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +"<input type=\"hidden\" id=\"post_written_on_3i\" name=\"post[written_on(3i)]\" value=\"1\" />\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", order: [ :month, :year ]) + end + + def test_date_select_without_day_and_month + @post = Post.new + @post.written_on = Date.new(2004, 2, 29) + + expected = +"<input type=\"hidden\" id=\"post_written_on_2i\" name=\"post[written_on(2i)]\" value=\"2\" />\n" + expected << "<input type=\"hidden\" id=\"post_written_on_3i\" name=\"post[written_on(3i)]\" value=\"1\" />\n" + + expected << %{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", order: [ :year ]) + end + + def test_date_select_without_day_with_separator + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +"<input type=\"hidden\" id=\"post_written_on_3i\" name=\"post[written_on(3i)]\" value=\"1\" />\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << "/" + + expected << %{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", date_separator: "/", order: [ :month, :year ]) + end + + def test_date_select_without_day_and_with_disabled_html_option + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +"<input type=\"hidden\" id=\"post_written_on_3i\" disabled=\"disabled\" name=\"post[written_on(3i)]\" value=\"1\" />\n" + + expected << %{<select id="post_written_on_2i" disabled="disabled" name="post[written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_1i" disabled="disabled" name="post[written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", { order: [ :month, :year ] }, { disabled: true }) + end + + def test_date_select_within_fields_for + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + output_buffer = fields_for :post, @post do |f| + concat f.date_select(:written_on) + end + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]">\n<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option selected="selected" value="2004">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n</select>\n} + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option selected="selected" value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n</select>\n} + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option selected="selected" value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n</select>\n} + + assert_dom_equal(expected, output_buffer) + end + + def test_date_select_within_fields_for_with_index + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + id = 27 + + output_buffer = fields_for :post, @post, index: id do |f| + concat f.date_select(:written_on) + end + + expected = +%{<select id="post_#{id}_written_on_1i" name="post[#{id}][written_on(1i)]">\n<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option selected="selected" value="2004">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n</select>\n} + expected << %{<select id="post_#{id}_written_on_2i" name="post[#{id}][written_on(2i)]">\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option selected="selected" value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n</select>\n} + expected << %{<select id="post_#{id}_written_on_3i" name="post[#{id}][written_on(3i)]">\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option selected="selected" value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n</select>\n} + + assert_dom_equal(expected, output_buffer) + end + + def test_date_select_within_fields_for_with_blank_index + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + id = nil + + output_buffer = fields_for :post, @post, index: id do |f| + concat f.date_select(:written_on) + end + + expected = +%{<select id="post_#{id}_written_on_1i" name="post[#{id}][written_on(1i)]">\n<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option selected="selected" value="2004">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n</select>\n} + expected << %{<select id="post_#{id}_written_on_2i" name="post[#{id}][written_on(2i)]">\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option selected="selected" value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n</select>\n} + expected << %{<select id="post_#{id}_written_on_3i" name="post[#{id}][written_on(3i)]">\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option selected="selected" value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n</select>\n} + + assert_dom_equal(expected, output_buffer) + end + + def test_date_select_with_index + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + id = 456 + + expected = +%{<select id="post_456_written_on_1i" name="post[#{id}][written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_456_written_on_2i" name="post[#{id}][written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_456_written_on_3i" name="post[#{id}][written_on(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", index: id) + end + + def test_date_select_with_auto_index + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + id = 123 + + expected = +%{<select id="post_123_written_on_1i" name="post[#{id}][written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_123_written_on_2i" name="post[#{id}][written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_123_written_on_3i" name="post[#{id}][written_on(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, date_select("post[]", "written_on") + end + + def test_date_select_with_different_order + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + 1.upto(31) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 15}>#{i}</option>\n) } + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + 1.upto(12) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 6}>#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + + expected << %{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + 1999.upto(2009) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 2004}>#{i}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", order: [:day, :month, :year]) + end + + def test_date_select_with_nil + @post = Post.new + + start_year = Time.now.year - 5 + end_year = Time.now.year + 5 + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + start_year.upto(end_year) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == Time.now.year}>#{i}</option>\n) } + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + 1.upto(12) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == Time.now.month}>#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + 1.upto(31) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == Time.now.day}>#{i}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on") + end + + def test_date_select_with_nil_and_blank + @post = Post.new + + start_year = Time.now.year - 5 + end_year = Time.now.year + 5 + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << "<option value=\"\"></option>\n" + start_year.upto(end_year) { |i| expected << %(<option value="#{i}">#{i}</option>\n) } + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << "<option value=\"\"></option>\n" + 1.upto(12) { |i| expected << %(<option value="#{i}">#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << "<option value=\"\"></option>\n" + 1.upto(31) { |i| expected << %(<option value="#{i}">#{i}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", include_blank: true) + end + + def test_date_select_with_nil_and_blank_and_order + @post = Post.new + + start_year = Time.now.year - 5 + end_year = Time.now.year + 5 + + expected = '<input name="post[written_on(3i)]" type="hidden" id="post_written_on_3i" value="1"/>' + "\n" + expected << %{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << "<option value=\"\"></option>\n" + start_year.upto(end_year) { |i| expected << %(<option value="#{i}">#{i}</option>\n) } + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << "<option value=\"\"></option>\n" + 1.upto(12) { |i| expected << %(<option value="#{i}">#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", order: [:year, :month], include_blank: true) + end + + def test_date_select_with_nil_and_blank_and_discard_month + @post = Post.new + + start_year = Time.now.year - 5 + end_year = Time.now.year + 5 + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << "<option value=\"\"></option>\n" + start_year.upto(end_year) { |i| expected << %(<option value="#{i}">#{i}</option>\n) } + expected << "</select>\n" + expected << '<input name="post[written_on(2i)]" type="hidden" id="post_written_on_2i" value="1"/>' + "\n" + expected << '<input name="post[written_on(3i)]" type="hidden" id="post_written_on_3i" value="1"/>' + "\n" + + assert_dom_equal expected, date_select("post", "written_on", discard_month: true, include_blank: true) + end + + def test_date_select_with_nil_and_blank_and_discard_year + @post = Post.new + + expected = '<input id="post_written_on_1i" name="post[written_on(1i)]" type="hidden" value="1" />' + "\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << "<option value=\"\"></option>\n" + 1.upto(12) { |i| expected << %(<option value="#{i}">#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << "<option value=\"\"></option>\n" + 1.upto(31) { |i| expected << %(<option value="#{i}">#{i}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", discard_year: true, include_blank: true) + end + + def test_date_select_cant_override_discard_hour + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", discard_hour: false) + end + + def test_date_select_with_html_options + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]" class="selector">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]" class="selector">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]" class="selector">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", {}, { class: "selector" }) + end + + def test_date_select_with_html_options_within_fields_for + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + output_buffer = fields_for :post, @post do |f| + concat f.date_select(:written_on, {}, { class: "selector" }) + end + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]" class="selector">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]" class="selector">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]" class="selector">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + + expected << "</select>\n" + + assert_dom_equal expected, output_buffer + end + + def test_date_select_with_separator + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << " / " + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << " / " + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", date_separator: " / ") + end + + def test_date_select_with_separator_and_order + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " / " + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << " / " + + expected << %{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", order: [:day, :month, :year], date_separator: " / ") + end + + def test_date_select_with_separator_and_order_and_year_discarded + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " / " + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + expected << %{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="2004" />\n} + + assert_dom_equal expected, date_select("post", "written_on", order: [:day, :month, :year], discard_year: true, date_separator: " / ") + end + + def test_date_select_with_default_prompt + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << %{<option value="">Year</option>\n<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value="">Month</option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << %{<option value="">Day</option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", prompt: true) + end + + def test_date_select_with_custom_prompt + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]">\n} + expected << %{<option value="">Choose year</option>\n<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]">\n} + expected << %{<option value="">Choose month</option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]">\n} + expected << %{<option value="">Choose day</option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", prompt: { year: "Choose year", month: "Choose month", day: "Choose day" }) + end + + def test_date_select_with_generic_with_css_classes + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]" class="year">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]" class="month">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]" class="day">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", with_css_classes: true) + end + + def test_date_select_with_custom_with_css_classes + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]" class="my-year">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]" class="my-month">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]" class="my-day">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "written_on", with_css_classes: { year: "my-year", month: "my-month", day: "my-day" }) + end + + def test_time_select + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" name="post[written_on(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" name="post[written_on(3i)]" value="15" />\n} + + expected << %(<select id="post_written_on_4i" name="post[written_on(4i)]">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on") + end + + def test_time_select_with_selected + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" name="post[written_on(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" name="post[written_on(3i)]" value="15" />\n} + + expected << %(<select id="post_written_on_4i" name="post[written_on(4i)]">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 12}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 20}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on", selected: Time.local(2004, 6, 15, 12, 20, 30)) + end + + def test_time_select_with_selected_nil + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="1" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" name="post[written_on(2i)]" value="1" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" name="post[written_on(3i)]" value="1" />\n} + + expected << %(<select id="post_written_on_4i" name="post[written_on(4i)]">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}">#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}">#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on", discard_year: true, discard_month: true, discard_day: true, selected: nil) + end + + def test_time_select_without_date_hidden_fields + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%(<select id="post_written_on_4i" name="post[written_on(4i)]">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on", ignore_date: true) + end + + def test_time_select_with_seconds + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" name="post[written_on(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" name="post[written_on(3i)]" value="15" />\n} + + expected << %(<select id="post_written_on_4i" name="post[written_on(4i)]">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %(<select id="post_written_on_6i" name="post[written_on(6i)]">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 35}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on", include_seconds: true) + end + + def test_time_select_with_html_options + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" name="post[written_on(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" name="post[written_on(3i)]" value="15" />\n} + + expected << %(<select id="post_written_on_4i" name="post[written_on(4i)]" class="selector">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]" class="selector">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on", {}, { class: "selector" }) + end + + def test_time_select_with_html_options_within_fields_for + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + output_buffer = fields_for :post, @post do |f| + concat f.time_select(:written_on, {}, { class: "selector" }) + end + + expected = +%{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" name="post[written_on(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" name="post[written_on(3i)]" value="15" />\n} + + expected << %(<select id="post_written_on_4i" name="post[written_on(4i)]" class="selector">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]" class="selector">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, output_buffer + end + + def test_time_select_with_separator + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" name="post[written_on(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" name="post[written_on(3i)]" value="15" />\n} + + expected << %(<select id="post_written_on_4i" name="post[written_on(4i)]">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + expected << " - " + + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + expected << " - " + + expected << %(<select id="post_written_on_6i" name="post[written_on(6i)]">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 35}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on", time_separator: " - ", include_seconds: true) + end + + def test_time_select_with_default_prompt + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" name="post[written_on(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" name="post[written_on(3i)]" value="15" />\n} + + expected << %(<select id="post_written_on_4i" name="post[written_on(4i)]">\n) + expected << %(<option value="">Hour</option>\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]">\n) + expected << %(<option value="">Minute</option>\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on", prompt: true) + end + + def test_time_select_with_custom_prompt + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" name="post[written_on(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" name="post[written_on(3i)]" value="15" />\n} + + expected << %(<select id="post_written_on_4i" name="post[written_on(4i)]">\n) + expected << %(<option value="">Choose hour</option>\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]">\n) + expected << %(<option value="">Choose minute</option>\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on", prompt: { hour: "Choose hour", minute: "Choose minute" }) + end + + def test_time_select_with_generic_with_css_classes + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" name="post[written_on(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" name="post[written_on(3i)]" value="15" />\n} + + expected << %(<select id="post_written_on_4i" name="post[written_on(4i)]" class="hour">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]" class="minute">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on", with_css_classes: true) + end + + def test_time_select_with_custom_with_css_classes + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_written_on_1i" name="post[written_on(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" name="post[written_on(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" name="post[written_on(3i)]" value="15" />\n} + + expected << %(<select id="post_written_on_4i" name="post[written_on(4i)]" class="my-hour">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="post_written_on_5i" name="post[written_on(5i)]" class="my-minute">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on", with_css_classes: { hour: "my-hour", minute: "my-minute" }) + end + + def test_time_select_with_disabled_html_option + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_written_on_1i" disabled="disabled" name="post[written_on(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_written_on_2i" disabled="disabled" name="post[written_on(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_written_on_3i" disabled="disabled" name="post[written_on(3i)]" value="15" />\n} + + expected << %(<select id="post_written_on_4i" disabled="disabled" name="post[written_on(4i)]">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %(<select id="post_written_on_5i" disabled="disabled" name="post[written_on(5i)]">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, time_select("post", "written_on", {}, { disabled: true }) + end + + def test_datetime_select + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 16, 35) + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35" selected="selected">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at") + end + + def test_datetime_select_with_selected + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 16, 35) + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3" selected="selected">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10" selected="selected">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12" selected="selected">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30" selected="selected">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", selected: Time.local(2004, 3, 10, 12, 30)) + end + + def test_datetime_select_with_selected_nil + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 16, 35) + + expected = '<input id="post_updated_at_1i" name="post[updated_at(1i)]" type="hidden" value="1"/>' + "\n" + + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", discard_year: true, selected: nil) + end + + def test_datetime_select_defaults_to_time_zone_now_when_config_time_zone_is_set + # The love zone is UTC+0 + mytz = Class.new(ActiveSupport::TimeZone) { + attr_accessor :now + }.create("tenderlove", 0, ActiveSupport::TimeZone.find_tzinfo("UTC")) + + now = Time.mktime(2004, 6, 15, 16, 35, 0) + mytz.now = now + Time.zone = mytz + + assert_equal mytz, Time.zone + + @post = Post.new + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35" selected="selected">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at") + ensure + Time.zone = nil + end + + def test_datetime_select_with_html_options_within_fields_for + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 16, 35) + + output_buffer = fields_for :post, @post do |f| + concat f.datetime_select(:updated_at, {}, { class: "selector" }) + end + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]" class="selector">\n<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option selected="selected" value="2004">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n</select>\n} + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]" class="selector">\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option selected="selected" value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n</select>\n} + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]" class="selector">\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option selected="selected" value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n</select>\n} + expected << %{ — <select id="post_updated_at_4i" name="post[updated_at(4i)]" class="selector">\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option selected="selected" value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n</select>\n} + expected << %{ : <select id="post_updated_at_5i" name="post[updated_at(5i)]" class="selector">\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option selected="selected" value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n</select>\n} + + assert_dom_equal expected, output_buffer + end + + def test_datetime_select_with_separators + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << " / " + + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << " / " + + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " , " + + expected << %(<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n) + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + expected << " - " + + expected << %(<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + expected << " - " + + expected << %(<select id="post_updated_at_6i" name="post[updated_at(6i)]">\n) + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 35}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", date_separator: " / ", datetime_separator: " , ", time_separator: " - ", include_seconds: true) + end + + def test_datetime_select_with_integer + @post = Post.new + @post.updated_at = 3 + datetime_select("post", "updated_at") + end + + def test_datetime_select_with_infinity # Float + @post = Post.new + @post.updated_at = (-1.0 / 0) + datetime_select("post", "updated_at") + end + + def test_datetime_select_with_default_prompt + @post = Post.new + @post.updated_at = nil + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + expected << %{<option value="">Year</option>\n<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + expected << %{<option value="">Month</option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + expected << %{<option value="">Day</option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + expected << %{<option value="">Hour</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + expected << %{<option value="">Minute</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", start_year: 1999, end_year: 2009, prompt: true) + end + + def test_datetime_select_with_custom_prompt + @post = Post.new + @post.updated_at = nil + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + expected << %{<option value="">Choose year</option>\n<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + expected << %{<option value="">Choose month</option>\n<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + expected << %{<option value="">Choose day</option>\n<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + expected << %{<option value="">Choose hour</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + expected << %{<option value="">Choose minute</option>\n<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", start_year: 1999, end_year: 2009, prompt: { year: "Choose year", month: "Choose month", day: "Choose day", hour: "Choose hour", minute: "Choose minute" }) + end + + def test_datetime_select_with_generic_with_css_classes + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]" class="year">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]" class="month">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]" class="day">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_written_on_4i" name="post[written_on(4i)]" class="hour">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_written_on_5i" name="post[written_on(5i)]" class="minute">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "written_on", start_year: 1999, end_year: 2009, with_css_classes: true) + end + + def test_datetime_select_with_custom_with_css_classes + @post = Post.new + @post.written_on = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<select id="post_written_on_1i" name="post[written_on(1i)]" class="my-year">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_2i" name="post[written_on(2i)]" class="my-month">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_written_on_3i" name="post[written_on(3i)]" class="my-day">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_written_on_4i" name="post[written_on(4i)]" class="my-hour">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_written_on_5i" name="post[written_on(5i)]" class="my-minute">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "written_on", start_year: 1999, end_year: 2009, with_css_classes: { year: "my-year", month: "my-month", day: "my-day", hour: "my-hour", minute: "my-minute" }) + end + + def test_date_select_with_zero_value_and_no_start_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + (Date.today.year - 5).upto(Date.today.year + 1) { |y| expected << %(<option value="#{y}">#{y}</option>\n) } + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(0, end_year: Date.today.year + 1, prefix: "date[first]") + end + + def test_date_select_with_zero_value_and_no_end_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + last_year = Time.now.year + 5 + 2003.upto(last_year) { |y| expected << %(<option value="#{y}">#{y}</option>\n) } + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(0, start_year: 2003, prefix: "date[first]") + end + + def test_date_select_with_zero_value_and_no_start_and_end_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + (Date.today.year - 5).upto(Date.today.year + 5) { |y| expected << %(<option value="#{y}">#{y}</option>\n) } + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(0, prefix: "date[first]") + end + + def test_date_select_with_nil_value_and_no_start_and_end_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + (Date.today.year - 5).upto(Date.today.year + 5) { |y| expected << %(<option value="#{y}">#{y}</option>\n) } + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_date(nil, prefix: "date[first]") + end + + def test_datetime_select_with_nil_value_and_no_start_and_end_year + expected = +%(<select id="date_first_year" name="date[first][year]">\n) + (Date.today.year - 5).upto(Date.today.year + 5) { |y| expected << %(<option value="#{y}">#{y}</option>\n) } + expected << "</select>\n" + + expected << %(<select id="date_first_month" name="date[first][month]">\n) + expected << %(<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n) + expected << "</select>\n" + + expected << %(<select id="date_first_day" name="date[first][day]">\n) + expected << %(<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n) + expected << "</select>\n" + + expected << " — " + + expected << %(<select id="date_first_hour" name="date[first][hour]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n) + expected << "</select>\n" + + expected << " : " + + expected << %(<select id="date_first_minute" name="date[first][minute]">\n) + expected << %(<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n) + expected << "</select>\n" + + assert_dom_equal expected, select_datetime(nil, prefix: "date[first]") + end + + def test_datetime_select_with_options_index + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 16, 35) + id = 456 + + expected = +%{<select id="post_456_updated_at_1i" name="post[#{id}][updated_at(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_456_updated_at_2i" name="post[#{id}][updated_at(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_456_updated_at_3i" name="post[#{id}][updated_at(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_456_updated_at_4i" name="post[#{id}][updated_at(4i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_456_updated_at_5i" name="post[#{id}][updated_at(5i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35" selected="selected">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", index: id) + end + + def test_datetime_select_within_fields_for_with_options_index + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 16, 35) + id = 456 + + output_buffer = fields_for :post, @post, index: id do |f| + concat f.datetime_select(:updated_at) + end + + expected = +%{<select id="post_456_updated_at_1i" name="post[#{id}][updated_at(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_456_updated_at_2i" name="post[#{id}][updated_at(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_456_updated_at_3i" name="post[#{id}][updated_at(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_456_updated_at_4i" name="post[#{id}][updated_at(4i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_456_updated_at_5i" name="post[#{id}][updated_at(5i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35" selected="selected">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, output_buffer + end + + def test_datetime_select_with_auto_index + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 16, 35) + id = @post.id + + expected = +%{<select id="post_123_updated_at_1i" name="post[#{id}][updated_at(1i)]">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_123_updated_at_2i" name="post[#{id}][updated_at(2i)]">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_123_updated_at_3i" name="post[#{id}][updated_at(3i)]">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_123_updated_at_4i" name="post[#{id}][updated_at(4i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_123_updated_at_5i" name="post[#{id}][updated_at(5i)]">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35" selected="selected">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post[]", "updated_at") + end + + def test_datetime_select_with_seconds + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + 1999.upto(2009) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 2004}>#{i}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + 1.upto(12) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 6}>#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + 1.upto(31) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 15}>#{i}</option>\n) } + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_6i" name="post[updated_at(6i)]">\n} + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 35}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", include_seconds: true) + end + + def test_datetime_select_discard_year + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_updated_at_1i" name="post[updated_at(1i)]" value="2004" />\n} + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + 1.upto(12) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 6}>#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + 1.upto(31) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 15}>#{i}</option>\n) } + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", discard_year: true) + end + + def test_datetime_select_discard_month + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + 1999.upto(2009) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 2004}>#{i}</option>\n) } + expected << "</select>\n" + expected << %{<input type="hidden" id="post_updated_at_2i" name="post[updated_at(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_updated_at_3i" name="post[updated_at(3i)]" value="1" />\n} + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", discard_month: true) + end + + def test_datetime_select_discard_year_and_month + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_updated_at_1i" name="post[updated_at(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_updated_at_2i" name="post[updated_at(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_updated_at_3i" name="post[updated_at(3i)]" value="1" />\n} + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", discard_year: true, discard_month: true) + end + + def test_datetime_select_discard_year_and_month_with_disabled_html_option + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_updated_at_1i" disabled="disabled" name="post[updated_at(1i)]" value="2004" />\n} + expected << %{<input type="hidden" id="post_updated_at_2i" disabled="disabled" name="post[updated_at(2i)]" value="6" />\n} + expected << %{<input type="hidden" id="post_updated_at_3i" disabled="disabled" name="post[updated_at(3i)]" value="1" />\n} + + expected << %{<select id="post_updated_at_4i" disabled="disabled" name="post[updated_at(4i)]">\n} + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" disabled="disabled" name="post[updated_at(5i)]">\n} + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", { discard_year: true, discard_month: true }, { disabled: true }) + end + + def test_datetime_select_discard_hour + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + 1999.upto(2009) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 2004}>#{i}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + 1.upto(12) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 6}>#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + 1.upto(31) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 15}>#{i}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", discard_hour: true) + end + + def test_datetime_select_discard_minute + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + 1999.upto(2009) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 2004}>#{i}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + 1.upto(12) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 6}>#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + 1.upto(31) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 15}>#{i}</option>\n) } + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << %{<input type="hidden" id="post_updated_at_5i" name="post[updated_at(5i)]" value="16" />\n} + + assert_dom_equal expected, datetime_select("post", "updated_at", discard_minute: true) + end + + def test_datetime_select_disabled_and_discard_minute + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<select id="post_updated_at_1i" disabled="disabled" name="post[updated_at(1i)]">\n} + 1999.upto(2009) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 2004}>#{i}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_2i" disabled="disabled" name="post[updated_at(2i)]">\n} + 1.upto(12) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 6}>#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_3i" disabled="disabled" name="post[updated_at(3i)]">\n} + 1.upto(31) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 15}>#{i}</option>\n) } + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" disabled="disabled" name="post[updated_at(4i)]">\n} + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << %{<input type="hidden" id="post_updated_at_5i" disabled="disabled" name="post[updated_at(5i)]" value="16" />\n} + + assert_dom_equal expected, datetime_select("post", "updated_at", discard_minute: true, disabled: true) + end + + def test_datetime_select_invalid_order + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + 1.upto(31) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 15}>#{i}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + 1.upto(12) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 6}>#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + 1999.upto(2009) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 2004}>#{i}</option>\n) } + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", order: [:minute, :day, :hour, :month, :year, :second]) + end + + def test_datetime_select_discard_with_order + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 15, 16, 35) + + expected = +%{<input type="hidden" id="post_updated_at_1i" name="post[updated_at(1i)]" value="2004" />\n} + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + 1.upto(31) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 15}>#{i}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + 1.upto(12) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 6}>#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", order: [:day, :month]) + end + + def test_datetime_select_with_default_value_as_time + @post = Post.new + @post.updated_at = nil + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + 2001.upto(2011) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 2006}>#{i}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + 1.upto(12) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 9}>#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + 1.upto(31) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 19}>#{i}</option>\n) } + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 15}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 16}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", default: Time.local(2006, 9, 19, 15, 16, 35)) + end + + def test_include_blank_overrides_default_option + @post = Post.new + @post.updated_at = nil + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + expected << %(<option value=""></option>\n) + (Time.now.year - 5).upto(Time.now.year + 5) { |i| expected << %(<option value="#{i}">#{i}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + expected << %(<option value=""></option>\n) + 1.upto(12) { |i| expected << %(<option value="#{i}">#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + expected << %(<option value=""></option>\n) + 1.upto(31) { |i| expected << %(<option value="#{i}">#{i}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, date_select("post", "updated_at", default: Time.local(2006, 9, 19, 15, 16, 35), include_blank: true) + end + + def test_datetime_select_with_default_value_as_hash + @post = Post.new + @post.updated_at = nil + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]">\n} + (Time.now.year - 5).upto(Time.now.year + 5) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == Time.now.year}>#{i}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]">\n} + 1.upto(12) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == 10}>#{Date::MONTHNAMES[i]}</option>\n) } + expected << "</select>\n" + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]">\n} + 1.upto(31) { |i| expected << %(<option value="#{i}"#{' selected="selected"' if i == Time.now.day}>#{i}</option>\n) } + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]">\n} + 0.upto(23) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 9}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]">\n} + 0.upto(59) { |i| expected << %(<option value="#{sprintf("%02d", i)}"#{' selected="selected"' if i == 42}>#{sprintf("%02d", i)}</option>\n) } + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", default: { month: 10, minute: 42, hour: 9 }) + end + + def test_datetime_select_with_html_options + @post = Post.new + @post.updated_at = Time.local(2004, 6, 15, 16, 35) + + expected = +%{<select id="post_updated_at_1i" name="post[updated_at(1i)]" class="selector">\n} + expected << %{<option value="1999">1999</option>\n<option value="2000">2000</option>\n<option value="2001">2001</option>\n<option value="2002">2002</option>\n<option value="2003">2003</option>\n<option value="2004" selected="selected">2004</option>\n<option value="2005">2005</option>\n<option value="2006">2006</option>\n<option value="2007">2007</option>\n<option value="2008">2008</option>\n<option value="2009">2009</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_2i" name="post[updated_at(2i)]" class="selector">\n} + expected << %{<option value="1">January</option>\n<option value="2">February</option>\n<option value="3">March</option>\n<option value="4">April</option>\n<option value="5">May</option>\n<option value="6" selected="selected">June</option>\n<option value="7">July</option>\n<option value="8">August</option>\n<option value="9">September</option>\n<option value="10">October</option>\n<option value="11">November</option>\n<option value="12">December</option>\n} + expected << "</select>\n" + + expected << %{<select id="post_updated_at_3i" name="post[updated_at(3i)]" class="selector">\n} + expected << %{<option value="1">1</option>\n<option value="2">2</option>\n<option value="3">3</option>\n<option value="4">4</option>\n<option value="5">5</option>\n<option value="6">6</option>\n<option value="7">7</option>\n<option value="8">8</option>\n<option value="9">9</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15" selected="selected">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n} + expected << "</select>\n" + + expected << " — " + + expected << %{<select id="post_updated_at_4i" name="post[updated_at(4i)]" class="selector">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16" selected="selected">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n} + expected << "</select>\n" + expected << " : " + expected << %{<select id="post_updated_at_5i" name="post[updated_at(5i)]" class="selector">\n} + expected << %{<option value="00">00</option>\n<option value="01">01</option>\n<option value="02">02</option>\n<option value="03">03</option>\n<option value="04">04</option>\n<option value="05">05</option>\n<option value="06">06</option>\n<option value="07">07</option>\n<option value="08">08</option>\n<option value="09">09</option>\n<option value="10">10</option>\n<option value="11">11</option>\n<option value="12">12</option>\n<option value="13">13</option>\n<option value="14">14</option>\n<option value="15">15</option>\n<option value="16">16</option>\n<option value="17">17</option>\n<option value="18">18</option>\n<option value="19">19</option>\n<option value="20">20</option>\n<option value="21">21</option>\n<option value="22">22</option>\n<option value="23">23</option>\n<option value="24">24</option>\n<option value="25">25</option>\n<option value="26">26</option>\n<option value="27">27</option>\n<option value="28">28</option>\n<option value="29">29</option>\n<option value="30">30</option>\n<option value="31">31</option>\n<option value="32">32</option>\n<option value="33">33</option>\n<option value="34">34</option>\n<option value="35" selected="selected">35</option>\n<option value="36">36</option>\n<option value="37">37</option>\n<option value="38">38</option>\n<option value="39">39</option>\n<option value="40">40</option>\n<option value="41">41</option>\n<option value="42">42</option>\n<option value="43">43</option>\n<option value="44">44</option>\n<option value="45">45</option>\n<option value="46">46</option>\n<option value="47">47</option>\n<option value="48">48</option>\n<option value="49">49</option>\n<option value="50">50</option>\n<option value="51">51</option>\n<option value="52">52</option>\n<option value="53">53</option>\n<option value="54">54</option>\n<option value="55">55</option>\n<option value="56">56</option>\n<option value="57">57</option>\n<option value="58">58</option>\n<option value="59">59</option>\n} + expected << "</select>\n" + + assert_dom_equal expected, datetime_select("post", "updated_at", {}, { class: "selector" }) + end + + def test_date_select_should_not_change_passed_options_hash + @post = Post.new + @post.updated_at = Time.local(2008, 7, 16, 23, 30) + + options = { + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + } + date_select(@post, :updated_at, options) + + # note: the literal hash is intentional to show that the actual options hash isn't modified + # don't change this! + assert_equal({ + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + }, options) + end + + def test_datetime_select_should_not_change_passed_options_hash + @post = Post.new + @post.updated_at = Time.local(2008, 7, 16, 23, 30) + + options = { + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + } + datetime_select(@post, :updated_at, options) + + # note: the literal hash is intentional to show that the actual options hash isn't modified + # don't change this! + assert_equal({ + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + }, options) + end + + def test_time_select_should_not_change_passed_options_hash + @post = Post.new + @post.updated_at = Time.local(2008, 7, 16, 23, 30) + + options = { + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + } + time_select(@post, :updated_at, options) + + # note: the literal hash is intentional to show that the actual options hash isn't modified + # don't change this! + assert_equal({ + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + }, options) + end + + def test_select_date_should_not_change_passed_options_hash + options = { + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + } + select_date(Date.today, options) + + # note: the literal hash is intentional to show that the actual options hash isn't modified + # don't change this! + assert_equal({ + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + }, options) + end + + def test_select_datetime_should_not_change_passed_options_hash + options = { + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + } + select_datetime(Time.now, options) + + # note: the literal hash is intentional to show that the actual options hash isn't modified + # don't change this! + assert_equal({ + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + }, options) + end + + def test_select_time_should_not_change_passed_options_hash + options = { + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + } + select_time(Time.now, options) + + # note: the literal hash is intentional to show that the actual options hash isn't modified + # don't change this! + assert_equal({ + order: [ :year, :month, :day ], + default: { year: 2008, month: 7, day: 16, hour: 23, minute: 30, second: 1 }, + discard_type: false, + include_blank: false, + ignore_date: false, + include_seconds: true + }, options) + end + + def test_select_html_safety + assert_predicate select_day(16), :html_safe? + assert_predicate select_month(8), :html_safe? + assert_predicate select_year(Time.mktime(2003, 8, 16, 8, 4, 18)), :html_safe? + assert_predicate select_minute(Time.mktime(2003, 8, 16, 8, 4, 18)), :html_safe? + assert_predicate select_second(Time.mktime(2003, 8, 16, 8, 4, 18)), :html_safe? + + assert_predicate select_minute(8, use_hidden: true), :html_safe? + assert_predicate select_month(8, prompt: "Choose month"), :html_safe? + + assert_predicate select_time(Time.mktime(2003, 8, 16, 8, 4, 18), {}, { class: "selector" }), :html_safe? + assert_predicate select_date(Time.mktime(2003, 8, 16), date_separator: " / ", start_year: 2003, end_year: 2005, prefix: "date[first]"), :html_safe? + end + + def test_object_select_html_safety + @post = Post.new + @post.written_on = Date.new(2004, 6, 15) + + assert_predicate date_select("post", "written_on", default: Time.local(2006, 9, 19, 15, 16, 35), include_blank: true), :html_safe? + assert_predicate time_select("post", "written_on", ignore_date: true), :html_safe? + end + + def test_time_tag_with_date + date = Date.new(2013, 2, 20) + expected = '<time datetime="2013-02-20">February 20, 2013</time>' + assert_equal expected, time_tag(date) + end + + def test_time_tag_with_time + time = Time.new(2013, 2, 20, 0, 0, 0, "+00:00") + expected = '<time datetime="2013-02-20T00:00:00+00:00">February 20, 2013 00:00</time>' + assert_equal expected, time_tag(time) + end + + def test_time_tag_with_given_text + assert_match(/<time.*>Right now<\/time>/, time_tag(Time.now, "Right now")) + end + + def test_time_tag_with_given_block + assert_match(/<time.*><span>Right now<\/span><\/time>/, time_tag(Time.now) { raw("<span>Right now</span>") }) + end + + def test_time_tag_with_different_format + time = Time.new(2013, 2, 20, 0, 0, 0, "+00:00") + expected = '<time datetime="2013-02-20T00:00:00+00:00">20 Feb 00:00</time>' + assert_equal expected, time_tag(time, format: :short) + end +end diff --git a/actionview/test/template/dependency_tracker_test.rb b/actionview/test/template/dependency_tracker_test.rb new file mode 100644 index 0000000000..ef7aeac039 --- /dev/null +++ b/actionview/test/template/dependency_tracker_test.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "action_view/dependency_tracker" + +class NeckbeardTracker + def self.call(name, template) + ["foo/#{name}"] + end +end + +class FakeTemplate + attr_reader :source, :handler + + def initialize(source, handler = Neckbeard) + @source, @handler = source, handler + end +end + +Neckbeard = lambda { |template| template.source } +Bowtie = lambda { |template| template.source } + +class DependencyTrackerTest < ActionView::TestCase + def tracker + ActionView::DependencyTracker + end + + def setup + ActionView::Template.register_template_handler :neckbeard, Neckbeard + tracker.register_tracker(:neckbeard, NeckbeardTracker) + end + + def teardown + ActionView::Template.unregister_template_handler :neckbeard + tracker.remove_tracker(:neckbeard) + end + + def test_finds_tracker_by_template_handler + template = FakeTemplate.new("boo/hoo") + dependencies = tracker.find_dependencies("boo/hoo", template) + assert_equal ["foo/boo/hoo"], dependencies + end + + def test_returns_empty_array_if_no_tracker_is_found + template = FakeTemplate.new("boo/hoo", Bowtie) + dependencies = tracker.find_dependencies("boo/hoo", template) + assert_equal [], dependencies + end +end + +class ERBTrackerTest < Minitest::Test + def make_tracker(name, template) + ActionView::DependencyTracker::ERBTracker.new(name, template) + end + + def test_dependency_of_erb_template_with_number_in_filename + template = FakeTemplate.new("<%# render 'messages/message123' %>", :erb) + tracker = make_tracker("messages/_message123", template) + + assert_equal ["messages/message123"], tracker.dependencies + end + + def test_dependency_of_template_partial_with_layout + template = FakeTemplate.new("<%# render partial: 'messages/show', layout: 'messages/layout' %>", :erb) + tracker = make_tracker("multiple/_dependencies", template) + + assert_equal ["messages/layout", "messages/show"], tracker.dependencies + end + + def test_dependency_of_template_layout_standalone + template = FakeTemplate.new("<%# render layout: 'messages/layout' do %>", :erb) + tracker = make_tracker("messages/layout", template) + + assert_equal ["messages/layout"], tracker.dependencies + end + + def test_finds_dependency_in_correct_directory + template = FakeTemplate.new("<%# render(message.topic) %>", :erb) + tracker = make_tracker("messages/_message", template) + + assert_equal ["topics/topic"], tracker.dependencies + end + + def test_finds_dependency_in_correct_directory_with_underscore + template = FakeTemplate.new("<%# render(message_type.messages) %>", :erb) + tracker = make_tracker("message_types/_message_type", template) + + assert_equal ["messages/message"], tracker.dependencies + end + + def test_dependency_of_erb_template_with_no_spaces_after_render + template = FakeTemplate.new("<%# render'messages/message' %>", :erb) + tracker = make_tracker("messages/_message", template) + + assert_equal ["messages/message"], tracker.dependencies + end + + def test_finds_no_dependency_when_render_begins_the_name_of_an_identifier + template = FakeTemplate.new("<%# rendering 'it useless' %>", :erb) + tracker = make_tracker("resources/_resource", template) + + assert_equal [], tracker.dependencies + end + + def test_finds_no_dependency_when_render_ends_the_name_of_another_method + template = FakeTemplate.new("<%# surrender 'to reason' %>", :erb) + tracker = make_tracker("resources/_resource", template) + + assert_equal [], tracker.dependencies + end + + def test_finds_dependency_on_multiline_render_calls + template = FakeTemplate.new("<%# + render :object => @all_posts, + :partial => 'posts' %>", :erb) + + tracker = make_tracker("some/_little_posts", template) + + assert_equal ["some/posts"], tracker.dependencies + end + + def test_finds_multiple_unrelated_odd_dependencies + template = FakeTemplate.new(" + <%# render('shared/header', title: 'Title') %> + <h2>Section title</h2> + <%# render@section %> + ", :erb) + + tracker = make_tracker("multiple/_dependencies", template) + + assert_equal ["shared/header", "sections/section"], tracker.dependencies + end + + def test_finds_dependencies_for_all_kinds_of_identifiers + template = FakeTemplate.new(" + <%# render $globals %> + <%# render @instance_variables %> + <%# render @@class_variables %> + ", :erb) + + tracker = make_tracker("identifiers/_all", template) + + assert_equal [ + "globals/global", + "instance_variables/instance_variable", + "class_variables/class_variable" + ], tracker.dependencies + end + + def test_finds_dependencies_on_method_chains + template = FakeTemplate.new("<%# render @parent.child.grandchildren %>", :erb) + tracker = make_tracker("method/_chains", template) + + assert_equal ["grandchildren/grandchild"], tracker.dependencies + end + + def test_finds_dependencies_with_special_characters + template = FakeTemplate.new("<%# render @pokémon, partial: 'ピカチュウ' %>", :erb) + tracker = make_tracker("special/_characters", template) + + assert_equal ["special/ピカチュウ"], tracker.dependencies + end + + def test_finds_dependencies_with_quotes_within + template = FakeTemplate.new(%{ + <%# render "single/quote's" %> + <%# render 'double/quote"s' %> + }, :erb) + + tracker = make_tracker("quotes/_single_and_double", template) + + assert_equal ["single/quote's", 'double/quote"s'], tracker.dependencies + end + + def test_finds_dependencies_with_extra_spaces + template = FakeTemplate.new(%{ + <%= render "header" %> + <%= render partial: "form" %> + <%= render @message %> + <%= render ( @message.events ) %> + <%= render :collection => @message.comments, + :partial => "comments/comment" %> + }, :erb) + + tracker = make_tracker("spaces/_extra", template) + + assert_equal [ + "spaces/header", + "spaces/form", + "messages/message", + "events/event", + "comments/comment" + ], tracker.dependencies + end +end diff --git a/actionview/test/template/digestor_test.rb b/actionview/test/template/digestor_test.rb new file mode 100644 index 0000000000..ddaa7febb3 --- /dev/null +++ b/actionview/test/template/digestor_test.rb @@ -0,0 +1,389 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "fileutils" +require "action_view/dependency_tracker" + +class FixtureFinder < ActionView::LookupContext + FIXTURES_DIR = File.expand_path("../fixtures/digestor", __dir__) + + def initialize(details = {}) + super(ActionView::PathSet.new(["digestor", "digestor/api"]), details, []) + @rendered_format = :html + end +end + +class ActionView::Digestor::Node + def flatten + [self] + children.flat_map(&:flatten) + end +end + +class TemplateDigestorTest < ActionView::TestCase + def setup + @cwd = Dir.pwd + @tmp_dir = Dir.mktmpdir + + ActionView::LookupContext::DetailsKey.clear + FileUtils.cp_r FixtureFinder::FIXTURES_DIR, @tmp_dir + Dir.chdir @tmp_dir + end + + def teardown + Dir.chdir @cwd + FileUtils.rm_r @tmp_dir + end + + def test_top_level_change_reflected + assert_digest_difference("messages/show") do + change_template("messages/show") + end + end + + def test_explicit_dependency + assert_digest_difference("messages/show") do + change_template("messages/_message") + end + end + + def test_explicit_dependency_in_multiline_erb_tag + assert_digest_difference("messages/show") do + change_template("messages/_form") + end + end + + def test_explicit_dependency_wildcard + assert_digest_difference("events/index") do + change_template("events/_completed") + end + end + + def test_explicit_dependency_wildcard_picks_up_added_file + disable_resolver_caching do + assert_digest_difference("events/index") do + add_template("events/_uncompleted") + end + end + end + + def test_explicit_dependency_wildcard_picks_up_removed_file + disable_resolver_caching do + add_template("events/_subscribers_changed") + + assert_digest_difference("events/index") do + remove_template("events/_subscribers_changed") + end + end + end + + def test_second_level_dependency + assert_digest_difference("messages/show") do + change_template("comments/_comments") + end + end + + def test_second_level_dependency_within_same_directory + assert_digest_difference("messages/show") do + change_template("messages/_header") + end + end + + def test_third_level_dependency + assert_digest_difference("messages/show") do + change_template("comments/_comment") + end + end + + def test_directory_depth_dependency + assert_digest_difference("level/below/index") do + change_template("level/below/_header") + end + end + + def test_logging_of_missing_template + assert_logged "Couldn't find template for digesting: messages/something_missing" do + digest("messages/show") + end + end + + def test_logging_of_missing_template_ending_with_number + assert_logged "Couldn't find template for digesting: messages/something_missing_1" do + digest("messages/show") + end + end + + def test_logging_of_missing_template_for_dependencies + assert_logged "Couldn't find template for digesting: messages/something_missing" do + dependencies("messages/something_missing") + end + end + + def test_logging_of_missing_template_for_nested_dependencies + assert_logged "Couldn't find template for digesting: messages/something_missing" do + nested_dependencies("messages/something_missing") + end + end + + def test_getting_of_singly_nested_dependencies + singly_nested_dependencies = ["messages/header", "messages/form", "messages/message", "events/event", "comments/comment"] + assert_equal singly_nested_dependencies, nested_dependencies("messages/edit") + end + + def test_getting_of_doubly_nested_dependencies + doubly_nested = [{ "comments/comments" => ["comments/comment"] }, "messages/message"] + assert_equal doubly_nested, nested_dependencies("messages/peek") + end + + def test_nested_template_directory + assert_digest_difference("messages/show") do + change_template("messages/actions/_move") + end + end + + def test_nested_template_deps + nested_deps = ["messages/header", { "comments/comments" => ["comments/comment"] }, "messages/actions/move", "events/event", "messages/something_missing", "messages/something_missing_1", "messages/message", "messages/form"] + assert_equal nested_deps, nested_dependencies("messages/show") + end + + def test_nested_template_deps_with_non_default_rendered_format + finder.rendered_format = nil + nested_deps = [{ "comments/comments" => ["comments/comment"] }] + assert_equal nested_deps, nested_dependencies("messages/thread") + end + + def test_template_formats_of_nested_deps_with_non_default_rendered_format + finder.rendered_format = nil + assert_equal [:json], tree_template_formats("messages/thread").uniq + end + + def test_template_formats_of_dependencies_with_same_logical_name_and_different_rendered_format + assert_equal [:html], tree_template_formats("messages/show").uniq + end + + def test_template_dependencies_with_fallback_from_js_to_html_format + finder.rendered_format = :js + assert_equal ["comments/comment"], dependencies("comments/show") + end + + def test_template_digest_with_fallback_from_js_to_html_format + finder.rendered_format = :js + assert_digest_difference("comments/show") do + change_template("comments/_comment") + end + end + + def test_recursion_in_renders + assert digest("level/recursion") # assert recursion is possible + assert_not_nil digest("level/recursion") # assert digest is stored + end + + def test_chaining_the_top_template_on_recursion + assert digest("level/recursion") # assert recursion is possible + + assert_digest_difference("level/recursion") do + change_template("level/recursion") + end + + assert_not_nil digest("level/recursion") # assert digest is stored + end + + def test_chaining_the_partial_template_on_recursion + assert digest("level/recursion") # assert recursion is possible + + assert_digest_difference("level/recursion") do + change_template("level/_recursion") + end + + assert_not_nil digest("level/recursion") # assert digest is stored + end + + def test_dont_generate_a_digest_for_missing_templates + assert_equal "", digest("nothing/there") + end + + def test_collection_dependency + assert_digest_difference("messages/index") do + change_template("messages/_message") + end + + assert_digest_difference("messages/index") do + change_template("events/_event") + end + end + + def test_collection_derived_from_record_dependency + assert_digest_difference("messages/show") do + change_template("events/_event") + end + end + + def test_details_are_included_in_cache_key + # Cache the template digest. + @finder = FixtureFinder.new(formats: [:html]) + old_digest = digest("events/_event") + + # Change the template; the cached digest remains unchanged. + change_template("events/_event") + + # The details are changed, so a new cache key is generated. + @finder = FixtureFinder.new + + # The cache is busted. + assert_not_equal old_digest, digest("events/_event") + end + + def test_extra_whitespace_in_render_partial + assert_digest_difference("messages/edit") do + change_template("messages/_form") + end + end + + def test_extra_whitespace_in_render_named_partial + assert_digest_difference("messages/edit") do + change_template("messages/_header") + end + end + + def test_extra_whitespace_in_render_record + assert_digest_difference("messages/edit") do + change_template("messages/_message") + end + end + + def test_extra_whitespace_in_render_with_parenthesis + assert_digest_difference("messages/edit") do + change_template("events/_event") + end + end + + def test_old_style_hash_in_render_invocation + assert_digest_difference("messages/edit") do + change_template("comments/_comment") + end + end + + def test_variants + assert_digest_difference("messages/new", variants: [:iphone]) do + change_template("messages/new", :iphone) + change_template("messages/_header", :iphone) + end + end + + def test_dependencies_via_options_results_in_different_digest + digest_plain = digest("comments/_comment") + digest_fridge = digest("comments/_comment", dependencies: ["fridge"]) + digest_phone = digest("comments/_comment", dependencies: ["phone"]) + digest_fridge_phone = digest("comments/_comment", dependencies: ["fridge", "phone"]) + + assert_not_equal digest_plain, digest_fridge + assert_not_equal digest_plain, digest_phone + assert_not_equal digest_plain, digest_fridge_phone + assert_not_equal digest_fridge, digest_phone + assert_not_equal digest_fridge, digest_fridge_phone + assert_not_equal digest_phone, digest_fridge_phone + end + + def test_different_formats_with_same_logical_template_names_results_in_different_digests + html_digest = digest("comments/_comment", format: :html) + json_digest = digest("comments/_comment", format: :json) + + assert_not_equal html_digest, json_digest + end + + def test_digest_cache_cleanup_with_recursion + first_digest = digest("level/_recursion") + second_digest = digest("level/_recursion") + + assert first_digest + + # If the cache is cleaned up correctly, subsequent digests should return the same + assert_equal first_digest, second_digest + end + + def test_digest_cache_cleanup_with_recursion_and_template_caching_off + disable_resolver_caching do + first_digest = digest("level/_recursion") + second_digest = digest("level/_recursion") + + assert first_digest + + # If the cache is cleaned up correctly, subsequent digests should return the same + assert_equal first_digest, second_digest + end + end + + private + def assert_logged(message) + old_logger = ActionView::Base.logger + log = StringIO.new + ActionView::Base.logger = Logger.new(log) + + begin + yield + + log.rewind + assert_match message, log.read + ensure + ActionView::Base.logger = old_logger + end + end + + def assert_digest_difference(template_name, options = {}) + previous_digest = digest(template_name, options) + finder.digest_cache.clear + + yield + + assert_not_equal previous_digest, digest(template_name, options), "digest didn't change" + finder.digest_cache.clear + end + + def digest(template_name, options = {}) + options = options.dup + finder_options = options.extract!(:variants, :format) + + finder.variants = finder_options[:variants] || [] + finder.rendered_format = finder_options[:format] if finder_options[:format] + + ActionView::Digestor.digest(name: template_name, finder: finder, dependencies: (options[:dependencies] || [])) + end + + def dependencies(template_name) + tree = ActionView::Digestor.tree(template_name, finder) + tree.children.map(&:name) + end + + def nested_dependencies(template_name) + tree = ActionView::Digestor.tree(template_name, finder) + tree.children.map(&:to_dep_map) + end + + def tree_template_formats(template_name) + tree = ActionView::Digestor.tree(template_name, finder) + tree.flatten.map(&:template).compact.flat_map(&:formats) + end + + def disable_resolver_caching + old_caching, ActionView::Resolver.caching = ActionView::Resolver.caching, false + yield + ensure + ActionView::Resolver.caching = old_caching + end + + def finder + @finder ||= FixtureFinder.new + end + + def change_template(template_name, variant = nil) + variant = "+#{variant}" if variant.present? + + File.open("digestor/#{template_name}.html#{variant}.erb", "w") do |f| + f.write "\nTHIS WAS CHANGED!" + end + end + alias_method :add_template, :change_template + + def remove_template(template_name) + File.delete("digestor/#{template_name}.html.erb") + end +end diff --git a/actionview/test/template/erb/form_for_test.rb b/actionview/test/template/erb/form_for_test.rb new file mode 100644 index 0000000000..b3a47e17a4 --- /dev/null +++ b/actionview/test/template/erb/form_for_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "template/erb/helper" + +module ERBTest + class TagHelperTest < BlockTestCase + test "form_for works" do + routes = ActionDispatch::Routing::RouteSet.new + routes.draw do + get "/blah/update", to: "blah#update" + end + output = render_content "form_for(:staticpage, :url => {:controller => 'blah', :action => 'update'})", "", routes + assert_match %r{<form.*action="/blah/update".*method="post">.*</form>}, output + end + end +end diff --git a/actionview/test/template/erb/helper.rb b/actionview/test/template/erb/helper.rb new file mode 100644 index 0000000000..727cc3dcf2 --- /dev/null +++ b/actionview/test/template/erb/helper.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ERBTest + class ViewContext + include ActionView::Helpers::UrlHelper + include ActionView::Helpers::TagHelper + include ActionView::Helpers::JavaScriptHelper + include ActionView::Helpers::FormHelper + + attr_accessor :output_buffer, :controller + + def protect_against_forgery?() false end + end + + class BlockTestCase < ActiveSupport::TestCase + def render_content(start, inside, routes = nil) + routes ||= ActionDispatch::Routing::RouteSet.new.tap do |rs| + rs.draw { } + end + context = Class.new(ViewContext) { + include routes.url_helpers + }.new + template = block_helper(start, inside) + ActionView::Template::Handlers::ERB.erb_implementation.new(template).evaluate(context) + end + + def block_helper(str, rest) + "<%= #{str} do %>#{rest}<% end %>" + end + end +end diff --git a/actionview/test/template/erb/tag_helper_test.rb b/actionview/test/template/erb/tag_helper_test.rb new file mode 100644 index 0000000000..24a7592950 --- /dev/null +++ b/actionview/test/template/erb/tag_helper_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "template/erb/helper" + +module ERBTest + class TagHelperTest < BlockTestCase + test "percent equals works for content_tag and does not require parenthesis on method call" do + assert_equal "<div>Hello world</div>", render_content("content_tag :div", "Hello world") + end + + test "percent equals works for javascript_tag" do + expected_output = "<script>\n//<![CDATA[\nalert('Hello')\n//]]>\n</script>" + assert_equal expected_output, render_content("javascript_tag", "alert('Hello')") + end + + test "percent equals works for javascript_tag with options" do + expected_output = "<script id=\"the_js_tag\">\n//<![CDATA[\nalert('Hello')\n//]]>\n</script>" + assert_equal expected_output, render_content("javascript_tag(:id => 'the_js_tag')", "alert('Hello')") + end + + test "percent equals works with form tags" do + expected_output = %r{<form.*action="/foo".*method="post">.*hello*</form>} + assert_match expected_output, render_content("form_tag('/foo')", "<%= 'hello' %>") + end + + test "percent equals works with fieldset tags" do + expected_output = "<fieldset><legend>foo</legend>hello</fieldset>" + assert_equal expected_output, render_content("field_set_tag('foo')", "<%= 'hello' %>") + end + end +end diff --git a/actionview/test/template/erb_util_test.rb b/actionview/test/template/erb_util_test.rb new file mode 100644 index 0000000000..bd702dbe94 --- /dev/null +++ b/actionview/test/template/erb_util_test.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/json" + +class ErbUtilTest < ActiveSupport::TestCase + include ERB::Util + + ERB::Util::HTML_ESCAPE.each do |given, expected| + define_method "test_html_escape_#{expected.gsub(/\W/, '')}" do + assert_equal expected, html_escape(given) + end + end + + ERB::Util::JSON_ESCAPE.each do |given, expected| + define_method "test_json_escape_#{expected.gsub(/\W/, '')}" do + assert_equal ERB::Util::JSON_ESCAPE[given], json_escape(given) + end + end + + HTML_ESCAPE_TEST_CASES = [ + ["<br>", "<br>"], + ["a & b", "a & b"], + ['"quoted" string', ""quoted" string"], + ["'quoted' string", "'quoted' string"], + [ + '<script type="application/javascript">alert("You are \'pwned\'!")</script>', + "<script type="application/javascript">alert("You are 'pwned'!")</script>" + ] + ] + + JSON_ESCAPE_TEST_CASES = [ + ["1", "1"], + ["null", "null"], + ['"&"', '"\u0026"'], + ['"</script>"', '"\u003c/script\u003e"'], + ['["</script>"]', '["\u003c/script\u003e"]'], + ['{"name":"</script>"}', '{"name":"\u003c/script\u003e"}'], + [%({"name":"d\u2028h\u2029h"}), '{"name":"d\u2028h\u2029h"}'] + ] + + def test_html_escape + HTML_ESCAPE_TEST_CASES.each do |(raw, expected)| + assert_equal expected, html_escape(raw) + end + end + + def test_json_escape + JSON_ESCAPE_TEST_CASES.each do |(raw, expected)| + assert_equal expected, json_escape(raw) + end + end + + def test_json_escape_does_not_alter_json_string_meaning + JSON_ESCAPE_TEST_CASES.each do |(raw, _)| + expected = ActiveSupport::JSON.decode(raw) + if expected.nil? + assert_nil ActiveSupport::JSON.decode(json_escape(raw)) + else + assert_equal expected, ActiveSupport::JSON.decode(json_escape(raw)) + end + end + end + + def test_json_escape_is_idempotent + JSON_ESCAPE_TEST_CASES.each do |(raw, _)| + assert_equal json_escape(raw), json_escape(json_escape(raw)) + end + end + + def test_json_escape_returns_unsafe_strings_when_passed_unsafe_strings + value = json_escape("asdf") + assert_not_predicate value, :html_safe? + end + + def test_json_escape_returns_safe_strings_when_passed_safe_strings + value = json_escape("asdf".html_safe) + assert_predicate value, :html_safe? + end + + def test_html_escape_is_html_safe + escaped = h("<p>") + assert_equal "<p>", escaped + assert_predicate escaped, :html_safe? + end + + def test_html_escape_passes_html_escape_unmodified + escaped = h("<p>".html_safe) + assert_equal "<p>", escaped + assert_predicate escaped, :html_safe? + end + + def test_rest_in_ascii + (0..127).to_a.map(&:chr).each do |chr| + next if %('"&<>).include?(chr) + assert_equal chr, html_escape(chr) + end + end + + def test_html_escape_once + assert_equal "1 <>&"' 2 & 3", html_escape_once('1 <>&"\' 2 & 3') + assert_equal " ' ' λ λ " ' < > ", html_escape_once(" ' ' λ λ \" ' < > ") + end + + def test_html_escape_once_returns_unsafe_strings_when_passed_unsafe_strings + value = html_escape_once("1 < 2 & 3") + assert_not_predicate value, :html_safe? + end + + def test_html_escape_once_returns_safe_strings_when_passed_safe_strings + value = html_escape_once("1 < 2 & 3".html_safe) + assert_predicate value, :html_safe? + end +end diff --git a/actionview/test/template/form_collections_helper_test.rb b/actionview/test/template/form_collections_helper_test.rb new file mode 100644 index 0000000000..6db55a1447 --- /dev/null +++ b/actionview/test/template/form_collections_helper_test.rb @@ -0,0 +1,527 @@ +# frozen_string_literal: true + +require "abstract_unit" + +Category = Struct.new(:id, :name) + +class FormCollectionsHelperTest < ActionView::TestCase + def assert_no_select(selector, value = nil) + assert_select(selector, text: value, count: 0) + end + + def with_collection_radio_buttons(*args, &block) + @output_buffer = collection_radio_buttons(*args, &block) + end + + def with_collection_check_boxes(*args, &block) + @output_buffer = collection_check_boxes(*args, &block) + end + + # COLLECTION RADIO BUTTONS + test "collection radio accepts a collection and generates inputs from value method" do + with_collection_radio_buttons :user, :active, [true, false], :to_s, :to_s + + assert_select "input[type=radio][value=true]#user_active_true" + assert_select "input[type=radio][value=false]#user_active_false" + end + + test "collection radio accepts a collection and generates inputs from label method" do + with_collection_radio_buttons :user, :active, [true, false], :to_s, :to_s + + assert_select "label[for=user_active_true]", "true" + assert_select "label[for=user_active_false]", "false" + end + + test "collection radio handles camelized collection values for labels correctly" do + with_collection_radio_buttons :user, :active, ["Yes", "No"], :to_s, :to_s + + assert_select "label[for=user_active_yes]", "Yes" + assert_select "label[for=user_active_no]", "No" + end + + test "collection radio generates labels for non-English values correctly" do + with_collection_radio_buttons :user, :title, ["Господин", "Госпожа"], :to_s, :to_s + + assert_select "input[type=radio]#user_title_господин" + assert_select "label[for=user_title_господин]", "Господин" + end + + test "collection radio should sanitize collection values for labels correctly" do + with_collection_radio_buttons :user, :name, ["$0.99", "$1.99"], :to_s, :to_s + assert_select "label[for=user_name_099]", "$0.99" + assert_select "label[for=user_name_199]", "$1.99" + end + + test "collection radio accepts checked item" do + with_collection_radio_buttons :user, :active, [[1, true], [0, false]], :last, :first, checked: true + + assert_select "input[type=radio][value=true][checked=checked]" + assert_no_select "input[type=radio][value=false][checked=checked]" + end + + test "collection radio accepts multiple disabled items" do + collection = [[1, true], [0, false], [2, "other"]] + with_collection_radio_buttons :user, :active, collection, :last, :first, disabled: [true, false] + + assert_select "input[type=radio][value=true][disabled=disabled]" + assert_select "input[type=radio][value=false][disabled=disabled]" + assert_no_select "input[type=radio][value=other][disabled=disabled]" + end + + test "collection radio accepts single disabled item" do + collection = [[1, true], [0, false]] + with_collection_radio_buttons :user, :active, collection, :last, :first, disabled: true + + assert_select "input[type=radio][value=true][disabled=disabled]" + assert_no_select "input[type=radio][value=false][disabled=disabled]" + end + + test "collection radio accepts multiple readonly items" do + collection = [[1, true], [0, false], [2, "other"]] + with_collection_radio_buttons :user, :active, collection, :last, :first, readonly: [true, false] + + assert_select "input[type=radio][value=true][readonly=readonly]" + assert_select "input[type=radio][value=false][readonly=readonly]" + assert_no_select "input[type=radio][value=other][readonly=readonly]" + end + + test "collection radio accepts single readonly item" do + collection = [[1, true], [0, false]] + with_collection_radio_buttons :user, :active, collection, :last, :first, readonly: true + + assert_select "input[type=radio][value=true][readonly=readonly]" + assert_no_select "input[type=radio][value=false][readonly=readonly]" + end + + test "collection radio accepts html options as input" do + collection = [[1, true], [0, false]] + with_collection_radio_buttons :user, :active, collection, :last, :first, {}, { class: "special-radio" } + + assert_select "input[type=radio][value=true].special-radio#user_active_true" + assert_select "input[type=radio][value=false].special-radio#user_active_false" + end + + test "collection radio accepts html options as the last element of array" do + collection = [[1, true, { class: "foo" }], [0, false, { class: "bar" }]] + with_collection_radio_buttons :user, :active, collection, :second, :first + + assert_select "input[type=radio][value=true].foo#user_active_true" + assert_select "input[type=radio][value=false].bar#user_active_false" + end + + test "collection radio sets the label class defined inside the block" do + collection = [[1, true, { class: "foo" }], [0, false, { class: "bar" }]] + with_collection_radio_buttons :user, :active, collection, :second, :first do |b| + b.label(class: "collection_radio_buttons") + end + + assert_select "label.collection_radio_buttons[for=user_active_true]" + assert_select "label.collection_radio_buttons[for=user_active_false]" + end + + test "collection radio does not include the input class in the respective label" do + collection = [[1, true, { class: "foo" }], [0, false, { class: "bar" }]] + with_collection_radio_buttons :user, :active, collection, :second, :first + + assert_no_select "label.foo[for=user_active_true]" + assert_no_select "label.bar[for=user_active_false]" + end + + test "collection radio does not wrap input inside the label" do + with_collection_radio_buttons :user, :active, [true, false], :to_s, :to_s + + assert_select "input[type=radio] + label" + assert_no_select "label input" + end + + test "collection radio accepts a block to render the label as radio button wrapper" do + with_collection_radio_buttons :user, :active, [true, false], :to_s, :to_s do |b| + b.label { b.radio_button } + end + + assert_select "label[for=user_active_true] > input#user_active_true[type=radio]" + assert_select "label[for=user_active_false] > input#user_active_false[type=radio]" + end + + test "collection radio accepts a block to change the order of label and radio button" do + with_collection_radio_buttons :user, :active, [true, false], :to_s, :to_s do |b| + b.label + b.radio_button + end + + assert_select "label[for=user_active_true] + input#user_active_true[type=radio]" + assert_select "label[for=user_active_false] + input#user_active_false[type=radio]" + end + + test "collection radio with block helpers accept extra html options" do + with_collection_radio_buttons :user, :active, [true, false], :to_s, :to_s do |b| + b.label(class: "radio_button") + b.radio_button(class: "radio_button") + end + + assert_select "label.radio_button[for=user_active_true] + input#user_active_true.radio_button[type=radio]" + assert_select "label.radio_button[for=user_active_false] + input#user_active_false.radio_button[type=radio]" + end + + test "collection radio with block helpers allows access to current text and value" do + with_collection_radio_buttons :user, :active, [true, false], :to_s, :to_s do |b| + b.label("data-value": b.value) { b.radio_button + b.text } + end + + assert_select "label[for=user_active_true][data-value=true]", "true" do + assert_select "input#user_active_true[type=radio]" + end + assert_select "label[for=user_active_false][data-value=false]", "false" do + assert_select "input#user_active_false[type=radio]" + end + end + + test "collection radio with block helpers allows access to the current object item in the collection to access extra properties" do + with_collection_radio_buttons :user, :active, [true, false], :to_s, :to_s do |b| + b.label(class: b.object) { b.radio_button + b.text } + end + + assert_select "label.true[for=user_active_true]", "true" do + assert_select "input#user_active_true[type=radio]" + end + assert_select "label.false[for=user_active_false]", "false" do + assert_select "input#user_active_false[type=radio]" + end + end + + test "collection radio buttons with fields for" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + @output_buffer = fields_for(:post) do |p| + p.collection_radio_buttons :category_id, collection, :id, :name + end + + assert_select 'input#post_category_id_1[type=radio][value="1"]' + assert_select 'input#post_category_id_2[type=radio][value="2"]' + + assert_select "label[for=post_category_id_1]", "Category 1" + assert_select "label[for=post_category_id_2]", "Category 2" + end + + test "collection radio accepts checked item which has a value of false" do + with_collection_radio_buttons :user, :active, [[1, true], [0, false]], :last, :first, checked: false + assert_no_select "input[type=radio][value=true][checked=checked]" + assert_select "input[type=radio][value=false][checked=checked]" + end + + test "collection radio buttons generates only one hidden field for the entire collection, to ensure something will be sent back to the server when posting an empty collection" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_radio_buttons :user, :category_ids, collection, :id, :name + + assert_select "input[type=hidden][name='user[category_ids]'][value='']", count: 1 + end + + test "collection radio buttons generates a hidden field using the given :name in :html_options" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_radio_buttons :user, :category_ids, collection, :id, :name, {}, { name: "user[other_category_ids]" } + + assert_select "input[type=hidden][name='user[other_category_ids]'][value='']", count: 1 + end + + test "collection radio buttons generates a hidden field with index if it was provided" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_radio_buttons :user, :category_ids, collection, :id, :name, index: 322 + + assert_select "input[type=hidden][name='user[322][category_ids]'][value='']", count: 1 + end + + test "collection radio buttons does not generate a hidden field if include_hidden option is false" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_radio_buttons :user, :category_ids, collection, :id, :name, include_hidden: false + + assert_select "input[type=hidden][name='user[category_ids]'][value='']", count: 0 + end + + test "collection radio buttons does not generate a hidden field if include_hidden option is false with key as string" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_radio_buttons :user, :category_ids, collection, :id, :name, "include_hidden" => false + + assert_select "input[type=hidden][name='user[category_ids]'][value='']", count: 0 + end + + # COLLECTION CHECK BOXES + test "collection check boxes accepts a collection and generate a series of checkboxes for value method" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_check_boxes :user, :category_ids, collection, :id, :name + + assert_select 'input#user_category_ids_1[type=checkbox][value="1"]' + assert_select 'input#user_category_ids_2[type=checkbox][value="2"]' + end + + test "collection check boxes generates only one hidden field for the entire collection, to ensure something will be sent back to the server when posting an empty collection" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_check_boxes :user, :category_ids, collection, :id, :name + + assert_select "input[type=hidden][name='user[category_ids][]'][value='']", count: 1 + end + + test "collection check boxes generates a hidden field using the given :name in :html_options" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_check_boxes :user, :category_ids, collection, :id, :name, {}, { name: "user[other_category_ids][]" } + + assert_select "input[type=hidden][name='user[other_category_ids][]'][value='']", count: 1 + end + + test "collection check boxes generates a hidden field with index if it was provided" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_check_boxes :user, :category_ids, collection, :id, :name, index: 322 + + assert_select "input[type=hidden][name='user[322][category_ids][]'][value='']", count: 1 + end + + test "collection check boxes does not generate a hidden field if include_hidden option is false" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_check_boxes :user, :category_ids, collection, :id, :name, include_hidden: false + + assert_select "input[type=hidden][name='user[category_ids][]'][value='']", count: 0 + end + + test "collection check boxes does not generate a hidden field if include_hidden option is false with key as string" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_check_boxes :user, :category_ids, collection, :id, :name, "include_hidden" => false + + assert_select "input[type=hidden][name='user[category_ids][]'][value='']", count: 0 + end + + test "collection check boxes accepts a collection and generate a series of checkboxes with labels for label method" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + with_collection_check_boxes :user, :category_ids, collection, :id, :name + + assert_select "label[for=user_category_ids_1]", "Category 1" + assert_select "label[for=user_category_ids_2]", "Category 2" + end + + test "collection check boxes handles camelized collection values for labels correctly" do + with_collection_check_boxes :user, :active, ["Yes", "No"], :to_s, :to_s + + assert_select "label[for=user_active_yes]", "Yes" + assert_select "label[for=user_active_no]", "No" + end + + test "collection check box should sanitize collection values for labels correctly" do + with_collection_check_boxes :user, :name, ["$0.99", "$1.99"], :to_s, :to_s + assert_select "label[for=user_name_099]", "$0.99" + assert_select "label[for=user_name_199]", "$1.99" + end + + test "collection check boxes generates labels for non-English values correctly" do + with_collection_check_boxes :user, :title, ["Господин", "Госпожа"], :to_s, :to_s + + assert_select "input[type=checkbox]#user_title_господин" + assert_select "label[for=user_title_господин]", "Господин" + end + + test "collection check boxes accepts html options as the last element of array" do + collection = [[1, "Category 1", { class: "foo" }], [2, "Category 2", { class: "bar" }]] + with_collection_check_boxes :user, :active, collection, :first, :second + + assert_select 'input[type=checkbox][value="1"].foo' + assert_select 'input[type=checkbox][value="2"].bar' + end + + test "collection check boxes propagates input id to the label for attribute" do + collection = [[1, "Category 1", { id: "foo" }], [2, "Category 2", { id: "bar" }]] + with_collection_check_boxes :user, :active, collection, :first, :second + + assert_select 'input[type=checkbox][value="1"]#foo' + assert_select 'input[type=checkbox][value="2"]#bar' + + assert_select "label[for=foo]" + assert_select "label[for=bar]" + end + + test "collection check boxes sets the label class defined inside the block" do + collection = [[1, "Category 1", { class: "foo" }], [2, "Category 2", { class: "bar" }]] + with_collection_check_boxes :user, :active, collection, :second, :first do |b| + b.label(class: "collection_check_boxes") + end + + assert_select "label.collection_check_boxes[for=user_active_category_1]" + assert_select "label.collection_check_boxes[for=user_active_category_2]" + end + + test "collection check boxes does not include the input class in the respective label" do + collection = [[1, "Category 1", { class: "foo" }], [2, "Category 2", { class: "bar" }]] + with_collection_check_boxes :user, :active, collection, :second, :first + + assert_no_select "label.foo[for=user_active_category_1]" + assert_no_select "label.bar[for=user_active_category_2]" + end + + test "collection check boxes accepts selected values as :checked option" do + collection = (1..3).map { |i| [i, "Category #{i}"] } + with_collection_check_boxes :user, :category_ids, collection, :first, :last, checked: [1, 3] + + assert_select 'input[type=checkbox][value="1"][checked=checked]' + assert_select 'input[type=checkbox][value="3"][checked=checked]' + assert_no_select 'input[type=checkbox][value="2"][checked=checked]' + end + + test "collection check boxes accepts selected string values as :checked option" do + collection = (1..3).map { |i| [i, "Category #{i}"] } + with_collection_check_boxes :user, :category_ids, collection, :first, :last, checked: ["1", "3"] + + assert_select 'input[type=checkbox][value="1"][checked=checked]' + assert_select 'input[type=checkbox][value="3"][checked=checked]' + assert_no_select 'input[type=checkbox][value="2"][checked=checked]' + end + + test "collection check boxes accepts a single checked value" do + collection = (1..3).map { |i| [i, "Category #{i}"] } + with_collection_check_boxes :user, :category_ids, collection, :first, :last, checked: 3 + + assert_select 'input[type=checkbox][value="3"][checked=checked]' + assert_no_select 'input[type=checkbox][value="1"][checked=checked]' + assert_no_select 'input[type=checkbox][value="2"][checked=checked]' + end + + test "collection check boxes accepts selected values as :checked option and override the model values" do + user = Struct.new(:category_ids).new(2) + collection = (1..3).map { |i| [i, "Category #{i}"] } + + @output_buffer = fields_for(:user, user) do |p| + p.collection_check_boxes :category_ids, collection, :first, :last, checked: [1, 3] + end + + assert_select 'input[type=checkbox][value="1"][checked=checked]' + assert_select 'input[type=checkbox][value="3"][checked=checked]' + assert_no_select 'input[type=checkbox][value="2"][checked=checked]' + end + + test "collection check boxes accepts multiple disabled items" do + collection = (1..3).map { |i| [i, "Category #{i}"] } + with_collection_check_boxes :user, :category_ids, collection, :first, :last, disabled: [1, 3] + + assert_select 'input[type=checkbox][value="1"][disabled=disabled]' + assert_select 'input[type=checkbox][value="3"][disabled=disabled]' + assert_no_select 'input[type=checkbox][value="2"][disabled=disabled]' + end + + test "collection check boxes accepts single disabled item" do + collection = (1..3).map { |i| [i, "Category #{i}"] } + with_collection_check_boxes :user, :category_ids, collection, :first, :last, disabled: 1 + + assert_select 'input[type=checkbox][value="1"][disabled=disabled]' + assert_no_select 'input[type=checkbox][value="3"][disabled=disabled]' + assert_no_select 'input[type=checkbox][value="2"][disabled=disabled]' + end + + test "collection check boxes accepts a proc to disabled items" do + collection = (1..3).map { |i| [i, "Category #{i}"] } + with_collection_check_boxes :user, :category_ids, collection, :first, :last, disabled: proc { |i| i.first == 1 } + + assert_select 'input[type=checkbox][value="1"][disabled=disabled]' + assert_no_select 'input[type=checkbox][value="3"][disabled=disabled]' + assert_no_select 'input[type=checkbox][value="2"][disabled=disabled]' + end + + test "collection check boxes accepts multiple readonly items" do + collection = (1..3).map { |i| [i, "Category #{i}"] } + with_collection_check_boxes :user, :category_ids, collection, :first, :last, readonly: [1, 3] + + assert_select 'input[type=checkbox][value="1"][readonly=readonly]' + assert_select 'input[type=checkbox][value="3"][readonly=readonly]' + assert_no_select 'input[type=checkbox][value="2"][readonly=readonly]' + end + + test "collection check boxes accepts single readonly item" do + collection = (1..3).map { |i| [i, "Category #{i}"] } + with_collection_check_boxes :user, :category_ids, collection, :first, :last, readonly: 1 + + assert_select 'input[type=checkbox][value="1"][readonly=readonly]' + assert_no_select 'input[type=checkbox][value="3"][readonly=readonly]' + assert_no_select 'input[type=checkbox][value="2"][readonly=readonly]' + end + + test "collection check boxes accepts a proc to readonly items" do + collection = (1..3).map { |i| [i, "Category #{i}"] } + with_collection_check_boxes :user, :category_ids, collection, :first, :last, readonly: proc { |i| i.first == 1 } + + assert_select 'input[type=checkbox][value="1"][readonly=readonly]' + assert_no_select 'input[type=checkbox][value="3"][readonly=readonly]' + assert_no_select 'input[type=checkbox][value="2"][readonly=readonly]' + end + + test "collection check boxes accepts html options" do + collection = [[1, "Category 1"], [2, "Category 2"]] + with_collection_check_boxes :user, :category_ids, collection, :first, :last, {}, { class: "check" } + + assert_select 'input.check[type=checkbox][value="1"]' + assert_select 'input.check[type=checkbox][value="2"]' + end + + test "collection check boxes with fields for" do + collection = [Category.new(1, "Category 1"), Category.new(2, "Category 2")] + @output_buffer = fields_for(:post) do |p| + p.collection_check_boxes :category_ids, collection, :id, :name + end + + assert_select 'input#post_category_ids_1[type=checkbox][value="1"]' + assert_select 'input#post_category_ids_2[type=checkbox][value="2"]' + + assert_select "label[for=post_category_ids_1]", "Category 1" + assert_select "label[for=post_category_ids_2]", "Category 2" + end + + test "collection check boxes does not wrap input inside the label" do + with_collection_check_boxes :user, :active, [true, false], :to_s, :to_s + + assert_select "input[type=checkbox] + label" + assert_no_select "label input" + end + + test "collection check boxes accepts a block to render the label as check box wrapper" do + with_collection_check_boxes :user, :active, [true, false], :to_s, :to_s do |b| + b.label { b.check_box } + end + + assert_select "label[for=user_active_true] > input#user_active_true[type=checkbox]" + assert_select "label[for=user_active_false] > input#user_active_false[type=checkbox]" + end + + test "collection check boxes accepts a block to change the order of label and check box" do + with_collection_check_boxes :user, :active, [true, false], :to_s, :to_s do |b| + b.label + b.check_box + end + + assert_select "label[for=user_active_true] + input#user_active_true[type=checkbox]" + assert_select "label[for=user_active_false] + input#user_active_false[type=checkbox]" + end + + test "collection check boxes with block helpers accept extra html options" do + with_collection_check_boxes :user, :active, [true, false], :to_s, :to_s do |b| + b.label(class: "check_box") + b.check_box(class: "check_box") + end + + assert_select "label.check_box[for=user_active_true] + input#user_active_true.check_box[type=checkbox]" + assert_select "label.check_box[for=user_active_false] + input#user_active_false.check_box[type=checkbox]" + end + + test "collection check boxes with block helpers allows access to current text and value" do + with_collection_check_boxes :user, :active, [true, false], :to_s, :to_s do |b| + b.label("data-value": b.value) { b.check_box + b.text } + end + + assert_select "label[for=user_active_true][data-value=true]", "true" do + assert_select "input#user_active_true[type=checkbox]" + end + assert_select "label[for=user_active_false][data-value=false]", "false" do + assert_select "input#user_active_false[type=checkbox]" + end + end + + test "collection check boxes with block helpers allows access to the current object item in the collection to access extra properties" do + with_collection_check_boxes :user, :active, [true, false], :to_s, :to_s do |b| + b.label(class: b.object) { b.check_box + b.text } + end + + assert_select "label.true[for=user_active_true]", "true" do + assert_select "input#user_active_true[type=checkbox]" + end + assert_select "label.false[for=user_active_false]", "false" do + assert_select "input#user_active_false[type=checkbox]" + end + end +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..f84c9b2b73 --- /dev/null +++ b/actionview/test/template/form_helper/form_with_test.rb @@ -0,0 +1,2367 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "controller/fake_models" + +class FormWithTest < ActionView::TestCase + include RenderERBUtils + + setup do + @old_value = ActionView::Helpers::FormHelper.form_with_generates_ids + ActionView::Helpers::FormHelper.form_with_generates_ids = true + end + + teardown do + ActionView::Helpers::FormHelper.form_with_generates_ids = @old_value + end + + private + def with_default_enforce_utf8(value) + old_value = ActionView::Helpers::FormTagHelper.default_enforce_utf8 + ActionView::Helpers::FormTagHelper.default_enforce_utf8 = value + + yield + ensure + ActionView::Helpers::FormTagHelper.default_enforce_utf8 = old_value + end +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_predicate actual, :html_safe? + end + + def test_form_with_default_enforce_utf8_false + with_default_enforce_utf8 false do + actual = form_with + expected = whole_form("http://www.example.com", skip_enforcing_utf8: true) + assert_dom_equal expected, actual + assert_predicate actual, :html_safe? + end + end + + def test_form_with_default_enforce_utf8_true + with_default_enforce_utf8 true do + actual = form_with + expected = whole_form("http://www.example.com", skip_enforcing_utf8: false) + assert_dom_equal expected, actual + assert_predicate actual, :html_safe? + end + 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 + + def test_form_with_with_block_in_erb_and_local_true + output_buffer = render_erb("<%= form_with(url: 'http://www.example.com', local: true) do %>Hello world!<% end %>") + + expected = whole_form("http://www.example.com", local: true) 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}" + }, + "blog/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") + @controller.singleton_class.include Routes.url_helpers + 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 + + 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[:controller] = "main" + object[: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.select(:category, %w( animal economy sports )) + 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' value='Hello World' id='post_title' />" \ + "<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' value='1' id='post_secret' />" \ + "<select name='post[category]' id='post_category'><option value='animal'>animal</option>\n<option value='economy'>economy</option>\n<option value='sports'>sports</option></select>" \ + "<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_not_outputting_ids + old_value = ActionView::Helpers::FormHelper.form_with_generates_ids + ActionView::Helpers::FormHelper.form_with_generates_ids = false + + 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.select(:category, %w( animal economy sports )) + concat f.submit("Create post") + end + + expected = whole_form("/posts/123", "create-post", method: "patch") do + "<label>The Title</label>" \ + "<input name='post[title]' type='text' value='Hello World' />" \ + "<textarea name='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' value='1' />" \ + "<select name='post[category]'><option value='animal'>animal</option>\n<option value='economy'>economy</option>\n<option value='sports'>sports</option></select>" \ + "<input name='commit' data-disable-with='Create post' type='submit' value='Create post' />" + end + + assert_dom_equal expected, output_buffer + ensure + ActionView::Helpers::FormHelper.form_with_generates_ids = old_value + end + + def test_form_with_only_url_on_create + form_with(url: "/posts") do |f| + concat f.label :title, "Label me" + concat f.text_field :title + end + + expected = whole_form("/posts") do + '<label for="title">Label me</label>' \ + '<input type="text" name="title" id="title">' + end + + assert_dom_equal expected, output_buffer + end + + def test_form_with_only_url_on_update + form_with(url: "/posts/123") do |f| + concat f.label :title, "Label me" + concat f.text_field :title + end + + expected = whole_form("/posts/123") do + '<label for="title">Label me</label>' \ + '<input type="text" name="title" id="title">' + end + + assert_dom_equal expected, output_buffer + end + + def test_form_with_general_attributes + form_with(url: "/posts/123") do |f| + concat f.text_field :no_model_to_back_this_badboy + end + + expected = whole_form("/posts/123") do + '<input type="text" name="no_model_to_back_this_badboy" id="no_model_to_back_this_badboy" >' + end + + assert_dom_equal expected, output_buffer + end + + def test_form_with_attribute_not_on_model + form_with(model: @post) do |f| + concat f.text_field :this_dont_exist_on_post + end + + expected = whole_form("/posts/123", method: :patch) do + '<input type="text" name="post[this_dont_exist_on_post]" id="post_this_dont_exist_on_post" >' + end + + assert_dom_equal expected, output_buffer + end + + def test_form_with_doesnt_call_private_or_protected_properties_on_form_object_skipping_value + obj = Class.new do + private + def private_property + "That would be great." + end + + protected + def protected_property + "I believe you have my stapler." + end + end.new + + form_with(model: obj, scope: "other_name", url: "/", id: "edit-other-name") do |f| + assert_dom_equal '<input type="hidden" name="other_name[private_property]" id="other_name_private_property">', f.hidden_field(:private_property) + assert_dom_equal '<input type="hidden" name="other_name[protected_property]" id="other_name_protected_property">', f.hidden_field(:protected_property) + end + end + + def test_form_with_with_collection_select + post = Post.new + def post.active; false; end + form_with(model: post) do |f| + concat f.collection_select(:active, [true, false], :to_s, :to_s) + end + + expected = whole_form("/posts") do + "<select name='post[active]' id='post_active'>" \ + "<option value='true'>true</option>\n" \ + "<option selected='selected' value='false'>false</option>" \ + "</select>" + 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 name='post[active]' type='radio' value='true' id='post_active_true' />" \ + "<label for='post_active_true'>true</label>" \ + "<input checked='checked' name='post[active]' type='radio' value='false' id='post_active_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 name='post[active]' type='radio' value='true' id='post_active_true' />" \ + "true</label>" \ + "<label for='post_active_false'>" \ + "<input checked='checked' name='post[active]' type='radio' value='false' id='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_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 name='post[active]' type='radio' value='true' id='post_active_true' />" \ + "true</label>" \ + "<label for='post_active_false'>" \ + "<input checked='checked' name='post[active]' type='radio' value='false' id='post_active_false' />" \ + "false</label>" \ + "<input name='post[id]' type='hidden' value='1' id='post_id' />" + 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 name='post[1][active]' type='radio' value='true' id='post_1_active_true' />" \ + "<label for='post_1_active_true'>true</label>" \ + "<input checked='checked' name='post[1][active]' type='radio' value='false' id='post_1_active_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' name='post[tag_ids][]' type='checkbox' value='1' id='post_tag_ids_1' />" \ + "<label for='post_tag_ids_1'>Tag 1</label>" \ + "<input name='post[tag_ids][]' type='checkbox' value='2' id='post_tag_ids_2' />" \ + "<label for='post_tag_ids_2'>Tag 2</label>" \ + "<input checked='checked' name='post[tag_ids][]' type='checkbox' value='3' id='post_tag_ids_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' name='post[tag_ids][]' type='checkbox' value='1' id='post_tag_ids_1' />" \ + "Tag 1</label>" \ + "<label for='post_tag_ids_2'>" \ + "<input name='post[tag_ids][]' type='checkbox' value='2' id='post_tag_ids_2' />" \ + "Tag 2</label>" \ + "<label for='post_tag_ids_3'>" \ + "<input checked='checked' name='post[tag_ids][]' type='checkbox' value='3' id='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_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' name='post[tag_ids][]' type='checkbox' value='1' id='post_tag_ids_1' />" \ + "Tag 1</label>" \ + "<label for='post_tag_ids_2'>" \ + "<input name='post[tag_ids][]' type='checkbox' value='2' id='post_tag_ids_2' />" \ + "Tag 2</label>" \ + "<label for='post_tag_ids_3'>" \ + "<input checked='checked' name='post[tag_ids][]' type='checkbox' value='3' id='post_tag_ids_3' />" \ + "Tag 3</label>" \ + "<input name='post[id]' type='hidden' value='1' id='post_id' />" + 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' name='post[1][tag_ids][]' type='checkbox' value='1' id='post_1_tag_ids_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 + 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 + 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' value='And his name will be forty and four.' id='post_title' />" \ + "<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]' value='Hello World' type='text' id='other_name_title' />" \ + "<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' value='1' type='checkbox' id='other_name_secret' />" \ + "<input name='commit' value='Create post' data-disable-with='Create post' type='submit' />" + end + + assert_dom_equal expected, output_buffer + 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' value='Hello World' id='post_title' />" \ + "<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' value='1' id='post_secret'/>" + 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' value='Hello World' id='post_title' />" \ + "<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' value='1' id='post_secret' />" + 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' value='Hello World' id='post_title' />" \ + "<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' value='1' id='post_secret' />" + end + + assert_dom_equal expected, output_buffer + end + + def test_form_is_not_remote_by_default_if_form_with_generates_remote_forms_is_false + old_value = ActionView::Helpers::FormHelper.form_with_generates_remote_forms + ActionView::Helpers::FormHelper.form_with_generates_remote_forms = false + + 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", local: true) do + "<input name='post[title]' type='text' value='Hello World' id='post_title' />" \ + "<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' value='1' id='post_secret' />" + end + + assert_dom_equal expected, output_buffer + ensure + ActionView::Helpers::FormHelper.form_with_generates_remote_forms = old_value + 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' value='Hello World' id='post_title' />" + 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' value='Hello World' id='post_title' />" + end + + assert_dom_equal expected, output_buffer + end + + def test_form_with_default_enforce_utf8_true + with_default_enforce_utf8 true do + form_with(scope: :post) do |f| + concat f.text_field(:title) + end + + expected = whole_form("/", skip_enforcing_utf8: false) do + "<input name='post[title]' type='text' value='Hello World' id='post_title' />" + end + + assert_dom_equal expected, output_buffer + end + end + + def test_form_with_default_enforce_utf8_false + with_default_enforce_utf8 false do + form_with(scope: :post) do |f| + concat f.text_field(:title) + end + + expected = whole_form("/", skip_enforcing_utf8: true) do + "<input name='post[title]' type='text' value='Hello World' id='post_title' />" + end + + assert_dom_equal expected, output_buffer + end + 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' value='Hello World' id='post_title' />" \ + "<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' value='1' id='post_secret' />" + 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' value='Hello World' id='post_123_title' />" \ + "<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' value='1' id='post_123_secret' />" + 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' value='Hello World' id='post__title' />" \ + "<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' value='1' id='post__secret' />" + 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' value='' id='post_author_name' /></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' value='' id='post_author_name' /></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_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_which_is_overwritten_by_scope_option + 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_submit_with_object_which_is_namespaced + blog_post = Blog::Post.new("And his name will be forty and four.", 44) + with_locale :submit do + form_with(model: blog_post) do |f| + concat f.submit + end + + expected = whole_form("/posts/44", 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_fields_with_attributes_not_on_model + form_with(model: @post) do |f| + concat f.fields(:comment) { |c| + concat c.text_field :dont_exist_on_model + } + end + + expected = whole_form("/posts/123", method: :patch) do + '<input type="text" name="post[comment][dont_exist_on_model]" id="post_comment_dont_exist_on_model" >' + end + + assert_dom_equal expected, output_buffer + end + + def test_fields_with_attributes_not_on_model_deep_nested + @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(:dont_exist_on_model) + } + end + end + end + + expected = whole_form do + '<input name="posts[post][0][comment][1][dont_exist_on_model]" type="text" id="posts_post_0_comment_1_dont_exist_on_model" >' + end + + assert_dom_equal expected, output_buffer + 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' value='Hello World' id='post_comment_body' />" + 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' value='comment #1' id='posts_post_0_comment_1_name' />" + 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' value='Hello World' id='post_123_title' />" \ + "<input name='post[123][comment][][name]' type='text' value='new comment' id='post_123_comment__name' />" + 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' value='Hello World' id='post_1_title' />" \ + "<input name='post[1][comment][1][name]' type='text' value='new comment' id='post_1_comment_1_name' />" + 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' value='Hello World' id='post_1_comment_title' />" + 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' value='Hello World' id='post_1_comment_5_title' />" + 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' value='Hello World' id='post_123_comment_title' />" + 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' value='hello' id='post_comment_5_title_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' value='Hello World' id='post_123_comment_123_title' />" + 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' value='Hello World' id='post_123_comment_5_title' />" + end + whole_form("/posts/123", method: "patch") do + "<input name='post[1][comment][123][title]' type='text' value='Hello World' id='post_1_comment_123_title' />" + 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" value="Hello World" id="post_title" />' \ + '<input name="post[author_attributes][name]" type="text" value="new author" id="post_author_attributes_name" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[author_attributes][name]" type="text" value="author #321" id="post_author_attributes_name" />' \ + '<input name="post[author_attributes][id]" type="hidden" value="321" id="post_author_attributes_id" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[author_attributes][name]" type="text" value="author #321" id="post_author_attributes_name" />' \ + '<input name="post[author_attributes][id]" type="hidden" value="321" id="post_author_attributes_id" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[author_attributes][name]" type="text" value="author #321" id="post_author_attributes_name" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[author_attributes][name]" type="text" value="author #321" id="post_author_attributes_name" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[author_attributes][name]" type="text" value="author #321" id="post_author_attributes_name" />' \ + '<input name="post[author_attributes][id]" type="hidden" value="321" id="post_author_attributes_id" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[author_attributes][id]" type="hidden" value="321" id="post_author_attributes_id" />' \ + '<input name="post[author_attributes][name]" type="text" value="author #321" id="post_author_attributes_name" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="comment #1" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][0][id]" type="hidden" value="1" id="post_comments_attributes_0_id" />' \ + '<input name="post[comments_attributes][1][name]" type="text" value="comment #2" id="post_comments_attributes_1_name" />' \ + '<input name="post[comments_attributes][1][id]" type="hidden" value="2" id="post_comments_attributes_1_id" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[author_attributes][name]" type="text" value="author #321" id="post_author_attributes_name" />' \ + '<input name="post[author_attributes][id]" type="hidden" value="321" id="post_author_attributes_id" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="comment #1" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][1][name]" type="text" value="comment #2" id="post_comments_attributes_1_name" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[author_attributes][name]" type="text" value="author #321" id="post_author_attributes_name" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="comment #1" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][1][name]" type="text" value="comment #2" id="post_comments_attributes_1_name" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[author_attributes][name]" type="text" value="author #321" id="post_author_attributes_name" />' \ + '<input name="post[author_attributes][id]" type="hidden" value="321" id="post_author_attributes_id" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="comment #1" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][1][name]" type="text" value="comment #2" id="post_comments_attributes_1_name" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="comment #1" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][0][id]" type="hidden" value="1" id="post_comments_attributes_0_id" />' \ + '<input name="post[comments_attributes][1][name]" type="text" value="comment #2" id="post_comments_attributes_1_name" />' \ + '<input name="post[comments_attributes][1][id]" type="hidden" value="2" id="post_comments_attributes_1_id" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[comments_attributes][0][id]" type="hidden" value="1" id="post_comments_attributes_0_id" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="comment #1" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][1][id]" type="hidden" value="2" id="post_comments_attributes_1_id" />' \ + '<input name="post[comments_attributes][1][name]" type="text" value="comment #2" id="post_comments_attributes_1_name" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="new comment" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][1][name]" type="text" value="new comment" id="post_comments_attributes_1_name" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="comment #321" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][0][id]" type="hidden" value="321" id="post_comments_attributes_0_id"/>' \ + '<input name="post[comments_attributes][1][name]" type="text" value="new comment" id="post_comments_attributes_1_name" />' + 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" value="Hello World" id="post_title" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="comment #1" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][0][id]" type="hidden" value="1" id="post_comments_attributes_0_id" />' \ + '<input name="post[comments_attributes][1][name]" type="text" value="comment #2" id="post_comments_attributes_1_name" />' \ + '<input name="post[comments_attributes][1][id]" type="hidden" value="2" id="post_comments_attributes_1_id" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="comment #1" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][0][id]" type="hidden" value="1" id="post_comments_attributes_0_id" />' \ + '<input name="post[comments_attributes][1][name]" type="text" value="comment #2" id="post_comments_attributes_1_name" />' \ + '<input name="post[comments_attributes][1][id]" type="hidden" value="2" id="post_comments_attributes_1_id" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="comment #1" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][0][id]" type="hidden" value="1" id="post_comments_attributes_0_id" />' \ + '<input name="post[comments_attributes][1][name]" type="text" value="comment #2" id="post_comments_attributes_1_name" />' \ + '<input name="post[comments_attributes][1][id]" type="hidden" value="2" id="post_comments_attributes_1_id" />' + 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" value="Hello World" id="post_title" />' \ + '<input name="post[comments_attributes][0][name]" type="text" value="comment #321" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][0][id]" type="hidden" value="321" id="post_comments_attributes_0_id" />' \ + '<input name="post[comments_attributes][1][name]" type="text" value="new comment" id="post_comments_attributes_1_name" />' + 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 name="post[comments_attributes][abc][name]" type="text" value="comment #321" id="post_comments_attributes_abc_name" />' \ + '<input name="post[comments_attributes][abc][id]" type="hidden" value="321" id="post_comments_attributes_abc_id" />' + 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 name="post[comments_attributes][abc][name]" type="text" value="comment #321" id="post_comments_attributes_abc_name" />' \ + '<input name="post[comments_attributes][abc][id]" type="hidden" value="321" id="post_comments_attributes_abc_id" />' + 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 name="post[comments_attributes][abc][name]" type="text" value="comment #321" id="post_comments_attributes_abc_name" />' \ + '<input name="post[comments_attributes][abc][id]" type="hidden" value="321" id="post_comments_attributes_abc_id" />' + 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 expected, cf.index + 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 expected, cf.index + 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 expected, cf.index + 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 "abc", cf.index + } + 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 name="post[comments_attributes][0][name]" type="text" value="comment #321" id="post_comments_attributes_0_name" />' \ + '<input name="post[comments_attributes][0][relevances_attributes][0][value]" type="text" value="commentrelevance #314" id="post_comments_attributes_0_relevances_attributes_0_value" />' \ + '<input name="post[comments_attributes][0][relevances_attributes][0][id]" type="hidden" value="314" id="post_comments_attributes_0_relevances_attributes_0_id"/>' \ + '<input name="post[comments_attributes][0][id]" type="hidden" value="321" id="post_comments_attributes_0_id"/>' \ + '<input name="post[tags_attributes][0][value]" type="text" value="tag #123" id="post_tags_attributes_0_value"/>' \ + '<input name="post[tags_attributes][0][relevances_attributes][0][value]" type="text" value="tagrelevance #3141" id="post_tags_attributes_0_relevances_attributes_0_value"/>' \ + '<input name="post[tags_attributes][0][relevances_attributes][0][id]" type="hidden" value="3141" id="post_tags_attributes_0_relevances_attributes_0_id"/>' \ + '<input name="post[tags_attributes][0][id]" type="hidden" value="123" id="post_tags_attributes_0_id"/>' \ + '<input name="post[tags_attributes][1][value]" type="text" value="tag #456" id="post_tags_attributes_1_value"/>' \ + '<input name="post[tags_attributes][1][relevances_attributes][0][value]" type="text" value="tagrelevance #31415" id="post_tags_attributes_1_relevances_attributes_0_value"/>' \ + '<input name="post[tags_attributes][1][relevances_attributes][0][id]" type="hidden" value="31415" id="post_tags_attributes_1_relevances_attributes_0_id"/>' \ + '<input name="post[tags_attributes][1][id]" type="hidden" value="456" id="post_tags_attributes_1_id"/>' + 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 name="post[author_attributes][name]" type="text" value="hash backed author" id="post_author_attributes_name" />' + 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' value='Hello World' id='post_title' />" \ + "<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' value='1' id='post_secret' />" + + 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' value='Hello World' id='post_123_title' />" \ + "<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' value='1' id='post_123_secret' />" + + 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' value='Hello World' id='post__title' />" \ + "<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' value='1' id='post__secret' />" + + 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' value='Hello World' id='post_abc_title' />" \ + "<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' value='1' id='post_abc_secret' />" + + 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' value='Hello World' id='post_title' />" \ + "<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' value='1' id='post_secret' />" + + 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' value='Hello World' id='post_title' />" \ + "<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' value='1' id='post_secret' />" + + 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' value='Hello World' id='author_post_title' id='author_post_1_title' />", + 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' value='Hello World' id='author_post_1_title' />", + 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' value='Hello World' id='post_title' />" \ + "<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' value='1' id='parent_post_secret' />" + 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' value='Hello World' id='post_title' />" \ + "<textarea name='post[body]' id='post_body'>\nBack to the hill and over it again!</textarea>" \ + "<input name='post[comment][name]' type='text' value='new comment' id='post_comment_name' />" + 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' value='Hello World' id='post_title'/><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' value='1' id='post_secret' /><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' value='Hello World' id='post_title' /><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' value='1' id='post_secret' /><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' value='Hello World' id='post_title' /><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' value='Hello World' id='post_title' /><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' value='Hello World' id='post_title' /><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' value='Hello World' id='post_title'/><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' value='1' id='post_secret' /><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 + + private + 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/form_helper_test.rb b/actionview/test/template/form_helper_test.rb new file mode 100644 index 0000000000..5972946074 --- /dev/null +++ b/actionview/test/template/form_helper_test.rb @@ -0,0 +1,3611 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "controller/fake_models" + +class FormHelperTest < ActionView::TestCase + include RenderERBUtils + + tests ActionView::Helpers::FormHelper + + class WithActiveStorageRoutesControllers < ActionController::Base + test_routes do + post "/rails/active_storage/direct_uploads" => "active_storage/direct_uploads#create", as: :rails_direct_uploads + end + + def url_options + { host: "testtwo.host" } + end + end + + def form_for(*) + @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}" + }, + "blog/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") + @controller.singleton_class.include Routes.url_helpers + 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[:controller] = "main" + object[:action] = "index" + end + + super + end + + class FooTag < ActionView::Helpers::Tags::Base + def initialize; end + end + + def test_tags_base_child_without_render_method + assert_raise(NotImplementedError) { FooTag.new.render } + end + + def test_label + assert_dom_equal('<label for="post_title">Title</label>', label("post", "title")) + assert_dom_equal( + '<label for="post_title">The title goes here</label>', + label("post", "title", "The title goes here") + ) + assert_dom_equal( + '<label class="title_label" for="post_title">Title</label>', + label("post", "title", nil, class: "title_label") + ) + assert_dom_equal('<label for="post_secret">Secret?</label>', label("post", "secret?")) + end + + def test_label_with_symbols + assert_dom_equal('<label for="post_title">Title</label>', label(:post, :title)) + assert_dom_equal('<label for="post_secret">Secret?</label>', label(:post, :secret?)) + end + + def test_label_with_locales_strings + with_locale :label do + assert_dom_equal('<label for="post_body">Write entire text here</label>', label("post", "body")) + end + end + + def test_label_with_human_attribute_name + with_locale :label do + assert_dom_equal('<label for="post_cost">Total cost</label>', label(:post, :cost)) + end + end + + def test_label_with_human_attribute_name_and_options + with_locale :label do + assert_dom_equal('<label for="post_language_spanish">Espanol</label>', label(:post, :language, value: "spanish")) + end + end + + def test_label_with_locales_symbols + with_locale :label do + assert_dom_equal('<label for="post_body">Write entire text here</label>', label(:post, :body)) + end + end + + def test_label_with_locales_and_options + with_locale :label do + assert_dom_equal( + '<label for="post_body" class="post_body">Write entire text here</label>', + label(:post, :body, class: "post_body") + ) + end + end + + def test_label_with_locales_and_value + with_locale :label do + assert_dom_equal('<label for="post_color_red">Rojo</label>', label(:post, :color, value: "red")) + end + end + + def test_label_with_locales_and_nested_attributes + with_locale :label do + form_for(@post, html: { id: "create-post" }) do |f| + f.fields_for(:comments) do |cf| + concat cf.label(:body) + end + end + + expected = whole_form("/posts/123", "create-post", "edit_post", method: "patch") do + '<label for="post_comments_attributes_0_body">Write body here</label>' + end + + assert_dom_equal expected, output_buffer + end + end + + def test_label_with_locales_fallback_and_nested_attributes + with_locale :label do + form_for(@post, html: { id: "create-post" }) do |f| + f.fields_for(:tags) do |cf| + concat cf.label(:value) + end + end + + expected = whole_form("/posts/123", "create-post", "edit_post", method: "patch") do + '<label for="post_tags_attributes_0_value">Tag</label>' + end + + assert_dom_equal expected, output_buffer + end + end + + def test_label_with_non_active_record_object + form_for(OpenStruct.new(name: "ok"), as: "person", url: "/an", html: { id: "create-person" }) do |f| + f.label(:name) + end + + expected = whole_form("/an", "create-person", "new_person", method: "post") do + '<label for="person_name">Name</label>' + end + + assert_dom_equal expected, output_buffer + end + + def test_label_with_for_attribute_as_symbol + assert_dom_equal('<label for="my_for">Title</label>', label(:post, :title, nil, for: "my_for")) + end + + def test_label_with_for_attribute_as_string + assert_dom_equal('<label for="my_for">Title</label>', label(:post, :title, nil, "for" => "my_for")) + end + + def test_label_does_not_generate_for_attribute_when_given_nil + assert_dom_equal("<label>Title</label>", label(:post, :title, for: nil)) + end + + def test_label_with_id_attribute_as_symbol + assert_dom_equal( + '<label for="post_title" id="my_id">Title</label>', + label(:post, :title, nil, id: "my_id") + ) + end + + def test_label_with_id_attribute_as_string + assert_dom_equal( + '<label for="post_title" id="my_id">Title</label>', + label(:post, :title, nil, "id" => "my_id") + ) + end + + def test_label_with_for_and_id_attributes_as_symbol + assert_dom_equal( + '<label for="my_for" id="my_id">Title</label>', + label(:post, :title, nil, for: "my_for", id: "my_id") + ) + end + + def test_label_with_for_and_id_attributes_as_string + assert_dom_equal( + '<label for="my_for" id="my_id">Title</label>', + label(:post, :title, nil, "for" => "my_for", "id" => "my_id") + ) + end + + def test_label_for_radio_buttons_with_value + assert_dom_equal( + '<label for="post_title_great_title">The title goes here</label>', + label("post", "title", "The title goes here", value: "great_title") + ) + assert_dom_equal( + '<label for="post_title_great_title">The title goes here</label>', + label("post", "title", "The title goes here", value: "great title") + ) + end + + def test_label_with_block + assert_dom_equal( + '<label for="post_title">The title, please:</label>', + label(:post, :title) { "The title, please:" } + ) + end + + def test_label_with_block_and_html + assert_dom_equal( + '<label for="post_terms">Accept <a href="/terms">Terms</a>.</label>', + label(:post, :terms) { raw('Accept <a href="/terms">Terms</a>.') } + ) + end + + def test_label_with_block_and_options + assert_dom_equal( + '<label for="my_for">The title, please:</label>', + label(:post, :title, "for" => "my_for") { "The title, please:" } + ) + end + + def test_label_with_block_and_builder + with_locale :label do + assert_dom_equal( + '<label for="post_body"><b>Write entire text here</b></label>', + label(:post, :body) { |b| raw("<b>#{b.translation}</b>") } + ) + end + end + + def test_label_with_block_in_erb + assert_dom_equal( + %{<label for="post_message">\n Message\n <input id="post_message" name="post[message]" type="text" />\n</label>}, + view.render("test/label_with_block") + ) + end + + def test_label_with_to_model + assert_dom_equal( + %{<label for="post_delegator_title">Delegate Title</label>}, + label(:post_delegator, :title) + ) + end + + def test_label_with_to_model_and_overridden_model_name + with_locale :label do + assert_dom_equal( + %{<label for="post_delegator_title">Delegate model_name title</label>}, + label(:post_delegator, :title) + ) + end + end + + def test_text_field_placeholder_without_locales + with_locale :placeholder do + assert_dom_equal('<input id="post_body" name="post[body]" placeholder="Body" type="text" value="Back to the hill and over it again!" />', text_field(:post, :body, placeholder: true)) + end + end + + def test_text_field_placeholder_with_locales + with_locale :placeholder do + assert_dom_equal('<input id="post_title" name="post[title]" placeholder="What is this about?" type="text" value="Hello World" />', text_field(:post, :title, placeholder: true)) + end + end + + def test_text_field_placeholder_with_locales_and_to_model + with_locale :placeholder do + assert_dom_equal( + '<input id="post_delegator_title" name="post_delegator[title]" placeholder="Delegate model_name title" type="text" value="Hello World" />', + text_field(:post_delegator, :title, placeholder: true) + ) + end + end + + def test_text_field_placeholder_with_human_attribute_name + with_locale :placeholder do + assert_dom_equal('<input id="post_cost" name="post[cost]" placeholder="Total cost" type="text" />', text_field(:post, :cost, placeholder: true)) + end + end + + def test_text_field_placeholder_with_human_attribute_name_and_to_model + assert_dom_equal( + '<input id="post_delegator_title" name="post_delegator[title]" placeholder="Delegate Title" type="text" value="Hello World" />', + text_field(:post_delegator, :title, placeholder: true) + ) + end + + def test_text_field_placeholder_with_string_value + with_locale :placeholder do + assert_dom_equal('<input id="post_cost" name="post[cost]" placeholder="HOW MUCH?" type="text" />', text_field(:post, :cost, placeholder: "HOW MUCH?")) + end + end + + def test_text_field_placeholder_with_human_attribute_name_and_value + with_locale :placeholder do + assert_dom_equal('<input id="post_cost" name="post[cost]" placeholder="Pounds" type="text" />', text_field(:post, :cost, placeholder: :uk)) + end + end + + def test_text_field_placeholder_with_locales_and_value + with_locale :placeholder do + assert_dom_equal('<input id="post_written_on" name="post[written_on]" placeholder="Escrito en" type="text" value="2004-06-15" />', text_field(:post, :written_on, placeholder: :spanish)) + end + end + + def test_text_field_placeholder_with_locales_and_nested_attributes + with_locale :placeholder do + form_for(@post, html: { id: "create-post" }) do |f| + f.fields_for(:comments) do |cf| + concat cf.text_field(:body, placeholder: true) + end + end + + expected = whole_form("/posts/123", "create-post", "edit_post", method: "patch") do + '<input id="post_comments_attributes_0_body" name="post[comments_attributes][0][body]" placeholder="Write body here" type="text" />' + end + + assert_dom_equal expected, output_buffer + end + end + + def test_text_field_placeholder_with_locales_fallback_and_nested_attributes + with_locale :placeholder do + form_for(@post, html: { id: "create-post" }) do |f| + f.fields_for(:tags) do |cf| + concat cf.text_field(:value, placeholder: true) + end + end + + expected = whole_form("/posts/123", "create-post", "edit_post", method: "patch") do + '<input id="post_tags_attributes_0_value" name="post[tags_attributes][0][value]" placeholder="Tag" type="text" value="new tag" />' + end + + assert_dom_equal expected, output_buffer + end + end + + def test_text_field + assert_dom_equal( + '<input id="post_title" name="post[title]" type="text" value="Hello World" />', + text_field("post", "title") + ) + assert_dom_equal( + '<input id="post_title" name="post[title]" type="password" />', + password_field("post", "title") + ) + assert_dom_equal( + '<input id="post_title" name="post[title]" type="password" value="Hello World" />', + password_field("post", "title", value: @post.title) + ) + assert_dom_equal( + '<input id="person_name" name="person[name]" type="password" />', + password_field("person", "name") + ) + end + + def test_text_field_with_escapes + @post.title = "<b>Hello World</b>" + assert_dom_equal( + '<input id="post_title" name="post[title]" type="text" value="<b>Hello World</b>" />', + text_field("post", "title") + ) + end + + def test_text_field_with_html_entities + @post.title = "The HTML Entity for & is &" + assert_dom_equal( + '<input id="post_title" name="post[title]" type="text" value="The HTML Entity for & is &amp;" />', + text_field("post", "title") + ) + end + + def test_text_field_with_options + expected = '<input id="post_title" name="post[title]" size="35" type="text" value="Hello World" />' + assert_dom_equal expected, text_field("post", "title", "size" => 35) + assert_dom_equal expected, text_field("post", "title", size: 35) + end + + def test_text_field_assuming_size + expected = '<input id="post_title" maxlength="35" name="post[title]" size="35" type="text" value="Hello World" />' + assert_dom_equal expected, text_field("post", "title", "maxlength" => 35) + assert_dom_equal expected, text_field("post", "title", maxlength: 35) + end + + def test_text_field_removing_size + expected = '<input id="post_title" maxlength="35" name="post[title]" type="text" value="Hello World" />' + assert_dom_equal expected, text_field("post", "title", "maxlength" => 35, "size" => nil) + assert_dom_equal expected, text_field("post", "title", maxlength: 35, size: nil) + end + + def test_text_field_with_nil_value + expected = '<input id="post_title" name="post[title]" type="text" />' + assert_dom_equal expected, text_field("post", "title", value: nil) + end + + def test_text_field_with_nil_name + expected = '<input id="post_title" type="text" value="Hello World" />' + assert_dom_equal expected, text_field("post", "title", name: nil) + end + + def test_text_field_doesnt_change_param_values + object_name = "post[]" + expected = '<input id="post_123_title" name="post[123][title]" type="text" value="Hello World" />' + assert_dom_equal expected, text_field(object_name, "title") + end + + def test_file_field_has_no_size + expected = '<input id="user_avatar" name="user[avatar]" type="file" />' + assert_dom_equal expected, file_field("user", "avatar") + end + + def test_file_field_with_multiple_behavior + expected = '<input id="import_file" multiple="multiple" name="import[file][]" type="file" />' + assert_dom_equal expected, file_field("import", "file", multiple: true) + end + + def test_file_field_with_multiple_behavior_and_explicit_name + expected = '<input id="import_file" multiple="multiple" name="custom" type="file" />' + assert_dom_equal expected, file_field("import", "file", multiple: true, name: "custom") + end + + def test_file_field_with_direct_upload_when_rails_direct_uploads_url_is_not_defined + expected = '<input type="file" name="import[file]" id="import_file" />' + assert_dom_equal expected, file_field("import", "file", direct_upload: true) + end + + def test_file_field_with_direct_upload_when_rails_direct_uploads_url_is_defined + @controller = WithActiveStorageRoutesControllers.new + + expected = '<input data-direct-upload-url="http://testtwo.host/rails/active_storage/direct_uploads" type="file" name="import[file]" id="import_file" />' + assert_dom_equal expected, file_field("import", "file", direct_upload: true) + end + + def test_file_field_with_direct_upload_dont_mutate_arguments + original_options = { class: "pix", direct_upload: true } + + expected = '<input class="pix" type="file" name="import[file]" id="import_file" />' + assert_dom_equal expected, file_field("import", "file", original_options) + + assert_equal({ class: "pix", direct_upload: true }, original_options) + end + + def test_hidden_field + assert_dom_equal( + '<input id="post_title" name="post[title]" type="hidden" value="Hello World" />', + hidden_field("post", "title") + ) + assert_dom_equal( + '<input id="post_secret" name="post[secret]" type="hidden" value="1" />', + hidden_field("post", "secret?") + ) + end + + def test_hidden_field_with_escapes + @post.title = "<b>Hello World</b>" + assert_dom_equal( + '<input id="post_title" name="post[title]" type="hidden" value="<b>Hello World</b>" />', + hidden_field("post", "title") + ) + end + + def test_hidden_field_with_nil_value + expected = '<input id="post_title" name="post[title]" type="hidden" />' + assert_dom_equal expected, hidden_field("post", "title", value: nil) + end + + def test_hidden_field_with_options + assert_dom_equal( + '<input id="post_title" name="post[title]" type="hidden" value="Something Else" />', + hidden_field("post", "title", value: "Something Else") + ) + end + + def test_text_field_with_custom_type + assert_dom_equal( + '<input id="user_email" name="user[email]" type="email" />', + text_field("user", "email", type: "email") + ) + end + + def test_check_box_is_html_safe + assert_predicate check_box("post", "secret"), :html_safe? + end + + def test_check_box_checked_if_object_value_is_same_that_check_value + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="0" /><input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret") + ) + end + + def test_check_box_not_checked_if_object_value_is_same_that_unchecked_value + @post.secret = 0 + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="0" /><input id="post_secret" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret") + ) + end + + def test_check_box_checked_if_option_checked_is_present + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="0" /><input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret", "checked" => "checked") + ) + end + + def test_check_box_checked_if_object_value_is_true + @post.secret = true + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="0" /><input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret") + ) + + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="0" /><input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret?") + ) + end + + def test_check_box_checked_if_object_value_includes_checked_value + @post.secret = ["0"] + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="0" /><input id="post_secret" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret") + ) + + @post.secret = ["1"] + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="0" /><input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret") + ) + + @post.secret = Set.new(["1"]) + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="0" /><input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret") + ) + end + + def test_check_box_with_include_hidden_false + @post.secret = false + assert_dom_equal( + '<input id="post_secret" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret", include_hidden: false) + ) + end + + def test_check_box_with_explicit_checked_and_unchecked_values_when_object_value_is_string + @post.secret = "on" + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="off" /><input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="on" />', + check_box("post", "secret", {}, "on", "off") + ) + + @post.secret = "off" + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="off" /><input id="post_secret" name="post[secret]" type="checkbox" value="on" />', + check_box("post", "secret", {}, "on", "off") + ) + end + + def test_check_box_with_explicit_checked_and_unchecked_values_when_object_value_is_boolean + @post.secret = false + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="true" /><input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="false" />', + check_box("post", "secret", {}, false, true) + ) + + @post.secret = true + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="true" /><input id="post_secret" name="post[secret]" type="checkbox" value="false" />', + check_box("post", "secret", {}, false, true) + ) + end + + def test_check_box_with_explicit_checked_and_unchecked_values_when_object_value_is_integer + @post.secret = 0 + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="1" /><input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="0" />', + check_box("post", "secret", {}, 0, 1) + ) + + @post.secret = 1 + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="1" /><input id="post_secret" name="post[secret]" type="checkbox" value="0" />', + check_box("post", "secret", {}, 0, 1) + ) + + @post.secret = 2 + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="1" /><input id="post_secret" name="post[secret]" type="checkbox" value="0" />', + check_box("post", "secret", {}, 0, 1) + ) + end + + def test_check_box_with_explicit_checked_and_unchecked_values_when_object_value_is_float + @post.secret = 0.0 + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="1" /><input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="0" />', + check_box("post", "secret", {}, 0, 1) + ) + + @post.secret = 1.1 + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="1" /><input id="post_secret" name="post[secret]" type="checkbox" value="0" />', + check_box("post", "secret", {}, 0, 1) + ) + + @post.secret = 2.2 + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="1" /><input id="post_secret" name="post[secret]" type="checkbox" value="0" />', + check_box("post", "secret", {}, 0, 1) + ) + end + + def test_check_box_with_explicit_checked_and_unchecked_values_when_object_value_is_big_decimal + @post.secret = BigDecimal(0) + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="1" /><input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="0" />', + check_box("post", "secret", {}, 0, 1) + ) + + @post.secret = BigDecimal(1) + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="1" /><input id="post_secret" name="post[secret]" type="checkbox" value="0" />', + check_box("post", "secret", {}, 0, 1) + ) + + @post.secret = BigDecimal(2.2, 1) + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="1" /><input id="post_secret" name="post[secret]" type="checkbox" value="0" />', + check_box("post", "secret", {}, 0, 1) + ) + end + + def test_check_box_with_nil_unchecked_value + @post.secret = "on" + assert_dom_equal( + '<input checked="checked" id="post_secret" name="post[secret]" type="checkbox" value="on" />', + check_box("post", "secret", {}, "on", nil) + ) + end + + def test_check_box_with_nil_unchecked_value_is_html_safe + assert_predicate check_box("post", "secret", {}, "on", nil), :html_safe? + end + + def test_check_box_with_multiple_behavior + @post.comment_ids = [2, 3] + assert_dom_equal( + '<input name="post[comment_ids][]" type="hidden" value="0" /><input id="post_comment_ids_1" name="post[comment_ids][]" type="checkbox" value="1" />', + check_box("post", "comment_ids", { multiple: true }, 1) + ) + assert_dom_equal( + '<input name="post[comment_ids][]" type="hidden" value="0" /><input checked="checked" id="post_comment_ids_3" name="post[comment_ids][]" type="checkbox" value="3" />', + check_box("post", "comment_ids", { multiple: true }, 3) + ) + end + + def test_check_box_with_multiple_behavior_and_index + @post.comment_ids = [2, 3] + assert_dom_equal( + '<input name="post[foo][comment_ids][]" type="hidden" value="0" /><input id="post_foo_comment_ids_1" name="post[foo][comment_ids][]" type="checkbox" value="1" />', + check_box("post", "comment_ids", { multiple: true, index: "foo" }, 1) + ) + assert_dom_equal( + '<input name="post[bar][comment_ids][]" type="hidden" value="0" /><input checked="checked" id="post_bar_comment_ids_3" name="post[bar][comment_ids][]" type="checkbox" value="3" />', + check_box("post", "comment_ids", { multiple: true, index: "bar" }, 3) + ) + end + + def test_checkbox_disabled_disables_hidden_field + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="0" disabled="disabled"/><input checked="checked" disabled="disabled" id="post_secret" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret", disabled: true) + ) + end + + def test_checkbox_form_html5_attribute + assert_dom_equal( + '<input form="new_form" name="post[secret]" type="hidden" value="0" /><input checked="checked" form="new_form" id="post_secret" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret", form: "new_form") + ) + end + + def test_radio_button + assert_dom_equal('<input checked="checked" id="post_title_hello_world" name="post[title]" type="radio" value="Hello World" />', + radio_button("post", "title", "Hello World") + ) + assert_dom_equal('<input id="post_title_goodbye_world" name="post[title]" type="radio" value="Goodbye World" />', + radio_button("post", "title", "Goodbye World") + ) + assert_dom_equal('<input id="item_subobject_title_inside_world" name="item[subobject][title]" type="radio" value="inside world"/>', + radio_button("item[subobject]", "title", "inside world") + ) + end + + def test_radio_button_is_checked_with_integers + assert_dom_equal('<input checked="checked" id="post_secret_1" name="post[secret]" type="radio" value="1" />', + radio_button("post", "secret", "1") + ) + end + + def test_radio_button_with_negative_integer_value + assert_dom_equal('<input id="post_secret_-1" name="post[secret]" type="radio" value="-1" />', + radio_button("post", "secret", "-1")) + end + + def test_radio_button_respects_passed_in_id + assert_dom_equal('<input checked="checked" id="foo" name="post[secret]" type="radio" value="1" />', + radio_button("post", "secret", "1", id: "foo") + ) + end + + def test_radio_button_with_booleans + assert_dom_equal('<input id="post_secret_true" name="post[secret]" type="radio" value="true" />', + radio_button("post", "secret", true) + ) + + assert_dom_equal('<input id="post_secret_false" name="post[secret]" type="radio" value="false" />', + radio_button("post", "secret", false) + ) + end + + def test_text_area_placeholder_without_locales + with_locale :placeholder do + assert_dom_equal( + %{<textarea id="post_body" name="post[body]" placeholder="Body">\nBack to the hill and over it again!</textarea>}, + text_area(:post, :body, placeholder: true) + ) + end + end + + def test_text_area_placeholder_with_locales + with_locale :placeholder do + assert_dom_equal( + %{<textarea id="post_title" name="post[title]" placeholder="What is this about?">\nHello World</textarea>}, + text_area(:post, :title, placeholder: true) + ) + end + end + + def test_text_area_placeholder_with_human_attribute_name + with_locale :placeholder do + assert_dom_equal( + %{<textarea id="post_cost" name="post[cost]" placeholder="Total cost">\n</textarea>}, + text_area(:post, :cost, placeholder: true) + ) + end + end + + def test_text_area_placeholder_with_string_value + with_locale :placeholder do + assert_dom_equal( + %{<textarea id="post_cost" name="post[cost]" placeholder="HOW MUCH?">\n</textarea>}, + text_area(:post, :cost, placeholder: "HOW MUCH?") + ) + end + end + + def test_text_area_placeholder_with_human_attribute_name_and_value + with_locale :placeholder do + assert_dom_equal( + %{<textarea id="post_cost" name="post[cost]" placeholder="Pounds">\n</textarea>}, + text_area(:post, :cost, placeholder: :uk) + ) + end + end + + def test_text_area_placeholder_with_locales_and_value + with_locale :placeholder do + assert_dom_equal( + %{<textarea id="post_written_on" name="post[written_on]" placeholder="Escrito en">\n2004-06-15</textarea>}, + text_area(:post, :written_on, placeholder: :spanish) + ) + end + end + + def test_text_area_placeholder_with_locales_and_nested_attributes + with_locale :placeholder do + form_for(@post, html: { id: "create-post" }) do |f| + f.fields_for(:comments) do |cf| + concat cf.text_area(:body, placeholder: true) + end + end + + expected = whole_form("/posts/123", "create-post", "edit_post", method: "patch") do + %{<textarea id="post_comments_attributes_0_body" name="post[comments_attributes][0][body]" placeholder="Write body here">\n</textarea>} + end + + assert_dom_equal expected, output_buffer + end + end + + def test_text_area_placeholder_with_locales_fallback_and_nested_attributes + with_locale :placeholder do + form_for(@post, html: { id: "create-post" }) do |f| + f.fields_for(:tags) do |cf| + concat cf.text_area(:value, placeholder: true) + end + end + + expected = whole_form("/posts/123", "create-post", "edit_post", method: "patch") do + %{<textarea id="post_tags_attributes_0_value" name="post[tags_attributes][0][value]" placeholder="Tag">\nnew tag</textarea>} + end + + assert_dom_equal expected, output_buffer + end + end + + def test_text_area + assert_dom_equal( + %{<textarea id="post_body" name="post[body]">\nBack to the hill and over it again!</textarea>}, + text_area("post", "body") + ) + end + + def test_text_area_with_escapes + @post.body = "Back to <i>the</i> hill and over it again!" + assert_dom_equal( + %{<textarea id="post_body" name="post[body]">\nBack to <i>the</i> hill and over it again!</textarea>}, + text_area("post", "body") + ) + end + + def test_text_area_with_alternate_value + assert_dom_equal( + %{<textarea id="post_body" name="post[body]">\nTesting alternate values.</textarea>}, + text_area("post", "body", value: "Testing alternate values.") + ) + end + + def test_text_area_with_nil_alternate_value + assert_dom_equal( + %{<textarea id="post_body" name="post[body]">\n</textarea>}, + text_area("post", "body", value: nil) + ) + end + + def test_inputs_use_before_type_cast_to_retain_information_from_validations_like_numericality + assert_dom_equal( + %{<textarea id="post_id" name="post[id]">\nomg</textarea>}, + text_area("post", "id") + ) + end + + def test_inputs_dont_use_before_type_cast_when_value_did_not_come_from_user + class << @post + undef id_came_from_user? + def id_came_from_user?; false; end + end + + assert_dom_equal( + %{<textarea id="post_id" name="post[id]">\n0</textarea>}, + text_area("post", "id") + ) + end + + def test_inputs_use_before_typecast_when_object_doesnt_respond_to_came_from_user + class << @post; undef id_came_from_user?; end + assert_dom_equal( + %{<textarea id="post_id" name="post[id]">\nomg</textarea>}, + text_area("post", "id") + ) + end + + def test_text_area_with_html_entities + @post.body = "The HTML Entity for & is &" + assert_dom_equal( + %{<textarea id="post_body" name="post[body]">\nThe HTML Entity for & is &amp;</textarea>}, + text_area("post", "body") + ) + end + + def test_text_area_with_size_option + assert_dom_equal( + %{<textarea cols="183" id="post_body" name="post[body]" rows="820">\nBack to the hill and over it again!</textarea>}, + text_area("post", "body", size: "183x820") + ) + end + + def test_color_field_with_valid_hex_color_string + expected = %{<input id="car_color" name="car[color]" type="color" value="#000fff" />} + assert_dom_equal(expected, color_field("car", "color")) + end + + def test_color_field_with_invalid_hex_color_string + expected = %{<input id="car_color" name="car[color]" type="color" value="#000000" />} + @car.color = "#1234TR" + assert_dom_equal(expected, color_field("car", "color")) + end + + def test_color_field_with_value_attr + expected = %{<input id="car_color" name="car[color]" type="color" value="#00FF00" />} + assert_dom_equal(expected, color_field("car", "color", value: "#00FF00")) + end + + def test_search_field + expected = %{<input id="contact_notes_query" name="contact[notes_query]" type="search" />} + assert_dom_equal(expected, search_field("contact", "notes_query")) + end + + def test_search_field_with_onsearch_value + expected = %{<input onsearch="true" type="search" name="contact[notes_query]" id="contact_notes_query" incremental="true" />} + assert_dom_equal(expected, search_field("contact", "notes_query", onsearch: true)) + end + + def test_telephone_field + expected = %{<input id="user_cell" name="user[cell]" type="tel" />} + assert_dom_equal(expected, telephone_field("user", "cell")) + end + + def test_date_field + expected = %{<input id="post_written_on" name="post[written_on]" type="date" value="2004-06-15" />} + assert_dom_equal(expected, date_field("post", "written_on")) + end + + def test_date_field_with_datetime_value + expected = %{<input id="post_written_on" name="post[written_on]" type="date" value="2004-06-15" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + assert_dom_equal(expected, date_field("post", "written_on")) + end + + def test_date_field_with_extra_attrs + expected = %{<input id="post_written_on" step="2" max="2010-08-15" min="2000-06-15" name="post[written_on]" type="date" value="2004-06-15" />} + @post.written_on = DateTime.new(2004, 6, 15) + min_value = DateTime.new(2000, 6, 15) + max_value = DateTime.new(2010, 8, 15) + step = 2 + assert_dom_equal(expected, date_field("post", "written_on", min: min_value, max: max_value, step: step)) + end + + def test_date_field_with_value_attr + expected = %{<input id="post_written_on" name="post[written_on]" type="date" value="2013-06-29" />} + value = Date.new(2013, 6, 29) + assert_dom_equal(expected, date_field("post", "written_on", value: value)) + end + + def test_date_field_with_timewithzone_value + previous_time_zone, Time.zone = Time.zone, "UTC" + expected = %{<input id="post_written_on" name="post[written_on]" type="date" value="2004-06-15" />} + @post.written_on = Time.zone.parse("2004-06-15 15:30:45") + assert_dom_equal(expected, date_field("post", "written_on")) + ensure + Time.zone = previous_time_zone + end + + def test_date_field_with_nil_value + expected = %{<input id="post_written_on" name="post[written_on]" type="date" />} + @post.written_on = nil + assert_dom_equal(expected, date_field("post", "written_on")) + end + + def test_date_field_with_string_values_for_min_and_max + expected = %{<input id="post_written_on" max="2010-08-15" min="2000-06-15" name="post[written_on]" type="date" value="2004-06-15" />} + @post.written_on = DateTime.new(2004, 6, 15) + min_value = "2000-06-15" + max_value = "2010-08-15" + assert_dom_equal(expected, date_field("post", "written_on", min: min_value, max: max_value)) + end + + def test_date_field_with_invalid_string_values_for_min_and_max + expected = %{<input id="post_written_on" name="post[written_on]" type="date" value="2004-06-15" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + min_value = "foo" + max_value = "bar" + assert_dom_equal(expected, date_field("post", "written_on", min: min_value, max: max_value)) + end + + def test_time_field + expected = %{<input id="post_written_on" name="post[written_on]" type="time" value="00:00:00.000" />} + assert_dom_equal(expected, time_field("post", "written_on")) + end + + def test_time_field_with_datetime_value + expected = %{<input id="post_written_on" name="post[written_on]" type="time" value="01:02:03.000" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + assert_dom_equal(expected, time_field("post", "written_on")) + end + + def test_time_field_with_extra_attrs + expected = %{<input id="post_written_on" step="60" max="10:25:00.000" min="20:45:30.000" name="post[written_on]" type="time" value="01:02:03.000" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + min_value = DateTime.new(2000, 6, 15, 20, 45, 30) + max_value = DateTime.new(2010, 8, 15, 10, 25, 00) + step = 60 + assert_dom_equal(expected, time_field("post", "written_on", min: min_value, max: max_value, step: step)) + end + + def test_time_field_with_timewithzone_value + previous_time_zone, Time.zone = Time.zone, "UTC" + expected = %{<input id="post_written_on" name="post[written_on]" type="time" value="01:02:03.000" />} + @post.written_on = Time.zone.parse("2004-06-15 01:02:03") + assert_dom_equal(expected, time_field("post", "written_on")) + ensure + Time.zone = previous_time_zone + end + + def test_time_field_with_nil_value + expected = %{<input id="post_written_on" name="post[written_on]" type="time" />} + @post.written_on = nil + assert_dom_equal(expected, time_field("post", "written_on")) + end + + def test_time_field_with_string_values_for_min_and_max + expected = %{<input id="post_written_on" max="10:25:00.000" min="20:45:30.000" name="post[written_on]" type="time" value="01:02:03.000" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + min_value = "20:45:30.000" + max_value = "10:25:00.000" + assert_dom_equal(expected, time_field("post", "written_on", min: min_value, max: max_value)) + end + + def test_time_field_with_invalid_string_values_for_min_and_max + expected = %{<input id="post_written_on" name="post[written_on]" type="time" value="01:02:03.000" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + min_value = "foo" + max_value = "bar" + assert_dom_equal(expected, time_field("post", "written_on", min: min_value, max: max_value)) + end + + def test_datetime_field + expected = %{<input id="post_written_on" name="post[written_on]" type="datetime-local" value="2004-06-15T00:00:00" />} + assert_dom_equal(expected, datetime_field("post", "written_on")) + end + + def test_datetime_field_with_datetime_value + expected = %{<input id="post_written_on" name="post[written_on]" type="datetime-local" value="2004-06-15T01:02:03" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + assert_dom_equal(expected, datetime_field("post", "written_on")) + end + + def test_datetime_field_with_extra_attrs + expected = %{<input id="post_written_on" step="60" max="2010-08-15T10:25:00" min="2000-06-15T20:45:30" name="post[written_on]" type="datetime-local" value="2004-06-15T01:02:03" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + min_value = DateTime.new(2000, 6, 15, 20, 45, 30) + max_value = DateTime.new(2010, 8, 15, 10, 25, 00) + step = 60 + assert_dom_equal(expected, datetime_field("post", "written_on", min: min_value, max: max_value, step: step)) + end + + def test_datetime_field_with_value_attr + expected = %{<input id="post_written_on" name="post[written_on]" type="datetime-local" value="2013-06-29T13:37:00+00:00" />} + value = DateTime.new(2013, 6, 29, 13, 37) + assert_dom_equal(expected, datetime_field("post", "written_on", value: value)) + end + + def test_datetime_field_with_timewithzone_value + previous_time_zone, Time.zone = Time.zone, "UTC" + expected = %{<input id="post_written_on" name="post[written_on]" type="datetime-local" value="2004-06-15T15:30:45" />} + @post.written_on = Time.zone.parse("2004-06-15 15:30:45") + assert_dom_equal(expected, datetime_field("post", "written_on")) + ensure + Time.zone = previous_time_zone + end + + def test_datetime_field_with_nil_value + expected = %{<input id="post_written_on" name="post[written_on]" type="datetime-local" />} + @post.written_on = nil + assert_dom_equal(expected, datetime_field("post", "written_on")) + end + + def test_datetime_field_with_string_values_for_min_and_max + expected = %{<input id="post_written_on" max="2010-08-15T10:25:00" min="2000-06-15T20:45:30" name="post[written_on]" type="datetime-local" value="2004-06-15T01:02:03" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + min_value = "2000-06-15T20:45:30" + max_value = "2010-08-15T10:25:00" + assert_dom_equal(expected, datetime_field("post", "written_on", min: min_value, max: max_value)) + end + + def test_datetime_field_with_invalid_string_values_for_min_and_max + expected = %{<input id="post_written_on" name="post[written_on]" type="datetime-local" value="2004-06-15T01:02:03" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + min_value = "foo" + max_value = "bar" + assert_dom_equal(expected, datetime_field("post", "written_on", min: min_value, max: max_value)) + end + + def test_datetime_local_field + expected = %{<input id="post_written_on" name="post[written_on]" type="datetime-local" value="2004-06-15T00:00:00" />} + assert_dom_equal(expected, datetime_local_field("post", "written_on")) + end + + def test_month_field + expected = %{<input id="post_written_on" name="post[written_on]" type="month" value="2004-06" />} + assert_dom_equal(expected, month_field("post", "written_on")) + end + + def test_month_field_with_nil_value + expected = %{<input id="post_written_on" name="post[written_on]" type="month" />} + @post.written_on = nil + assert_dom_equal(expected, month_field("post", "written_on")) + end + + def test_month_field_with_datetime_value + expected = %{<input id="post_written_on" name="post[written_on]" type="month" value="2004-06" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + assert_dom_equal(expected, month_field("post", "written_on")) + end + + def test_month_field_with_extra_attrs + expected = %{<input id="post_written_on" step="2" max="2010-12" min="2000-02" name="post[written_on]" type="month" value="2004-06" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + min_value = DateTime.new(2000, 2, 13) + max_value = DateTime.new(2010, 12, 23) + step = 2 + assert_dom_equal(expected, month_field("post", "written_on", min: min_value, max: max_value, step: step)) + end + + def test_month_field_with_timewithzone_value + previous_time_zone, Time.zone = Time.zone, "UTC" + expected = %{<input id="post_written_on" name="post[written_on]" type="month" value="2004-06" />} + @post.written_on = Time.zone.parse("2004-06-15 15:30:45") + assert_dom_equal(expected, month_field("post", "written_on")) + ensure + Time.zone = previous_time_zone + end + + def test_week_field + expected = %{<input id="post_written_on" name="post[written_on]" type="week" value="2004-W25" />} + assert_dom_equal(expected, week_field("post", "written_on")) + end + + def test_week_field_with_nil_value + expected = %{<input id="post_written_on" name="post[written_on]" type="week" />} + @post.written_on = nil + assert_dom_equal(expected, week_field("post", "written_on")) + end + + def test_week_field_with_datetime_value + expected = %{<input id="post_written_on" name="post[written_on]" type="week" value="2004-W25" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + assert_dom_equal(expected, week_field("post", "written_on")) + end + + def test_week_field_with_extra_attrs + expected = %{<input id="post_written_on" step="2" max="2010-W51" min="2000-W06" name="post[written_on]" type="week" value="2004-W25" />} + @post.written_on = DateTime.new(2004, 6, 15, 1, 2, 3) + min_value = DateTime.new(2000, 2, 13) + max_value = DateTime.new(2010, 12, 23) + step = 2 + assert_dom_equal(expected, week_field("post", "written_on", min: min_value, max: max_value, step: step)) + end + + def test_week_field_with_timewithzone_value + previous_time_zone, Time.zone = Time.zone, "UTC" + expected = %{<input id="post_written_on" name="post[written_on]" type="week" value="2004-W25" />} + @post.written_on = Time.zone.parse("2004-06-15 15:30:45") + assert_dom_equal(expected, week_field("post", "written_on")) + ensure + Time.zone = previous_time_zone + end + + def test_week_field_week_number_base + expected = %{<input id="post_written_on" name="post[written_on]" type="week" value="2015-W01" />} + @post.written_on = DateTime.new(2015, 1, 1, 1, 2, 3) + assert_dom_equal(expected, week_field("post", "written_on")) + end + + def test_url_field + expected = %{<input id="user_homepage" name="user[homepage]" type="url" />} + assert_dom_equal(expected, url_field("user", "homepage")) + end + + def test_email_field + expected = %{<input id="user_address" name="user[address]" type="email" />} + assert_dom_equal(expected, email_field("user", "address")) + end + + def test_number_field + expected = %{<input name="order[quantity]" max="9" id="order_quantity" type="number" min="1" />} + assert_dom_equal(expected, number_field("order", "quantity", in: 1...10)) + expected = %{<input name="order[quantity]" size="30" max="9" id="order_quantity" type="number" min="1" />} + assert_dom_equal(expected, number_field("order", "quantity", size: 30, in: 1...10)) + end + + def test_range_input + expected = %{<input name="hifi[volume]" step="0.1" max="11" id="hifi_volume" type="range" min="0" />} + assert_dom_equal(expected, range_field("hifi", "volume", in: 0..11, step: 0.1)) + expected = %{<input name="hifi[volume]" step="0.1" size="30" max="11" id="hifi_volume" type="range" min="0" />} + assert_dom_equal(expected, range_field("hifi", "volume", size: 30, in: 0..11, step: 0.1)) + end + + def test_explicit_name + assert_dom_equal( + '<input id="post_title" name="dont guess" type="text" value="Hello World" />', + text_field("post", "title", "name" => "dont guess") + ) + assert_dom_equal( + %{<textarea id="post_body" name="really!">\nBack to the hill and over it again!</textarea>}, + text_area("post", "body", "name" => "really!") + ) + assert_dom_equal( + '<input name="i mean it" type="hidden" value="0" /><input checked="checked" id="post_secret" name="i mean it" type="checkbox" value="1" />', + check_box("post", "secret", "name" => "i mean it") + ) + assert_dom_equal( + text_field("post", "title", "name" => "dont guess"), + text_field("post", "title", name: "dont guess") + ) + assert_dom_equal( + text_area("post", "body", "name" => "really!"), + text_area("post", "body", name: "really!") + ) + assert_dom_equal( + check_box("post", "secret", "name" => "i mean it"), + check_box("post", "secret", name: "i mean it") + ) + end + + def test_explicit_id + assert_dom_equal( + '<input id="dont guess" name="post[title]" type="text" value="Hello World" />', + text_field("post", "title", "id" => "dont guess") + ) + assert_dom_equal( + %{<textarea id="really!" name="post[body]">\nBack to the hill and over it again!</textarea>}, + text_area("post", "body", "id" => "really!") + ) + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="0" /><input checked="checked" id="i mean it" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret", "id" => "i mean it") + ) + assert_dom_equal( + text_field("post", "title", "id" => "dont guess"), + text_field("post", "title", id: "dont guess") + ) + assert_dom_equal( + text_area("post", "body", "id" => "really!"), + text_area("post", "body", id: "really!") + ) + assert_dom_equal( + check_box("post", "secret", "id" => "i mean it"), + check_box("post", "secret", id: "i mean it") + ) + end + + def test_nil_id + assert_dom_equal( + '<input name="post[title]" type="text" value="Hello World" />', + text_field("post", "title", "id" => nil) + ) + assert_dom_equal( + %{<textarea name="post[body]">\nBack to the hill and over it again!</textarea>}, + text_area("post", "body", "id" => nil) + ) + assert_dom_equal( + '<input name="post[secret]" type="hidden" value="0" /><input checked="checked" name="post[secret]" type="checkbox" value="1" />', + check_box("post", "secret", "id" => nil) + ) + assert_dom_equal( + '<input type="radio" name="post[secret]" value="0" />', + radio_button("post", "secret", "0", "id" => nil) + ) + assert_dom_equal( + '<select name="post[secret]"></select>', + select("post", "secret", [], {}, { "id" => nil }) + ) + assert_dom_equal( + text_field("post", "title", "id" => nil), + text_field("post", "title", id: nil) + ) + assert_dom_equal( + text_area("post", "body", "id" => nil), + text_area("post", "body", id: nil) + ) + assert_dom_equal( + check_box("post", "secret", "id" => nil), + check_box("post", "secret", id: nil) + ) + assert_dom_equal( + radio_button("post", "secret", "0", "id" => nil), + radio_button("post", "secret", "0", id: nil) + ) + end + + def test_index + assert_dom_equal( + '<input name="post[5][title]" id="post_5_title" type="text" value="Hello World" />', + text_field("post", "title", "index" => 5) + ) + assert_dom_equal( + %{<textarea name="post[5][body]" id="post_5_body">\nBack to the hill and over it again!</textarea>}, + text_area("post", "body", "index" => 5) + ) + assert_dom_equal( + '<input name="post[5][secret]" type="hidden" value="0" /><input checked="checked" name="post[5][secret]" type="checkbox" value="1" id="post_5_secret" />', + check_box("post", "secret", "index" => 5) + ) + assert_dom_equal( + text_field("post", "title", "index" => 5), + text_field("post", "title", "index" => 5) + ) + assert_dom_equal( + text_area("post", "body", "index" => 5), + text_area("post", "body", "index" => 5) + ) + assert_dom_equal( + check_box("post", "secret", "index" => 5), + check_box("post", "secret", "index" => 5) + ) + end + + def test_index_with_nil_id + assert_dom_equal( + '<input name="post[5][title]" type="text" value="Hello World" />', + text_field("post", "title", "index" => 5, "id" => nil) + ) + assert_dom_equal( + %{<textarea name="post[5][body]">\nBack to the hill and over it again!</textarea>}, + text_area("post", "body", "index" => 5, "id" => nil) + ) + assert_dom_equal( + '<input name="post[5][secret]" type="hidden" value="0" /><input checked="checked" name="post[5][secret]" type="checkbox" value="1" />', + check_box("post", "secret", "index" => 5, "id" => nil) + ) + assert_dom_equal( + text_field("post", "title", "index" => 5, "id" => nil), + text_field("post", "title", index: 5, id: nil) + ) + assert_dom_equal( + text_area("post", "body", "index" => 5, "id" => nil), + text_area("post", "body", index: 5, id: nil) + ) + assert_dom_equal( + check_box("post", "secret", "index" => 5, "id" => nil), + check_box("post", "secret", index: 5, id: nil) + ) + end + + def test_auto_index + pid = 123 + assert_dom_equal( + %{<label for="post_#{pid}_title">Title</label>}, + label("post[]", "title") + ) + assert_dom_equal( + %{<input id="post_#{pid}_title" name="post[#{pid}][title]" type="text" value="Hello World" />}, + text_field("post[]", "title") + ) + assert_dom_equal( + %{<textarea id="post_#{pid}_body" name="post[#{pid}][body]">\nBack to the hill and over it again!</textarea>}, + text_area("post[]", "body") + ) + assert_dom_equal( + %{<input name="post[#{pid}][secret]" type="hidden" value="0" /><input checked="checked" id="post_#{pid}_secret" name="post[#{pid}][secret]" type="checkbox" value="1" />}, + check_box("post[]", "secret") + ) + assert_dom_equal( + %{<input checked="checked" id="post_#{pid}_title_hello_world" name="post[#{pid}][title]" type="radio" value="Hello World" />}, + radio_button("post[]", "title", "Hello World") + ) + assert_dom_equal( + %{<input id="post_#{pid}_title_goodbye_world" name="post[#{pid}][title]" type="radio" value="Goodbye World" />}, + radio_button("post[]", "title", "Goodbye World") + ) + end + + def test_auto_index_with_nil_id + pid = 123 + assert_dom_equal( + %{<input name="post[#{pid}][title]" type="text" value="Hello World" />}, + text_field("post[]", "title", id: nil) + ) + assert_dom_equal( + %{<textarea name="post[#{pid}][body]">\nBack to the hill and over it again!</textarea>}, + text_area("post[]", "body", id: nil) + ) + assert_dom_equal( + %{<input name="post[#{pid}][secret]" type="hidden" value="0" /><input checked="checked" name="post[#{pid}][secret]" type="checkbox" value="1" />}, + check_box("post[]", "secret", id: nil) + ) + assert_dom_equal( + %{<input checked="checked" name="post[#{pid}][title]" type="radio" value="Hello World" />}, + radio_button("post[]", "title", "Hello World", id: nil) + ) + assert_dom_equal( + %{<input name="post[#{pid}][title]" type="radio" value="Goodbye World" />}, + radio_button("post[]", "title", "Goodbye World", id: nil) + ) + end + + def test_form_for_requires_block + error = assert_raises(ArgumentError) do + form_for(@post, html: { id: "create-post" }) + end + assert_equal "Missing block", error.message + end + + def test_form_for_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_for + form_for(@post, html: { 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", "edit_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_for_is_not_affected_by_form_with_generates_ids + old_value = ActionView::Helpers::FormHelper.form_with_generates_ids + ActionView::Helpers::FormHelper.form_with_generates_ids = false + + form_for(@post, html: { 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", "edit_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 + ensure + ActionView::Helpers::FormHelper.form_with_generates_ids = old_value + end + + def test_form_for_with_collection_radio_buttons + post = Post.new + def post.active; false; end + form_for(post) do |f| + concat f.collection_radio_buttons(:active, [true, false], :to_s, :to_s) + end + + expected = whole_form("/posts", "new_post", "new_post") 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_for_with_collection_radio_buttons_with_custom_builder_block + post = Post.new + def post.active; false; end + + form_for(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", "new_post", "new_post") 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_for_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_for(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", "new_post_1", "new_post") 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_namespace_and_with_collection_radio_buttons + post = Post.new + def post.active; false; end + + form_for(post, namespace: "foo") do |f| + concat f.collection_radio_buttons(:active, [true, false], :to_s, :to_s) + end + + expected = whole_form("/posts", "foo_new_post", "new_post") do + "<input type='hidden' name='post[active]' value='' />" \ + "<input id='foo_post_active_true' name='post[active]' type='radio' value='true' />" \ + "<label for='foo_post_active_true'>true</label>" \ + "<input checked='checked' id='foo_post_active_false' name='post[active]' type='radio' value='false' />" \ + "<label for='foo_post_active_false'>false</label>" + end + + assert_dom_equal expected, output_buffer + end + + def test_form_with_index_and_with_collection_radio_buttons + post = Post.new + def post.active; false; end + + form_for(post, index: "1") do |f| + concat f.collection_radio_buttons(:active, [true, false], :to_s, :to_s) + end + + expected = whole_form("/posts", "new_post", "new_post") 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_for_with_collection_check_boxes + post = Post.new + def post.tag_ids; [1, 3]; end + collection = (1..3).map { |i| [i, "Tag #{i}"] } + form_for(post) do |f| + concat f.collection_check_boxes(:tag_ids, collection, :first, :last) + end + + expected = whole_form("/posts", "new_post", "new_post") 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_for_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_for(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", "new_post", "new_post") 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_for_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_for(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", "new_post_1", "new_post") 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_namespace_and_with_collection_check_boxes + post = Post.new + def post.tag_ids; [1]; end + collection = [[1, "Tag 1"]] + + form_for(post, namespace: "foo") do |f| + concat f.collection_check_boxes(:tag_ids, collection, :first, :last) + end + + expected = whole_form("/posts", "foo_new_post", "new_post") do + "<input name='post[tag_ids][]' type='hidden' value='' />" \ + "<input checked='checked' id='foo_post_tag_ids_1' name='post[tag_ids][]' type='checkbox' value='1' />" \ + "<label for='foo_post_tag_ids_1'>Tag 1</label>" + 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_for(post, index: "1") do |f| + concat f.collection_check_boxes(:tag_ids, collection, :first, :last) + end + + expected = whole_form("/posts", "new_post", "new_post") 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_for_with_file_field_generate_multipart + form_for(@post, html: { id: "create-post" }) do |f| + concat f.file_field(:file) + end + + expected = whole_form("/posts/123", "create-post", "edit_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_for_with_file_field_generate_multipart + form_for(@post) do |f| + concat f.fields_for(:comment, @post) { |c| + concat c.file_field(:file) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_format + form_for(@post, format: :json, html: { 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_for_with_model_using_relative_model_naming + blog_post = Blog::Post.new("And his name will be forty and four.", 44) + + form_for(blog_post) do |f| + concat f.text_field :title + concat f.submit("Edit post") + end + + expected = whole_form("/posts/44", "edit_post_44", "edit_post", 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_for_with_symbol_as + form_for(@post, as: "other_name", html: { 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", "edit_other_name", 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_for(obj, as: "other_name", url: "/", html: { id: "edit-other-name" }) do |f| + assert_raise(NoMethodError) { f.hidden_field(:private_property) } + end + end + + def test_form_for_with_method_as_part_of_html_options + form_for(@post, url: "/", html: { id: "create-post", 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", "edit_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_for_with_method + form_for(@post, url: "/", method: :delete, html: { 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", "edit_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_for_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_for(Post.new, url: "/search", html: { id: "search-post", method: :get }) do |f| + concat f.search_field(:title) + end + + expected = whole_form("/search", "search-post", "new_post", method: "get") do + "<input name='post[title]' type='search' id='post_title' />" + end + + assert_dom_equal expected, output_buffer + end + + def test_form_for_with_remote + form_for(@post, url: "/", remote: true, html: { 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", "edit_post", method: "patch", remote: true) 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_for_enforce_utf8_true + form_for(:post, enforce_utf8: true) do |f| + concat f.text_field(:title) + end + + expected = whole_form("/", nil, nil, enforce_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_for_enforce_utf8_false + form_for(:post, enforce_utf8: false) do |f| + concat f.text_field(:title) + end + + expected = whole_form("/", nil, nil, enforce_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_for_default_enforce_utf8_false + with_default_enforce_utf8 false do + form_for(:post) do |f| + concat f.text_field(:title) + end + + expected = whole_form("/", nil, nil, enforce_utf8: false) do + "<input name='post[title]' type='text' id='post_title' value='Hello World' />" + end + + assert_dom_equal expected, output_buffer + end + end + + def test_form_for_default_enforce_utf8_true + with_default_enforce_utf8 true do + form_for(:post) do |f| + concat f.text_field(:title) + end + + expected = whole_form("/", nil, nil, enforce_utf8: true) do + "<input name='post[title]' type='text' id='post_title' value='Hello World' />" + end + + assert_dom_equal expected, output_buffer + end + end + + def test_form_for_with_remote_in_html + form_for(@post, url: "/", html: { remote: true, 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", "edit_post", method: "patch", remote: true) 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_for_with_remote_without_html + @post.persisted = false + @post.stub(:to_key, nil) do + form_for(@post, remote: true) do |f| + concat f.text_field(:title) + concat f.text_area(:body) + concat f.check_box(:secret) + end + + expected = whole_form("/posts", "new_post", "new_post", remote: true) 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 + end + + def test_form_for_without_object + form_for(:post, html: { 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_for_with_index + form_for(@post, as: "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", "edit_post[]", "edit_post[]", 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_for_with_nil_index_option_override + form_for(@post, as: "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", "edit_post[]", "edit_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_for_label_error_wrapping + form_for(@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", "edit_post_123", "edit_post", 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_for_label_error_wrapping_without_conventional_instance_variable + post = remove_instance_variable :@post + + form_for(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", "edit_post_123", "edit_post", 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_for_label_error_wrapping_block_and_non_block_versions + form_for(@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", "edit_post_123", "edit_post", 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_for_with_namespace + 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_form_for_with_namespace_with_date_select + form_for(@post, namespace: "namespace") do |f| + concat f.date_select(:written_on) + end + + assert_select "select#namespace_post_written_on_1i" + end + + def test_form_for_with_namespace_with_label + form_for(@post, namespace: "namespace") do |f| + concat f.label(:title) + concat f.text_field(:title) + end + + expected = whole_form("/posts/123", "namespace_edit_post_123", "edit_post", method: "patch") do + "<label for='namespace_post_title'>Title</label>" \ + "<input name='post[title]' type='text' id='namespace_post_title' value='Hello World' />" + end + + assert_dom_equal expected, output_buffer + end + + def test_form_for_with_namespace_and_as_option + form_for(@post, namespace: "namespace", as: "custom_name") do |f| + concat f.text_field(:title) + end + + expected = whole_form("/posts/123", "namespace_edit_custom_name", "edit_custom_name", method: "patch") do + "<input id='namespace_custom_name_title' name='custom_name[title]' type='text' value='Hello World' />" + end + + assert_dom_equal expected, output_buffer + end + + def test_two_form_for_with_namespace + form_for(@post, namespace: "namespace_1") do |f| + concat f.label(:title) + concat f.text_field(:title) + end + + expected_1 = whole_form("/posts/123", "namespace_1_edit_post_123", "edit_post", method: "patch") do + "<label for='namespace_1_post_title'>Title</label>" \ + "<input name='post[title]' type='text' id='namespace_1_post_title' value='Hello World' />" + end + + assert_dom_equal expected_1, output_buffer + + form_for(@post, namespace: "namespace_2") do |f| + concat f.label(:title) + concat f.text_field(:title) + end + + expected_2 = whole_form("/posts/123", "namespace_2_edit_post_123", "edit_post", method: "patch") do + "<label for='namespace_2_post_title'>Title</label>" \ + "<input name='post[title]' type='text' id='namespace_2_post_title' value='Hello World' />" + end + + assert_dom_equal expected_2, output_buffer + end + + def test_fields_for_with_namespace + @comment.body = "Hello World" + form_for(@post, namespace: "namespace") do |f| + concat f.text_field(:title) + concat f.text_area(:body) + concat f.fields_for(@comment) { |c| + concat c.text_field(:body) + } + 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[comment][body]' type='text' id='namespace_post_comment_body' value='Hello World' />" + 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_for(@post) do |f| + concat f.submit + end + + expected = whole_form("/posts", "new_post", "new_post") 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_for(@post) do |f| + concat f.submit + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for(: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_which_is_overwritten_by_as_option + with_locale :submit do + form_for(@post, as: :another_post) do |f| + concat f.submit + end + + expected = whole_form("/posts/123", "edit_another_post", "edit_another_post", 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_submit_with_object_which_is_namespaced + blog_post = Blog::Post.new("And his name will be forty and four.", 44) + with_locale :submit do + form_for(blog_post) do |f| + concat f.submit + end + + expected = whole_form("/posts/44", "edit_post_44", "edit_post", 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_for + @comment.body = "Hello World" + form_for(@post) do |f| + concat f.fields_for(@comment) { |c| + concat c.text_field(:body) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for + @comment.save + form_for(:posts) do |f| + f.fields_for("post[]", @post) do |f2| + f2.text_field(:id) + @post.comments.each do |comment| + concat f2.fields_for("comment[]", 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_for_with_nested_collections + form_for(@post, as: "post[]") do |f| + concat f.text_field(:title) + concat f.fields_for("comment[]", @comment) { |c| + concat c.text_field(:name) + } + concat f.text_field(:body) + end + + expected = whole_form("/posts/123", "edit_post[]", "edit_post[]", 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' />" \ + "<input name='post[123][body]' type='text' id='post_123_body' value='Back to the hill and over it again!' />" + end + + assert_dom_equal expected, output_buffer + end + + def test_nested_fields_for_with_index_and_parent_fields + form_for(@post, index: 1) do |c| + concat c.text_field(:title) + concat c.fields_for("comment", @comment, index: 1) { |r| + concat r.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_index_and_nested_fields_for + output_buffer = form_for(@post, index: 1) do |f| + concat f.fields_for(:comment, @post) { |c| + concat c.text_field(:title) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_index_on_both + form_for(@post, index: 1) do |f| + concat f.fields_for(:comment, @post, index: 5) { |c| + concat c.text_field(:title) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_auto_index + form_for(@post, as: "post[]") do |f| + concat f.fields_for(:comment, @post) { |c| + concat c.text_field(:title) + } + end + + expected = whole_form("/posts/123", "edit_post[]", "edit_post[]", 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_for_with_index_radio_button + form_for(@post) do |f| + concat f.fields_for(:comment, @post, index: 5) { |c| + concat c.radio_button(:title, "hello") + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_auto_index_on_both + form_for(@post, as: "post[]") do |f| + concat f.fields_for("comment[]", @post) { |c| + concat c.text_field(:title) + } + end + + expected = whole_form("/posts/123", "edit_post[]", "edit_post[]", 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_for_with_index_and_auto_index + output_buffer = form_for(@post, as: "post[]") do |f| + concat f.fields_for(:comment, @post, index: 5) { |c| + concat c.text_field(:title) + } + end + + output_buffer << form_for(@post, as: :post, index: 1) do |f| + concat f.fields_for("comment[]", @post) { |c| + concat c.text_field(:title) + } + end + + expected = whole_form("/posts/123", "edit_post[]", "edit_post[]", 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", "edit_post", "edit_post", 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_for_with_a_new_record_on_a_nested_attributes_one_to_one_association + @post.author = Author.new + + form_for(@post) do |f| + concat f.text_field(:title) + concat f.fields_for(:author) { |af| + concat af.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_explicitly_passed_object_on_a_nested_attributes_one_to_one_association + form_for(@post) do |f| + f.fields_for(:author, Author.new(123)) do |af| + assert_not_nil af.object + assert_equal 123, af.object.id + end + end + end + + def test_nested_fields_for_with_an_existing_record_on_a_nested_attributes_one_to_one_association + @post.author = Author.new(321) + + form_for(@post) do |f| + concat f.text_field(:title) + concat f.fields_for(:author) { |af| + concat af.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_an_existing_record_on_a_nested_attributes_one_to_one_association_using_erb_and_inline_block + @post.author = Author.new(321) + + form_for(@post) do |f| + concat f.text_field(:title) + concat f.fields_for(:author) { |af| + af.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_an_existing_record_on_a_nested_attributes_one_to_one_association_with_disabled_hidden_id + @post.author = Author.new(321) + + form_for(@post) do |f| + concat f.text_field(:title) + concat f.fields_for(:author, include_id: false) { |af| + af.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_an_existing_record_on_a_nested_attributes_one_to_one_association_with_disabled_hidden_id_inherited + @post.author = Author.new(321) + + form_for(@post, include_id: false) do |f| + concat f.text_field(:title) + concat f.fields_for(:author) { |af| + af.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_an_existing_record_on_a_nested_attributes_one_to_one_association_with_disabled_hidden_id_override + @post.author = Author.new(321) + + form_for(@post, include_id: false) do |f| + concat f.text_field(:title) + concat f.fields_for(:author, include_id: true) { |af| + af.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_existing_records_on_a_nested_attributes_one_to_one_association_with_explicit_hidden_field_placement + @post.author = Author.new(321) + + form_for(@post) do |f| + concat f.text_field(:title) + concat f.fields_for(:author) { |af| + concat af.hidden_field(:id) + concat af.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_existing_records_on_a_nested_attributes_collection_association + @post.comments = Array.new(2) { |id| Comment.new(id + 1) } + + form_for(@post) do |f| + concat f.text_field(:title) + @post.comments.each do |comment| + concat f.fields_for(:comments, comment) { |cf| + concat cf.text_field(:name) + } + end + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_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_for(@post) do |f| + concat f.text_field(:title) + concat f.fields_for(:author) { |af| + concat af.text_field(:name) + } + @post.comments.each do |comment| + concat f.fields_for(:comments, comment, include_id: false) { |cf| + concat cf.text_field(:name) + } + end + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_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_for(@post, include_id: false) do |f| + concat f.text_field(:title) + concat f.fields_for(:author) { |af| + concat af.text_field(:name) + } + @post.comments.each do |comment| + concat f.fields_for(:comments, comment) { |cf| + concat cf.text_field(:name) + } + end + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_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_for(@post, include_id: false) do |f| + concat f.text_field(:title) + concat f.fields_for(:author, include_id: true) { |af| + concat af.text_field(:name) + } + @post.comments.each do |comment| + concat f.fields_for(:comments, comment) { |cf| + concat cf.text_field(:name) + } + end + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_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_for(@post) do |f| + concat f.text_field(:title) + @post.comments.each do |comment| + concat f.fields_for(:comments, comment) { |cf| + cf.text_field(:name) + } + end + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_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_for(@post) do |f| + concat f.text_field(:title) + @post.comments.each do |comment| + concat f.fields_for(:comments, comment) { |cf| + concat cf.hidden_field(:id) + concat cf.text_field(:name) + } + end + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_new_records_on_a_nested_attributes_collection_association + @post.comments = [Comment.new, Comment.new] + + form_for(@post) do |f| + concat f.text_field(:title) + @post.comments.each do |comment| + concat f.fields_for(:comments, comment) { |cf| + concat cf.text_field(:name) + } + end + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_existing_and_new_records_on_a_nested_attributes_collection_association + @post.comments = [Comment.new(321), Comment.new] + + form_for(@post) do |f| + concat f.text_field(:title) + @post.comments.each do |comment| + concat f.fields_for(:comments, comment) { |cf| + concat cf.text_field(:name) + } + end + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_an_empty_supplied_attributes_collection + form_for(@post) do |f| + concat f.text_field(:title) + f.fields_for(:comments, []) do |cf| + concat cf.text_field(:name) + end + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_existing_records_on_a_supplied_nested_attributes_collection + @post.comments = Array.new(2) { |id| Comment.new(id + 1) } + + form_for(@post) do |f| + concat f.text_field(:title) + concat f.fields_for(:comments, @post.comments) { |cf| + concat cf.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_arel_like + @post.comments = ArelLike.new + + form_for(@post) do |f| + concat f.text_field(:title) + concat f.fields_for(:comments, @post.comments) { |cf| + concat cf.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for(@post) do |f| + f.fields_for(:comments) do |cf| + concat cf.label(:body) + end + end + end + end + + def test_nested_fields_for_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_for(@post) do |f| + concat f.text_field(:title) + concat f.fields_for(:comments, comments) { |cf| + concat cf.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_on_a_nested_attributes_collection_association_yields_only_builder + @post.comments = [Comment.new(321), Comment.new] + yielded_comments = [] + + form_for(@post) do |f| + concat f.text_field(:title) + concat f.fields_for(:comments) { |cf| + concat cf.text_field(:name) + yielded_comments << cf.object + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_child_index_option_override_on_a_nested_attributes_collection_association + @post.comments = [] + + form_for(@post) do |f| + concat f.fields_for(:comments, Comment.new(321), child_index: "abc") { |cf| + concat cf.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_child_index_as_lambda_option_override_on_a_nested_attributes_collection_association + @post.comments = [] + + form_for(@post) do |f| + concat f.fields_for(:comments, Comment.new(321), child_index: -> { "abc" }) { |cf| + concat cf.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_child_index_option_override_on_a_nested_attributes_collection_association_with_proxy + @post.comments = FakeAssociationProxy.new + + form_for(@post) do |f| + concat f.fields_for(:comments, Comment.new(321), child_index: "abc") { |cf| + concat cf.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_index_method_with_existing_records_on_a_nested_attributes_collection_association + @post.comments = Array.new(2) { |id| Comment.new(id + 1) } + + form_for(@post) do |f| + expected = 0 + @post.comments.each do |comment| + f.fields_for(:comments, comment) { |cf| + assert_equal expected, cf.index + expected += 1 + } + end + end + end + + def test_nested_fields_for_index_method_with_existing_and_new_records_on_a_nested_attributes_collection_association + @post.comments = [Comment.new(321), Comment.new] + + form_for(@post) do |f| + expected = 0 + @post.comments.each do |comment| + f.fields_for(:comments, comment) { |cf| + assert_equal expected, cf.index + expected += 1 + } + end + end + end + + def test_nested_fields_for_index_method_with_existing_records_on_a_supplied_nested_attributes_collection + @post.comments = Array.new(2) { |id| Comment.new(id + 1) } + + form_for(@post) do |f| + expected = 0 + f.fields_for(:comments, @post.comments) { |cf| + assert_equal expected, cf.index + expected += 1 + } + end + end + + def test_nested_fields_for_index_method_with_child_index_option_override_on_a_nested_attributes_collection_association + @post.comments = [] + + form_for(@post) do |f| + f.fields_for(:comments, Comment.new(321), child_index: "abc") { |cf| + assert_equal "abc", cf.index + } + 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_for(@post) do |f| + concat f.fields_for(:comments, @post.comments[0]) { |cf| + concat cf.text_field(:name) + concat cf.fields_for(:relevances, CommentRelevance.new(314)) { |crf| + concat crf.text_field(:value) + } + } + concat f.fields_for(:tags, @post.tags[0]) { |tf| + concat tf.text_field(:value) + concat tf.fields_for(:relevances, TagRelevance.new(3141)) { |trf| + concat trf.text_field(:value) + } + } + concat f.fields_for("tags", @post.tags[1]) { |tf| + concat tf.text_field(:value) + concat tf.fields_for(:relevances, TagRelevance.new(31415)) { |trf| + concat trf.text_field(:value) + } + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_hash_like_model + @author = HashBackedAuthor.new + + form_for(@post) do |f| + concat f.fields_for(:author, @author) { |af| + concat af.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for + output_buffer = fields_for(:post, @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_for_with_index + output_buffer = fields_for("post[]", @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_for_with_nil_index_option_override + output_buffer = fields_for("post[]", @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_for_with_index_option_override + output_buffer = fields_for("post[]", @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_for_without_object + output_buffer = fields_for(: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_for_with_only_object + output_buffer = fields_for(@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_for_object_with_bracketed_name + output_buffer = fields_for("author[post]", @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_for_object_with_bracketed_name_and_index + output_buffer = fields_for("author[post]", @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_for_method + assert_not_includes ActionView::Helpers::FormBuilder.instance_methods, :form_for + end + + def test_form_for_and_fields_for + form_for(@post, as: :post, html: { id: "create-post" }) do |post_form| + concat post_form.text_field(:title) + concat post_form.text_area(:body) + + concat fields_for(:parent_post, @post) { |parent_fields| + concat parent_fields.check_box(:secret) + } + end + + expected = whole_form("/posts/123", "create-post", "edit_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_for_and_fields_for_with_object + form_for(@post, as: :post, html: { id: "create-post" }) do |post_form| + concat post_form.text_field(:title) + concat post_form.text_area(:body) + + concat post_form.fields_for(@comment) { |comment_fields| + concat comment_fields.text_field(:name) + } + end + + expected = whole_form("/posts/123", "create-post", "edit_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_for_and_fields_for_with_non_nested_association_and_without_object + form_for(@post) do |f| + concat f.fields_for(:category) { |c| + concat c.text_field(:name) + } + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for_with_labelled_builder + form_for(@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", "edit_post_123", "edit_post", 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_for(@post) do |f| + concat f.text_field(:title) + concat f.text_area(:body) + concat f.check_box(:secret) + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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, "FormHelperTest::LabelledFormBuilder" + + form_for(@post) do |f| + concat f.text_field(:title) + end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", 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_for(:post, @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 = "FormHelperTest::LabelledFormBuilder" + + output_buffer = fields_for(:post, @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_for_with_labelled_builder + output_buffer = fields_for(:post, @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_for_with_labelled_builder_with_nested_fields_for_without_options_hash + klass = nil + + form_for(@post, builder: LabelledFormBuilder) do |f| + f.fields_for(:comments, Comment.new) do |nested_fields| + klass = nested_fields.class + "" + end + end + + assert_equal LabelledFormBuilder, klass + end + + def test_form_for_with_labelled_builder_with_nested_fields_for_with_options_hash + klass = nil + + form_for(@post, builder: LabelledFormBuilder) do |f| + f.fields_for(:comments, Comment.new, index: "foo") do |nested_fields| + klass = nested_fields.class + "" + end + end + + assert_equal LabelledFormBuilder, klass + end + + def test_form_for_with_labelled_builder_path + path = nil + + form_for(@post, builder: LabelledFormBuilder) do |f| + path = f.to_partial_path + "" + end + + assert_equal "labelled_form", path + end + + class LabelledFormBuilderSubclass < LabelledFormBuilder; end + + def test_form_for_with_labelled_builder_with_nested_fields_for_with_custom_builder + klass = nil + + form_for(@post, builder: LabelledFormBuilder) do |f| + f.fields_for(:comments, Comment.new, builder: LabelledFormBuilderSubclass) do |nested_fields| + klass = nested_fields.class + "" + end + end + + assert_equal LabelledFormBuilderSubclass, klass + end + + def test_form_for_with_html_options_adds_options_to_form_tag + form_for(@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_for_with_string_url_option + form_for(@post, url: "http://www.otherdomain.com") do |f| end + + assert_dom_equal whole_form("http://www.otherdomain.com", "edit_post_123", "edit_post", method: "patch"), output_buffer + end + + def test_form_for_with_hash_url_option + form_for(@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_for_with_record_url_option + form_for(@post, url: @post) do |f| end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", method: "patch") + assert_dom_equal expected, output_buffer + end + + def test_form_for_with_existing_object + form_for(@post) do |f| end + + expected = whole_form("/posts/123", "edit_post_123", "edit_post", method: "patch") + assert_dom_equal expected, output_buffer + end + + def test_form_for_with_new_object + post = Post.new + post.persisted = false + def post.to_key; nil; end + + form_for(post) do |f| end + + expected = whole_form("/posts", "new_post", "new_post") + assert_dom_equal expected, output_buffer + end + + def test_form_for_with_existing_object_in_list + @comment.save + form_for([@post, @comment]) { } + + expected = whole_form(post_comment_path(@post, @comment), "edit_comment_1", "edit_comment", method: "patch") + assert_dom_equal expected, output_buffer + end + + def test_form_for_with_new_object_in_list + form_for([@post, @comment]) { } + + expected = whole_form(post_comments_path(@post), "new_comment", "new_comment") + assert_dom_equal expected, output_buffer + end + + def test_form_for_with_existing_object_and_namespace_in_list + @comment.save + form_for([:admin, @post, @comment]) { } + + expected = whole_form(admin_post_comment_path(@post, @comment), "edit_comment_1", "edit_comment", method: "patch") + assert_dom_equal expected, output_buffer + end + + def test_form_for_with_new_object_and_namespace_in_list + form_for([:admin, @post, @comment]) { } + + expected = whole_form(admin_post_comments_path(@post), "new_comment", "new_comment") + assert_dom_equal expected, output_buffer + end + + def test_form_for_with_existing_object_and_custom_url + form_for(@post, url: "/super_posts") do |f| end + + expected = whole_form("/super_posts", "edit_post_123", "edit_post", method: "patch") + assert_dom_equal expected, output_buffer + end + + def test_form_for_with_default_method_as_patch + form_for(@post) { } + expected = whole_form("/posts/123", "edit_post_123", "edit_post", method: "patch") + assert_dom_equal expected, output_buffer + end + + def test_form_for_with_data_attributes + form_for(@post, data: { behavior: "stuff" }, remote: true) { } + assert_match %r|data-behavior="stuff"|, output_buffer + assert_match %r|data-remote="true"|, output_buffer + end + + def test_fields_for_returns_block_result + output = fields_for(Post.new) { |f| "fields" } + assert_equal "fields", output + end + + def test_form_for_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_for(@post, builder: builder_class) { } + assert_equal 1, initialization_count, "form builder instantiated more than once" + end + + private + + def hidden_fields(options = {}) + method = options[:method] + + if options.fetch(:enforce_utf8, true) + txt = +%{<input name="utf8" type="hidden" value="✓" />} + else + txt = +"" + 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, remote = nil, multipart = nil, method = nil) + txt = +%{<form accept-charset="UTF-8" action="#{action}"} + txt << %{ enctype="multipart/form-data"} if multipart + txt << %{ data-remote="true"} if remote + 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, options = {}) + contents = block_given? ? yield : "" + + method, remote, multipart = options.values_at(:method, :remote, :multipart) + + form_text(action, id, html_class, remote, multipart, method) + hidden_fields(options.slice :method, :enforce_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 + + def with_default_enforce_utf8(value) + old_value = ActionView::Helpers::FormTagHelper.default_enforce_utf8 + ActionView::Helpers::FormTagHelper.default_enforce_utf8 = value + + yield + ensure + ActionView::Helpers::FormTagHelper.default_enforce_utf8 = old_value + end +end diff --git a/actionview/test/template/form_options_helper_i18n_test.rb b/actionview/test/template/form_options_helper_i18n_test.rb new file mode 100644 index 0000000000..21295fa547 --- /dev/null +++ b/actionview/test/template/form_options_helper_i18n_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class FormOptionsHelperI18nTests < ActionView::TestCase + tests ActionView::Helpers::FormOptionsHelper + + def setup + @prompt_message = "Select!" + I18n.backend.send(:init_translations) + I18n.backend.store_translations :en, helpers: { select: { prompt: @prompt_message } } + end + + def teardown + I18n.backend = I18n::Backend::Simple.new + end + + def test_select_with_prompt_true_translates_prompt_message + assert_called_with(I18n, :translate, ["helpers.select.prompt", { default: "Please select" }]) do + select("post", "category", [], prompt: true) + end + end + + def test_select_with_translated_prompt + assert_dom_equal( + %Q(<select id="post_category" name="post[category]"><option value="">#{@prompt_message}</option>\n</select>), + select("post", "category", [], prompt: true) + ) + end +end diff --git a/actionview/test/template/form_options_helper_test.rb b/actionview/test/template/form_options_helper_test.rb new file mode 100644 index 0000000000..4ccd3ae336 --- /dev/null +++ b/actionview/test/template/form_options_helper_test.rb @@ -0,0 +1,1476 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class Map < Hash + def category + "<mus>" + end +end + +class CustomEnumerable + include Enumerable + + def each + yield "one" + yield "two" + end +end + +class FormOptionsHelperTest < ActionView::TestCase + tests ActionView::Helpers::FormOptionsHelper + + silence_warnings do + Post = Struct.new("Post", :title, :author_name, :body, :written_on, :category, :origin, :allow_comments) do + private + def secret + "This is super secret: #{author_name} is not the real author of #{title}" + end + end + Continent = Struct.new("Continent", :continent_name, :countries) + Country = Struct.new("Country", :country_id, :country_name) + Firm = Struct.new("Firm", :time_zone) + Album = Struct.new("Album", :id, :title, :genre) + end + + module FakeZones + FakeZone = Struct.new(:name) do + def to_s; name; end + def =~(_re); end + end + + module ClassMethods + def [](id); fake_zones ? fake_zones[id] : super; end + def all; fake_zones ? fake_zones.values : super; end + def dummy; :test; end + end + + def self.prepended(base) + class << base + mattr_accessor(:fake_zones) + prepend ClassMethods + end + end + end + + ActiveSupport::TimeZone.prepend FakeZones + + setup do + ActiveSupport::TimeZone.fake_zones = %w(A B C D E).map do |id| + [ id, FakeZones::FakeZone.new(id) ] + end.to_h + + @fake_timezones = ActiveSupport::TimeZone.all + end + + teardown do + ActiveSupport::TimeZone.fake_zones = nil + end + + def test_collection_options + assert_dom_equal( + "<option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\">Babe went home</option>\n<option value=\"Cabe\">Cabe went home</option>", + options_from_collection_for_select(dummy_posts, "author_name", "title") + ) + end + + def test_collection_options_with_private_value_method + assert_deprecated("Using private methods from view helpers is deprecated (calling private Struct::Post#secret)") { options_from_collection_for_select(dummy_posts, "secret", "title") } + end + + def test_collection_options_with_private_text_method + assert_deprecated("Using private methods from view helpers is deprecated (calling private Struct::Post#secret)") { options_from_collection_for_select(dummy_posts, "author_name", "secret") } + end + + def test_collection_options_with_preselected_value + assert_dom_equal( + "<option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\" selected=\"selected\">Babe went home</option>\n<option value=\"Cabe\">Cabe went home</option>", + options_from_collection_for_select(dummy_posts, "author_name", "title", "Babe") + ) + end + + def test_collection_options_with_preselected_value_array + assert_dom_equal( + "<option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\" selected=\"selected\">Babe went home</option>\n<option value=\"Cabe\" selected=\"selected\">Cabe went home</option>", + options_from_collection_for_select(dummy_posts, "author_name", "title", [ "Babe", "Cabe" ]) + ) + end + + def test_collection_options_with_proc_for_selected + assert_dom_equal( + "<option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\" selected=\"selected\">Babe went home</option>\n<option value=\"Cabe\">Cabe went home</option>", + options_from_collection_for_select(dummy_posts, "author_name", "title", lambda { |p| p.author_name == "Babe" }) + ) + end + + def test_collection_options_with_disabled_value + assert_dom_equal( + "<option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\" disabled=\"disabled\">Babe went home</option>\n<option value=\"Cabe\">Cabe went home</option>", + options_from_collection_for_select(dummy_posts, "author_name", "title", disabled: "Babe") + ) + end + + def test_collection_options_with_disabled_array + assert_dom_equal( + "<option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\" disabled=\"disabled\">Babe went home</option>\n<option value=\"Cabe\" disabled=\"disabled\">Cabe went home</option>", + options_from_collection_for_select(dummy_posts, "author_name", "title", disabled: [ "Babe", "Cabe" ]) + ) + end + + def test_collection_options_with_preselected_and_disabled_value + assert_dom_equal( + "<option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\" disabled=\"disabled\">Babe went home</option>\n<option value=\"Cabe\" selected=\"selected\">Cabe went home</option>", + options_from_collection_for_select(dummy_posts, "author_name", "title", selected: "Cabe", disabled: "Babe") + ) + end + + def test_collection_options_with_proc_for_disabled + assert_dom_equal( + "<option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\" disabled=\"disabled\">Babe went home</option>\n<option value=\"Cabe\" disabled=\"disabled\">Cabe went home</option>", + options_from_collection_for_select(dummy_posts, "author_name", "title", disabled: lambda { |p| %w(Babe Cabe).include?(p.author_name) }) + ) + end + + def test_collection_options_with_proc_for_value_method + assert_dom_equal( + "<option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\">Babe went home</option>\n<option value=\"Cabe\">Cabe went home</option>", + options_from_collection_for_select(dummy_posts, lambda { |p| p.author_name }, "title") + ) + end + + def test_collection_options_with_proc_for_text_method + assert_dom_equal( + "<option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\">Babe went home</option>\n<option value=\"Cabe\">Cabe went home</option>", + options_from_collection_for_select(dummy_posts, "author_name", lambda { |p| p.title }) + ) + end + + def test_collection_options_with_element_attributes + assert_dom_equal( + "<option value=\"USA\" class=\"bold\">USA</option>", + options_from_collection_for_select([[ "USA", "USA", { class: "bold" } ]], :first, :second) + ) + end + + def test_string_options_for_select + options = "<option value=\"Denmark\">Denmark</option><option value=\"USA\">USA</option><option value=\"Sweden\">Sweden</option>" + assert_dom_equal( + options, + options_for_select(options) + ) + end + + def test_array_options_for_select + assert_dom_equal( + "<option value=\"<Denmark>\"><Denmark></option>\n<option value=\"USA\">USA</option>\n<option value=\"Sweden\">Sweden</option>", + options_for_select([ "<Denmark>", "USA", "Sweden" ]) + ) + end + + def test_array_options_for_select_with_custom_defined_selected + assert_dom_equal( + "<option selected=\"selected\" type=\"Coach\" value=\"1\">Richard Bandler</option>\n<option type=\"Coachee\" value=\"1\">Richard Bandler</option>", + options_for_select([ + ["Richard Bandler", 1, { type: "Coach", selected: "selected" }], + ["Richard Bandler", 1, { type: "Coachee" }] + ]) + ) + end + + def test_array_options_for_select_with_custom_defined_disabled + assert_dom_equal( + "<option disabled=\"disabled\" type=\"Coach\" value=\"1\">Richard Bandler</option>\n<option type=\"Coachee\" value=\"1\">Richard Bandler</option>", + options_for_select([ + ["Richard Bandler", 1, { type: "Coach", disabled: "disabled" }], + ["Richard Bandler", 1, { type: "Coachee" }] + ]) + ) + end + + def test_array_options_for_select_with_selection + assert_dom_equal( + "<option value=\"Denmark\">Denmark</option>\n<option value=\"<USA>\" selected=\"selected\"><USA></option>\n<option value=\"Sweden\">Sweden</option>", + options_for_select([ "Denmark", "<USA>", "Sweden" ], "<USA>") + ) + end + + def test_array_options_for_select_with_selection_array + assert_dom_equal( + "<option value=\"Denmark\">Denmark</option>\n<option value=\"<USA>\" selected=\"selected\"><USA></option>\n<option value=\"Sweden\" selected=\"selected\">Sweden</option>", + options_for_select([ "Denmark", "<USA>", "Sweden" ], [ "<USA>", "Sweden" ]) + ) + end + + def test_array_options_for_select_with_disabled_value + assert_dom_equal( + "<option value=\"Denmark\">Denmark</option>\n<option value=\"<USA>\" disabled=\"disabled\"><USA></option>\n<option value=\"Sweden\">Sweden</option>", + options_for_select([ "Denmark", "<USA>", "Sweden" ], disabled: "<USA>") + ) + end + + def test_array_options_for_select_with_disabled_array + assert_dom_equal( + "<option value=\"Denmark\">Denmark</option>\n<option value=\"<USA>\" disabled=\"disabled\"><USA></option>\n<option value=\"Sweden\" disabled=\"disabled\">Sweden</option>", + options_for_select([ "Denmark", "<USA>", "Sweden" ], disabled: ["<USA>", "Sweden"]) + ) + end + + def test_array_options_for_select_with_selection_and_disabled_value + assert_dom_equal( + "<option value=\"Denmark\" selected=\"selected\">Denmark</option>\n<option value=\"<USA>\" disabled=\"disabled\"><USA></option>\n<option value=\"Sweden\">Sweden</option>", + options_for_select([ "Denmark", "<USA>", "Sweden" ], selected: "Denmark", disabled: "<USA>") + ) + end + + def test_boolean_array_options_for_select_with_selection_and_disabled_value + assert_dom_equal( + "<option value=\"true\">true</option>\n<option value=\"false\" selected=\"selected\">false</option>", + options_for_select([ true, false ], selected: false, disabled: nil) + ) + end + + def test_range_options_for_select + assert_dom_equal( + "<option value=\"1\">1</option>\n<option value=\"2\">2</option>\n<option value=\"3\">3</option>", + options_for_select(1..3) + ) + end + + def test_array_options_for_string_include_in_other_string_bug_fix + assert_dom_equal( + "<option value=\"ruby\">ruby</option>\n<option value=\"rubyonrails\" selected=\"selected\">rubyonrails</option>", + options_for_select([ "ruby", "rubyonrails" ], "rubyonrails") + ) + assert_dom_equal( + "<option value=\"ruby\" selected=\"selected\">ruby</option>\n<option value=\"rubyonrails\">rubyonrails</option>", + options_for_select([ "ruby", "rubyonrails" ], "ruby") + ) + assert_dom_equal( + %(<option value="ruby" selected="selected">ruby</option>\n<option value="rubyonrails">rubyonrails</option>\n<option value=""></option>), + options_for_select([ "ruby", "rubyonrails", nil ], "ruby") + ) + end + + def test_hash_options_for_select + assert_dom_equal( + "<option value=\"Dollar\">$</option>\n<option value=\"<Kroner>\"><DKR></option>", + options_for_select("$" => "Dollar", "<DKR>" => "<Kroner>").split("\n").join("\n") + ) + assert_dom_equal( + "<option value=\"Dollar\" selected=\"selected\">$</option>\n<option value=\"<Kroner>\"><DKR></option>", + options_for_select({ "$" => "Dollar", "<DKR>" => "<Kroner>" }, "Dollar").split("\n").join("\n") + ) + assert_dom_equal( + "<option value=\"Dollar\" selected=\"selected\">$</option>\n<option value=\"<Kroner>\" selected=\"selected\"><DKR></option>", + options_for_select({ "$" => "Dollar", "<DKR>" => "<Kroner>" }, [ "Dollar", "<Kroner>" ]).split("\n").join("\n") + ) + end + + def test_ducktyped_options_for_select + quack = Struct.new(:first, :last) + assert_dom_equal( + "<option value=\"<Kroner>\"><DKR></option>\n<option value=\"Dollar\">$</option>", + options_for_select([quack.new("<DKR>", "<Kroner>"), quack.new("$", "Dollar")]) + ) + assert_dom_equal( + "<option value=\"<Kroner>\"><DKR></option>\n<option value=\"Dollar\" selected=\"selected\">$</option>", + options_for_select([quack.new("<DKR>", "<Kroner>"), quack.new("$", "Dollar")], "Dollar") + ) + assert_dom_equal( + "<option value=\"<Kroner>\" selected=\"selected\"><DKR></option>\n<option value=\"Dollar\" selected=\"selected\">$</option>", + options_for_select([quack.new("<DKR>", "<Kroner>"), quack.new("$", "Dollar")], ["Dollar", "<Kroner>"]) + ) + end + + def test_collection_options_with_preselected_value_as_string_and_option_value_is_integer + albums = [ Album.new(1, "first", "rap"), Album.new(2, "second", "pop")] + assert_dom_equal( + %(<option selected="selected" value="1">rap</option>\n<option value="2">pop</option>), + options_from_collection_for_select(albums, "id", "genre", selected: "1") + ) + end + + def test_collection_options_with_preselected_value_as_integer_and_option_value_is_string + albums = [ Album.new("1", "first", "rap"), Album.new("2", "second", "pop")] + + assert_dom_equal( + %(<option selected="selected" value="1">rap</option>\n<option value="2">pop</option>), + options_from_collection_for_select(albums, "id", "genre", selected: 1) + ) + end + + def test_collection_options_with_preselected_value_as_string_and_option_value_is_float + albums = [ Album.new(1.0, "first", "rap"), Album.new(2.0, "second", "pop")] + + assert_dom_equal( + %(<option value="1.0">rap</option>\n<option value="2.0" selected="selected">pop</option>), + options_from_collection_for_select(albums, "id", "genre", selected: "2.0") + ) + end + + def test_collection_options_with_preselected_value_as_nil + albums = [ Album.new(1.0, "first", "rap"), Album.new(2.0, "second", "pop")] + + assert_dom_equal( + %(<option value="1.0">rap</option>\n<option value="2.0">pop</option>), + options_from_collection_for_select(albums, "id", "genre", selected: nil) + ) + end + + def test_collection_options_with_disabled_value_as_nil + albums = [ Album.new(1.0, "first", "rap"), Album.new(2.0, "second", "pop")] + + assert_dom_equal( + %(<option value="1.0">rap</option>\n<option value="2.0">pop</option>), + options_from_collection_for_select(albums, "id", "genre", disabled: nil) + ) + end + + def test_collection_options_with_disabled_value_as_array + albums = [ Album.new(1.0, "first", "rap"), Album.new(2.0, "second", "pop")] + + assert_dom_equal( + %(<option disabled="disabled" value="1.0">rap</option>\n<option disabled="disabled" value="2.0">pop</option>), + options_from_collection_for_select(albums, "id", "genre", disabled: ["1.0", 2.0]) + ) + end + + def test_collection_options_with_preselected_values_as_string_array_and_option_value_is_float + albums = [ Album.new(1.0, "first", "rap"), Album.new(2.0, "second", "pop"), Album.new(3.0, "third", "country") ] + + assert_dom_equal( + %(<option value="1.0" selected="selected">rap</option>\n<option value="2.0">pop</option>\n<option value="3.0" selected="selected">country</option>), + options_from_collection_for_select(albums, "id", "genre", ["1.0", "3.0"]) + ) + end + + def test_option_groups_from_collection_for_select + assert_dom_equal( + "<optgroup label=\"<Africa>\"><option value=\"<sa>\"><South Africa></option>\n<option value=\"so\">Somalia</option></optgroup><optgroup label=\"Europe\"><option value=\"dk\" selected=\"selected\">Denmark</option>\n<option value=\"ie\">Ireland</option></optgroup>", + option_groups_from_collection_for_select(dummy_continents, "countries", "continent_name", "country_id", "country_name", "dk") + ) + end + + def test_option_groups_from_collection_for_select_with_callable_group_method + group_proc = Proc.new { |c| c.countries } + assert_dom_equal( + "<optgroup label=\"<Africa>\"><option value=\"<sa>\"><South Africa></option>\n<option value=\"so\">Somalia</option></optgroup><optgroup label=\"Europe\"><option value=\"dk\" selected=\"selected\">Denmark</option>\n<option value=\"ie\">Ireland</option></optgroup>", + option_groups_from_collection_for_select(dummy_continents, group_proc, "continent_name", "country_id", "country_name", "dk") + ) + end + + def test_option_groups_from_collection_for_select_with_callable_group_label_method + label_proc = Proc.new { |c| c.continent_name } + assert_dom_equal( + "<optgroup label=\"<Africa>\"><option value=\"<sa>\"><South Africa></option>\n<option value=\"so\">Somalia</option></optgroup><optgroup label=\"Europe\"><option value=\"dk\" selected=\"selected\">Denmark</option>\n<option value=\"ie\">Ireland</option></optgroup>", + option_groups_from_collection_for_select(dummy_continents, "countries", label_proc, "country_id", "country_name", "dk") + ) + end + + def test_option_groups_from_collection_for_select_returns_html_safe_string + assert_predicate option_groups_from_collection_for_select(dummy_continents, "countries", "continent_name", "country_id", "country_name", "dk"), :html_safe? + end + + def test_grouped_options_for_select_with_array + assert_dom_equal( + "<optgroup label=\"North America\"><option value=\"US\">United States</option>\n<option value=\"Canada\">Canada</option></optgroup><optgroup label=\"Europe\"><option value=\"GB\">Great Britain</option>\n<option value=\"Germany\">Germany</option></optgroup>", + grouped_options_for_select([ + ["North America", + [["United States", "US"], "Canada"]], + ["Europe", + [["Great Britain", "GB"], "Germany"]] + ]) + ) + end + + def test_grouped_options_for_select_with_array_and_html_attributes + assert_dom_equal( + "<optgroup label=\"North America\" data-foo=\"bar\"><option value=\"US\">United States</option>\n<option value=\"Canada\">Canada</option></optgroup><optgroup label=\"Europe\" disabled=\"disabled\"><option value=\"GB\">Great Britain</option>\n<option value=\"Germany\">Germany</option></optgroup>", + grouped_options_for_select([ + ["North America", [["United States", "US"], "Canada"], data: { foo: "bar" }], + ["Europe", [["Great Britain", "GB"], "Germany"], disabled: "disabled"] + ]) + ) + end + + def test_grouped_options_for_select_with_optional_divider + assert_dom_equal( + "<optgroup label=\"----------\"><option value=\"US\">US</option>\n<option value=\"Canada\">Canada</option></optgroup><optgroup label=\"----------\"><option value=\"GB\">GB</option>\n<option value=\"Germany\">Germany</option></optgroup>", + + grouped_options_for_select([["US", "Canada"], ["GB", "Germany"]], nil, divider: "----------") + ) + end + + def test_grouped_options_for_select_with_selected_and_prompt + assert_dom_equal( + "<option value=\"\">Choose a product...</option><optgroup label=\"Hats\"><option value=\"Baseball Cap\">Baseball Cap</option>\n<option selected=\"selected\" value=\"Cowboy Hat\">Cowboy Hat</option></optgroup>", + grouped_options_for_select([["Hats", ["Baseball Cap", "Cowboy Hat"]]], "Cowboy Hat", prompt: "Choose a product...") + ) + end + + def test_grouped_options_for_select_with_selected_and_prompt_true + assert_dom_equal( + "<option value=\"\">Please select</option><optgroup label=\"Hats\"><option value=\"Baseball Cap\">Baseball Cap</option>\n<option selected=\"selected\" value=\"Cowboy Hat\">Cowboy Hat</option></optgroup>", + grouped_options_for_select([["Hats", ["Baseball Cap", "Cowboy Hat"]]], "Cowboy Hat", prompt: true) + ) + end + + def test_grouped_options_for_select_returns_html_safe_string + assert_predicate grouped_options_for_select([["Hats", ["Baseball Cap", "Cowboy Hat"]]]), :html_safe? + end + + def test_grouped_options_for_select_with_prompt_returns_html_escaped_string + assert_dom_equal( + "<option value=\"\"><Choose One></option><optgroup label=\"Hats\"><option value=\"Baseball Cap\">Baseball Cap</option>\n<option value=\"Cowboy Hat\">Cowboy Hat</option></optgroup>", + grouped_options_for_select([["Hats", ["Baseball Cap", "Cowboy Hat"]]], nil, prompt: "<Choose One>")) + end + + def test_optgroups_with_with_options_with_hash + assert_dom_equal( + "<optgroup label=\"North America\"><option value=\"United States\">United States</option>\n<option value=\"Canada\">Canada</option></optgroup><optgroup label=\"Europe\"><option value=\"Denmark\">Denmark</option>\n<option value=\"Germany\">Germany</option></optgroup>", + grouped_options_for_select("North America" => ["United States", "Canada"], "Europe" => ["Denmark", "Germany"]) + ) + end + + def test_time_zone_options_no_params + opts = time_zone_options_for_select + assert_dom_equal "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\">D</option>\n" \ + "<option value=\"E\">E</option>", + opts + end + + def test_time_zone_options_with_selected + opts = time_zone_options_for_select("D") + assert_dom_equal "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>", + opts + end + + def test_time_zone_options_with_unknown_selected + opts = time_zone_options_for_select("K") + assert_dom_equal "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\">D</option>\n" \ + "<option value=\"E\">E</option>", + opts + end + + def test_time_zone_options_with_priority_zones + zones = [ ActiveSupport::TimeZone.new("B"), ActiveSupport::TimeZone.new("E") ] + opts = time_zone_options_for_select(nil, zones) + assert_dom_equal "<option value=\"B\">B</option>\n" \ + "<option value=\"E\">E</option>" \ + "<option value=\"\" disabled=\"disabled\">-------------</option>\n" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\">D</option>", + opts + end + + def test_time_zone_options_with_selected_priority_zones + zones = [ ActiveSupport::TimeZone.new("B"), ActiveSupport::TimeZone.new("E") ] + opts = time_zone_options_for_select("E", zones) + assert_dom_equal "<option value=\"B\">B</option>\n" \ + "<option value=\"E\" selected=\"selected\">E</option>" \ + "<option value=\"\" disabled=\"disabled\">-------------</option>\n" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\">D</option>", + opts + end + + def test_time_zone_options_with_unselected_priority_zones + zones = [ ActiveSupport::TimeZone.new("B"), ActiveSupport::TimeZone.new("E") ] + opts = time_zone_options_for_select("C", zones) + assert_dom_equal "<option value=\"B\">B</option>\n" \ + "<option value=\"E\">E</option>" \ + "<option value=\"\" disabled=\"disabled\">-------------</option>\n" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"C\" selected=\"selected\">C</option>\n" \ + "<option value=\"D\">D</option>", + opts + end + + def test_time_zone_options_with_priority_zones_does_not_mutate_time_zones + original_zones = ActiveSupport::TimeZone.all.dup + zones = [ ActiveSupport::TimeZone.new("B"), ActiveSupport::TimeZone.new("E") ] + time_zone_options_for_select(nil, zones) + assert_equal original_zones, ActiveSupport::TimeZone.all + end + + def test_time_zone_options_returns_html_safe_string + assert_predicate time_zone_options_for_select, :html_safe? + end + + def test_select + @post = Post.new + @post.category = "<mus>" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"abe\">abe</option>\n<option value=\"<mus>\" selected=\"selected\"><mus></option>\n<option value=\"hest\">hest</option></select>", + select("post", "category", %w( abe <mus> hest)) + ) + end + + def test_select_without_multiple + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"></select>", + select(:post, :category, "", {}, { multiple: false }) + ) + end + + def test_required_select_with_default_and_selected_placeholder + assert_dom_equal( + ['<select required="required" name="post[category]" id="post_category"><option disabled="disabled" selected="selected" value="">Choose one</option>', + '<option value="lifestyle">lifestyle</option>', + '<option value="programming">programming</option>', + '<option value="spiritual">spiritual</option></select>'].join("\n"), + select(:post, :category, ["lifestyle", "programming", "spiritual"], { selected: "", disabled: "", prompt: "Choose one" }, { required: true }) + ) + end + + def test_select_with_grouped_collection_as_nested_array + @post = Post.new + + countries_by_continent = [ + ["<Africa>", [["<South Africa>", "<sa>"], ["Somalia", "so"]]], + ["Europe", [["Denmark", "dk"], ["Ireland", "ie"]]], + ] + + assert_dom_equal( + [ + '<select id="post_origin" name="post[origin]"><optgroup label="<Africa>"><option value="<sa>"><South Africa></option>', + '<option value="so">Somalia</option></optgroup><optgroup label="Europe"><option value="dk">Denmark</option>', + '<option value="ie">Ireland</option></optgroup></select>', + ].join("\n"), + select("post", "origin", countries_by_continent) + ) + end + + def test_select_with_grouped_collection_as_hash + @post = Post.new + + countries_by_continent = { + "<Africa>" => [["<South Africa>", "<sa>"], ["Somalia", "so"]], + "Europe" => [["Denmark", "dk"], ["Ireland", "ie"]], + } + + assert_dom_equal( + [ + '<select id="post_origin" name="post[origin]"><optgroup label="<Africa>"><option value="<sa>"><South Africa></option>', + '<option value="so">Somalia</option></optgroup><optgroup label="Europe"><option value="dk">Denmark</option>', + '<option value="ie">Ireland</option></optgroup></select>', + ].join("\n"), + select("post", "origin", countries_by_continent) + ) + end + + def test_select_with_boolean_method + @post = Post.new + @post.allow_comments = false + assert_dom_equal( + "<select id=\"post_allow_comments\" name=\"post[allow_comments]\"><option value=\"true\">true</option>\n<option value=\"false\" selected=\"selected\">false</option></select>", + select("post", "allow_comments", %w( true false )) + ) + end + + def test_select_under_fields_for + @post = Post.new + @post.category = "<mus>" + + output_buffer = fields_for :post, @post do |f| + concat f.select(:category, %w( abe <mus> hest)) + end + + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"abe\">abe</option>\n<option value=\"<mus>\" selected=\"selected\"><mus></option>\n<option value=\"hest\">hest</option></select>", + output_buffer + ) + end + + def test_fields_for_with_record_inherited_from_hash + map = Map.new + + output_buffer = fields_for :map, map do |f| + concat f.select(:category, %w( abe <mus> hest)) + end + + assert_dom_equal( + "<select id=\"map_category\" name=\"map[category]\"><option value=\"abe\">abe</option>\n<option value=\"<mus>\" selected=\"selected\"><mus></option>\n<option value=\"hest\">hest</option></select>", + output_buffer + ) + end + + def test_select_under_fields_for_with_index + @post = Post.new + @post.category = "<mus>" + + output_buffer = fields_for :post, @post, index: 108 do |f| + concat f.select(:category, %w( abe <mus> hest)) + end + + assert_dom_equal( + "<select id=\"post_108_category\" name=\"post[108][category]\"><option value=\"abe\">abe</option>\n<option value=\"<mus>\" selected=\"selected\"><mus></option>\n<option value=\"hest\">hest</option></select>", + output_buffer + ) + end + + def test_select_under_fields_for_with_auto_index + @post = Post.new + @post.category = "<mus>" + def @post.to_param; 108; end + + output_buffer = fields_for "post[]", @post do |f| + concat f.select(:category, %w( abe <mus> hest)) + end + + assert_dom_equal( + "<select id=\"post_108_category\" name=\"post[108][category]\"><option value=\"abe\">abe</option>\n<option value=\"<mus>\" selected=\"selected\"><mus></option>\n<option value=\"hest\">hest</option></select>", + output_buffer + ) + end + + def test_select_under_fields_for_with_string_and_given_prompt + @post = Post.new + options = raw("<option value=\"abe\">abe</option><option value=\"mus\">mus</option><option value=\"hest\">hest</option>") + + output_buffer = fields_for :post, @post do |f| + concat f.select(:category, options, prompt: "The prompt") + end + + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\">The prompt</option>\n#{options}</select>", + output_buffer + ) + end + + def test_select_under_fields_for_with_block + @post = Post.new + + output_buffer = fields_for :post, @post do |f| + concat(f.select(:category) do + concat content_tag(:option, "hello world") + end) + end + + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option>hello world</option></select>", + output_buffer + ) + end + + def test_select_under_fields_for_with_block_without_options + @post = Post.new + + output_buffer = fields_for :post, @post do |f| + concat(f.select(:category) { }) + end + + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"></select>", + output_buffer + ) + end + + def test_select_with_multiple_to_add_hidden_input + output_buffer = select(:post, :category, "", {}, { multiple: true }) + assert_dom_equal( + "<input type=\"hidden\" name=\"post[category][]\" value=\"\"/><select multiple=\"multiple\" id=\"post_category\" name=\"post[category][]\"></select>", + output_buffer + ) + end + + def test_select_with_multiple_and_without_hidden_input + output_buffer = select(:post, :category, "", { include_hidden: false }, { multiple: true }) + assert_dom_equal( + "<select multiple=\"multiple\" id=\"post_category\" name=\"post[category][]\"></select>", + output_buffer + ) + end + + def test_select_with_multiple_and_with_explicit_name_ending_with_brackets + output_buffer = select(:post, :category, [], { include_hidden: false }, { multiple: true, name: "post[category][]" }) + assert_dom_equal( + "<select multiple=\"multiple\" id=\"post_category\" name=\"post[category][]\"></select>", + output_buffer + ) + end + + def test_select_with_multiple_and_disabled_to_add_disabled_hidden_input + output_buffer = select(:post, :category, "", {}, { multiple: true, disabled: true }) + assert_dom_equal( + "<input disabled=\"disabled\"type=\"hidden\" name=\"post[category][]\" value=\"\"/><select multiple=\"multiple\" disabled=\"disabled\" id=\"post_category\" name=\"post[category][]\"></select>", + output_buffer + ) + end + + def test_select_with_blank + @post = Post.new + @post.category = "<mus>" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\"></option>\n<option value=\"abe\">abe</option>\n<option value=\"<mus>\" selected=\"selected\"><mus></option>\n<option value=\"hest\">hest</option></select>", + select("post", "category", %w( abe <mus> hest), include_blank: true) + ) + end + + def test_select_with_include_blank_false_and_required + @post = Post.new + @post.category = "<mus>" + e = assert_raises(ArgumentError) { select("post", "category", %w( abe <mus> hest), { include_blank: false }, { required: "required" }) } + assert_match(/include_blank cannot be false for a required field./, e.message) + end + + def test_select_with_blank_as_string + @post = Post.new + @post.category = "<mus>" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\">None</option>\n<option value=\"abe\">abe</option>\n<option value=\"<mus>\" selected=\"selected\"><mus></option>\n<option value=\"hest\">hest</option></select>", + select("post", "category", %w( abe <mus> hest), include_blank: "None") + ) + end + + def test_select_with_blank_as_string_escaped + @post = Post.new + @post.category = "<mus>" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\"><None></option>\n<option value=\"abe\">abe</option>\n<option value=\"<mus>\" selected=\"selected\"><mus></option>\n<option value=\"hest\">hest</option></select>", + select("post", "category", %w( abe <mus> hest), include_blank: "<None>") + ) + end + + def test_select_with_default_prompt + @post = Post.new + @post.category = "" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\">Please select</option>\n<option value=\"abe\">abe</option>\n<option value=\"<mus>\"><mus></option>\n<option value=\"hest\">hest</option></select>", + select("post", "category", %w( abe <mus> hest), prompt: true) + ) + end + + def test_select_no_prompt_when_select_has_value + @post = Post.new + @post.category = "<mus>" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"abe\">abe</option>\n<option value=\"<mus>\" selected=\"selected\"><mus></option>\n<option value=\"hest\">hest</option></select>", + select("post", "category", %w( abe <mus> hest), prompt: true) + ) + end + + def test_select_with_given_prompt + @post = Post.new + @post.category = "" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\">The prompt</option>\n<option value=\"abe\">abe</option>\n<option value=\"<mus>\"><mus></option>\n<option value=\"hest\">hest</option></select>", + select("post", "category", %w( abe <mus> hest), prompt: "The prompt") + ) + end + + def test_select_with_given_prompt_escaped + @post = Post.new + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\"><The prompt></option>\n<option value=\"abe\">abe</option>\n<option value=\"<mus>\"><mus></option>\n<option value=\"hest\">hest</option></select>", + select("post", "category", %w( abe <mus> hest), prompt: "<The prompt>") + ) + end + + def test_select_with_prompt_and_blank + @post = Post.new + @post.category = "" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\">Please select</option>\n<option value=\"\"></option>\n<option value=\"abe\">abe</option>\n<option value=\"<mus>\"><mus></option>\n<option value=\"hest\">hest</option></select>", + select("post", "category", %w( abe <mus> hest), prompt: true, include_blank: true) + ) + end + + def test_select_with_empty + @post = Post.new + @post.category = "" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\">Please select</option>\n<option value=\"\"></option>\n</select>", + select("post", "category", [], prompt: true, include_blank: true) + ) + end + + def test_select_with_html_options + @post = Post.new + @post.category = "" + assert_dom_equal( + "<select class=\"disabled\" disabled=\"disabled\" name=\"post[category]\" id=\"post_category\"><option value=\"\">Please select</option>\n<option value=\"\"></option>\n</select>", + select("post", "category", [], { prompt: true, include_blank: true }, { class: "disabled", disabled: true }) + ) + end + + def test_select_with_nil + @post = Post.new + @post.category = "othervalue" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\"></option>\n<option value=\"othervalue\" selected=\"selected\">othervalue</option></select>", + select("post", "category", [nil, "othervalue"]) + ) + end + + def test_required_select + assert_dom_equal( + %(<select id="post_category" name="post[category]" required="required"><option value=""></option>\n<option value="abe">abe</option>\n<option value="mus">mus</option>\n<option value="hest">hest</option></select>), + select("post", "category", %w(abe mus hest), {}, { required: true }) + ) + end + + def test_required_select_with_include_blank_prompt + assert_dom_equal( + %(<select id="post_category" name="post[category]" required="required"><option value="">Select one</option>\n<option value="abe">abe</option>\n<option value="mus">mus</option>\n<option value="hest">hest</option></select>), + select("post", "category", %w(abe mus hest), { include_blank: "Select one" }, { required: true }) + ) + end + + def test_required_select_with_prompt + assert_dom_equal( + %(<select id="post_category" name="post[category]" required="required"><option value="">Select one</option>\n<option value="abe">abe</option>\n<option value="mus">mus</option>\n<option value="hest">hest</option></select>), + select("post", "category", %w(abe mus hest), { prompt: "Select one" }, { required: true }) + ) + end + + def test_required_select_display_size_equals_to_one + assert_dom_equal( + %(<select id="post_category" name="post[category]" required="required" size="1"><option value=""></option>\n<option value="abe">abe</option>\n<option value="mus">mus</option>\n<option value="hest">hest</option></select>), + select("post", "category", %w(abe mus hest), {}, { required: true, size: 1 }) + ) + end + + def test_required_select_with_display_size_bigger_than_one + assert_dom_equal( + %(<select id="post_category" name="post[category]" required="required" size="2"><option value="abe">abe</option>\n<option value="mus">mus</option>\n<option value="hest">hest</option></select>), + select("post", "category", %w(abe mus hest), {}, { required: true, size: 2 }) + ) + end + + def test_required_select_with_multiple_option + assert_dom_equal( + %(<input name="post[category][]" type="hidden" value=""/><select id="post_category" multiple="multiple" name="post[category][]" required="required"><option value="abe">abe</option>\n<option value="mus">mus</option>\n<option value="hest">hest</option></select>), + select("post", "category", %w(abe mus hest), {}, { required: true, multiple: true }) + ) + end + + def test_select_with_integer + @post = Post.new + @post.category = "" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\">Please select</option>\n<option value=\"\"></option>\n<option value=\"1\">1</option></select>", + select("post", "category", [1], prompt: true, include_blank: true) + ) + end + + def test_list_of_lists + @post = Post.new + @post.category = "" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"\">Please select</option>\n<option value=\"\"></option>\n<option value=\"number\">Number</option>\n<option value=\"text\">Text</option>\n<option value=\"boolean\">Yes/No</option></select>", + select("post", "category", [["Number", "number"], ["Text", "text"], ["Yes/No", "boolean"]], prompt: true, include_blank: true) + ) + end + + def test_select_with_selected_value + @post = Post.new + @post.category = "<mus>" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"abe\" selected=\"selected\">abe</option>\n<option value=\"<mus>\"><mus></option>\n<option value=\"hest\">hest</option></select>", + select("post", "category", %w( abe <mus> hest ), selected: "abe") + ) + end + + def test_select_with_index_option + @album = Album.new + @album.id = 1 + + expected = "<select id=\"album__genre\" name=\"album[][genre]\"><option value=\"rap\">rap</option>\n<option value=\"rock\">rock</option>\n<option value=\"country\">country</option></select>" + + assert_dom_equal( + expected, + select("album[]", "genre", %w[rap rock country], {}, { index: nil }) + ) + end + + def test_select_escapes_options + assert_dom_equal( + '<select id="post_title" name="post[title]"><script>alert(1)</script></select>', + select("post", "title", "<script>alert(1)</script>") + ) + end + + def test_select_with_selected_nil + @post = Post.new + @post.category = "<mus>" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"abe\">abe</option>\n<option value=\"<mus>\"><mus></option>\n<option value=\"hest\">hest</option></select>", + select("post", "category", %w( abe <mus> hest ), selected: nil) + ) + end + + def test_select_with_disabled_value + @post = Post.new + @post.category = "<mus>" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"abe\">abe</option>\n<option value=\"<mus>\" selected=\"selected\"><mus></option>\n<option value=\"hest\" disabled=\"disabled\">hest</option></select>", + select("post", "category", %w( abe <mus> hest ), disabled: "hest") + ) + end + + def test_select_not_existing_method_with_selected_value + @post = Post.new + assert_dom_equal( + "<select id=\"post_locale\" name=\"post[locale]\"><option value=\"en\">en</option>\n<option value=\"ru\" selected=\"selected\">ru</option></select>", + select("post", "locale", %w( en ru ), selected: "ru") + ) + end + + def test_select_with_prompt_and_selected_value + @post = Post.new + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"one\">one</option>\n<option selected=\"selected\" value=\"two\">two</option></select>", + select("post", "category", %w( one two ), selected: "two", prompt: true) + ) + end + + def test_select_with_disabled_array + @post = Post.new + @post.category = "<mus>" + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"abe\" disabled=\"disabled\">abe</option>\n<option value=\"<mus>\" selected=\"selected\"><mus></option>\n<option value=\"hest\" disabled=\"disabled\">hest</option></select>", + select("post", "category", %w( abe <mus> hest ), disabled: ["hest", "abe"]) + ) + end + + def test_select_with_range + @post = Post.new + @post.category = 0 + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"1\">1</option>\n<option value=\"2\">2</option>\n<option value=\"3\">3</option></select>", + select("post", "category", 1..3) + ) + end + + def test_select_with_enumerable + @post = Post.new + assert_dom_equal( + "<select id=\"post_category\" name=\"post[category]\"><option value=\"one\">one</option>\n<option value=\"two\">two</option></select>", + select("post", "category", CustomEnumerable.new) + ) + end + + def test_collection_select + @post = Post.new + @post.author_name = "Babe" + + assert_dom_equal( + "<select id=\"post_author_name\" name=\"post[author_name]\"><option value=\"<Abe>\"><Abe></option>\n<option value=\"Babe\" selected=\"selected\">Babe</option>\n<option value=\"Cabe\">Cabe</option></select>", + collection_select("post", "author_name", dummy_posts, "author_name", "author_name") + ) + end + + def test_collection_select_under_fields_for + @post = Post.new + @post.author_name = "Babe" + + output_buffer = fields_for :post, @post do |f| + concat f.collection_select(:author_name, dummy_posts, :author_name, :author_name) + end + + assert_dom_equal( + "<select id=\"post_author_name\" name=\"post[author_name]\"><option value=\"<Abe>\"><Abe></option>\n<option value=\"Babe\" selected=\"selected\">Babe</option>\n<option value=\"Cabe\">Cabe</option></select>", + output_buffer + ) + end + + def test_collection_select_under_fields_for_with_index + @post = Post.new + @post.author_name = "Babe" + + output_buffer = fields_for :post, @post, index: 815 do |f| + concat f.collection_select(:author_name, dummy_posts, :author_name, :author_name) + end + + assert_dom_equal( + "<select id=\"post_815_author_name\" name=\"post[815][author_name]\"><option value=\"<Abe>\"><Abe></option>\n<option value=\"Babe\" selected=\"selected\">Babe</option>\n<option value=\"Cabe\">Cabe</option></select>", + output_buffer + ) + end + + def test_collection_select_under_fields_for_with_auto_index + @post = Post.new + @post.author_name = "Babe" + def @post.to_param; 815; end + + output_buffer = fields_for "post[]", @post do |f| + concat f.collection_select(:author_name, dummy_posts, :author_name, :author_name) + end + + assert_dom_equal( + "<select id=\"post_815_author_name\" name=\"post[815][author_name]\"><option value=\"<Abe>\"><Abe></option>\n<option value=\"Babe\" selected=\"selected\">Babe</option>\n<option value=\"Cabe\">Cabe</option></select>", + output_buffer + ) + end + + def test_collection_select_with_blank_and_style + @post = Post.new + @post.author_name = "Babe" + + assert_dom_equal( + "<select id=\"post_author_name\" name=\"post[author_name]\" style=\"width: 200px\"><option value=\"\"></option>\n<option value=\"<Abe>\"><Abe></option>\n<option value=\"Babe\" selected=\"selected\">Babe</option>\n<option value=\"Cabe\">Cabe</option></select>", + collection_select("post", "author_name", dummy_posts, "author_name", "author_name", { include_blank: true }, { "style" => "width: 200px" }) + ) + end + + def test_collection_select_with_blank_as_string_and_style + @post = Post.new + @post.author_name = "Babe" + + assert_dom_equal( + "<select id=\"post_author_name\" name=\"post[author_name]\" style=\"width: 200px\"><option value=\"\">No Selection</option>\n<option value=\"<Abe>\"><Abe></option>\n<option value=\"Babe\" selected=\"selected\">Babe</option>\n<option value=\"Cabe\">Cabe</option></select>", + collection_select("post", "author_name", dummy_posts, "author_name", "author_name", { include_blank: "No Selection" }, { "style" => "width: 200px" }) + ) + end + + def test_collection_select_with_multiple_option_appends_array_brackets_and_hidden_input + @post = Post.new + @post.author_name = "Babe" + + expected = "<input type=\"hidden\" name=\"post[author_name][]\" value=\"\"/><select id=\"post_author_name\" name=\"post[author_name][]\" multiple=\"multiple\"><option value=\"\"></option>\n<option value=\"<Abe>\"><Abe></option>\n<option value=\"Babe\" selected=\"selected\">Babe</option>\n<option value=\"Cabe\">Cabe</option></select>" + + # Should suffix default name with []. + assert_dom_equal expected, collection_select("post", "author_name", dummy_posts, "author_name", "author_name", { include_blank: true }, { multiple: true }) + + # Shouldn't suffix custom name with []. + assert_dom_equal expected, collection_select("post", "author_name", dummy_posts, "author_name", "author_name", { include_blank: true, name: "post[author_name][]" }, { multiple: true }) + end + + def test_collection_select_with_blank_and_selected + @post = Post.new + @post.author_name = "Babe" + + assert_dom_equal( + %{<select id="post_author_name" name="post[author_name]"><option value=""></option>\n<option value="<Abe>" selected="selected"><Abe></option>\n<option value="Babe">Babe</option>\n<option value="Cabe">Cabe</option></select>}, + collection_select("post", "author_name", dummy_posts, "author_name", "author_name", include_blank: true, selected: "<Abe>") + ) + end + + def test_collection_select_with_disabled + @post = Post.new + @post.author_name = "Babe" + + assert_dom_equal( + "<select id=\"post_author_name\" name=\"post[author_name]\"><option value=\"<Abe>\"><Abe></option>\n<option value=\"Babe\" selected=\"selected\">Babe</option>\n<option value=\"Cabe\" disabled=\"disabled\">Cabe</option></select>", + collection_select("post", "author_name", dummy_posts, "author_name", "author_name", disabled: "Cabe") + ) + end + + def test_collection_select_with_proc_for_value_method + @post = Post.new + + assert_dom_equal( + "<select id=\"post_author_name\" name=\"post[author_name]\"><option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\">Babe went home</option>\n<option value=\"Cabe\">Cabe went home</option></select>", + collection_select("post", "author_name", dummy_posts, lambda { |p| p.author_name }, "title") + ) + end + + def test_collection_select_with_proc_for_text_method + @post = Post.new + + assert_dom_equal( + "<select id=\"post_author_name\" name=\"post[author_name]\"><option value=\"<Abe>\"><Abe> went home</option>\n<option value=\"Babe\">Babe went home</option>\n<option value=\"Cabe\">Cabe went home</option></select>", + collection_select("post", "author_name", dummy_posts, "author_name", lambda { |p| p.title }) + ) + end + + def test_time_zone_select + @firm = Firm.new("D") + html = time_zone_select("firm", "time_zone") + assert_dom_equal "<select id=\"firm_time_zone\" name=\"firm[time_zone]\">" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + html + end + + def test_time_zone_select_under_fields_for + @firm = Firm.new("D") + + output_buffer = fields_for :firm, @firm do |f| + concat f.time_zone_select(:time_zone) + end + + assert_dom_equal( + "<select id=\"firm_time_zone\" name=\"firm[time_zone]\">" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + output_buffer + ) + end + + def test_time_zone_select_under_fields_for_with_index + @firm = Firm.new("D") + + output_buffer = fields_for :firm, @firm, index: 305 do |f| + concat f.time_zone_select(:time_zone) + end + + assert_dom_equal( + "<select id=\"firm_305_time_zone\" name=\"firm[305][time_zone]\">" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + output_buffer + ) + end + + def test_time_zone_select_under_fields_for_with_auto_index + @firm = Firm.new("D") + def @firm.to_param; 305; end + + output_buffer = fields_for "firm[]", @firm do |f| + concat f.time_zone_select(:time_zone) + end + + assert_dom_equal( + "<select id=\"firm_305_time_zone\" name=\"firm[305][time_zone]\">" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + output_buffer + ) + end + + def test_time_zone_select_with_blank + @firm = Firm.new("D") + html = time_zone_select("firm", "time_zone", nil, include_blank: true) + assert_dom_equal "<select id=\"firm_time_zone\" name=\"firm[time_zone]\">" \ + "<option value=\"\"></option>\n" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + html + end + + def test_time_zone_select_with_blank_as_string + @firm = Firm.new("D") + html = time_zone_select("firm", "time_zone", nil, include_blank: "No Zone") + assert_dom_equal "<select id=\"firm_time_zone\" name=\"firm[time_zone]\">" \ + "<option value=\"\">No Zone</option>\n" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + html + end + + def test_time_zone_select_with_style + @firm = Firm.new("D") + html = time_zone_select("firm", "time_zone", nil, {}, + { "style" => "color: red" }) + assert_dom_equal "<select id=\"firm_time_zone\" name=\"firm[time_zone]\" style=\"color: red\">" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + html + assert_dom_equal html, time_zone_select("firm", "time_zone", nil, {}, + { style: "color: red" }) + end + + def test_time_zone_select_with_blank_and_style + @firm = Firm.new("D") + html = time_zone_select("firm", "time_zone", nil, + { include_blank: true }, { "style" => "color: red" }) + assert_dom_equal "<select id=\"firm_time_zone\" name=\"firm[time_zone]\" style=\"color: red\">" \ + "<option value=\"\"></option>\n" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + html + assert_dom_equal html, time_zone_select("firm", "time_zone", nil, + { include_blank: true }, { style: "color: red" }) + end + + def test_time_zone_select_with_blank_as_string_and_style + @firm = Firm.new("D") + html = time_zone_select("firm", "time_zone", nil, + { include_blank: "No Zone" }, { "style" => "color: red" }) + assert_dom_equal "<select id=\"firm_time_zone\" name=\"firm[time_zone]\" style=\"color: red\">" \ + "<option value=\"\">No Zone</option>\n" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + html + assert_dom_equal html, time_zone_select("firm", "time_zone", nil, + { include_blank: "No Zone" }, { style: "color: red" }) + end + + def test_time_zone_select_with_priority_zones + @firm = Firm.new("D") + zones = [ ActiveSupport::TimeZone.new("A"), ActiveSupport::TimeZone.new("D") ] + html = time_zone_select("firm", "time_zone", zones) + assert_dom_equal "<select id=\"firm_time_zone\" name=\"firm[time_zone]\">" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>" \ + "<option value=\"\" disabled=\"disabled\">-------------</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + html + end + + def test_time_zone_select_with_priority_zones_as_regexp + @firm = Firm.new("D") + + @fake_timezones.each do |tz| + def tz.=~(re); %(A D).include?(name) end + end + + html = time_zone_select("firm", "time_zone", /A|D/) + assert_dom_equal "<select id=\"firm_time_zone\" name=\"firm[time_zone]\">" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>" \ + "<option value=\"\" disabled=\"disabled\">-------------</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + html + end + + def test_time_zone_select_with_priority_zones_is_not_implemented_with_grep + @firm = Firm.new("D") + + # `time_zone_select` can't be written with `grep` because Active Support + # time zones don't support implicit string coercion with `to_str`. + @fake_timezones.each do |tz| + def tz.===(zone); raise Exception; end + end + + html = time_zone_select("firm", "time_zone", /A|D/) + assert_dom_equal "<select id=\"firm_time_zone\" name=\"firm[time_zone]\">" \ + "<option value=\"\" disabled=\"disabled\">-------------</option>\n" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + html + end + + def test_time_zone_select_with_priority_zones_and_errors + @firm = Firm.new("D") + @firm.extend ActiveModel::Validations + @firm.errors[:time_zone] << "invalid" + zones = [ ActiveSupport::TimeZone.new("A"), ActiveSupport::TimeZone.new("D") ] + html = time_zone_select("firm", "time_zone", zones) + assert_dom_equal "<div class=\"field_with_errors\">" \ + "<select id=\"firm_time_zone\" name=\"firm[time_zone]\">" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>" \ + "<option value=\"\" disabled=\"disabled\">-------------</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>" \ + "</div>", + html + end + + def test_time_zone_select_with_default_time_zone_and_nil_value + @firm = Firm.new() + @firm.time_zone = nil + + html = time_zone_select("firm", "time_zone", nil, default: "B") + assert_dom_equal "<select id=\"firm_time_zone\" name=\"firm[time_zone]\">" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\" selected=\"selected\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + html + end + + def test_time_zone_select_with_default_time_zone_and_value + @firm = Firm.new("D") + + html = time_zone_select("firm", "time_zone", nil, default: "B") + assert_dom_equal "<select id=\"firm_time_zone\" name=\"firm[time_zone]\">" \ + "<option value=\"A\">A</option>\n" \ + "<option value=\"B\">B</option>\n" \ + "<option value=\"C\">C</option>\n" \ + "<option value=\"D\" selected=\"selected\">D</option>\n" \ + "<option value=\"E\">E</option>" \ + "</select>", + html + end + + def test_options_for_select_with_element_attributes + assert_dom_equal( + "<option value=\"<Denmark>\" class=\"bold\"><Denmark></option>\n<option value=\"USA\" onclick=\"alert('Hello World');\">USA</option>\n<option value=\"Sweden\">Sweden</option>\n<option value=\"Germany\">Germany</option>", + options_for_select([ [ "<Denmark>", { class: "bold" } ], [ "USA", { onclick: "alert('Hello World');" } ], [ "Sweden" ], "Germany" ]) + ) + end + + def test_options_for_select_with_data_element + assert_dom_equal( + "<option value=\"<Denmark>\" data-test=\"bold\"><Denmark></option>", + options_for_select([ [ "<Denmark>", { data: { test: "bold" } } ] ]) + ) + end + + def test_options_for_select_with_data_element_with_special_characters + assert_dom_equal( + "<option value=\"<Denmark>\" data-test=\"<bold>\"><Denmark></option>", + options_for_select([ [ "<Denmark>", { data: { test: "<bold>" } } ] ]) + ) + end + + def test_options_for_select_with_element_attributes_and_selection + assert_dom_equal( + "<option value=\"<Denmark>\"><Denmark></option>\n<option value=\"USA\" class=\"bold\" selected=\"selected\">USA</option>\n<option value=\"Sweden\">Sweden</option>", + options_for_select([ "<Denmark>", [ "USA", { class: "bold" } ], "Sweden" ], "USA") + ) + end + + def test_options_for_select_with_element_attributes_and_selection_array + assert_dom_equal( + "<option value=\"<Denmark>\"><Denmark></option>\n<option value=\"USA\" class=\"bold\" selected=\"selected\">USA</option>\n<option value=\"Sweden\" selected=\"selected\">Sweden</option>", + options_for_select([ "<Denmark>", [ "USA", { class: "bold" } ], "Sweden" ], [ "USA", "Sweden" ]) + ) + end + + def test_options_for_select_with_special_characters + assert_dom_equal( + "<option value=\"<Denmark>\" onclick=\"alert("<code>")\"><Denmark></option>", + options_for_select([ [ "<Denmark>", { onclick: %(alert("<code>")) } ] ]) + ) + end + + def test_option_html_attributes_with_no_array_element + assert_equal({}, option_html_attributes("foo")) + end + + def test_option_html_attributes_without_hash + assert_equal({}, option_html_attributes([ "foo", "bar" ])) + end + + def test_option_html_attributes_with_single_element_hash + assert_equal( + { class: "fancy" }, + option_html_attributes([ "foo", "bar", { class: "fancy" } ]) + ) + end + + def test_option_html_attributes_with_multiple_element_hash + assert_equal( + { :class => "fancy", "onclick" => "alert('Hello World');" }, + option_html_attributes([ "foo", "bar", { :class => "fancy", "onclick" => "alert('Hello World');" } ]) + ) + end + + def test_option_html_attributes_with_multiple_hashes + assert_equal( + { :class => "fancy", "onclick" => "alert('Hello World');" }, + option_html_attributes([ "foo", "bar", { class: "fancy" }, { "onclick" => "alert('Hello World');" } ]) + ) + end + + def test_option_html_attributes_with_multiple_hashes_does_not_modify_them + options1 = { class: "fancy" } + options2 = { onclick: "alert('Hello World');" } + option_html_attributes([ "foo", "bar", options1, options2 ]) + + assert_equal({ class: "fancy" }, options1) + assert_equal({ onclick: "alert('Hello World');" }, options2) + end + + def test_grouped_collection_select + @post = Post.new + @post.origin = "dk" + + assert_dom_equal( + %Q{<select id="post_origin" name="post[origin]"><optgroup label="<Africa>"><option value="<sa>"><South Africa></option>\n<option value="so">Somalia</option></optgroup><optgroup label="Europe"><option value="dk" selected="selected">Denmark</option>\n<option value="ie">Ireland</option></optgroup></select>}, + grouped_collection_select("post", "origin", dummy_continents, :countries, :continent_name, :country_id, :country_name) + ) + end + + def test_grouped_collection_select_with_selected + @post = Post.new + + assert_dom_equal( + %Q{<select id="post_origin" name="post[origin]"><optgroup label="<Africa>"><option value="<sa>"><South Africa></option>\n<option value="so">Somalia</option></optgroup><optgroup label="Europe"><option value="dk" selected="selected">Denmark</option>\n<option value="ie">Ireland</option></optgroup></select>}, + grouped_collection_select("post", "origin", dummy_continents, :countries, :continent_name, :country_id, :country_name, selected: "dk") + ) + end + + def test_grouped_collection_select_with_disabled_value + @post = Post.new + + assert_dom_equal( + %Q{<select id="post_origin" name="post[origin]"><optgroup label="<Africa>"><option value="<sa>"><South Africa></option>\n<option value="so">Somalia</option></optgroup><optgroup label="Europe"><option disabled="disabled" value="dk">Denmark</option>\n<option value="ie">Ireland</option></optgroup></select>}, + grouped_collection_select("post", "origin", dummy_continents, :countries, :continent_name, :country_id, :country_name, disabled: "dk") + ) + end + + def test_grouped_collection_select_under_fields_for + @post = Post.new + @post.origin = "dk" + + output_buffer = fields_for :post, @post do |f| + concat f.grouped_collection_select("origin", dummy_continents, :countries, :continent_name, :country_id, :country_name) + end + + assert_dom_equal( + %Q{<select id="post_origin" name="post[origin]"><optgroup label="<Africa>"><option value="<sa>"><South Africa></option>\n<option value="so">Somalia</option></optgroup><optgroup label="Europe"><option value="dk" selected="selected">Denmark</option>\n<option value="ie">Ireland</option></optgroup></select>}, + output_buffer + ) + end + + private + + def dummy_posts + [ Post.new("<Abe> went home", "<Abe>", "To a little house", "shh!"), + Post.new("Babe went home", "Babe", "To a little house", "shh!"), + Post.new("Cabe went home", "Cabe", "To a little house", "shh!") ] + end + + def dummy_continents + [ Continent.new("<Africa>", [Country.new("<sa>", "<South Africa>"), Country.new("so", "Somalia")]), + Continent.new("Europe", [Country.new("dk", "Denmark"), Country.new("ie", "Ireland")]) ] + end +end diff --git a/actionview/test/template/form_tag_helper_test.rb b/actionview/test/template/form_tag_helper_test.rb new file mode 100644 index 0000000000..9ece9f3ad1 --- /dev/null +++ b/actionview/test/template/form_tag_helper_test.rb @@ -0,0 +1,812 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class FormTagHelperTest < ActionView::TestCase + include RenderERBUtils + + tests ActionView::Helpers::FormTagHelper + + class WithActiveStorageRoutesControllers < ActionController::Base + test_routes do + post "/rails/active_storage/direct_uploads" => "active_storage/direct_uploads#create", as: :rails_direct_uploads + end + + def url_options + { host: "testtwo.host" } + end + end + + def setup + super + @controller = BasicController.new + end + + def hidden_fields(options = {}) + method = options[:method] + enforce_utf8 = options.fetch(:enforce_utf8, true) + + (+"").tap do |txt| + if enforce_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", options = {}) + remote, enctype, html_class, id, method = options.values_at(:remote, :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"} if remote + 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 + + VALID_HTML_ID = /^[A-Za-z][-_:.A-Za-z0-9]*$/ # see http://www.w3.org/TR/html4/types.html#type-name + + def test_check_box_tag + actual = check_box_tag "admin" + expected = %(<input id="admin" name="admin" type="checkbox" value="1" />) + assert_dom_equal expected, actual + end + + def test_check_box_tag_disabled + actual = check_box_tag "admin", "1", false, disabled: true + expected = %(<input id="admin" disabled="disabled" name="admin" type="checkbox" value="1" />) + assert_dom_equal expected, actual + end + + def test_check_box_tag_default_checked + actual = check_box_tag "admin", "1", true + expected = %(<input id="admin" checked="checked" name="admin" type="checkbox" value="1" />) + assert_dom_equal expected, actual + end + + def test_check_box_tag_id_sanitized + label_elem = root_elem(check_box_tag("project[2][admin]")) + assert_match VALID_HTML_ID, label_elem["id"] + end + + def test_form_tag + actual = form_tag + expected = whole_form + assert_dom_equal expected, actual + end + + def test_form_tag_multipart + actual = form_tag({}, { "multipart" => true }) + expected = whole_form("http://www.example.com", enctype: true) + assert_dom_equal expected, actual + end + + def test_form_tag_with_method_patch + actual = form_tag({}, { method: :patch }) + expected = whole_form("http://www.example.com", method: :patch) + assert_dom_equal expected, actual + end + + def test_form_tag_with_method_put + actual = form_tag({}, { method: :put }) + expected = whole_form("http://www.example.com", method: :put) + assert_dom_equal expected, actual + end + + def test_form_tag_with_method_delete + actual = form_tag({}, { method: :delete }) + + expected = whole_form("http://www.example.com", method: :delete) + assert_dom_equal expected, actual + end + + def test_form_tag_with_remote + actual = form_tag({}, { remote: true }) + + expected = whole_form("http://www.example.com", remote: true) + assert_dom_equal expected, actual + end + + def test_form_tag_with_remote_false + actual = form_tag({}, { remote: false }) + + expected = whole_form + assert_dom_equal expected, actual + end + + def test_form_tag_enforce_utf8_true + actual = form_tag({}, { enforce_utf8: true }) + expected = whole_form("http://www.example.com", enforce_utf8: true) + assert_dom_equal expected, actual + assert_predicate actual, :html_safe? + end + + def test_form_tag_enforce_utf8_false + actual = form_tag({}, { enforce_utf8: false }) + expected = whole_form("http://www.example.com", enforce_utf8: false) + assert_dom_equal expected, actual + assert_predicate actual, :html_safe? + end + + def test_form_tag_default_enforce_utf8_false + with_default_enforce_utf8 false do + actual = form_tag({}) + expected = whole_form("http://www.example.com", enforce_utf8: false) + assert_dom_equal expected, actual + assert_predicate actual, :html_safe? + end + end + + def test_form_tag_default_enforce_utf8_true + with_default_enforce_utf8 true do + actual = form_tag({}) + expected = whole_form("http://www.example.com", enforce_utf8: true) + assert_dom_equal expected, actual + assert_predicate actual, :html_safe? + end + end + + def test_form_tag_with_block_in_erb + output_buffer = render_erb("<%= form_tag('http://www.example.com') do %>Hello world!<% end %>") + + expected = whole_form { "Hello world!" } + assert_dom_equal expected, output_buffer + end + + def test_form_tag_with_block_and_method_in_erb + output_buffer = render_erb("<%= form_tag('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 + + def test_hidden_field_tag + actual = hidden_field_tag "id", 3 + expected = %(<input id="id" name="id" type="hidden" value="3" />) + assert_dom_equal expected, actual + end + + def test_hidden_field_tag_id_sanitized + input_elem = root_elem(hidden_field_tag("item[][title]")) + assert_match VALID_HTML_ID, input_elem["id"] + end + + def test_file_field_tag + assert_dom_equal "<input name=\"picsplz\" type=\"file\" id=\"picsplz\" />", file_field_tag("picsplz") + end + + def test_file_field_tag_with_options + assert_dom_equal "<input name=\"picsplz\" type=\"file\" id=\"picsplz\" class=\"pix\"/>", file_field_tag("picsplz", class: "pix") + end + + def test_file_field_tag_with_direct_upload_when_rails_direct_uploads_url_is_not_defined + assert_dom_equal( + "<input name=\"picsplz\" type=\"file\" id=\"picsplz\" class=\"pix\"/>", + file_field_tag("picsplz", class: "pix", direct_upload: true) + ) + end + + def test_file_field_tag_with_direct_upload_when_rails_direct_uploads_url_is_defined + @controller = WithActiveStorageRoutesControllers.new + + assert_dom_equal( + "<input name=\"picsplz\" type=\"file\" id=\"picsplz\" class=\"pix\" data-direct-upload-url=\"http://testtwo.host/rails/active_storage/direct_uploads\"/>", + file_field_tag("picsplz", class: "pix", direct_upload: true) + ) + end + + def test_file_field_tag_with_direct_upload_dont_mutate_arguments + original_options = { class: "pix", direct_upload: true } + + assert_dom_equal( + "<input name=\"picsplz\" type=\"file\" id=\"picsplz\" class=\"pix\"/>", + file_field_tag("picsplz", original_options) + ) + + assert_equal({ class: "pix", direct_upload: true }, original_options) + end + + def test_password_field_tag + actual = password_field_tag + expected = %(<input id="password" name="password" type="password" />) + assert_dom_equal expected, actual + end + + def test_multiple_field_tags_with_same_options + options = { class: "important" } + assert_dom_equal %(<input name="title" type="file" id="title" class="important"/>), file_field_tag("title", options) + assert_dom_equal %(<input type="password" name="title" id="title" value="Hello!" class="important" />), password_field_tag("title", "Hello!", options) + assert_dom_equal %(<input type="text" name="title" id="title" value="Hello!" class="important" />), text_field_tag("title", "Hello!", options) + end + + def test_radio_button_tag + actual = radio_button_tag "people", "david" + expected = %(<input id="people_david" name="people" type="radio" value="david" />) + assert_dom_equal expected, actual + + actual = radio_button_tag("num_people", 5) + expected = %(<input id="num_people_5" name="num_people" type="radio" value="5" />) + assert_dom_equal expected, actual + + actual = radio_button_tag("gender", "m") + radio_button_tag("gender", "f") + expected = %(<input id="gender_m" name="gender" type="radio" value="m" /><input id="gender_f" name="gender" type="radio" value="f" />) + assert_dom_equal expected, actual + + actual = radio_button_tag("opinion", "-1") + radio_button_tag("opinion", "1") + expected = %(<input id="opinion_-1" name="opinion" type="radio" value="-1" /><input id="opinion_1" name="opinion" type="radio" value="1" />) + assert_dom_equal expected, actual + + actual = radio_button_tag("person[gender]", "m") + expected = %(<input id="person_gender_m" name="person[gender]" type="radio" value="m" />) + assert_dom_equal expected, actual + + actual = radio_button_tag("ctrlname", "apache2.2") + expected = %(<input id="ctrlname_apache2.2" name="ctrlname" type="radio" value="apache2.2" />) + assert_dom_equal expected, actual + end + + def test_select_tag + actual = select_tag "people", raw("<option>david</option>") + expected = %(<select id="people" name="people"><option>david</option></select>) + assert_dom_equal expected, actual + end + + def test_select_tag_with_multiple + actual = select_tag "colors", raw("<option>Red</option><option>Blue</option><option>Green</option>"), multiple: true + expected = %(<select id="colors" multiple="multiple" name="colors[]"><option>Red</option><option>Blue</option><option>Green</option></select>) + assert_dom_equal expected, actual + end + + def test_select_tag_disabled + actual = select_tag "places", raw("<option>Home</option><option>Work</option><option>Pub</option>"), disabled: true + expected = %(<select id="places" disabled="disabled" name="places"><option>Home</option><option>Work</option><option>Pub</option></select>) + assert_dom_equal expected, actual + end + + def test_select_tag_id_sanitized + input_elem = root_elem(select_tag("project[1]people", "<option>david</option>")) + assert_match VALID_HTML_ID, input_elem["id"] + end + + def test_select_tag_with_include_blank + actual = select_tag "places", raw("<option>Home</option><option>Work</option><option>Pub</option>"), include_blank: true + expected = %(<select id="places" name="places"><option value="" label=" "></option><option>Home</option><option>Work</option><option>Pub</option></select>) + assert_dom_equal expected, actual + end + + def test_select_tag_with_include_blank_false + actual = select_tag "places", raw("<option>Home</option><option>Work</option><option>Pub</option>"), include_blank: false + expected = %(<select id="places" name="places"><option>Home</option><option>Work</option><option>Pub</option></select>) + assert_dom_equal expected, actual + end + + def test_select_tag_with_include_blank_string + actual = select_tag "places", raw("<option>Home</option><option>Work</option><option>Pub</option>"), include_blank: "Choose" + expected = %(<select id="places" name="places"><option value="">Choose</option><option>Home</option><option>Work</option><option>Pub</option></select>) + assert_dom_equal expected, actual + end + + def test_select_tag_with_prompt + actual = select_tag "places", raw("<option>Home</option><option>Work</option><option>Pub</option>"), prompt: "string" + expected = %(<select id="places" name="places"><option value="">string</option><option>Home</option><option>Work</option><option>Pub</option></select>) + assert_dom_equal expected, actual + end + + def test_select_tag_escapes_prompt + actual = select_tag "places", raw("<option>Home</option><option>Work</option><option>Pub</option>"), prompt: "<script>alert(1337)</script>" + expected = %(<select id="places" name="places"><option value=""><script>alert(1337)</script></option><option>Home</option><option>Work</option><option>Pub</option></select>) + assert_dom_equal expected, actual + end + + def test_select_tag_with_prompt_and_include_blank + actual = select_tag "places", raw("<option>Home</option><option>Work</option><option>Pub</option>"), prompt: "string", include_blank: true + expected = %(<select name="places" id="places"><option value="">string</option><option value="" label=" "></option><option>Home</option><option>Work</option><option>Pub</option></select>) + assert_dom_equal expected, actual + end + + def test_select_tag_with_nil_option_tags_and_include_blank + actual = select_tag "places", nil, include_blank: true + expected = %(<select id="places" name="places"><option value="" label=" "></option></select>) + assert_dom_equal expected, actual + end + + def test_select_tag_with_nil_option_tags_and_prompt + actual = select_tag "places", nil, prompt: "string" + expected = %(<select id="places" name="places"><option value="">string</option></select>) + assert_dom_equal expected, actual + end + + def test_text_area_tag_size_string + actual = text_area_tag "body", "hello world", "size" => "20x40" + expected = %(<textarea cols="20" id="body" name="body" rows="40">\nhello world</textarea>) + assert_dom_equal expected, actual + end + + def test_text_area_tag_size_symbol + actual = text_area_tag "body", "hello world", size: "20x40" + expected = %(<textarea cols="20" id="body" name="body" rows="40">\nhello world</textarea>) + assert_dom_equal expected, actual + end + + def test_text_area_tag_should_disregard_size_if_its_given_as_an_integer + actual = text_area_tag "body", "hello world", size: 20 + expected = %(<textarea id="body" name="body">\nhello world</textarea>) + assert_dom_equal expected, actual + end + + def test_text_area_tag_id_sanitized + input_elem = root_elem(text_area_tag("item[][description]")) + assert_match VALID_HTML_ID, input_elem["id"] + end + + def test_text_area_tag_escape_content + actual = text_area_tag "body", "<b>hello world</b>", size: "20x40" + expected = %(<textarea cols="20" id="body" name="body" rows="40">\n<b>hello world</b></textarea>) + assert_dom_equal expected, actual + end + + def test_text_area_tag_unescaped_content + actual = text_area_tag "body", "<b>hello world</b>", size: "20x40", escape: false + expected = %(<textarea cols="20" id="body" name="body" rows="40">\n<b>hello world</b></textarea>) + assert_dom_equal expected, actual + end + + def test_text_area_tag_unescaped_nil_content + actual = text_area_tag "body", nil, escape: false + expected = %(<textarea id="body" name="body">\n</textarea>) + assert_dom_equal expected, actual + end + + def test_text_field_tag + actual = text_field_tag "title", "Hello!" + expected = %(<input id="title" name="title" type="text" value="Hello!" />) + assert_dom_equal expected, actual + end + + def test_text_field_tag_class_string + actual = text_field_tag "title", "Hello!", "class" => "admin" + expected = %(<input class="admin" id="title" name="title" type="text" value="Hello!" />) + assert_dom_equal expected, actual + end + + def test_text_field_tag_size_symbol + actual = text_field_tag "title", "Hello!", size: 75 + expected = %(<input id="title" name="title" size="75" type="text" value="Hello!" />) + assert_dom_equal expected, actual + end + + def test_text_field_tag_with_ac_parameters + actual = text_field_tag "title", ActionController::Parameters.new(key: "value") + expected = %(<input id="title" name="title" type="text" value="{"key"=>"value"}" />) + assert_dom_equal expected, actual + end + + def test_text_field_tag_size_string + actual = text_field_tag "title", "Hello!", "size" => "75" + expected = %(<input id="title" name="title" size="75" type="text" value="Hello!" />) + assert_dom_equal expected, actual + end + + def test_text_field_tag_maxlength_symbol + actual = text_field_tag "title", "Hello!", maxlength: 75 + expected = %(<input id="title" name="title" maxlength="75" type="text" value="Hello!" />) + assert_dom_equal expected, actual + end + + def test_text_field_tag_maxlength_string + actual = text_field_tag "title", "Hello!", "maxlength" => "75" + expected = %(<input id="title" name="title" maxlength="75" type="text" value="Hello!" />) + assert_dom_equal expected, actual + end + + def test_text_field_tag_disabled + actual = text_field_tag "title", "Hello!", disabled: true + expected = %(<input id="title" name="title" disabled="disabled" type="text" value="Hello!" />) + assert_dom_equal expected, actual + end + + def test_text_field_tag_with_placeholder_option + actual = text_field_tag "title", "Hello!", placeholder: "Enter search term..." + expected = %(<input id="title" name="title" placeholder="Enter search term..." type="text" value="Hello!" />) + assert_dom_equal expected, actual + end + + def test_text_field_tag_with_multiple_options + actual = text_field_tag "title", "Hello!", size: 70, maxlength: 80 + expected = %(<input id="title" name="title" size="70" maxlength="80" type="text" value="Hello!" />) + assert_dom_equal expected, actual + end + + def test_text_field_tag_id_sanitized + input_elem = root_elem(text_field_tag("item[][title]")) + assert_match VALID_HTML_ID, input_elem["id"] + end + + def test_label_tag_without_text + actual = label_tag "title" + expected = %(<label for="title">Title</label>) + assert_dom_equal expected, actual + end + + def test_label_tag_with_symbol + actual = label_tag :title + expected = %(<label for="title">Title</label>) + assert_dom_equal expected, actual + end + + def test_label_tag_with_text + actual = label_tag "title", "My Title" + expected = %(<label for="title">My Title</label>) + assert_dom_equal expected, actual + end + + def test_label_tag_class_string + actual = label_tag "title", "My Title", "class" => "small_label" + expected = %(<label for="title" class="small_label">My Title</label>) + assert_dom_equal expected, actual + end + + def test_label_tag_id_sanitized + label_elem = root_elem(label_tag("item[title]")) + assert_match VALID_HTML_ID, label_elem["for"] + end + + def test_label_tag_with_block + assert_dom_equal("<label>Blocked</label>", label_tag { "Blocked" }) + end + + def test_label_tag_with_block_and_argument + output = label_tag("clock") { "Grandfather" } + assert_dom_equal('<label for="clock">Grandfather</label>', output) + end + + def test_label_tag_with_block_and_argument_and_options + output = label_tag("clock", id: "label_clock") { "Grandfather" } + assert_dom_equal('<label for="clock" id="label_clock">Grandfather</label>', output) + end + + def test_boolean_options + assert_dom_equal %(<input checked="checked" disabled="disabled" id="admin" name="admin" readonly="readonly" type="checkbox" value="1" />), check_box_tag("admin", 1, true, "disabled" => true, :readonly => "yes") + assert_dom_equal %(<input checked="checked" id="admin" name="admin" type="checkbox" value="1" />), check_box_tag("admin", 1, true, disabled: false, readonly: nil) + assert_dom_equal %(<input type="checkbox" />), tag(:input, type: "checkbox", checked: false) + assert_dom_equal %(<select id="people" multiple="multiple" name="people[]"><option>david</option></select>), select_tag("people", raw("<option>david</option>"), multiple: true) + assert_dom_equal %(<select id="people_" multiple="multiple" name="people[]"><option>david</option></select>), select_tag("people[]", raw("<option>david</option>"), multiple: true) + assert_dom_equal %(<select id="people" name="people"><option>david</option></select>), select_tag("people", raw("<option>david</option>"), multiple: nil) + end + + def test_stringify_symbol_keys + actual = text_field_tag "title", "Hello!", id: "admin" + expected = %(<input id="admin" name="title" type="text" value="Hello!" />) + assert_dom_equal expected, actual + end + + def test_submit_tag + assert_dom_equal( + %(<input name='commit' data-disable-with="Saving..." onclick="alert('hello!')" type="submit" value="Save" />), + submit_tag("Save", onclick: "alert('hello!')", data: { disable_with: "Saving..." }) + ) + end + + def test_empty_submit_tag + assert_dom_equal( + %(<input data-disable-with="Save" name='commit' type="submit" value="Save" />), + submit_tag("Save") + ) + end + + def test_empty_submit_tag_with_opt_out + ActionView::Base.automatically_disable_submit_tag = false + assert_dom_equal( + %(<input name='commit' type="submit" value="Save" />), + submit_tag("Save") + ) + ensure + ActionView::Base.automatically_disable_submit_tag = true + end + + def test_submit_tag_having_data_disable_with_string + assert_dom_equal( + %(<input data-disable-with="Processing..." data-confirm="Are you sure?" name='commit' type="submit" value="Save" />), + submit_tag("Save", "data-disable-with" => "Processing...", "data-confirm" => "Are you sure?") + ) + end + + def test_submit_tag_having_data_disable_with_boolean + assert_dom_equal( + %(<input data-confirm="Are you sure?" name='commit' type="submit" value="Save" />), + submit_tag("Save", "data-disable-with" => false, "data-confirm" => "Are you sure?") + ) + end + + def test_submit_tag_having_data_hash_disable_with_boolean + assert_dom_equal( + %(<input data-confirm="Are you sure?" name='commit' type="submit" value="Save" />), + submit_tag("Save", data: { confirm: "Are you sure?", disable_with: false }) + ) + end + + def test_submit_tag_with_no_onclick_options + assert_dom_equal( + %(<input name='commit' data-disable-with="Saving..." type="submit" value="Save" />), + submit_tag("Save", data: { disable_with: "Saving..." }) + ) + end + + def test_submit_tag_with_confirmation + assert_dom_equal( + %(<input name='commit' type='submit' value='Save' data-confirm="Are you sure?" data-disable-with="Save" />), + submit_tag("Save", data: { confirm: "Are you sure?" }) + ) + end + + def test_submit_tag_doesnt_have_data_disable_with_twice + assert_equal( + %(<input type="submit" name="commit" value="Save" data-confirm="Are you sure?" data-disable-with="Processing..." />), + submit_tag("Save", "data-disable-with" => "Processing...", "data-confirm" => "Are you sure?") + ) + end + + def test_submit_tag_doesnt_have_data_disable_with_twice_with_hash + assert_equal( + %(<input type="submit" name="commit" value="Save" data-disable-with="Processing..." />), + submit_tag("Save", data: { disable_with: "Processing..." }) + ) + end + + def test_submit_tag_with_symbol_value + assert_dom_equal( + %(<input data-disable-with="Save" name='commit' type="submit" value="Save" />), + submit_tag(:Save) + ) + end + + def test_button_tag + assert_dom_equal( + %(<button name="button" type="submit">Button</button>), + button_tag + ) + end + + def test_button_tag_with_submit_type + assert_dom_equal( + %(<button name="button" type="submit">Save</button>), + button_tag("Save", type: "submit") + ) + end + + def test_button_tag_with_button_type + assert_dom_equal( + %(<button name="button" type="button">Button</button>), + button_tag("Button", type: "button") + ) + end + + def test_button_tag_with_reset_type + assert_dom_equal( + %(<button name="button" type="reset">Reset</button>), + button_tag("Reset", type: "reset") + ) + end + + def test_button_tag_with_disabled_option + assert_dom_equal( + %(<button name="button" type="reset" disabled="disabled">Reset</button>), + button_tag("Reset", type: "reset", disabled: true) + ) + end + + def test_button_tag_escape_content + assert_dom_equal( + %(<button name="button" type="reset" disabled="disabled"><b>Reset</b></button>), + button_tag("<b>Reset</b>", type: "reset", disabled: true) + ) + end + + def test_button_tag_with_block + assert_dom_equal('<button name="button" type="submit">Content</button>', button_tag { "Content" }) + end + + def test_button_tag_with_block_and_options + output = button_tag(name: "temptation", type: "button") { content_tag(:strong, "Do not press me") } + assert_dom_equal('<button name="temptation" type="button"><strong>Do not press me</strong></button>', output) + end + + def test_button_tag_defaults_with_block_and_options + output = button_tag(name: "temptation", value: "within") { content_tag(:strong, "Do not press me") } + assert_dom_equal('<button name="temptation" value="within" type="submit" ><strong>Do not press me</strong></button>', output) + end + + def test_button_tag_with_confirmation + assert_dom_equal( + %(<button name="button" type="submit" data-confirm="Are you sure?">Save</button>), + button_tag("Save", type: "submit", data: { confirm: "Are you sure?" }) + ) + end + + def test_button_tag_with_data_disable_with_option + assert_dom_equal( + %(<button name="button" type="submit" data-disable-with="Please wait...">Checkout</button>), + button_tag("Checkout", data: { disable_with: "Please wait..." }) + ) + end + + def test_image_submit_tag_with_confirmation + assert_dom_equal( + %(<input type="image" src="/images/save.gif" data-confirm="Are you sure?" />), + image_submit_tag("save.gif", data: { confirm: "Are you sure?" }) + ) + end + + def test_color_field_tag + expected = %{<input id="car" name="car" type="color" />} + assert_dom_equal(expected, color_field_tag("car")) + end + + def test_search_field_tag + expected = %{<input id="query" name="query" type="search" />} + assert_dom_equal(expected, search_field_tag("query")) + end + + def test_telephone_field_tag + expected = %{<input id="cell" name="cell" type="tel" />} + assert_dom_equal(expected, telephone_field_tag("cell")) + end + + def test_date_field_tag + expected = %{<input id="cell" name="cell" type="date" />} + assert_dom_equal(expected, date_field_tag("cell")) + end + + def test_time_field_tag + expected = %{<input id="cell" name="cell" type="time" />} + assert_dom_equal(expected, time_field_tag("cell")) + end + + def test_datetime_field_tag + expected = %{<input id="appointment" name="appointment" type="datetime-local" />} + assert_dom_equal(expected, datetime_field_tag("appointment")) + end + + def test_datetime_local_field_tag + expected = %{<input id="appointment" name="appointment" type="datetime-local" />} + assert_dom_equal(expected, datetime_local_field_tag("appointment")) + end + + def test_month_field_tag + expected = %{<input id="birthday" name="birthday" type="month" />} + assert_dom_equal(expected, month_field_tag("birthday")) + end + + def test_week_field_tag + expected = %{<input id="birthday" name="birthday" type="week" />} + assert_dom_equal(expected, week_field_tag("birthday")) + end + + def test_url_field_tag + expected = %{<input id="homepage" name="homepage" type="url" />} + assert_dom_equal(expected, url_field_tag("homepage")) + end + + def test_email_field_tag + expected = %{<input id="address" name="address" type="email" />} + assert_dom_equal(expected, email_field_tag("address")) + end + + def test_number_field_tag + expected = %{<input name="quantity" max="9" id="quantity" type="number" min="1" />} + assert_dom_equal(expected, number_field_tag("quantity", nil, in: 1...10)) + end + + def test_range_input_tag + expected = %{<input name="volume" step="0.1" max="11" id="volume" type="range" min="0" />} + assert_dom_equal(expected, range_field_tag("volume", nil, in: 0..11, step: 0.1)) + end + + def test_field_set_tag_in_erb + output_buffer = render_erb("<%= field_set_tag('Your details') do %>Hello world!<% end %>") + + expected = %(<fieldset><legend>Your details</legend>Hello world!</fieldset>) + assert_dom_equal expected, output_buffer + + output_buffer = render_erb("<%= field_set_tag do %>Hello world!<% end %>") + + expected = %(<fieldset>Hello world!</fieldset>) + assert_dom_equal expected, output_buffer + + output_buffer = render_erb("<%= field_set_tag('') do %>Hello world!<% end %>") + + expected = %(<fieldset>Hello world!</fieldset>) + assert_dom_equal expected, output_buffer + + output_buffer = render_erb("<%= field_set_tag('', :class => 'format') do %>Hello world!<% end %>") + + expected = %(<fieldset class="format">Hello world!</fieldset>) + assert_dom_equal expected, output_buffer + + output_buffer = render_erb("<%= field_set_tag %>") + + expected = %(<fieldset></fieldset>) + assert_dom_equal expected, output_buffer + + output_buffer = render_erb("<%= field_set_tag('You legend!') %>") + + expected = %(<fieldset><legend>You legend!</legend></fieldset>) + assert_dom_equal expected, output_buffer + end + + def test_text_area_tag_options_symbolize_keys_side_effects + options = { option: "random_option" } + text_area_tag "body", "hello world", options + assert_equal({ option: "random_option" }, options) + end + + def test_submit_tag_options_symbolize_keys_side_effects + options = { option: "random_option" } + submit_tag "submit value", options + assert_equal({ option: "random_option" }, options) + end + + def test_button_tag_options_symbolize_keys_side_effects + options = { option: "random_option" } + button_tag "button value", options + assert_equal({ option: "random_option" }, options) + end + + def test_image_submit_tag_options_symbolize_keys_side_effects + options = { option: "random_option" } + image_submit_tag "submit source", options + assert_equal({ option: "random_option" }, options) + end + + def test_image_label_tag_options_symbolize_keys_side_effects + options = { option: "random_option" } + label_tag "submit source", "title", options + assert_equal({ option: "random_option" }, options) + end + + def protect_against_forgery? + false + end + + private + + def root_elem(rendered_content) + Nokogiri::HTML::DocumentFragment.parse(rendered_content).children.first # extract from nodeset + end + + def with_default_enforce_utf8(value) + old_value = ActionView::Helpers::FormTagHelper.default_enforce_utf8 + ActionView::Helpers::FormTagHelper.default_enforce_utf8 = value + + yield + ensure + ActionView::Helpers::FormTagHelper.default_enforce_utf8 = old_value + end +end diff --git a/actionview/test/template/html_test.rb b/actionview/test/template/html_test.rb new file mode 100644 index 0000000000..5cdff74d60 --- /dev/null +++ b/actionview/test/template/html_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class HTMLTest < ActiveSupport::TestCase + test "formats returns symbol for recognized MIME type" do + assert_equal [:html], ActionView::Template::HTML.new("", :html).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::HTML.new("", foo).formats + end + + test "formats returns string for unknown MIME type" do + assert_equal ["foo"], ActionView::Template::HTML.new("", "foo").formats + end +end diff --git a/actionview/test/template/javascript_helper_test.rb b/actionview/test/template/javascript_helper_test.rb new file mode 100644 index 0000000000..4c28aeaee1 --- /dev/null +++ b/actionview/test/template/javascript_helper_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class JavaScriptHelperTest < ActionView::TestCase + tests ActionView::Helpers::JavaScriptHelper + + attr_accessor :output_buffer + attr_reader :request + + setup do + @old_escape_html_entities_in_json = ActiveSupport.escape_html_entities_in_json + ActiveSupport.escape_html_entities_in_json = true + @template = self + @request = Class.new do + def send_early_hints(links) end + end.new + end + + def teardown + ActiveSupport.escape_html_entities_in_json = @old_escape_html_entities_in_json + end + + def test_escape_javascript + assert_equal "", escape_javascript(nil) + assert_equal "123", escape_javascript(123) + assert_equal "en", escape_javascript(:en) + assert_equal "false", escape_javascript(false) + assert_equal "true", escape_javascript(true) + assert_equal %(This \\"thing\\" is really\\n netos\\'), escape_javascript(%(This "thing" is really\n netos')) + assert_equal %(backslash\\\\test), escape_javascript(%(backslash\\test)) + assert_equal %(dont <\\/close> tags), escape_javascript(%(dont </close> tags)) + assert_equal %(unicode 
 newline), escape_javascript((+%(unicode \342\200\250 newline)).force_encoding(Encoding::UTF_8).encode!) + assert_equal %(unicode 
 newline), escape_javascript((+%(unicode \342\200\251 newline)).force_encoding(Encoding::UTF_8).encode!) + + assert_equal %(dont <\\/close> tags), j(%(dont </close> tags)) + end + + def test_escape_javascript_with_safebuffer + given = %('quoted' "double-quoted" new-line:\n </closed>) + expect = %(\\'quoted\\' \\"double-quoted\\" new-line:\\n <\\/closed>) + assert_equal expect, escape_javascript(given) + assert_equal expect, escape_javascript(ActiveSupport::SafeBuffer.new(given)) + assert_instance_of String, escape_javascript(given) + assert_instance_of ActiveSupport::SafeBuffer, escape_javascript(ActiveSupport::SafeBuffer.new(given)) + end + + def test_javascript_tag + self.output_buffer = "foo" + + assert_dom_equal "<script>\n//<![CDATA[\nalert('hello')\n//]]>\n</script>", + javascript_tag("alert('hello')") + + assert_equal "foo", output_buffer, "javascript_tag without a block should not concat to output_buffer" + end + + # Setting the :extname option will control what extension (if any) is appended to the url for assets + def test_javascript_include_tag + assert_dom_equal "<script src='/foo.js'></script>", javascript_include_tag("/foo") + assert_dom_equal "<script src='/foo'></script>", javascript_include_tag("/foo", extname: false) + assert_dom_equal "<script src='/foo.bar'></script>", javascript_include_tag("/foo", extname: ".bar") + end + + def test_javascript_tag_with_options + assert_dom_equal "<script id=\"the_js_tag\">\n//<![CDATA[\nalert('hello')\n//]]>\n</script>", + javascript_tag("alert('hello')", id: "the_js_tag") + end + + def test_javascript_cdata_section + assert_dom_equal "\n//<![CDATA[\nalert('hello')\n//]]>\n", javascript_cdata_section("alert('hello')") + end +end diff --git a/actionview/test/template/log_subscriber_test.rb b/actionview/test/template/log_subscriber_test.rb new file mode 100644 index 0000000000..9fcf80bb24 --- /dev/null +++ b/actionview/test/template/log_subscriber_test.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/log_subscriber/test_helper" +require "action_view/log_subscriber" +require "controller/fake_models" + +class AVLogSubscriberTest < ActiveSupport::TestCase + include ActiveSupport::LogSubscriber::TestHelper + + def setup + super + + view_paths = ActionController::Base.view_paths + lookup_context = ActionView::LookupContext.new(view_paths, {}, ["test"]) + renderer = ActionView::Renderer.new(lookup_context) + @view = ActionView::Base.new(renderer, {}) + + ActionView::LogSubscriber.attach_to :action_view + + unless Rails.respond_to?(:root) + @defined_root = true + def Rails.root; :defined_root; end # Minitest `stub` expects the method to be defined. + end + end + + def teardown + super + + ActiveSupport::LogSubscriber.log_subscribers.clear + + # We need to undef `root`, RenderTestCases don't want this to be defined + Rails.instance_eval { undef :root } if defined?(@defined_root) + end + + def set_logger(logger) + ActionView::Base.logger = logger + end + + def set_cache_controller + controller = ActionController::Base.new + controller.perform_caching = true + controller.cache_store = ActiveSupport::Cache::MemoryStore.new + @view.controller = controller + end + + def set_view_cache_dependencies + def @view.view_cache_dependencies; []; end + def @view.combined_fragment_cache_key(*); "ahoy `controller` dependency"; end + end + + def test_render_file_template + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + @view.render(file: "test/hello_world") + wait + + assert_equal 2, @logger.logged(:info).size + assert_match(/Rendering test\/hello_world\.erb/, @logger.logged(:info).first) + assert_match(/Rendered test\/hello_world\.erb/, @logger.logged(:info).last) + end + end + + def test_render_text_template + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + @view.render(plain: "TEXT") + wait + + assert_equal 2, @logger.logged(:info).size + assert_match(/Rendering text template/, @logger.logged(:info).first) + assert_match(/Rendered text template/, @logger.logged(:info).last) + end + end + + def test_render_inline_template + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + @view.render(inline: "<%= 'TEXT' %>") + wait + + assert_equal 2, @logger.logged(:info).size + assert_match(/Rendering inline template/, @logger.logged(:info).first) + assert_match(/Rendered inline template/, @logger.logged(:info).last) + end + end + + def test_render_partial_with_implicit_path + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + @view.render(Customer.new("david"), greeting: "hi") + wait + + assert_equal 1, @logger.logged(:info).size + assert_match(/Rendered customers\/_customer\.html\.erb/, @logger.logged(:info).last) + end + end + + def test_render_partial_with_cache_missed + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) + wait + + assert_equal 1, @logger.logged(:info).size + assert_match(/Rendered test\/_cached_customer\.erb (.*) \[cache miss\]/, @logger.logged(:info).last) + end + end + + def test_render_partial_with_cache_hitted + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + # Second render should hit cache. + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) + wait + + assert_equal 2, @logger.logged(:info).size + assert_match(/Rendered test\/_cached_customer\.erb (.*) \[cache hit\]/, @logger.logged(:info).last) + end + end + + def test_render_uncached_outer_partial_with_inner_cached_partial_wont_mix_cache_hits_or_misses + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + @view.render(partial: "test/nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) + wait + *, cached_inner, uncached_outer = @logger.logged(:info) + assert_match(/Rendered test\/_cached_customer\.erb (.*) \[cache miss\]/, cached_inner) + assert_match(/Rendered test\/_nested_cached_customer\.erb \(Duration: .*?ms \| Allocations: .*?\)$/, uncached_outer) + + # Second render hits the cache for the _cached_customer partial. Outer template's log shouldn't be affected. + @view.render(partial: "test/nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) + wait + *, cached_inner, uncached_outer = @logger.logged(:info) + assert_match(/Rendered test\/_cached_customer\.erb (.*) \[cache hit\]/, cached_inner) + assert_match(/Rendered test\/_nested_cached_customer\.erb \(Duration: .*?ms \| Allocations: .*?\)$/, uncached_outer) + end + end + + def test_render_cached_outer_partial_with_cached_inner_partial + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + @view.render(partial: "test/cached_nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) + wait + *, cached_inner, cached_outer = @logger.logged(:info) + assert_match(/Rendered test\/_cached_customer\.erb (.*) \[cache miss\]/, cached_inner) + assert_match(/Rendered test\/_cached_nested_cached_customer\.erb (.*) \[cache miss\]/, cached_outer) + + # One render: inner partial skipped, because the outer has been cached. + assert_difference -> { @logger.logged(:info).size }, +1 do + @view.render(partial: "test/cached_nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) + wait + end + assert_match(/Rendered test\/_cached_nested_cached_customer\.erb (.*) \[cache hit\]/, @logger.logged(:info).last) + end + end + + def test_render_partial_with_cache_hitted_and_missed + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) + wait + assert_match(/Rendered test\/_cached_customer\.erb (.*) \[cache miss\]/, @logger.logged(:info).last) + + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) + wait + assert_match(/Rendered test\/_cached_customer\.erb (.*) \[cache hit\]/, @logger.logged(:info).last) + + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("Stan") }) + wait + assert_match(/Rendered test\/_cached_customer\.erb (.*) \[cache miss\]/, @logger.logged(:info).last) + end + end + + def test_render_collection_template + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + @view.render(partial: "test/customer", collection: [ Customer.new("david"), Customer.new("mary") ]) + wait + + assert_equal 1, @logger.logged(:info).size + assert_match(/Rendered collection of test\/_customer.erb \[2 times\]/, @logger.logged(:info).last) + end + end + + def test_render_collection_with_implicit_path + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + @view.render([ Customer.new("david"), Customer.new("mary") ], greeting: "hi") + wait + + assert_equal 1, @logger.logged(:info).size + assert_match(/Rendered collection of customers\/_customer\.html\.erb \[2 times\]/, @logger.logged(:info).last) + end + end + + def test_render_collection_template_without_path + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + @view.render([ GoodCustomer.new("david"), Customer.new("mary") ], greeting: "hi") + wait + + assert_equal 1, @logger.logged(:info).size + assert_match(/Rendered collection of templates/, @logger.logged(:info).last) + end + end + + def test_render_collection_with_cached_set + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + + @view.render(partial: "customers/customer", collection: [ Customer.new("david"), Customer.new("mary") ], cached: true, + locals: { greeting: "hi" }) + wait + + assert_equal 1, @logger.logged(:info).size + assert_match(/Rendered collection of customers\/_customer\.html\.erb \[0 \/ 2 cache hits\]/, @logger.logged(:info).last) + end + end +end diff --git a/actionview/test/template/lookup_context_test.rb b/actionview/test/template/lookup_context_test.rb new file mode 100644 index 0000000000..68e151f154 --- /dev/null +++ b/actionview/test/template/lookup_context_test.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "abstract_controller/rendering" + +class LookupContextTest < ActiveSupport::TestCase + def setup + @lookup_context = ActionView::LookupContext.new(FIXTURE_LOAD_PATH, {}) + ActionView::LookupContext::DetailsKey.clear + end + + def teardown + I18n.locale = :en + end + + test "allows to override default_formats with ActionView::Base.default_formats" do + formats = ActionView::Base.default_formats + ActionView::Base.default_formats = [:foo, :bar] + + assert_equal [:foo, :bar], ActionView::LookupContext.new([]).default_formats + ensure + ActionView::Base.default_formats = formats + end + + test "process view paths on initialization" do + assert_kind_of ActionView::PathSet, @lookup_context.view_paths + end + + test "normalizes details on initialization" do + assert_equal Mime::SET.to_a, @lookup_context.formats + assert_equal :en, @lookup_context.locale + end + + test "allows me to freeze and retrieve frozen formats" do + @lookup_context.formats.freeze + assert_predicate @lookup_context.formats, :frozen? + end + + test "provides getters and setters for variants" do + @lookup_context.variants = [:mobile] + assert_equal [:mobile], @lookup_context.variants + end + + test "provides getters and setters for formats" do + @lookup_context.formats = [:html] + assert_equal [:html], @lookup_context.formats + end + + test "handles */* formats" do + @lookup_context.formats = ["*/*"] + assert_equal Mime::SET.to_a, @lookup_context.formats + end + + test "handles explicitly defined */* formats fallback to :js" do + @lookup_context.formats = [:js, Mime::ALL] + assert_equal [:js, *Mime::SET.symbols], @lookup_context.formats + end + + test "adds :html fallback to :js formats" do + @lookup_context.formats = [:js] + assert_equal [:js, :html], @lookup_context.formats + end + + test "provides getters and setters for locale" do + @lookup_context.locale = :pt + assert_equal :pt, @lookup_context.locale + end + + test "changing lookup_context locale, changes I18n.locale" do + @lookup_context.locale = :pt + assert_equal :pt, I18n.locale + end + + test "delegates changing the locale to the I18n configuration object if it contains a lookup_context object" do + begin + I18n.config = ActionView::I18nProxy.new(I18n.config, @lookup_context) + @lookup_context.locale = :pt + assert_equal :pt, I18n.locale + assert_equal :pt, @lookup_context.locale + ensure + I18n.config = I18n.config.original_config + end + + assert_equal :pt, I18n.locale + end + + test "find templates using the given view paths and configured details" do + template = @lookup_context.find("hello_world", %w(test)) + assert_equal "Hello world!", template.source + + @lookup_context.locale = :da + template = @lookup_context.find("hello_world", %w(test)) + assert_equal "Hey verden", template.source + end + + test "find templates with given variants" do + @lookup_context.formats = [:html] + @lookup_context.variants = [:phone] + + template = @lookup_context.find("hello_world", %w(test)) + assert_equal "Hello phone!", template.source + + @lookup_context.variants = [:phone] + @lookup_context.formats = [:text] + + template = @lookup_context.find("hello_world", %w(test)) + assert_equal "Hello texty phone!", template.source + end + + test "found templates respects given formats if one cannot be found from template or handler" do + assert_called(ActionView::Template::Handlers::Builder, :default_format, returns: nil) do + @lookup_context.formats = [:text] + template = @lookup_context.find("hello", %w(test)) + assert_equal [:text], template.formats + end + end + + test "adds fallbacks to view paths when required" do + assert_equal 1, @lookup_context.view_paths.size + + @lookup_context.with_fallbacks do + assert_equal 3, @lookup_context.view_paths.size + assert_includes @lookup_context.view_paths, ActionView::FallbackFileSystemResolver.new("") + assert_includes @lookup_context.view_paths, ActionView::FallbackFileSystemResolver.new("/") + end + end + + test "add fallbacks just once in nested fallbacks calls" do + @lookup_context.with_fallbacks do + @lookup_context.with_fallbacks do + assert_equal 3, @lookup_context.view_paths.size + end + end + end + + test "generates a new details key for each details hash" do + keys = [] + keys << @lookup_context.details_key + assert_equal 1, keys.uniq.size + + @lookup_context.locale = :da + keys << @lookup_context.details_key + assert_equal 2, keys.uniq.size + + @lookup_context.locale = :en + keys << @lookup_context.details_key + assert_equal 2, keys.uniq.size + + @lookup_context.formats = [:html] + keys << @lookup_context.details_key + assert_equal 3, keys.uniq.size + + @lookup_context.formats = nil + keys << @lookup_context.details_key + assert_equal 3, keys.uniq.size + end + + test "gives the key forward to the resolver, so it can be used as cache key" do + @lookup_context.view_paths = ActionView::FixtureResolver.new("test/_foo.erb" => "Foo") + template = @lookup_context.find("foo", %w(test), true) + assert_equal "Foo", template.source + + # Now we are going to change the template, but it won't change the returned template + # since we will hit the cache. + @lookup_context.view_paths.first.hash["test/_foo.erb"] = "Bar" + template = @lookup_context.find("foo", %w(test), true) + assert_equal "Foo", template.source + + # This time we will change the locale. The updated template should be picked since + # lookup_context generated a new key after we changed the locale. + @lookup_context.locale = :da + template = @lookup_context.find("foo", %w(test), true) + assert_equal "Bar", template.source + + # Now we will change back the locale and it will still pick the old template. + # This is expected because lookup_context will reuse the previous key for :en locale. + @lookup_context.locale = :en + template = @lookup_context.find("foo", %w(test), true) + assert_equal "Foo", template.source + + # Finally, we can expire the cache. And the expected template will be used. + @lookup_context.view_paths.first.clear_cache + template = @lookup_context.find("foo", %w(test), true) + assert_equal "Bar", template.source + end + + test "can disable the cache on demand" do + @lookup_context.view_paths = ActionView::FixtureResolver.new("test/_foo.erb" => "Foo") + old_template = @lookup_context.find("foo", %w(test), true) + + template = @lookup_context.find("foo", %w(test), true) + assert_equal template, old_template + + assert @lookup_context.cache + template = @lookup_context.disable_cache do + assert_not @lookup_context.cache + @lookup_context.find("foo", %w(test), true) + end + assert @lookup_context.cache + + assert_not_equal template, old_template + end + + test "responds to #prefixes" do + assert_equal [], @lookup_context.prefixes + @lookup_context.prefixes = ["foo"] + assert_equal ["foo"], @lookup_context.prefixes + end +end + +class LookupContextWithFalseCaching < ActiveSupport::TestCase + def setup + @resolver = ActionView::FixtureResolver.new("test/_foo.erb" => ["Foo", Time.utc(2000)]) + @lookup_context = ActionView::LookupContext.new(@resolver, {}) + end + + test "templates are always found in the resolver but timestamp is checked before being compiled" do + ActionView::Resolver.stub(:caching?, false) do + template = @lookup_context.find("foo", %w(test), true) + assert_equal "Foo", template.source + + # Now we are going to change the template, but it won't change the returned template + # since the timestamp is the same. + @resolver.hash["test/_foo.erb"][0] = "Bar" + template = @lookup_context.find("foo", %w(test), true) + assert_equal "Foo", template.source + + # Now update the timestamp. + @resolver.hash["test/_foo.erb"][1] = Time.now.utc + template = @lookup_context.find("foo", %w(test), true) + assert_equal "Bar", template.source + end + end + + test "if no template was found in the second lookup, with no cache, raise error" do + ActionView::Resolver.stub(:caching?, false) do + template = @lookup_context.find("foo", %w(test), true) + assert_equal "Foo", template.source + + @resolver.hash.clear + assert_raise ActionView::MissingTemplate do + @lookup_context.find("foo", %w(test), true) + end + end + end + + test "if no template was cached in the first lookup, retrieval should work in the second call" do + ActionView::Resolver.stub(:caching?, false) do + @resolver.hash.clear + assert_raise ActionView::MissingTemplate do + @lookup_context.find("foo", %w(test), true) + end + + @resolver.hash["test/_foo.erb"] = ["Foo", Time.utc(2000)] + template = @lookup_context.find("foo", %w(test), true) + assert_equal "Foo", template.source + end + end +end + +class TestMissingTemplate < ActiveSupport::TestCase + def setup + @lookup_context = ActionView::LookupContext.new("/Path/to/views", {}) + end + + test "if no template was found we get a helpful error message including the inheritance chain" do + e = assert_raise ActionView::MissingTemplate do + @lookup_context.find("foo", %w(parent child)) + end + assert_match %r{Missing template parent/foo, child/foo with .* Searched in:\n \* "/Path/to/views"\n}, e.message + end + + test "if no partial was found we get a helpful error message including the inheritance chain" do + e = assert_raise ActionView::MissingTemplate do + @lookup_context.find("foo", %w(parent child), true) + end + assert_match %r{Missing partial parent/_foo, child/_foo with .* Searched in:\n \* "/Path/to/views"\n}, e.message + end + + test "if a single prefix is passed as a string and the lookup fails, MissingTemplate accepts it" do + e = assert_raise ActionView::MissingTemplate do + details = { handlers: [], formats: [], variants: [], locale: [] } + @lookup_context.view_paths.find("foo", "parent", true, details) + end + assert_match %r{Missing partial parent/_foo with .* Searched in:\n \* "/Path/to/views"\n}, e.message + end +end diff --git a/actionview/test/template/number_helper_test.rb b/actionview/test/template/number_helper_test.rb new file mode 100644 index 0000000000..357ae1326a --- /dev/null +++ b/actionview/test/template/number_helper_test.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class NumberHelperTest < ActionView::TestCase + tests ActionView::Helpers::NumberHelper + + def test_number_to_phone + assert_nil number_to_phone(nil) + assert_equal "555-1234", number_to_phone(5551234) + assert_equal "(800) 555-1212 x 123", number_to_phone(8005551212, area_code: true, extension: 123) + assert_equal "+18005551212", number_to_phone(8005551212, country_code: 1, delimiter: "") + assert_equal "+<script></script>8005551212", number_to_phone(8005551212, country_code: "<script></script>", delimiter: "") + assert_equal "8005551212 x <script></script>", number_to_phone(8005551212, extension: "<script></script>", delimiter: "") + end + + def test_number_to_currency + assert_nil number_to_currency(nil) + assert_equal "$1,234,567,890.50", number_to_currency(1234567890.50) + assert_equal "$1,234,567,892", number_to_currency(1234567891.50, precision: 0) + assert_equal "1,234,567,890.50 - Kč", number_to_currency("-1234567890.50", unit: raw("Kč"), format: "%n %u", negative_format: "%n - %u") + assert_equal "&pound;1,234,567,890.50", number_to_currency("1234567890.50", unit: "£") + assert_equal "<b>1,234,567,890.50</b> $", number_to_currency("1234567890.50", format: "<b>%n</b> %u") + assert_equal "<b>1,234,567,890.50</b> $", number_to_currency("-1234567890.50", negative_format: "<b>%n</b> %u") + assert_equal "<b>1,234,567,890.50</b> $", number_to_currency("-1234567890.50", "negative_format" => "<b>%n</b> %u") + assert_equal "₹ 12,30,000.00", number_to_currency(1230000, delimiter_pattern: /(\d+?)(?=(\d\d)+(\d)(?!\d))/, unit: "₹", format: "%u %n") + end + + def test_number_to_percentage + assert_nil number_to_percentage(nil) + assert_equal "100.000%", number_to_percentage(100) + assert_equal "100.000 %", number_to_percentage(100, format: "%n %") + assert_equal "<b>100.000</b> %", number_to_percentage(100, format: "<b>%n</b> %") + assert_equal "<b>100.000</b> %", number_to_percentage(100, format: raw("<b>%n</b> %")) + assert_equal "100%", number_to_percentage(100, precision: 0) + assert_equal "123.4%", number_to_percentage(123.400, precision: 3, strip_insignificant_zeros: true) + assert_equal "1.000,000%", number_to_percentage(1000, delimiter: ".", separator: ",") + assert_equal "98a%", number_to_percentage("98a") + assert_equal "NaN%", number_to_percentage(Float::NAN) + assert_equal "Inf%", number_to_percentage(Float::INFINITY) + assert_equal "NaN%", number_to_percentage(Float::NAN, precision: 0) + assert_equal "Inf%", number_to_percentage(Float::INFINITY, precision: 0) + assert_equal "NaN%", number_to_percentage(Float::NAN, precision: 1) + assert_equal "Inf%", number_to_percentage(Float::INFINITY, precision: 1) + end + + def test_number_with_delimiter + assert_nil number_with_delimiter(nil) + assert_equal "12,345,678", number_with_delimiter(12345678) + assert_equal "0", number_with_delimiter(0) + end + + def test_number_with_precision + assert_nil number_with_precision(nil) + assert_equal "-111.235", number_with_precision(-111.2346) + assert_equal "111.00", number_with_precision(111, precision: 2) + assert_equal "0.00100", number_with_precision(0.001, precision: 5) + assert_equal "3.33", number_with_precision(Rational(10, 3), precision: 2) + end + + def test_number_to_human_size + assert_nil number_to_human_size(nil) + assert_equal "3 Bytes", number_to_human_size(3.14159265) + assert_equal "1.2 MB", number_to_human_size(1234567, precision: 2) + end + + def test_number_to_human + assert_nil number_to_human(nil) + assert_equal "0", number_to_human(0) + assert_equal "1.23 Thousand", number_to_human(1234) + assert_equal "489.0 Thousand", number_to_human(489000, precision: 4, strip_insignificant_zeros: false) + end + + def test_number_to_human_escape_units + volume = { unit: "<b>ml</b>", thousand: "<b>lt</b>", million: "<b>m3</b>", trillion: "<b>km3</b>", quadrillion: "<b>Pl</b>" } + assert_equal "123 <b>lt</b>", number_to_human(123456, units: volume) + assert_equal "12 <b>ml</b>", number_to_human(12, units: volume) + assert_equal "1.23 <b>m3</b>", number_to_human(1234567, units: volume) + assert_equal "1.23 <b>km3</b>", number_to_human(1_234_567_000_000, units: volume) + assert_equal "1.23 <b>Pl</b>", number_to_human(1_234_567_000_000_000, units: volume) + + # Including fractionals + distance = { mili: "<b>mm</b>", centi: "<b>cm</b>", deci: "<b>dm</b>", unit: "<b>m</b>", + ten: "<b>dam</b>", hundred: "<b>hm</b>", thousand: "<b>km</b>", + micro: "<b>um</b>", nano: "<b>nm</b>", pico: "<b>pm</b>", femto: "<b>fm</b>" } + assert_equal "1.23 <b>mm</b>", number_to_human(0.00123, units: distance) + assert_equal "1.23 <b>cm</b>", number_to_human(0.0123, units: distance) + assert_equal "1.23 <b>dm</b>", number_to_human(0.123, units: distance) + assert_equal "1.23 <b>m</b>", number_to_human(1.23, units: distance) + assert_equal "1.23 <b>dam</b>", number_to_human(12.3, units: distance) + assert_equal "1.23 <b>hm</b>", number_to_human(123, units: distance) + assert_equal "1.23 <b>km</b>", number_to_human(1230, units: distance) + assert_equal "1.23 <b>um</b>", number_to_human(0.00000123, units: distance) + assert_equal "1.23 <b>nm</b>", number_to_human(0.00000000123, units: distance) + assert_equal "1.23 <b>pm</b>", number_to_human(0.00000000000123, units: distance) + assert_equal "1.23 <b>fm</b>", number_to_human(0.00000000000000123, units: distance) + end + + def test_number_helpers_escape_delimiter_and_separator + assert_equal "111<script></script>111<script></script>1111", number_to_phone(1111111111, delimiter: "<script></script>") + + assert_equal "$1<script></script>01", number_to_currency(1.01, separator: "<script></script>") + assert_equal "$1<script></script>000.00", number_to_currency(1000, delimiter: "<script></script>") + + assert_equal "1<script></script>010%", number_to_percentage(1.01, separator: "<script></script>") + assert_equal "1<script></script>000.000%", number_to_percentage(1000, delimiter: "<script></script>") + + assert_equal "1<script></script>01", number_with_delimiter(1.01, separator: "<script></script>") + assert_equal "1<script></script>000", number_with_delimiter(1000, delimiter: "<script></script>") + + assert_equal "1<script></script>010", number_with_precision(1.01, separator: "<script></script>") + assert_equal "1<script></script>000.000", number_with_precision(1000, delimiter: "<script></script>") + + assert_equal "9<script></script>86 KB", number_to_human_size(10100, separator: "<script></script>") + + assert_equal "1<script></script>01", number_to_human(1.01, separator: "<script></script>") + assert_equal "100<script></script>000 Quadrillion", number_to_human(10**20, delimiter: "<script></script>") + end + + def test_number_to_human_with_custom_translation_scope + I18n.backend.store_translations "ts", + custom_units_for_number_to_human: { mili: "mm", centi: "cm", deci: "dm", unit: "m", ten: "dam", hundred: "hm", thousand: "km" } + assert_equal "1.01 cm", number_to_human(0.0101, locale: "ts", units: :custom_units_for_number_to_human) + ensure + I18n.reload! + end + + def test_number_helpers_outputs_are_html_safe + assert_predicate number_to_human(1), :html_safe? + assert_not_predicate number_to_human("<script></script>"), :html_safe? + assert_predicate number_to_human("asdf".html_safe), :html_safe? + assert_predicate number_to_human("1".html_safe), :html_safe? + + assert_predicate number_to_human_size(1), :html_safe? + assert_predicate number_to_human_size(1000000), :html_safe? + assert_not_predicate number_to_human_size("<script></script>"), :html_safe? + assert_predicate number_to_human_size("asdf".html_safe), :html_safe? + assert_predicate number_to_human_size("1".html_safe), :html_safe? + + assert_predicate number_with_precision(1, strip_insignificant_zeros: false), :html_safe? + assert_predicate number_with_precision(1, strip_insignificant_zeros: true), :html_safe? + assert_not_predicate number_with_precision("<script></script>"), :html_safe? + assert_predicate number_with_precision("asdf".html_safe), :html_safe? + assert_predicate number_with_precision("1".html_safe), :html_safe? + + assert_predicate number_to_currency(1), :html_safe? + assert_not_predicate number_to_currency("<script></script>"), :html_safe? + assert_predicate number_to_currency("asdf".html_safe), :html_safe? + assert_predicate number_to_currency("1".html_safe), :html_safe? + + assert_predicate number_to_percentage(1), :html_safe? + assert_not_predicate number_to_percentage("<script></script>"), :html_safe? + assert_predicate number_to_percentage("asdf".html_safe), :html_safe? + assert_predicate number_to_percentage("1".html_safe), :html_safe? + + assert_predicate number_to_phone(1), :html_safe? + assert_equal "<script></script>", number_to_phone("<script></script>") + assert_predicate number_to_phone("<script></script>"), :html_safe? + assert_predicate number_to_phone("asdf".html_safe), :html_safe? + assert_predicate number_to_phone("1".html_safe), :html_safe? + + assert_predicate number_with_delimiter(1), :html_safe? + assert_not_predicate number_with_delimiter("<script></script>"), :html_safe? + assert_predicate number_with_delimiter("asdf".html_safe), :html_safe? + assert_predicate number_with_delimiter("1".html_safe), :html_safe? + end + + def test_number_helpers_should_raise_error_if_invalid_when_specified + exception = assert_raise InvalidNumberError do + number_to_human("x", raise: true) + end + assert_equal "x", exception.number + + exception = assert_raise InvalidNumberError do + number_to_human_size("x", raise: true) + end + assert_equal "x", exception.number + + exception = assert_raise InvalidNumberError do + number_with_precision("x", raise: true) + end + assert_equal "x", exception.number + + exception = assert_raise InvalidNumberError do + number_to_currency("x", raise: true) + end + assert_equal "x", exception.number + + exception = assert_raise InvalidNumberError do + number_to_percentage("x", raise: true) + end + assert_equal "x", exception.number + + exception = assert_raise InvalidNumberError do + number_with_delimiter("x", raise: true) + end + assert_equal "x", exception.number + + exception = assert_raise InvalidNumberError do + number_to_phone("x", raise: true) + end + assert_equal "x", exception.number + end +end diff --git a/actionview/test/template/output_safety_helper_test.rb b/actionview/test/template/output_safety_helper_test.rb new file mode 100644 index 0000000000..faeeded1c8 --- /dev/null +++ b/actionview/test/template/output_safety_helper_test.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class OutputSafetyHelperTest < ActionView::TestCase + tests ActionView::Helpers::OutputSafetyHelper + + def setup + @string = "hello" + end + + test "raw returns the safe string" do + result = raw(@string) + assert_equal @string, result + assert_predicate result, :html_safe? + end + + test "raw handles nil values correctly" do + assert_equal "", raw(nil) + end + + test "safe_join should html_escape any items, including the separator, if they are not html_safe" do + joined = safe_join([raw("<p>foo</p>"), "<p>bar</p>"], "<br />") + assert_equal "<p>foo</p><br /><p>bar</p>", joined + + joined = safe_join([raw("<p>foo</p>"), raw("<p>bar</p>")], raw("<br />")) + assert_equal "<p>foo</p><br /><p>bar</p>", joined + end + + test "safe_join should work recursively similarly to Array.join" do + joined = safe_join(["a", ["b", "c"]], ":") + assert_equal "a:b:c", joined + + joined = safe_join(['"a"', ["<b>", "<c>"]], " <br/> ") + assert_equal ""a" <br/> <b> <br/> <c>", joined + end + + test "safe_join should return the safe string separated by $, when second argument is not passed" do + default_delimeter = $, + + begin + $, = nil + joined = safe_join(["a", "b"]) + assert_equal "ab", joined + + $, = "|" + joined = safe_join(["a", "b"]) + assert_equal "a|b", joined + ensure + $, = default_delimeter + end + end + + test "to_sentence should escape non-html_safe values" do + actual = to_sentence(%w(< > & ' ")) + assert_predicate actual, :html_safe? + assert_equal("<, >, &, ', and "", actual) + + actual = to_sentence(%w(<script>)) + assert_predicate actual, :html_safe? + assert_equal("<script>", actual) + end + + test "to_sentence does not double escape if single value is html_safe" do + assert_equal("<script>", to_sentence([ERB::Util.html_escape("<script>")])) + assert_equal("<script>", to_sentence(["<script>".html_safe])) + assert_equal("&lt;script&gt;", to_sentence(["<script>"])) + end + + test "to_sentence connector words are checked for html safety" do + assert_equal "one & two, and three", to_sentence(["one", "two", "three"], words_connector: " & ".html_safe) + assert_equal "one & two", to_sentence(["one", "two"], two_words_connector: " & ".html_safe) + assert_equal "one, two <script>alert(1)</script> three", to_sentence(["one", "two", "three"], last_word_connector: " <script>alert(1)</script> ") + end + + test "to_sentence should not escape html_safe values" do + ptag = content_tag("p") do + safe_join(["<marquee>shady stuff</marquee>", tag("br")]) + end + url = "https://example.com" + expected = %(<a href="#{url}">#{url}</a> and <p><marquee>shady stuff</marquee><br /></p>) + actual = to_sentence([link_to(url, url), ptag]) + assert_predicate actual, :html_safe? + assert_equal(expected, actual) + end + + test "to_sentence handles blank strings" do + actual = to_sentence(["", "two", "three"]) + assert_predicate actual, :html_safe? + assert_equal ", two, and three", actual + end + + test "to_sentence handles nil values" do + actual = to_sentence([nil, "two", "three"]) + assert_predicate actual, :html_safe? + assert_equal ", two, and three", actual + end + + test "to_sentence still supports ActiveSupports Array#to_sentence arguments" do + assert_equal "one two, and three", to_sentence(["one", "two", "three"], words_connector: " ") + assert_equal "one & two, and three", to_sentence(["one", "two", "three"], words_connector: " & ".html_safe) + assert_equal "onetwo, and three", to_sentence(["one", "two", "three"], words_connector: nil) + assert_equal "one, two, and also three", to_sentence(["one", "two", "three"], last_word_connector: ", and also ") + assert_equal "one, twothree", to_sentence(["one", "two", "three"], last_word_connector: nil) + assert_equal "one, two three", to_sentence(["one", "two", "three"], last_word_connector: " ") + assert_equal "one, two and three", to_sentence(["one", "two", "three"], last_word_connector: " and ") + end + + test "to_sentence is not affected by $," do + separator_was = $, + $, = "|" + begin + assert_equal "one and two", to_sentence(["one", "two"]) + assert_equal "one, two, and three", to_sentence(["one", "two", "three"]) + ensure + $, = separator_was + end + end +end diff --git a/actionview/test/template/partial_iteration_test.rb b/actionview/test/template/partial_iteration_test.rb new file mode 100644 index 0000000000..1c3c566667 --- /dev/null +++ b/actionview/test/template/partial_iteration_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "action_view/renderer/partial_renderer" + +class PartialIterationTest < ActiveSupport::TestCase + def test_has_size_and_index + iteration = ActionView::PartialIteration.new 3 + assert_equal 0, iteration.index, "should be at the first index" + assert_equal 3, iteration.size, "should have the size" + end + + def test_first_is_true_when_current_is_at_the_first_index + iteration = ActionView::PartialIteration.new 3 + assert iteration.first?, "first when current is 0" + end + + def test_first_is_false_unless_current_is_at_the_first_index + iteration = ActionView::PartialIteration.new 3 + iteration.iterate! + assert_not iteration.first?, "not first when current is 1" + end + + def test_last_is_true_when_current_is_at_the_last_index + iteration = ActionView::PartialIteration.new 3 + iteration.iterate! + iteration.iterate! + assert iteration.last?, "last when current is 2" + end + + def test_last_is_false_unless_current_is_at_the_last_index + iteration = ActionView::PartialIteration.new 3 + assert_not iteration.last?, "not last when current is 0" + end +end diff --git a/actionview/test/template/record_identifier_test.rb b/actionview/test/template/record_identifier_test.rb new file mode 100644 index 0000000000..29012e943d --- /dev/null +++ b/actionview/test/template/record_identifier_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "controller/fake_models" + +class RecordIdentifierTest < ActiveSupport::TestCase + include ActionView::RecordIdentifier + + def setup + @klass = Comment + @record = @klass.new + @singular = "comment" + @plural = "comments" + end + + def test_dom_id_with_new_record + assert_equal "new_#{@singular}", dom_id(@record) + end + + def test_dom_id_with_new_record_and_prefix + assert_equal "custom_prefix_#{@singular}", dom_id(@record, :custom_prefix) + end + + def test_dom_id_with_saved_record + @record.save + assert_equal "#{@singular}_1", dom_id(@record) + end + + def test_dom_id_with_prefix + @record.save + assert_equal "edit_#{@singular}_1", dom_id(@record, :edit) + end + + def test_dom_class + assert_equal @singular, dom_class(@record) + end + + def test_dom_class_with_prefix + assert_equal "custom_prefix_#{@singular}", dom_class(@record, :custom_prefix) + end + + def test_dom_id_as_singleton_method + @record.save + assert_equal "#{@singular}_1", ActionView::RecordIdentifier.dom_id(@record) + end + + def test_dom_class_as_singleton_method + assert_equal @singular, ActionView::RecordIdentifier.dom_class(@record) + end +end + +class RecordIdentifierWithoutActiveModelTest < ActiveSupport::TestCase + include ActionView::RecordIdentifier + + def setup + @record = Plane.new + end + + def test_dom_id_with_new_record + assert_equal "new_airplane", dom_id(@record) + end + + def test_dom_id_with_new_record_and_prefix + assert_equal "custom_prefix_airplane", dom_id(@record, :custom_prefix) + end + + def test_dom_id_with_saved_record + @record.save + assert_equal "airplane_1", dom_id(@record) + end + + def test_dom_id_with_prefix + @record.save + assert_equal "edit_airplane_1", dom_id(@record, :edit) + end + + def test_dom_class + assert_equal "airplane", dom_class(@record) + end + + def test_dom_class_with_prefix + assert_equal "custom_prefix_airplane", dom_class(@record, :custom_prefix) + end + + def test_dom_id_as_singleton_method + @record.save + assert_equal "airplane_1", ActionView::RecordIdentifier.dom_id(@record) + end + + def test_dom_class_as_singleton_method + assert_equal "airplane", ActionView::RecordIdentifier.dom_class(@record) + end +end diff --git a/actionview/test/template/render_test.rb b/actionview/test/template/render_test.rb new file mode 100644 index 0000000000..afe68b7ff0 --- /dev/null +++ b/actionview/test/template/render_test.rb @@ -0,0 +1,725 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "controller/fake_models" + +class TestController < ActionController::Base +end + +module RenderTestCases + def setup_view(paths) + @assigns = { secret: "in the sauce" } + @view = Class.new(ActionView::Base) do + def view_cache_dependencies; []; end + + def combined_fragment_cache_key(key) + [ :views, key ] + end + end.new(paths, @assigns) + + @controller_view = TestController.new.view_context + + # Reload and register danish language for testing + I18n.backend.store_translations "da", {} + I18n.backend.store_translations "pt-BR", {} + + # Ensure original are still the same since we are reindexing view paths + assert_equal ORIGINAL_LOCALES, I18n.available_locales.map(&:to_s).sort + end + + def test_render_without_options + e = assert_raises(ArgumentError) { @view.render() } + assert_match(/You invoked render but did not give any of (.+) option\./, e.message) + end + + def test_render_file + assert_equal "Hello world!", @view.render(file: "test/hello_world") + end + + # Test if :formats, :locale etc. options are passed correctly to the resolvers. + def test_render_file_with_format + assert_match "<h1>No Comment</h1>", @view.render(file: "comments/empty", formats: [:html]) + assert_match "<error>No Comment</error>", @view.render(file: "comments/empty", formats: [:xml]) + assert_match "<error>No Comment</error>", @view.render(file: "comments/empty", formats: :xml) + end + + def test_render_template_with_format + assert_match "<h1>No Comment</h1>", @view.render(template: "comments/empty", formats: [:html]) + assert_match "<error>No Comment</error>", @view.render(template: "comments/empty", formats: [:xml]) + end + + def test_rendered_format_without_format + @view.render(inline: "test") + assert_equal :html, @view.lookup_context.rendered_format + end + + def test_render_partial_implicitly_use_format_of_the_rendered_template + @view.lookup_context.formats = [:json] + assert_equal "Hello world", @view.render(template: "test/one", formats: [:html]) + end + + def test_render_partial_implicitly_use_format_of_the_rendered_partial + @view.lookup_context.formats = [:html] + assert_equal "Third level", @view.render(template: "test/html_template") + end + + def test_render_partial_use_last_prepended_format_for_partials_with_the_same_names + @view.lookup_context.formats = [:html] + assert_equal "\nHTML Template, but JSON partial", @view.render(template: "test/change_priority") + end + + def test_render_template_with_a_missing_partial_of_another_format + @view.lookup_context.formats = [:html] + e = assert_raise ActionView::Template::Error do + @view.render(template: "with_format", formats: [:json]) + end + assert_includes(e.message, "Missing partial /_missing with {:locale=>[:en], :formats=>[:json], :variants=>[], :handlers=>[:raw, :erb, :html, :builder, :ruby]}.") + end + + def test_render_file_with_locale + assert_equal "<h1>Kein Kommentar</h1>", @view.render(file: "comments/empty", locale: [:de]) + assert_equal "<h1>Kein Kommentar</h1>", @view.render(file: "comments/empty", locale: :de) + end + + def test_render_template_with_locale + assert_equal "<h1>Kein Kommentar</h1>", @view.render(template: "comments/empty", locale: [:de]) + end + + def test_render_template_with_variants + assert_equal "<h1>No Comment</h1>\n", @view.render(template: "comments/empty", variants: :grid) + end + + def test_render_file_with_handlers + assert_equal "<h1>No Comment</h1>\n", @view.render(file: "comments/empty", handlers: [:builder]) + assert_equal "<h1>No Comment</h1>\n", @view.render(file: "comments/empty", handlers: :builder) + end + + def test_render_template_with_handlers + assert_equal "<h1>No Comment</h1>\n", @view.render(template: "comments/empty", handlers: [:builder]) + end + + def test_render_raw_template_with_handlers + assert_equal "<%= hello_world %>\n", @view.render(template: "plain_text") + end + + def test_render_raw_template_with_quotes + assert_equal %q;Here are some characters: !@#$%^&*()-="'}{`; + "\n", @view.render(template: "plain_text_with_characters") + end + + def test_render_raw_is_html_safe_and_does_not_escape_output + buffer = ActiveSupport::SafeBuffer.new + buffer << @view.render(file: "plain_text") + assert_equal true, buffer.html_safe? + assert_equal buffer, "<%= hello_world %>\n" + end + + def test_render_ruby_template_with_handlers + assert_equal "Hello from Ruby code", @view.render(template: "ruby_template") + end + + def test_render_ruby_template_inline + assert_equal "4", @view.render(inline: "(2**2).to_s", type: :ruby) + end + + def test_render_file_with_localization_on_context_level + old_locale, @view.locale = @view.locale, :da + assert_equal "Hey verden", @view.render(file: "test/hello_world") + ensure + @view.locale = old_locale + end + + def test_render_file_with_dashed_locale + old_locale, @view.locale = @view.locale, :"pt-BR" + assert_equal "Ola mundo", @view.render(file: "test/hello_world") + ensure + @view.locale = old_locale + end + + def test_render_file_at_top_level + assert_equal "Elastica", @view.render(file: "/shared") + end + + def test_render_file_with_full_path + template_path = File.expand_path("../fixtures/test/hello_world", __dir__) + assert_equal "Hello world!", @view.render(file: template_path) + end + + def test_render_file_with_instance_variables + assert_equal "The secret is in the sauce\n", @view.render(file: "test/render_file_with_ivar") + end + + def test_render_file_with_locals + locals = { secret: "in the sauce" } + assert_equal "The secret is in the sauce\n", @view.render(file: "test/render_file_with_locals", locals: locals) + end + + def test_render_file_not_using_full_path_with_dot_in_path + assert_equal "The secret is in the sauce\n", @view.render(file: "test/dot.directory/render_file_with_ivar") + end + + def test_render_partial_from_default + assert_equal "only partial", @view.render("test/partial_only") + end + + def test_render_outside_path + assert File.exist?(File.expand_path("../../test/abstract_unit.rb", __dir__)) + assert_raises ActionView::MissingTemplate do + @view.render(template: "../\\../test/abstract_unit.rb") + end + end + + def test_render_partial + assert_equal "only partial", @view.render(partial: "test/partial_only") + end + + def test_render_partial_with_format + assert_equal "partial html", @view.render(partial: "test/partial") + end + + def test_render_partial_with_variants + assert_equal "<h1>Partial with variants</h1>\n", @view.render(partial: "test/partial_with_variants", variants: :grid) + end + + def test_render_partial_with_selected_format + assert_equal "partial html", @view.render(partial: "test/partial", formats: :html) + assert_equal "partial js", @view.render(partial: "test/partial", formats: [:js]) + end + + def test_render_partial_at_top_level + # file fixtures/_top_level_partial_only (not fixtures/test) + assert_equal "top level partial", @view.render(partial: "/top_level_partial_only") + end + + def test_render_partial_with_format_at_top_level + # file fixtures/_top_level_partial.html (not fixtures/test, with format extension) + assert_equal "top level partial html", @view.render(partial: "/top_level_partial") + end + + def test_render_partial_with_locals + assert_equal "5", @view.render(partial: "test/counter", locals: { counter_counter: 5 }) + end + + def test_render_partial_with_locals_from_default + assert_equal "only partial", @view.render("test/partial_only", counter_counter: 5) + end + + def test_render_partial_with_number + assert_nothing_raised { @view.render(partial: "test/200") } + end + + def test_render_partial_with_missing_filename + assert_raises(ActionView::MissingTemplate) { @view.render(partial: "test/") } + end + + def test_render_partial_with_incompatible_object + e = assert_raises(ArgumentError) { @view.render(partial: nil) } + assert_equal "'#{nil.inspect}' is not an ActiveModel-compatible object. It must implement :to_partial_path.", e.message + end + + def test_render_partial_starting_with_a_capital + assert_nothing_raised { @view.render(partial: "test/FooBar") } + end + + def test_render_partial_with_hyphen + assert_nothing_raised { @view.render(partial: "test/a-in") } + end + + def test_render_partial_with_unicode_text + assert_nothing_raised { @view.render(partial: "test/🍣") } + end + + def test_render_partial_with_invalid_option_as + e = assert_raises(ArgumentError) { @view.render(partial: "test/partial_only", as: "a-in") } + assert_equal "The value (a-in) of the option `as` is not a valid Ruby identifier; " \ + "make sure it starts with lowercase letter, " \ + "and is followed by any combination of letters, numbers and underscores.", e.message + end + + def test_render_partial_with_hyphen_and_invalid_option_as + e = assert_raises(ArgumentError) { @view.render(partial: "test/a-in", as: "a-in") } + assert_equal "The value (a-in) of the option `as` is not a valid Ruby identifier; " \ + "make sure it starts with lowercase letter, " \ + "and is followed by any combination of letters, numbers and underscores.", e.message + end + + def test_render_partial_with_errors + e = assert_raises(ActionView::Template::Error) { @view.render(partial: "test/raise") } + assert_match %r!method.*doesnt_exist!, e.message + assert_equal "", e.sub_template_message + assert_equal "1", e.line_number + assert_equal "1: <%= doesnt_exist %>", e.annoted_source_code[0].strip + assert_equal File.expand_path("#{FIXTURE_LOAD_PATH}/test/_raise.html.erb"), e.file_name + end + + def test_render_error_indentation + e = assert_raises(ActionView::Template::Error) { @view.render(partial: "test/raise_indentation") } + error_lines = e.annoted_source_code + assert_match %r!error\shere!, e.message + assert_equal "11", e.line_number + assert_equal " 9: <p>Ninth paragraph</p>", error_lines.second + assert_equal " 10: <p>Tenth paragraph</p>", error_lines.third + end + + def test_render_sub_template_with_errors + e = assert_raises(ActionView::Template::Error) { @view.render(template: "test/sub_template_raise") } + assert_match %r!method.*doesnt_exist!, e.message + assert_match %r{Trace of template inclusion: .*test/sub_template_raise\.html\.erb}, e.sub_template_message + assert_equal "1", e.line_number + assert_equal File.expand_path("#{FIXTURE_LOAD_PATH}/test/_raise.html.erb"), e.file_name + end + + def test_render_file_with_errors + e = assert_raises(ActionView::Template::Error) { @view.render(file: File.expand_path("test/_raise", FIXTURE_LOAD_PATH)) } + assert_match %r!method.*doesnt_exist!, e.message + assert_equal "", e.sub_template_message + assert_equal "1", e.line_number + assert_equal "1: <%= doesnt_exist %>", e.annoted_source_code[0].strip + assert_equal File.expand_path("#{FIXTURE_LOAD_PATH}/test/_raise.html.erb"), e.file_name + end + + def test_render_object + assert_equal "Hello: david", @view.render(partial: "test/customer", object: Customer.new("david")) + assert_equal "FalseClass", @view.render(partial: "test/klass", object: false) + assert_equal "NilClass", @view.render(partial: "test/klass", object: nil) + end + + def test_render_object_with_array + assert_equal "[1, 2, 3]", @view.render(partial: "test/object_inspector", object: [1, 2, 3]) + end + + def test_render_partial_collection + assert_equal "Hello: davidHello: mary", @view.render(partial: "test/customer", collection: [ Customer.new("david"), Customer.new("mary") ]) + end + + def test_render_partial_collection_with_partial_name_containing_dot + assert_equal "Hello: davidHello: mary", + @view.render(partial: "test/customer.mobile", collection: [ Customer.new("david"), Customer.new("mary") ]) + end + + def test_render_partial_collection_as_by_string + assert_equal "david david davidmary mary mary", + @view.render(partial: "test/customer_with_var", collection: [ Customer.new("david"), Customer.new("mary") ], as: "customer") + end + + def test_render_partial_collection_as_by_symbol + assert_equal "david david davidmary mary mary", + @view.render(partial: "test/customer_with_var", collection: [ Customer.new("david"), Customer.new("mary") ], as: :customer) + end + + def test_render_partial_collection_without_as + assert_equal "local_inspector,local_inspector_counter,local_inspector_iteration", + @view.render(partial: "test/local_inspector", collection: [ Customer.new("mary") ]) + end + + def test_render_partial_collection_with_different_partials_still_provides_partial_iteration + a = {} + b = {} + def a.to_partial_path; "test/partial_iteration_1"; end + def b.to_partial_path; "test/partial_iteration_2"; end + + assert_equal "local-variable\nlocal-variable", @controller_view.render([a, b]) + end + + def test_render_partial_with_empty_collection_should_return_nil + assert_nil @view.render(partial: "test/customer", collection: []) + end + + def test_render_partial_with_nil_collection_should_return_nil + assert_nil @view.render(partial: "test/customer", collection: nil) + end + + def test_render_partial_collection_for_non_array + customers = Enumerator.new do |y| + y.yield(Customer.new("david")) + y.yield(Customer.new("mary")) + end + assert_equal "Hello: davidHello: mary", @view.render(partial: "test/customer", collection: customers) + end + + def test_render_partial_without_object_does_not_put_partial_name_to_local_assigns + assert_equal "false", @view.render(partial: "test/partial_name_in_local_assigns") + end + + def test_render_partial_with_nil_object_puts_partial_name_to_local_assigns + assert_equal "true", @view.render(partial: "test/partial_name_in_local_assigns", object: nil) + end + + def test_render_partial_with_nil_values_in_collection + assert_equal "Hello: davidHello: Anonymous", @view.render(partial: "test/customer", collection: [ Customer.new("david"), nil ]) + end + + def test_render_partial_with_layout_using_collection_and_template + assert_equal "<b>Hello: Amazon</b><b>Hello: Yahoo</b>", @view.render(partial: "test/customer", layout: "test/b_layout_for_partial", collection: [ Customer.new("Amazon"), Customer.new("Yahoo") ]) + end + + def test_render_partial_with_layout_using_collection_and_template_makes_current_item_available_in_layout + assert_equal '<b class="amazon">Hello: Amazon</b><b class="yahoo">Hello: Yahoo</b>', + @view.render(partial: "test/customer", layout: "test/b_layout_for_partial_with_object", collection: [ Customer.new("Amazon"), Customer.new("Yahoo") ]) + end + + def test_render_partial_with_layout_using_collection_and_template_makes_current_item_counter_available_in_layout + assert_equal '<b data-counter="0">Hello: Amazon</b><b data-counter="1">Hello: Yahoo</b>', + @view.render(partial: "test/customer", layout: "test/b_layout_for_partial_with_object_counter", collection: [ Customer.new("Amazon"), Customer.new("Yahoo") ]) + end + + def test_render_partial_with_layout_using_object_and_template_makes_object_available_in_layout + assert_equal '<b class="amazon">Hello: Amazon</b>', + @view.render(partial: "test/customer", layout: "test/b_layout_for_partial_with_object", object: Customer.new("Amazon")) + end + + def test_render_partial_with_empty_array_should_return_nil + assert_nil @view.render(partial: []) + end + + def test_render_partial_using_string + assert_equal "Hello: Anonymous", @controller_view.render("customer") + end + + def test_render_partial_with_locals_using_string + assert_equal "Hola: david", @controller_view.render("customer_greeting", greeting: "Hola", customer_greeting: Customer.new("david")) + end + + def test_render_partial_with_object_uses_render_partial_path + assert_equal "Hello: lifo", + @controller_view.render(partial: Customer.new("lifo"), locals: { greeting: "Hello" }) + end + + def test_render_partial_with_object_and_format_uses_render_partial_path + assert_equal "<greeting>Hello</greeting><name>lifo</name>", + @controller_view.render(partial: Customer.new("lifo"), formats: :xml, locals: { greeting: "Hello" }) + end + + def test_render_partial_using_object + assert_equal "Hello: lifo", + @controller_view.render(Customer.new("lifo"), greeting: "Hello") + end + + def test_render_partial_using_collection + customers = [ Customer.new("Amazon"), Customer.new("Yahoo") ] + assert_equal "Hello: AmazonHello: Yahoo", + @controller_view.render(customers, greeting: "Hello") + end + + def test_render_partial_using_collection_without_path + assert_equal "hi good customer: david0", @controller_view.render([ GoodCustomer.new("david") ], greeting: "hi") + end + + def test_render_partial_without_object_or_collection_does_not_generate_partial_name_local_variable + exception = assert_raises ActionView::Template::Error do + @controller_view.render("partial_name_local_variable") + end + assert_instance_of NameError, exception.cause + assert_equal :partial_name_local_variable, exception.cause.name + end + + def test_render_partial_with_no_block_given_to_yield + assert_equal "Before (Josh)\n\nAfter", @view.render(partial: "test/layout_for_partial", locals: { name: "Josh" }) + end + + def test_render_partial_with_non_existent_format_and_raise_missing_template + @view.formats = [:xml] + assert_raises(ActionView::MissingTemplate) { @view.render(partial: "test/layout_for_partial") } + ensure + @view.formats = nil + end + + def test_render_layout_with_block_and_other_partial_inside + render = @view.render(layout: "test/layout_with_partial_and_yield") { "Yield!" } + assert_equal "Before\npartial html\nYield!\nAfter\n", render + end + + def test_render_inline + assert_equal "Hello, World!", @view.render(inline: "Hello, World!") + end + + def test_render_inline_with_locals + assert_equal "Hello, Josh!", @view.render(inline: "Hello, <%= name %>!", locals: { name: "Josh" }) + end + + def test_render_fallbacks_to_erb_for_unknown_types + assert_equal "Hello, World!", @view.render(inline: "Hello, World!", type: :bar) + end + + CustomHandler = lambda do |template| + "@output_buffer = ''.dup\n" \ + "@output_buffer << 'source: #{template.source.inspect}'\n" + end + + def test_render_inline_with_render_from_to_proc + ActionView::Template.register_template_handler :ruby_handler, :source.to_proc + assert_equal "3", @view.render(inline: "(1 + 2).to_s", type: :ruby_handler) + ensure + ActionView::Template.unregister_template_handler :ruby_handler + end + + def test_render_inline_with_compilable_custom_type + ActionView::Template.register_template_handler :foo, CustomHandler + assert_equal 'source: "Hello, World!"', @view.render(inline: "Hello, World!", type: :foo) + ensure + ActionView::Template.unregister_template_handler :foo + end + + def test_render_inline_with_locals_and_compilable_custom_type + ActionView::Template.register_template_handler :foo, CustomHandler + assert_equal 'source: "Hello, <%= name %>!"', @view.render(inline: "Hello, <%= name %>!", locals: { name: "Josh" }, type: :foo) + ensure + ActionView::Template.unregister_template_handler :foo + end + + def test_render_body + assert_equal "some body", @view.render(body: "some body") + end + + def test_render_plain + assert_equal "some plaintext", @view.render(plain: "some plaintext") + end + + def test_render_knows_about_types_registered_when_extensions_are_checked_earlier_in_initialization + ActionView::Template::Handlers.extensions + ActionView::Template.register_template_handler :foo, CustomHandler + assert_includes ActionView::Template::Handlers.extensions, :foo + ensure + ActionView::Template.unregister_template_handler :foo + end + + def test_render_does_not_use_unregistered_extension_and_template_handler + ActionView::Template.register_template_handler :foo, CustomHandler + ActionView::Template.unregister_template_handler :foo + assert_not ActionView::Template::Handlers.extensions.include?(:foo) + assert_equal "Hello, World!", @view.render(inline: "Hello, World!", type: :foo) + ensure + ActionView::Template::Handlers.class_variable_get(:@@template_handlers).delete(:foo) + end + + def test_render_ignores_templates_with_malformed_template_handlers + %w(malformed malformed.erb malformed.html.erb malformed.en.html.erb).each do |name| + assert File.exist?(File.expand_path("#{FIXTURE_LOAD_PATH}/test/malformed/#{name}~")), "Malformed file (#{name}~) which should be ignored does not exists" + assert_raises(ActionView::MissingTemplate) { @view.render(file: "test/malformed/#{name}") } + end + end + + def test_render_with_layout + assert_equal %(<title></title>\nHello world!\n), + @view.render(file: "test/hello_world", layout: "layouts/yield") + end + + def test_render_with_layout_which_has_render_inline + assert_equal %(welcome\nHello world!\n), + @view.render(file: "test/hello_world", layout: "layouts/yield_with_render_inline_inside") + end + + def test_render_with_layout_which_renders_another_partial + assert_equal %(partial html\nHello world!\n), + @view.render(file: "test/hello_world", layout: "layouts/yield_with_render_partial_inside") + end + + def test_render_partial_with_html_only_extension + assert_equal %(<h1>partial html</h1>\nHello world!\n), + @view.render(file: "test/hello_world", layout: "layouts/render_partial_html") + end + + def test_render_layout_with_block_and_yield + assert_equal %(Content from block!\n), + @view.render(layout: "layouts/yield_only") { "Content from block!" } + end + + def test_render_layout_with_block_and_yield_with_params + assert_equal %(Yield! Content from block!\n), + @view.render(layout: "layouts/yield_with_params") { |param| "#{param} Content from block!" } + end + + def test_render_layout_with_block_which_renders_another_partial_and_yields + assert_equal %(partial html\nContent from block!\n), + @view.render(layout: "layouts/partial_and_yield") { "Content from block!" } + end + + def test_render_partial_and_layout_without_block_with_locals + assert_equal %(Before (Foo!)\npartial html\nAfter), + @view.render(partial: "test/partial", layout: "test/layout_for_partial", locals: { name: "Foo!" }) + end + + def test_render_partial_and_layout_without_block_with_locals_and_rendering_another_partial + assert_equal %(Before (Foo!)\npartial html\npartial with partial\n\nAfter), + @view.render(partial: "test/partial_with_partial", layout: "test/layout_for_partial", locals: { name: "Foo!" }) + end + + def test_render_partial_shortcut_with_block_content + assert_equal %(Before (shortcut test)\nBefore\n\n Yielded: arg1/arg2\n\nAfter\nAfter), + @view.render(partial: "test/partial_shortcut_with_block_content", layout: "test/layout_for_partial", locals: { name: "shortcut test" }) + end + + def test_render_layout_with_a_nested_render_layout_call + assert_equal %(Before (Foo!)\nBefore (Bar!)\npartial html\nAfter\npartial with layout\n\nAfter), + @view.render(partial: "test/partial_with_layout", layout: "test/layout_for_partial", locals: { name: "Foo!" }) + end + + def test_render_layout_with_a_nested_render_layout_call_using_block_with_render_partial + assert_equal %(Before (Foo!)\nBefore (Bar!)\n\n partial html\n\nAfterpartial with layout\n\nAfter), + @view.render(partial: "test/partial_with_layout_block_partial", layout: "test/layout_for_partial", locals: { name: "Foo!" }) + end + + def test_render_layout_with_a_nested_render_layout_call_using_block_with_render_content + assert_equal %(Before (Foo!)\nBefore (Bar!)\n\n Content from inside layout!\n\nAfterpartial with layout\n\nAfter), + @view.render(partial: "test/partial_with_layout_block_content", layout: "test/layout_for_partial", locals: { name: "Foo!" }) + end + + def test_render_partial_with_layout_raises_descriptive_error + e = assert_raises(ActionView::MissingTemplate) { @view.render(partial: "test/partial", layout: true) } + assert_match "Missing partial /_true with", e.message + end + + def test_render_with_nested_layout + assert_equal %(<title>title</title>\n\n<div id="column">column</div>\n<div id="content">content</div>\n), + @view.render(file: "test/nested_layout", layout: "layouts/yield") + end + + def test_render_with_file_in_layout + assert_equal %(\n<title>title</title>\n\n), + @view.render(file: "test/layout_render_file") + end + + def test_render_layout_with_object + assert_equal %(<title>David</title>), + @view.render(file: "test/layout_render_object") + end + + def test_render_with_passing_couple_extensions_to_one_register_template_handler_function_call + ActionView::Template.register_template_handler :foo1, :foo2, CustomHandler + assert_equal @view.render(inline: +"Hello, World!", type: :foo1), @view.render(inline: +"Hello, World!", type: :foo2) + ensure + ActionView::Template.unregister_template_handler :foo1, :foo2 + end + + def test_render_throws_exception_when_no_extensions_passed_to_register_template_handler_function_call + assert_raises(ArgumentError) { ActionView::Template.register_template_handler CustomHandler } + end +end + +class CachedViewRenderTest < ActiveSupport::TestCase + include RenderTestCases + + # Ensure view path cache is primed + def setup + view_paths = ActionController::Base.view_paths + assert_equal ActionView::OptimizedFileSystemResolver, view_paths.first.class + setup_view(view_paths) + end + + def teardown + GC.start + I18n.reload! + end +end + +class LazyViewRenderTest < ActiveSupport::TestCase + include RenderTestCases + + # Test the same thing as above, but make sure the view path + # is not eager loaded + def setup + path = ActionView::FileSystemResolver.new(FIXTURE_LOAD_PATH) + view_paths = ActionView::PathSet.new([path]) + assert_equal ActionView::FileSystemResolver.new(FIXTURE_LOAD_PATH), view_paths.first + setup_view(view_paths) + end + + def teardown + GC.start + I18n.reload! + end + + def test_render_utf8_template_with_magic_comment + with_external_encoding Encoding::ASCII_8BIT do + result = @view.render(file: "test/utf8_magic", formats: [:html], layouts: "layouts/yield") + assert_equal Encoding::UTF_8, result.encoding + assert_equal "\nРусский \nтекст\n\nUTF-8\nUTF-8\nUTF-8\n", result + end + end + + def test_render_utf8_template_with_default_external_encoding + with_external_encoding Encoding::UTF_8 do + result = @view.render(file: "test/utf8", formats: [:html], layouts: "layouts/yield") + assert_equal Encoding::UTF_8, result.encoding + assert_equal "Русский текст\n\nUTF-8\nUTF-8\nUTF-8\n", result + end + end + + def test_render_utf8_template_with_incompatible_external_encoding + with_external_encoding Encoding::SHIFT_JIS do + e = assert_raises(ActionView::Template::Error) { @view.render(file: "test/utf8", formats: [:html], layouts: "layouts/yield") } + assert_match "Your template was not saved as valid Shift_JIS", e.cause.message + end + end + + def test_render_utf8_template_with_partial_with_incompatible_encoding + with_external_encoding Encoding::SHIFT_JIS do + e = assert_raises(ActionView::Template::Error) { @view.render(file: "test/utf8_magic_with_bare_partial", formats: [:html], layouts: "layouts/yield") } + assert_match "Your template was not saved as valid Shift_JIS", e.cause.message + end + end + + def with_external_encoding(encoding) + old = Encoding.default_external + silence_warnings { Encoding.default_external = encoding } + yield + ensure + silence_warnings { Encoding.default_external = old } + end +end + +class CachedCollectionViewRenderTest < ActiveSupport::TestCase + class CachedCustomer < Customer; end + + include RenderTestCases + + # Ensure view path cache is primed + setup do + view_paths = ActionController::Base.view_paths + assert_equal ActionView::OptimizedFileSystemResolver, view_paths.first.class + + ActionView::PartialRenderer.collection_cache = ActiveSupport::Cache::MemoryStore.new + + setup_view(view_paths) + end + + teardown do + GC.start + I18n.reload! + end + + test "collection caching does not cache by default" do + customer = Customer.new("david", 1) + key = cache_key(customer, "test/_customer") + + ActionView::PartialRenderer.collection_cache.write(key, "Cached") + + assert_not_equal "Cached", + @view.render(partial: "test/customer", collection: [customer]) + end + + test "collection caching with partial that doesn't use fragment caching" do + customer = Customer.new("david", 1) + key = cache_key(customer, "test/_customer") + + ActionView::PartialRenderer.collection_cache.write(key, "Cached") + + assert_equal "Cached", + @view.render(partial: "test/customer", collection: [customer], cached: true) + end + + test "collection caching with cached true" do + customer = CachedCustomer.new("david", 1) + key = cache_key(customer, "test/_cached_customer") + + ActionView::PartialRenderer.collection_cache.write(key, "Cached") + + assert_equal "Cached", + @view.render(partial: "test/cached_customer", collection: [customer], cached: true) + end + + private + def cache_key(*names, virtual_path) + digest = ActionView::Digestor.digest name: virtual_path, finder: @view.lookup_context, dependencies: [] + @view.combined_fragment_cache_key([ "#{virtual_path}:#{digest}", *names ]) + end +end diff --git a/actionview/test/template/resolver_cache_test.rb b/actionview/test/template/resolver_cache_test.rb new file mode 100644 index 0000000000..8a5db1346a --- /dev/null +++ b/actionview/test/template/resolver_cache_test.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class ResolverCacheTest < ActiveSupport::TestCase + def test_inspect_shields_cache_internals + assert_match %r(#<ActionView::Resolver:0x[0-9a-f]+ @cache=#<ActionView::Resolver::Cache:0x[0-9a-f]+ keys=0 queries=0>>), ActionView::Resolver.new.inspect + end +end diff --git a/actionview/test/template/resolver_patterns_test.rb b/actionview/test/template/resolver_patterns_test.rb new file mode 100644 index 0000000000..1e1a4c5063 --- /dev/null +++ b/actionview/test/template/resolver_patterns_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class ResolverPatternsTest < ActiveSupport::TestCase + def setup + path = File.expand_path("../fixtures", __dir__) + pattern = ":prefix/{:formats/,}:action{.:formats,}{+:variants,}{.:handlers,}" + @resolver = ActionView::FileSystemResolver.new(path, pattern) + end + + def test_should_return_empty_list_for_unknown_path + templates = @resolver.find_all("unknown", "custom_pattern", false, locale: [], formats: [:html], variants: [], handlers: [:erb]) + assert_equal [], templates, "expected an empty list of templates" + end + + def test_should_return_template_for_declared_path + templates = @resolver.find_all("path", "custom_pattern", false, locale: [], formats: [:html], variants: [], handlers: [:erb]) + assert_equal 1, templates.size, "expected one template" + assert_equal "Hello custom patterns!", templates.first.source + assert_equal "custom_pattern/path", templates.first.virtual_path + assert_equal [:html], templates.first.formats + end + + def test_should_return_all_templates_when_ambiguous_pattern + templates = @resolver.find_all("another", "custom_pattern", false, locale: [], formats: [:html], variants: [], handlers: [:erb]) + assert_equal 2, templates.size, "expected two templates" + assert_equal "Another template!", templates[0].source + assert_equal "custom_pattern/another", templates[0].virtual_path + assert_equal "Hello custom patterns!", templates[1].source + assert_equal "custom_pattern/another", templates[1].virtual_path + end + + def test_should_return_all_variants_for_any + templates = @resolver.find_all("hello_world", "test", false, locale: [], formats: [:html, :text], variants: :any, handlers: [:erb]) + assert_equal 3, templates.size, "expected three templates" + assert_equal "Hello phone!", templates[0].source + assert_equal "test/hello_world", templates[0].virtual_path + assert_equal "Hello texty phone!", templates[1].source + assert_equal "test/hello_world", templates[1].virtual_path + assert_equal "Hello world!", templates[2].source + assert_equal "test/hello_world", templates[2].virtual_path + end +end diff --git a/actionview/test/template/sanitize_helper_test.rb b/actionview/test/template/sanitize_helper_test.rb new file mode 100644 index 0000000000..181f09ab65 --- /dev/null +++ b/actionview/test/template/sanitize_helper_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "abstract_unit" + +# The exhaustive tests are in the rails-html-sanitizer gem. +# This tests that the helpers hook up correctly to the sanitizer classes. +class SanitizeHelperTest < ActionView::TestCase + tests ActionView::Helpers::SanitizeHelper + + def test_strip_links + assert_equal "Dont touch me", strip_links("Dont touch me") + assert_equal "on my mind\nall day long", strip_links("<a href='almost'>on my mind</a>\n<A href='almost'>all day long</A>") + assert_equal "Magic", strip_links("<a href='http://www.rubyonrails.com/'>Mag<a href='http://www.ruby-lang.org/'>ic") + assert_equal "My mind\nall <b>day</b> long", strip_links("<a href='almost'>My mind</a>\n<A href='almost'>all <b>day</b> long</A>") + assert_equal "<malformed & link", strip_links('<<a href="https://example.org">malformed & link</a>') + end + + def test_sanitize_form + assert_equal "", sanitize("<form action=\"/foo/bar\" method=\"post\"><input></form>") + end + + def test_should_sanitize_illegal_style_properties + raw = %(display:block; position:absolute; left:0; top:0; width:100%; height:100%; z-index:1; background-color:black; background-image:url(http://www.ragingplatypus.com/i/cam-full.jpg); background-x:center; background-y:center; background-repeat:repeat;) + expected = %r(\Adisplay:\s?block;\s?width:\s?100%;\s?height:\s?100%;\s?background-color:\s?black;\s?background-x:\s?center;\s?background-y:\s?center;\z) + assert_match expected, sanitize_css(raw) + end + + def test_strip_tags + assert_equal("Dont touch me", strip_tags("Dont touch me")) + assert_equal("This is a test.", strip_tags("<p>This <u>is<u> a <a href='test.html'><strong>test</strong></a>.</p>")) + assert_equal "This has a here.", strip_tags("This has a <!-- comment --> here.") + assert_equal("Jekyll & Hyde", strip_tags("Jekyll & Hyde")) + assert_equal "", strip_tags("<script>") + end + + def test_strip_tags_will_not_encode_special_characters + assert_equal "test\r\n\r\ntest", strip_tags("test\r\n\r\ntest") + end + + def test_sanitize_is_marked_safe + assert_predicate sanitize("<html><script></script></html>"), :html_safe? + end +end diff --git a/actionview/test/template/streaming_render_test.rb b/actionview/test/template/streaming_render_test.rb new file mode 100644 index 0000000000..f196c42c4f --- /dev/null +++ b/actionview/test/template/streaming_render_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class TestController < ActionController::Base +end + +class SetupFiberedBase < ActiveSupport::TestCase + def setup + view_paths = ActionController::Base.view_paths + @assigns = { secret: "in the sauce", name: nil } + @view = ActionView::Base.new(view_paths, @assigns) + @controller_view = TestController.new.view_context + end + + def render_body(options) + @view.view_renderer.render_body(@view, options) + end + + def buffered_render(options) + body = render_body(options) + string = +"" + body.each do |piece| + string << piece + end + string + end +end + +class FiberedTest < SetupFiberedBase + def test_streaming_works + content = [] + body = render_body(template: "test/hello_world", layout: "layouts/yield") + + body.each do |piece| + content << piece + end + + assert_equal "<title>", content[0] + assert_equal "", content[1] + assert_equal "</title>\n", content[2] + assert_equal "Hello world!", content[3] + assert_equal "\n", content[4] + end + + def test_render_file + assert_equal "Hello world!", buffered_render(file: "test/hello_world") + end + + def test_render_file_with_locals + locals = { secret: "in the sauce" } + assert_equal "The secret is in the sauce\n", buffered_render(file: "test/render_file_with_locals", locals: locals) + end + + def test_render_partial + assert_equal "only partial", buffered_render(partial: "test/partial_only") + end + + def test_render_inline + assert_equal "Hello, World!", buffered_render(inline: "Hello, World!") + end + + def test_render_without_layout + assert_equal "Hello world!", buffered_render(template: "test/hello_world") + end + + def test_render_with_layout + assert_equal %(<title></title>\nHello world!\n), + buffered_render(template: "test/hello_world", layout: "layouts/yield") + end + + def test_render_with_layout_which_has_render_inline + assert_equal %(welcome\nHello world!\n), + buffered_render(template: "test/hello_world", layout: "layouts/yield_with_render_inline_inside") + end + + def test_render_with_layout_which_renders_another_partial + assert_equal %(partial html\nHello world!\n), + buffered_render(template: "test/hello_world", layout: "layouts/yield_with_render_partial_inside") + end + + def test_render_with_nested_layout + assert_equal %(<title>title</title>\n\n<div id="column">column</div>\n<div id="content">content</div>\n), + buffered_render(template: "test/nested_layout", layout: "layouts/yield") + end + + def test_render_with_file_in_layout + assert_equal %(\n<title>title</title>\n\n), + buffered_render(template: "test/layout_render_file") + end + + def test_render_with_handler_without_streaming_support + assert_match "<p>This is grand!</p>", buffered_render(template: "test/hello") + end + + def test_render_with_streaming_multiple_yields_provide_and_content_for + assert_equal "Yes, \nthis works\n like a charm.", + buffered_render(template: "test/streaming", layout: "layouts/streaming") + end + + def test_render_with_streaming_with_fake_yields_and_streaming_buster + assert_equal "This won't look\n good.", + buffered_render(template: "test/streaming_buster", layout: "layouts/streaming") + end + + def test_render_with_nested_streaming_multiple_yields_provide_and_content_for + assert_equal "?Yes, \n\nthis works\n\n? like a charm.", + buffered_render(template: "test/nested_streaming", layout: "layouts/streaming") + end + + def test_render_with_streaming_and_capture + assert_equal "Yes, \n this works\n like a charm.", + buffered_render(template: "test/streaming", layout: "layouts/streaming_with_capture") + end +end + +class FiberedWithLocaleTest < SetupFiberedBase + def setup + @old_locale = I18n.locale + I18n.locale = "da" + super + end + + def teardown + I18n.locale = @old_locale + end + + def test_render_with_streaming_and_locale + assert_equal "layout.locale: da\nview.locale: da\n\n", + buffered_render(template: "test/streaming_with_locale", layout: "layouts/streaming_with_locale") + end +end diff --git a/actionview/test/template/tag_helper_test.rb b/actionview/test/template/tag_helper_test.rb new file mode 100644 index 0000000000..9a6226fd04 --- /dev/null +++ b/actionview/test/template/tag_helper_test.rb @@ -0,0 +1,357 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class TagHelperTest < ActionView::TestCase + include RenderERBUtils + + tests ActionView::Helpers::TagHelper + + def test_tag + assert_equal "<br />", tag("br") + assert_equal "<br clear=\"left\" />", tag(:br, clear: "left") + assert_equal "<br>", tag("br", nil, true) + end + + def test_tag_builder + assert_equal "<span></span>", tag.span + assert_equal "<span class=\"bookmark\"></span>", tag.span(class: "bookmark") + end + + def test_tag_builder_void_tag + assert_equal "<br>", tag.br + assert_equal "<br class=\"some_class\">", tag.br(class: "some_class") + end + + def test_tag_builder_void_tag_with_forced_content + assert_equal "<br>some content</br>", tag.br("some content") + end + + def test_tag_builder_is_singleton + assert_equal tag, tag + end + + def test_tag_options + str = tag("p", "class" => "show", :class => "elsewhere") + assert_match(/class="show"/, str) + assert_match(/class="elsewhere"/, str) + end + + def test_tag_options_rejects_nil_option + assert_equal "<p />", tag("p", ignored: nil) + end + + def test_tag_builder_options_rejects_nil_option + assert_equal "<p></p>", tag.p(ignored: nil) + end + + def test_tag_options_accepts_false_option + assert_equal "<p value=\"false\" />", tag("p", value: false) + end + + def test_tag_builder_options_accepts_false_option + assert_equal "<p value=\"false\"></p>", tag.p(value: false) + end + + def test_tag_options_accepts_blank_option + assert_equal "<p included=\"\" />", tag("p", included: "") + end + + def test_tag_builder_options_accepts_blank_option + assert_equal "<p included=\"\"></p>", tag.p(included: "") + end + + def test_tag_options_accepts_symbol_option_when_not_escaping + assert_equal "<p value=\"symbol\" />", tag("p", { value: :symbol }, false, false) + end + + def test_tag_options_accepts_integer_option_when_not_escaping + assert_equal "<p value=\"42\" />", tag("p", { value: 42 }, false, false) + end + + def test_tag_options_converts_boolean_option + assert_dom_equal '<p disabled="disabled" itemscope="itemscope" multiple="multiple" readonly="readonly" allowfullscreen="allowfullscreen" seamless="seamless" typemustmatch="typemustmatch" sortable="sortable" default="default" inert="inert" truespeed="truespeed" />', + tag("p", disabled: true, itemscope: true, multiple: true, readonly: true, allowfullscreen: true, seamless: true, typemustmatch: true, sortable: true, default: true, inert: true, truespeed: true) + end + + def test_tag_builder_options_converts_boolean_option + assert_dom_equal '<p disabled="disabled" itemscope="itemscope" multiple="multiple" readonly="readonly" allowfullscreen="allowfullscreen" seamless="seamless" typemustmatch="typemustmatch" sortable="sortable" default="default" inert="inert" truespeed="truespeed" />', + tag.p(disabled: true, itemscope: true, multiple: true, readonly: true, allowfullscreen: true, seamless: true, typemustmatch: true, sortable: true, default: true, inert: true, truespeed: true) + end + + def test_content_tag + assert_equal "<a href=\"create\">Create</a>", content_tag("a", "Create", "href" => "create") + assert_predicate content_tag("a", "Create", "href" => "create"), :html_safe? + assert_equal content_tag("a", "Create", "href" => "create"), + content_tag("a", "Create", href: "create") + assert_equal "<p><script>evil_js</script></p>", + content_tag(:p, "<script>evil_js</script>") + assert_equal "<p><script>evil_js</script></p>", + content_tag(:p, "<script>evil_js</script>", nil, false) + end + + def test_tag_builder_with_content + assert_equal "<div id=\"post_1\">Content</div>", tag.div("Content", id: "post_1") + assert_predicate tag.div("Content", id: "post_1"), :html_safe? + assert_equal tag.div("Content", id: "post_1"), + tag.div("Content", "id": "post_1") + assert_equal "<p><script>evil_js</script></p>", + tag.p("<script>evil_js</script>") + assert_equal "<p><script>evil_js</script></p>", + tag.p("<script>evil_js</script>", escape_attributes: false) + end + + def test_tag_builder_nested + assert_equal "<div>content</div>", + tag.div { "content" } + assert_equal "<div id=\"header\"><span>hello</span></div>", + tag.div(id: "header") { |tag| tag.span "hello" } + assert_equal "<div id=\"header\"><div class=\"world\"><span>hello</span></div></div>", + tag.div(id: "header") { |tag| tag.div(class: "world") { tag.span "hello" } } + end + + def test_content_tag_with_block_in_erb + buffer = render_erb("<%= content_tag(:div) do %>Hello world!<% end %>") + assert_dom_equal "<div>Hello world!</div>", buffer + end + + def test_tag_builder_with_block_in_erb + buffer = render_erb("<%= tag.div do %>Hello world!<% end %>") + assert_dom_equal "<div>Hello world!</div>", buffer + end + + def test_content_tag_with_block_in_erb_containing_non_displayed_erb + buffer = render_erb("<%= content_tag(:p) do %><% 1 %><% end %>") + assert_dom_equal "<p></p>", buffer + end + + def test_tag_builder_with_block_in_erb_containing_non_displayed_erb + buffer = render_erb("<%= tag.p do %><% 1 %><% end %>") + assert_dom_equal "<p></p>", buffer + end + + def test_content_tag_with_block_and_options_in_erb + buffer = render_erb("<%= content_tag(:div, :class => 'green') do %>Hello world!<% end %>") + assert_dom_equal %(<div class="green">Hello world!</div>), buffer + end + + def test_tag_builder_with_block_and_options_in_erb + buffer = render_erb("<%= tag.div(class: 'green') do %>Hello world!<% end %>") + assert_dom_equal %(<div class="green">Hello world!</div>), buffer + end + + def test_content_tag_with_block_and_options_out_of_erb + assert_dom_equal %(<div class="green">Hello world!</div>), content_tag(:div, class: "green") { "Hello world!" } + end + + def test_tag_builder_with_block_and_options_out_of_erb + assert_dom_equal %(<div class="green">Hello world!</div>), tag.div(class: "green") { "Hello world!" } + end + + def test_content_tag_with_block_and_options_outside_out_of_erb + assert_equal content_tag("a", "Create", href: "create"), + content_tag("a", "href" => "create") { "Create" } + end + + def test_tag_builder_with_block_and_options_outside_out_of_erb + assert_equal tag.a("Create", href: "create"), + tag.a("href": "create") { "Create" } + end + + def test_content_tag_with_block_and_non_string_outside_out_of_erb + assert_equal content_tag("p"), + content_tag("p") { 3.times { "do_something" } } + end + + def test_tag_builder_with_block_and_non_string_outside_out_of_erb + assert_equal tag.p, + tag.p { 3.times { "do_something" } } + end + + def test_content_tag_nested_in_content_tag_out_of_erb + assert_equal content_tag("p", content_tag("b", "Hello")), + content_tag("p") { content_tag("b", "Hello") }, + output_buffer + assert_equal tag.p(tag.b("Hello")), + tag.p { tag.b("Hello") }, + output_buffer + end + + def test_content_tag_nested_in_content_tag_in_erb + assert_equal "<p>\n <b>Hello</b>\n</p>", view.render("test/content_tag_nested_in_content_tag") + assert_equal "<p>\n <b>Hello</b>\n</p>", view.render("test/builder_tag_nested_in_content_tag") + end + + def test_content_tag_with_escaped_array_class + str = content_tag("p", "limelight", class: ["song", "play>"]) + assert_equal "<p class=\"song play>\">limelight</p>", str + + str = content_tag("p", "limelight", class: ["song", "play"]) + assert_equal "<p class=\"song play\">limelight</p>", str + + str = content_tag("p", "limelight", class: ["song", ["play"]]) + assert_equal "<p class=\"song play\">limelight</p>", str + end + + def test_tag_builder_with_escaped_array_class + str = tag.p "limelight", class: ["song", "play>"] + assert_equal "<p class=\"song play>\">limelight</p>", str + + str = tag.p "limelight", class: ["song", "play"] + assert_equal "<p class=\"song play\">limelight</p>", str + + str = tag.p "limelight", class: ["song", ["play"]] + assert_equal "<p class=\"song play\">limelight</p>", str + end + + def test_content_tag_with_unescaped_array_class + str = content_tag("p", "limelight", { class: ["song", "play>"] }, false) + assert_equal "<p class=\"song play>\">limelight</p>", str + + str = content_tag("p", "limelight", { class: ["song", ["play>"]] }, false) + assert_equal "<p class=\"song play>\">limelight</p>", str + end + + def test_tag_builder_with_unescaped_array_class + str = tag.p "limelight", class: ["song", "play>"], escape_attributes: false + assert_equal "<p class=\"song play>\">limelight</p>", str + + str = tag.p "limelight", class: ["song", ["play>"]], escape_attributes: false + assert_equal "<p class=\"song play>\">limelight</p>", str + end + + def test_content_tag_with_empty_array_class + str = content_tag("p", "limelight", class: []) + assert_equal '<p class="">limelight</p>', str + end + + def test_tag_builder_with_empty_array_class + assert_equal '<p class="">limelight</p>', tag.p("limelight", class: []) + end + + def test_content_tag_with_unescaped_empty_array_class + str = content_tag("p", "limelight", { class: [] }, false) + assert_equal '<p class="">limelight</p>', str + end + + def test_tag_builder_with_unescaped_empty_array_class + str = tag.p "limelight", class: [], escape_attributes: false + assert_equal '<p class="">limelight</p>', str + end + + def test_content_tag_with_data_attributes + assert_dom_equal '<p data-number="1" data-string="hello" data-string-with-quotes="double"quote"party"">limelight</p>', + content_tag("p", "limelight", data: { number: 1, string: "hello", string_with_quotes: 'double"quote"party"' }) + end + + def test_tag_builder_with_data_attributes + assert_dom_equal '<p data-number="1" data-string="hello" data-string-with-quotes="double"quote"party"">limelight</p>', + tag.p("limelight", data: { number: 1, string: "hello", string_with_quotes: 'double"quote"party"' }) + end + + def test_cdata_section + assert_equal "<![CDATA[<hello world>]]>", cdata_section("<hello world>") + end + + def test_cdata_section_with_string_conversion + assert_equal "<![CDATA[]]>", cdata_section(nil) + end + + def test_cdata_section_splitted + assert_equal "<![CDATA[hello]]]]><![CDATA[>world]]>", cdata_section("hello]]>world") + assert_equal "<![CDATA[hello]]]]><![CDATA[>world]]]]><![CDATA[>again]]>", cdata_section("hello]]>world]]>again") + end + + def test_escape_once + assert_equal "1 < 2 & 3", escape_once("1 < 2 & 3") + assert_equal " ' ' λ λ " ' < > ", escape_once(" ' ' λ λ \" ' < > ") + end + + def test_tag_honors_html_safe_for_param_values + ["1&2", "1 < 2", "“test“"].each do |escaped| + assert_equal %(<a href="#{escaped}" />), tag("a", href: escaped.html_safe) + assert_equal %(<a href="#{escaped}"></a>), tag.a(href: escaped.html_safe) + end + end + + def test_tag_honors_html_safe_with_escaped_array_class + assert_equal '<p class="song> play>" />', tag("p", class: ["song>", raw("play>")]) + assert_equal '<p class="song> play>" />', tag("p", class: [raw("song>"), "play>"]) + end + + def test_tag_builder_honors_html_safe_with_escaped_array_class + assert_equal '<p class="song> play>"></p>', tag.p(class: ["song>", raw("play>")]) + assert_equal '<p class="song> play>"></p>', tag.p(class: [raw("song>"), "play>"]) + end + + def test_tag_does_not_honor_html_safe_double_quotes_as_attributes + assert_dom_equal '<p title=""">content</p>', + content_tag("p", "content", title: '"'.html_safe) + end + + def test_data_tag_does_not_honor_html_safe_double_quotes_as_attributes + assert_dom_equal '<p data-title=""">content</p>', + content_tag("p", "content", data: { title: '"'.html_safe }) + end + + def test_skip_invalid_escaped_attributes + ["&1;", "dfa3;", "& #123;"].each do |escaped| + assert_equal %(<a href="#{escaped.gsub(/&/, '&')}" />), tag("a", href: escaped) + assert_equal %(<a href="#{escaped.gsub(/&/, '&')}"></a>), tag.a(href: escaped) + end + end + + def test_disable_escaping + assert_equal '<a href="&" />', tag("a", { href: "&" }, false, false) + end + + def test_tag_builder_disable_escaping + assert_equal '<a href="&"></a>', tag.a(href: "&", escape_attributes: false) + assert_equal '<a href="&">cnt</a>', tag.a(href: "&", escape_attributes: false) { "cnt" } + assert_equal '<br data-hidden="&">', tag.br("data-hidden": "&", escape_attributes: false) + assert_equal '<a href="&">content</a>', tag.a("content", href: "&", escape_attributes: false) + assert_equal '<a href="&">content</a>', tag.a(href: "&", escape_attributes: false) { "content" } + end + + def test_data_attributes + ["data", :data].each { |data| + assert_dom_equal '<a data-a-float="3.14" data-a-big-decimal="-123.456" data-a-number="1" data-array="[1,2,3]" data-hash="{"key":"value"}" data-string-with-quotes="double"quote"party"" data-string="hello" data-symbol="foo" />', + tag("a", data => { a_float: 3.14, a_big_decimal: BigDecimal("-123.456"), a_number: 1, string: "hello", symbol: :foo, array: [1, 2, 3], hash: { key: "value" }, string_with_quotes: 'double"quote"party"' }) + assert_dom_equal '<a data-a-float="3.14" data-a-big-decimal="-123.456" data-a-number="1" data-array="[1,2,3]" data-hash="{"key":"value"}" data-string-with-quotes="double"quote"party"" data-string="hello" data-symbol="foo" />', + tag.a(data: { a_float: 3.14, a_big_decimal: BigDecimal("-123.456"), a_number: 1, string: "hello", symbol: :foo, array: [1, 2, 3], hash: { key: "value" }, string_with_quotes: 'double"quote"party"' }) + } + end + + def test_aria_attributes + ["aria", :aria].each { |aria| + assert_dom_equal '<a aria-a-float="3.14" aria-a-big-decimal="-123.456" aria-a-number="1" aria-array="[1,2,3]" aria-hash="{"key":"value"}" aria-string-with-quotes="double"quote"party"" aria-string="hello" aria-symbol="foo" />', + tag("a", aria => { a_float: 3.14, a_big_decimal: BigDecimal("-123.456"), a_number: 1, string: "hello", symbol: :foo, array: [1, 2, 3], hash: { key: "value" }, string_with_quotes: 'double"quote"party"' }) + assert_dom_equal '<a aria-a-float="3.14" aria-a-big-decimal="-123.456" aria-a-number="1" aria-array="[1,2,3]" aria-hash="{"key":"value"}" aria-string-with-quotes="double"quote"party"" aria-string="hello" aria-symbol="foo" />', + tag.a(aria: { a_float: 3.14, a_big_decimal: BigDecimal("-123.456"), a_number: 1, string: "hello", symbol: :foo, array: [1, 2, 3], hash: { key: "value" }, string_with_quotes: 'double"quote"party"' }) + } + end + + def test_link_to_data_nil_equal + div_type1 = content_tag(:div, "test", "data-tooltip" => nil) + div_type2 = content_tag(:div, "test", data: { tooltip: nil }) + assert_dom_equal div_type1, div_type2 + end + + def test_tag_builder_link_to_data_nil_equal + div_type1 = tag.div "test", 'data-tooltip': nil + div_type2 = tag.div "test", data: { tooltip: nil } + assert_dom_equal div_type1, div_type2 + end + + def test_tag_builder_allow_call_via_method_object + assert_equal "<foo></foo>", tag.method(:foo).call + end + + def test_tag_builder_dasherize_names + assert_equal "<img-slider></img-slider>", tag.img_slider + end + + def test_respond_to + assert_respond_to tag, :any_tag + end +end diff --git a/actionview/test/template/template_error_test.rb b/actionview/test/template/template_error_test.rb new file mode 100644 index 0000000000..c4dc88e4aa --- /dev/null +++ b/actionview/test/template/template_error_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class TemplateErrorTest < ActiveSupport::TestCase + def test_provides_original_message + error = begin + raise Exception.new("original") + rescue Exception + raise ActionView::Template::Error.new("test") rescue $! + end + + assert_equal "original", error.message + end + + def test_provides_original_backtrace + error = begin + original_exception = Exception.new + original_exception.set_backtrace(%W[ foo bar baz ]) + raise original_exception + rescue Exception + raise ActionView::Template::Error.new("test") rescue $! + end + + assert_equal %W[ foo bar baz ], error.backtrace + end + + def test_provides_useful_inspect + error = begin + raise Exception.new("original") + rescue Exception + raise ActionView::Template::Error.new("test") rescue $! + end + + assert_equal "#<ActionView::Template::Error: original>", error.inspect + end +end diff --git a/actionview/test/template/template_test.rb b/actionview/test/template/template_test.rb new file mode 100644 index 0000000000..b348d1f17b --- /dev/null +++ b/actionview/test/template/template_test.rb @@ -0,0 +1,214 @@ +# encoding: US-ASCII +# frozen_string_literal: true + +require "abstract_unit" +require "logger" + +class TestERBTemplate < ActiveSupport::TestCase + ERBHandler = ActionView::Template::Handlers::ERB.new + + class LookupContext + def disable_cache + yield + end + + def find_template(*args) + end + + attr_accessor :formats + end + + class Context + def initialize + @output_buffer = "original" + @virtual_path = nil + end + + def hello + "Hello" + end + + def apostrophe + "l'apostrophe" + end + + def partial + ActionView::Template.new( + "<%= @virtual_path %>", + "partial", + ERBHandler, + virtual_path: "partial" + ) + end + + def lookup_context + @lookup_context ||= LookupContext.new + end + + def logger + ActiveSupport::Logger.new(STDERR) + end + + def my_buffer + @output_buffer + end + end + + def new_template(body = "<%= hello %>", details = { format: :html }) + ActionView::Template.new(body.dup, "hello template", details.fetch(:handler) { ERBHandler }, { virtual_path: "hello" }.merge!(details)) + end + + def render(locals = {}) + @template.render(@context, locals) + end + + def setup + @context = Context.new + end + + def test_basic_template + @template = new_template + assert_equal "Hello", render + end + + def test_basic_template_does_html_escape + @template = new_template("<%= apostrophe %>") + assert_equal "l'apostrophe", render + end + + def test_text_template_does_not_html_escape + @template = new_template("<%= apostrophe %> <%== apostrophe %>", format: :text) + assert_equal "l'apostrophe l'apostrophe", render + end + + def test_raw_template + @template = new_template("<%= hello %>", handler: ActionView::Template::Handlers::Raw.new) + assert_equal "<%= hello %>", render + end + + def test_template_loses_its_source_after_rendering + @template = new_template + render + assert_nil @template.source + end + + def test_template_does_not_lose_its_source_after_rendering_if_it_does_not_have_a_virtual_path + @template = new_template("Hello", virtual_path: nil) + render + assert_equal "Hello", @template.source + end + + def test_locals + @template = new_template("<%= my_local %>") + @template.locals = [:my_local] + assert_equal "I am a local", render(my_local: "I am a local") + end + + def test_restores_buffer + @template = new_template + assert_equal "Hello", render + assert_equal "original", @context.my_buffer + end + + def test_virtual_path + @template = new_template("<%= @virtual_path %>" \ + "<%= partial.render(self, {}) %>" \ + "<%= @virtual_path %>") + assert_equal "hellopartialhello", render + end + + def test_refresh_with_templates + @template = new_template("Hello", virtual_path: "test/foo/bar") + @template.locals = [:key] + assert_called_with(@context.lookup_context, :find_template, ["bar", %w(test/foo), false, [:key]], returns: "template") do + assert_equal "template", @template.refresh(@context) + end + end + + def test_refresh_with_partials + @template = new_template("Hello", virtual_path: "test/_foo") + @template.locals = [:key] + assert_called_with(@context.lookup_context, :find_template, ["foo", %w(test), true, [:key]], returns: "partial") do + assert_equal "partial", @template.refresh(@context) + end + end + + def test_refresh_raises_an_error_without_virtual_path + @template = new_template("Hello", virtual_path: nil) + assert_raise RuntimeError do + @template.refresh(@context) + end + end + + def test_resulting_string_is_utf8 + @template = new_template + assert_equal Encoding::UTF_8, render.encoding + end + + def test_no_magic_comment_word_with_utf_8 + @template = new_template("hello \u{fc}mlat") + assert_equal Encoding::UTF_8, render.encoding + assert_equal "hello \u{fc}mlat", render + end + + # This test ensures that if the default_external + # is set to something other than UTF-8, we don't + # get any errors and get back a UTF-8 String. + def test_default_external_works + with_external_encoding "ISO-8859-1" do + @template = new_template("hello \xFCmlat") + assert_equal Encoding::UTF_8, render.encoding + assert_equal "hello \u{fc}mlat", render + end + end + + def test_encoding_can_be_specified_with_magic_comment + @template = new_template("# encoding: ISO-8859-1\nhello \xFCmlat") + assert_equal Encoding::UTF_8, render.encoding + assert_equal "\nhello \u{fc}mlat", render + end + + # TODO: This is currently handled inside ERB. The case of explicitly + # lying about encodings via the normal Rails API should be handled + # inside Rails. + def test_lying_with_magic_comment + assert_raises(ActionView::Template::Error) do + @template = new_template("# encoding: UTF-8\nhello \xFCmlat", virtual_path: nil) + render + end + end + + def test_encoding_can_be_specified_with_magic_comment_in_erb + with_external_encoding Encoding::UTF_8 do + @template = new_template("<%# encoding: ISO-8859-1 %>hello \xFCmlat", virtual_path: nil) + assert_equal Encoding::UTF_8, render.encoding + assert_equal "hello \u{fc}mlat", render + end + end + + def test_error_when_template_isnt_valid_utf8 + e = assert_raises ActionView::Template::Error do + @template = new_template("hello \xFCmlat", virtual_path: nil) + render + end + # Hack: We write the regexp this way because the parser of RuboCop + # errs with /\xFC/. + assert_match(Regexp.new("\xFC"), e.message) + end + + def test_template_is_marshalable + template = new_template + serialized = Marshal.load(Marshal.dump(template)) + assert_equal template.identifier, serialized.identifier + assert_equal template.source, serialized.source + end + + def with_external_encoding(encoding) + old = Encoding.default_external + Encoding::Converter.new old, encoding if old != encoding + silence_warnings { Encoding.default_external = encoding } + yield + ensure + silence_warnings { Encoding.default_external = old } + end +end diff --git a/actionview/test/template/test_case_test.rb b/actionview/test/template/test_case_test.rb new file mode 100644 index 0000000000..976b6bc77e --- /dev/null +++ b/actionview/test/template/test_case_test.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "rails/engine" + +module ActionView + module ATestHelper + end + + module AnotherTestHelper + def from_another_helper + "Howdy!" + end + end + + module ASharedTestHelper + def from_shared_helper + "Holla!" + end + end + + class TestCase + helper ASharedTestHelper + DeveloperStruct = Struct.new(:name) + + module SharedTests + def self.included(test_case) + test_case.class_eval do + test "helpers defined on ActionView::TestCase are available" do + assert_includes test_case.ancestors, ASharedTestHelper + assert_equal "Holla!", from_shared_helper + end + end + end + end + end + + class GeneralViewTest < ActionView::TestCase + include SharedTests + test_case = self + + test "memoizes the view" do + assert_same view, view + end + + test "exposes params" do + assert params.is_a? ActionController::Parameters + end + + test "exposes view as _view for backwards compatibility" do + assert_same _view, view + end + + test "retrieve non existing config values" do + assert_nil ActionView::Base.new.config.something_odd + end + + test "works without testing a helper module" do + assert_equal "Eloy", render("developers/developer", developer: DeveloperStruct.new("Eloy")) + end + + test "can render a layout with block" do + assert_equal "Before (ChrisCruft)\n!\nAfter", + render(layout: "test/layout_for_partial", locals: { name: "ChrisCruft" }) { "!" } + end + + helper AnotherTestHelper + test "additional helper classes can be specified as in a controller" do + assert_includes test_case.ancestors, AnotherTestHelper + assert_equal "Howdy!", from_another_helper + end + + test "determine_default_helper_class returns nil if the test name constant resolves to a class" do + assert_nil self.class.determine_default_helper_class("String") + end + + test "delegates notice to request.flash[:notice]" do + assert_called_with(view.request.flash, :[], [:notice]) do + view.notice + end + end + + test "delegates alert to request.flash[:alert]" do + assert_called_with(view.request.flash, :[], [:alert]) do + view.alert + end + end + + test "uses controller lookup context" do + assert_equal lookup_context, @controller.lookup_context + end + end + + class ClassMethodsTest < ActionView::TestCase + include SharedTests + test_case = self + + tests ATestHelper + test "tests the specified helper module" do + assert_equal ATestHelper, test_case.helper_class + assert_includes test_case.ancestors, ATestHelper + end + + helper AnotherTestHelper + test "additional helper classes can be specified as in a controller" do + assert_includes test_case.ancestors, AnotherTestHelper + assert_equal "Howdy!", from_another_helper + + test_case.helper_class.module_eval do + def render_from_helper + from_another_helper + end + end + assert_equal "Howdy!", render(partial: "test/from_helper") + end + end + + class HelperInclusionTest < ActionView::TestCase + module RenderHelper + def render_from_helper + render partial: "customer", collection: @customers + end + end + + helper RenderHelper + + test "helper class that is being tested is always included in view instance" do + @controller.controller_path = "test" + + @customers = [DeveloperStruct.new("Eloy"), DeveloperStruct.new("Manfred")] + assert_match(/Hello: EloyHello: Manfred/, render(partial: "test/from_helper")) + end + end + + class ControllerHelperMethod < ActionView::TestCase + module SomeHelper + def some_method + render partial: "test/from_helper" + end + end + + helper SomeHelper + + test "can call a helper method defined on the current controller from a helper" do + @controller.singleton_class.class_eval <<-EOF, __FILE__, __LINE__ + 1 + def render_from_helper + 'controller_helper_method' + end + EOF + @controller.class.helper_method :render_from_helper + + assert_equal "controller_helper_method", some_method + end + end + + class ViewAssignsTest < ActionView::TestCase + test "view_assigns returns a Hash of user defined ivars" do + @a = "b" + @c = "d" + assert_equal({ a: "b", c: "d" }, view_assigns) + end + + test "view_assigns excludes internal ivars" do + INTERNAL_IVARS.each do |ivar| + assert defined?(ivar), "expected #{ivar} to be defined" + assert_not_includes view_assigns.keys, ivar.to_s.tr("@", "").to_sym, "expected #{ivar} to be excluded from view_assigns" + end + end + end + + class HelperExposureTest < ActionView::TestCase + helper(Module.new do + def render_from_helper + from_test_case + end + end) + test "is able to make methods available to the view" do + assert_equal "Word!", render(partial: "test/from_helper") + end + + def from_test_case; "Word!"; end + helper_method :from_test_case + end + + class IgnoreProtectAgainstForgeryTest < ActionView::TestCase + module HelperThatInvokesProtectAgainstForgery + def help_me + protect_against_forgery? + end + end + + helper HelperThatInvokesProtectAgainstForgery + + test "protect_from_forgery? in any helpers returns false" do + assert_not view.help_me + end + end + + class ATestHelperTest < ActionView::TestCase + include SharedTests + test_case = self + + test "inflects the name of the helper module to test from the test case class" do + assert_equal ATestHelper, test_case.helper_class + assert_includes test_case.ancestors, ATestHelper + end + + test "a configured test controller is available" do + assert_kind_of ActionController::Base, controller + assert_equal "", controller.controller_path + end + + test "no additional helpers should shared across test cases" do + assert_not_includes test_case.ancestors, AnotherTestHelper + assert_raise(NoMethodError) { send :from_another_helper } + end + + test "is able to use routes" do + controller.request.assign_parameters(@routes, "foo", "index", {}, "/foo", []) + with_routing do |set| + set.draw { + get :foo, to: "foo#index" + get :bar, to: "bar#index" + } + assert_equal "/foo", url_for + assert_equal "/bar", url_for(controller: "bar") + end + end + + test "is able to use named routes" do + with_routing do |set| + set.draw { resources :contents } + assert_equal "http://test.host/contents/new", new_content_url + assert_equal "http://test.host/contents/1", content_url(id: 1) + end + end + + test "is able to use mounted routes" do + with_routing do |set| + app = Class.new(Rails::Engine) do + def self.routes + @routes ||= ActionDispatch::Routing::RouteSet.new + end + + routes.draw { get "bar", to: lambda { } } + + def self.call(*) + end + end + + set.draw { mount app => "/foo", :as => "foo_app" } + + singleton_class.include set.mounted_helpers + + assert_equal "/foo/bar", foo_app.bar_path + end + end + + test "named routes can be used from helper included in view" do + with_routing do |set| + set.draw { resources :contents } + _helpers.module_eval do + def render_from_helper + new_content_url + end + end + + assert_equal "http://test.host/contents/new", render(partial: "test/from_helper") + end + end + + test "is able to render partials with local variables" do + assert_equal "Eloy", render("developers/developer", developer: DeveloperStruct.new("Eloy")) + assert_equal "Eloy", render(partial: "developers/developer", + locals: { developer: DeveloperStruct.new("Eloy") }) + end + + test "is able to render partials from templates and also use instance variables" do + @controller.controller_path = "test" + + @customers = [DeveloperStruct.new("Eloy"), DeveloperStruct.new("Manfred")] + assert_match(/Hello: EloyHello: Manfred/, render(file: "test/list")) + end + + test "is able to render partials from templates and also use instance variables after view has been referenced" do + @controller.controller_path = "test" + + view + + @customers = [DeveloperStruct.new("Eloy"), DeveloperStruct.new("Manfred")] + assert_match(/Hello: EloyHello: Manfred/, render(file: "test/list")) + end + + test "is able to use helpers that depend on the view flow" do + assert_not content_for?(:foo) + + content_for :foo, "bar" + assert content_for?(:foo) + assert_equal "bar", content_for(:foo) + end + end + + class AssertionsTest < ActionView::TestCase + def render_from_helper + form_tag("/foo") do + safe_concat render(plain: "<ul><li>foo</li></ul>") + end + end + helper_method :render_from_helper + + test "uses the output_buffer for assert_select" do + render(partial: "test/from_helper") + + assert_select "form" do + assert_select "li", text: "foo" + end + end + + test "do not memoize the document_root_element in view tests" do + concat form_tag("/foo") + + assert_select "form" + + concat content_tag(:b, "Strong", class: "foo") + + assert_select "form" + assert_select "b.foo" + end + end + + module AHelperWithInitialize + def initialize(*) + super + @called_initialize = true + end + end + + class AHelperWithInitializeTest < ActionView::TestCase + test "the helper's initialize was actually called" do + assert @called_initialize + end + end +end diff --git a/actionview/test/template/test_test.rb b/actionview/test/template/test_test.rb new file mode 100644 index 0000000000..78ba536dfc --- /dev/null +++ b/actionview/test/template/test_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "abstract_unit" + +module PeopleHelper + def title(text) + content_tag(:h1, text) + end + + def homepage_path + people_path + end + + def homepage_url + people_url + end + + def link_to_person(person) + link_to person.name, person + end +end + +class PeopleHelperTest < ActionView::TestCase + def test_title + assert_equal "<h1>Ruby on Rails</h1>", title("Ruby on Rails") + end + + def test_homepage_path + with_test_route_set do + assert_equal "/people", homepage_path + end + end + + def test_homepage_url + with_test_route_set do + assert_equal "http://test.host/people", homepage_url + end + end + + def test_link_to_person + with_test_route_set do + person = Struct.new(:name) { + extend ActiveModel::Naming + def to_model; self; end + def persisted?; true; end + def self.name; "Minitest::Mock"; end + }.new "David" + + the_model = nil + extend Module.new { + define_method(:minitest_mock_path) { |model, *args| + the_model = model + "/people/1" + } + } + assert_equal '<a href="/people/1">David</a>', link_to_person(person) + assert_equal person, the_model + end + end + + private + def with_test_route_set + with_routing do |set| + set.draw do + get "people", to: "people#index", as: :people + end + yield + end + end +end + +class CrazyHelperTest < ActionView::TestCase + tests PeopleHelper + + def test_helper_class_can_be_set_manually_not_just_inferred + assert_equal PeopleHelper, self.class.helper_class + end +end + +class CrazySymbolHelperTest < ActionView::TestCase + tests :people + + def test_set_helper_class_using_symbol + assert_equal PeopleHelper, self.class.helper_class + end +end + +class CrazyStringHelperTest < ActionView::TestCase + tests "people" + + def test_set_helper_class_using_string + assert_equal PeopleHelper, self.class.helper_class + end +end diff --git a/actionview/test/template/testing/fixture_resolver_test.rb b/actionview/test/template/testing/fixture_resolver_test.rb new file mode 100644 index 0000000000..9954e3500d --- /dev/null +++ b/actionview/test/template/testing/fixture_resolver_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class FixtureResolverTest < ActiveSupport::TestCase + def test_should_return_empty_list_for_unknown_path + resolver = ActionView::FixtureResolver.new() + templates = resolver.find_all("path", "arbitrary", false, locale: [], formats: [:html], variants: [], handlers: []) + assert_equal [], templates, "expected an empty list of templates" + end + + def test_should_return_template_for_declared_path + resolver = ActionView::FixtureResolver.new("arbitrary/path.erb" => "this text") + templates = resolver.find_all("path", "arbitrary", false, locale: [], formats: [:html], variants: [], handlers: [:erb]) + assert_equal 1, templates.size, "expected one template" + assert_equal "this text", templates.first.source + assert_equal "arbitrary/path", templates.first.virtual_path + assert_equal [:html], templates.first.formats + end +end diff --git a/actionview/test/template/testing/null_resolver_test.rb b/actionview/test/template/testing/null_resolver_test.rb new file mode 100644 index 0000000000..53364c1d90 --- /dev/null +++ b/actionview/test/template/testing/null_resolver_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class NullResolverTest < ActiveSupport::TestCase + def test_should_return_template_for_any_path + resolver = ActionView::NullResolver.new() + templates = resolver.find_all("path.erb", "arbitrary", false, locale: [], formats: [:html], handlers: []) + assert_equal 1, templates.size, "expected one template" + assert_equal "Template generated by Null Resolver", templates.first.source + assert_equal "arbitrary/path.erb", templates.first.virtual_path.to_s + assert_equal [:html], templates.first.formats + end +end diff --git a/actionview/test/template/text_helper_test.rb b/actionview/test/template/text_helper_test.rb new file mode 100644 index 0000000000..e961a770e6 --- /dev/null +++ b/actionview/test/template/text_helper_test.rb @@ -0,0 +1,539 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class TextHelperTest < ActionView::TestCase + tests ActionView::Helpers::TextHelper + + def setup + super + # This simulates the fact that instance variables are reset every time + # a view is rendered. The cycle helper depends on this behavior. + @_cycles = nil if defined?(@_cycles) + end + + def test_concat + self.output_buffer = +"foo" + assert_equal "foobar", concat("bar") + assert_equal "foobar", output_buffer + end + + def test_simple_format_should_be_html_safe + assert_predicate simple_format("<b> test with html tags </b>"), :html_safe? + end + + def test_simple_format_included_in_isolation + helper_klass = Class.new { include ActionView::Helpers::TextHelper } + assert_predicate helper_klass.new.simple_format("<b> test with html tags </b>"), :html_safe? + end + + def test_simple_format + assert_equal "<p></p>", simple_format(nil) + + assert_equal "<p>crazy\n<br /> cross\n<br /> platform linebreaks</p>", simple_format("crazy\r\n cross\r platform linebreaks") + assert_equal "<p>A paragraph</p>\n\n<p>and another one!</p>", simple_format("A paragraph\n\nand another one!") + assert_equal "<p>A paragraph\n<br /> With a newline</p>", simple_format("A paragraph\n With a newline") + + text = "A\nB\nC\nD" + assert_equal "<p>A\n<br />B\n<br />C\n<br />D</p>", simple_format(text) + + text = "A\r\n \nB\n\n\r\n\t\nC\nD" + assert_equal "<p>A\n<br /> \n<br />B</p>\n\n<p>\t\n<br />C\n<br />D</p>", simple_format(text) + + assert_equal '<p class="test">This is a classy test</p>', simple_format("This is a classy test", class: "test") + assert_equal %Q(<p class="test">para 1</p>\n\n<p class="test">para 2</p>), simple_format("para 1\n\npara 2", class: "test") + end + + def test_simple_format_should_sanitize_input_when_sanitize_option_is_not_false + assert_equal "<p><b> test with unsafe string </b>code!</p>", simple_format("<b> test with unsafe string </b><script>code!</script>") + end + + def test_simple_format_should_sanitize_input_when_sanitize_option_is_true + assert_equal "<p><b> test with unsafe string </b>code!</p>", + simple_format("<b> test with unsafe string </b><script>code!</script>", {}, { sanitize: true }) + end + + def test_simple_format_should_not_sanitize_input_when_sanitize_option_is_false + assert_equal "<p><b> test with unsafe string </b><script>code!</script></p>", simple_format("<b> test with unsafe string </b><script>code!</script>", {}, { sanitize: false }) + end + + def test_simple_format_with_custom_wrapper + assert_equal "<div></div>", simple_format(nil, {}, { wrapper_tag: "div" }) + end + + def test_simple_format_with_custom_wrapper_and_multi_line_breaks + assert_equal "<div>We want to put a wrapper...</div>\n\n<div>...right there.</div>", simple_format("We want to put a wrapper...\n\n...right there.", {}, { wrapper_tag: "div" }) + end + + def test_simple_format_should_not_change_the_text_passed + text = "<b>Ok</b><script>code!</script>" + text_clone = text.dup + simple_format(text) + assert_equal text_clone, text + end + + def test_simple_format_does_not_modify_the_html_options_hash + options = { class: "foobar" } + passed_options = options.dup + simple_format("some text", passed_options) + assert_equal options, passed_options + end + + def test_simple_format_does_not_modify_the_options_hash + options = { wrapper_tag: :div, sanitize: false } + passed_options = options.dup + simple_format("some text", {}, passed_options) + assert_equal options, passed_options + end + + def test_truncate + assert_equal "Hello World!", truncate("Hello World!", length: 12) + assert_equal "Hello Wor...", truncate("Hello World!!", length: 12) + end + + def test_truncate_should_use_default_length_of_30 + str = "This is a string that will go longer then the default truncate length of 30" + assert_equal str[0...27] + "...", truncate(str) + end + + def test_truncate_with_options_hash + assert_equal "This is a string that wil[...]", truncate("This is a string that will go longer then the default truncate length of 30", omission: "[...]") + assert_equal "Hello W...", truncate("Hello World!", length: 10) + assert_equal "Hello[...]", truncate("Hello World!", omission: "[...]", length: 10) + assert_equal "Hello[...]", truncate("Hello Big World!", omission: "[...]", length: 13, separator: " ") + assert_equal "Hello Big[...]", truncate("Hello Big World!", omission: "[...]", length: 14, separator: " ") + assert_equal "Hello Big[...]", truncate("Hello Big World!", omission: "[...]", length: 15, separator: " ") + end + + def test_truncate_multibyte + assert_equal (+"\354\225\204\353\246\254\353\236\221 \354\225\204\353\246\254 ...").force_encoding(Encoding::UTF_8), + truncate((+"\354\225\204\353\246\254\353\236\221 \354\225\204\353\246\254 \354\225\204\353\235\274\353\246\254\354\230\244").force_encoding(Encoding::UTF_8), length: 10) + end + + def test_truncate_does_not_modify_the_options_hash + options = { length: 10 } + passed_options = options.dup + truncate("some text", passed_options) + assert_equal options, passed_options + end + + def test_truncate_with_link_options + assert_equal "Here is a long test and ...<a href=\"#\">Continue</a>", + truncate("Here is a long test and I need a continue to read link", length: 27) { link_to "Continue", "#" } + end + + def test_truncate_should_be_html_safe + assert_predicate truncate("Hello World!", length: 12), :html_safe? + end + + def test_truncate_should_escape_the_input + assert_equal "Hello <sc...", truncate("Hello <script>code!</script>World!!", length: 12) + end + + def test_truncate_should_not_escape_the_input_with_escape_false + assert_equal "Hello <sc...", truncate("Hello <script>code!</script>World!!", length: 12, escape: false) + end + + def test_truncate_with_escape_false_should_be_html_safe + truncated = truncate("Hello <script>code!</script>World!!", length: 12, escape: false) + assert_predicate truncated, :html_safe? + end + + def test_truncate_with_block_should_be_html_safe + truncated = truncate("Here's a long test and I need a continue to read link", length: 27) { link_to "Continue", "#" } + assert_predicate truncated, :html_safe? + end + + def test_truncate_with_block_should_escape_the_input + assert_equal "<script>code!</script>He...<a href=\"#\">Continue</a>", + truncate("<script>code!</script>Here's a long test and I need a continue to read link", length: 27) { link_to "Continue", "#" } + end + + def test_truncate_with_block_should_not_escape_the_input_with_escape_false + assert_equal "<script>code!</script>He...<a href=\"#\">Continue</a>", + truncate("<script>code!</script>Here's a long test and I need a continue to read link", length: 27, escape: false) { link_to "Continue", "#" } + end + + def test_truncate_with_block_with_escape_false_should_be_html_safe + truncated = truncate("<script>code!</script>Here's a long test and I need a continue to read link", length: 27, escape: false) { link_to "Continue", "#" } + assert_predicate truncated, :html_safe? + end + + def test_truncate_with_block_should_escape_the_block + assert_equal "Here is a long test and ...<script>alert('foo');</script>", + truncate("Here is a long test and I need a continue to read link", length: 27) { "<script>alert('foo');</script>" } + end + + def test_highlight_should_be_html_safe + assert_predicate highlight("This is a beautiful morning", "beautiful"), :html_safe? + end + + def test_highlight + assert_equal( + "This is a <mark>beautiful</mark> morning", + highlight("This is a beautiful morning", "beautiful") + ) + + assert_equal( + "This is a <mark>beautiful</mark> morning, but also a <mark>beautiful</mark> day", + highlight("This is a beautiful morning, but also a beautiful day", "beautiful") + ) + + assert_equal( + "This is a <b>beautiful</b> morning, but also a <b>beautiful</b> day", + highlight("This is a beautiful morning, but also a beautiful day", "beautiful", highlighter: '<b>\1</b>') + ) + + assert_equal( + "This text is not changed because we supplied an empty phrase", + highlight("This text is not changed because we supplied an empty phrase", nil) + ) + end + + def test_highlight_pending + assert_equal " ", highlight(" ", "blank text is returned verbatim") + end + + def test_highlight_should_return_blank_string_for_nil + assert_equal "", highlight(nil, "blank string is returned for nil") + end + + def test_highlight_should_sanitize_input + assert_equal( + "This is a <mark>beautiful</mark> morningcode!", + highlight("This is a beautiful morning<script>code!</script>", "beautiful") + ) + end + + def test_highlight_should_not_sanitize_if_sanitize_option_if_false + assert_equal( + "This is a <mark>beautiful</mark> morning<script>code!</script>", + highlight("This is a beautiful morning<script>code!</script>", "beautiful", sanitize: false) + ) + end + + def test_highlight_with_regexp + assert_equal( + "This is a <mark>beautiful!</mark> morning", + highlight("This is a beautiful! morning", "beautiful!") + ) + + assert_equal( + "This is a <mark>beautiful! morning</mark>", + highlight("This is a beautiful! morning", "beautiful! morning") + ) + + assert_equal( + "This is a <mark>beautiful? morning</mark>", + highlight("This is a beautiful? morning", "beautiful? morning") + ) + end + + def test_highlight_accepts_regexp + assert_equal("This day was challenging for judge <mark>Allen</mark> and his colleagues.", + highlight("This day was challenging for judge Allen and his colleagues.", /\ballen\b/i)) + end + + def test_highlight_with_multiple_phrases_in_one_pass + assert_equal %(<em>wow</em> <em>em</em>), highlight("wow em", %w(wow em), highlighter: '<em>\1</em>') + end + + def test_highlight_with_html + assert_equal( + "<p>This is a <mark>beautiful</mark> morning, but also a <mark>beautiful</mark> day</p>", + highlight("<p>This is a beautiful morning, but also a beautiful day</p>", "beautiful") + ) + assert_equal( + "<p>This is a <em><mark>beautiful</mark></em> morning, but also a <mark>beautiful</mark> day</p>", + highlight("<p>This is a <em>beautiful</em> morning, but also a beautiful day</p>", "beautiful") + ) + assert_equal( + "<p>This is a <em class=\"error\"><mark>beautiful</mark></em> morning, but also a <mark>beautiful</mark> <span class=\"last\">day</span></p>", + highlight("<p>This is a <em class=\"error\">beautiful</em> morning, but also a beautiful <span class=\"last\">day</span></p>", "beautiful") + ) + assert_equal( + "<p class=\"beautiful\">This is a <mark>beautiful</mark> morning, but also a <mark>beautiful</mark> day</p>", + highlight("<p class=\"beautiful\">This is a beautiful morning, but also a beautiful day</p>", "beautiful") + ) + assert_equal( + "<p>This is a <mark>beautiful</mark> <a href=\"http://example.com/beautiful#top?what=beautiful%20morning&when=now+then\">morning</a>, but also a <mark>beautiful</mark> day</p>", + highlight("<p>This is a beautiful <a href=\"http://example.com/beautiful\#top?what=beautiful%20morning&when=now+then\">morning</a>, but also a beautiful day</p>", "beautiful") + ) + assert_equal( + "<div>abc <b>div</b></div>", + highlight("<div>abc div</div>", "div", highlighter: '<b>\1</b>') + ) + end + + def test_highlight_does_not_modify_the_options_hash + options = { highlighter: '<b>\1</b>', sanitize: false } + passed_options = options.dup + highlight("<div>abc div</div>", "div", passed_options) + assert_equal options, passed_options + end + + def test_highlight_with_block + assert_equal( + "<b>one</b> <b>two</b> <b>three</b>", + highlight("one two three", ["one", "two", "three"]) { |word| "<b>#{word}</b>" } + ) + end + + def test_excerpt + assert_equal("...is a beautiful morn...", excerpt("This is a beautiful morning", "beautiful", radius: 5)) + assert_equal("This is a...", excerpt("This is a beautiful morning", "this", radius: 5)) + assert_equal("...iful morning", excerpt("This is a beautiful morning", "morning", radius: 5)) + assert_nil excerpt("This is a beautiful morning", "day") + end + + def test_excerpt_with_regex + assert_equal("...is a beautiful! mor...", excerpt("This is a beautiful! morning", "beautiful", radius: 5)) + assert_equal("...is a beautiful? mor...", excerpt("This is a beautiful? morning", "beautiful", radius: 5)) + assert_equal("...is a beautiful? mor...", excerpt("This is a beautiful? morning", /\bbeau\w*\b/i, radius: 5)) + assert_equal("...is a beautiful? mor...", excerpt("This is a beautiful? morning", /\b(beau\w*)\b/i, radius: 5)) + assert_equal("...udge Allen and...", excerpt("This day was challenging for judge Allen and his colleagues.", /\ballen\b/i, radius: 5)) + assert_equal("...judge Allen and...", excerpt("This day was challenging for judge Allen and his colleagues.", /\ballen\b/i, radius: 1, separator: " ")) + assert_equal("...was challenging for...", excerpt("This day was challenging for judge Allen and his colleagues.", /\b(\w*allen\w*)\b/i, radius: 5)) + end + + def test_excerpt_should_not_be_html_safe + assert_not_predicate excerpt("This is a beautiful! morning", "beautiful", radius: 5), :html_safe? + end + + def test_excerpt_in_borderline_cases + assert_equal("", excerpt("", "", radius: 0)) + assert_equal("a", excerpt("a", "a", radius: 0)) + assert_equal("...b...", excerpt("abc", "b", radius: 0)) + assert_equal("abc", excerpt("abc", "b", radius: 1)) + assert_equal("abc...", excerpt("abcd", "b", radius: 1)) + assert_equal("...abc", excerpt("zabc", "b", radius: 1)) + assert_equal("...abc...", excerpt("zabcd", "b", radius: 1)) + assert_equal("zabcd", excerpt("zabcd", "b", radius: 2)) + + # excerpt strips the resulting string before ap-/prepending excerpt_string. + # whether this behavior is meaningful when excerpt_string is not to be + # appended is questionable. + assert_equal("zabcd", excerpt(" zabcd ", "b", radius: 4)) + assert_equal("...abc...", excerpt("z abc d", "b", radius: 1)) + end + + def test_excerpt_with_omission + assert_equal("[...]is a beautiful morn[...]", excerpt("This is a beautiful morning", "beautiful", omission: "[...]", radius: 5)) + assert_equal( + "This is the ultimate supercalifragilisticexpialidoceous very looooooooooooooooooong looooooooooooong beautiful morning with amazing sunshine and awesome tempera[...]", + excerpt("This is the ultimate supercalifragilisticexpialidoceous very looooooooooooooooooong looooooooooooong beautiful morning with amazing sunshine and awesome temperatures. So what are you gonna do about it?", "very", + omission: "[...]") + ) + end + + def test_excerpt_with_utf8 + assert_equal((+"...\357\254\203ciency could not be...").force_encoding(Encoding::UTF_8), excerpt((+"That's why e\357\254\203ciency could not be helped").force_encoding(Encoding::UTF_8), "could", radius: 8)) + end + + def test_excerpt_does_not_modify_the_options_hash + options = { omission: "[...]", radius: 5 } + passed_options = options.dup + excerpt("This is a beautiful morning", "beautiful", passed_options) + assert_equal options, passed_options + end + + def test_excerpt_with_separator + options = { separator: " ", radius: 1 } + assert_equal("...a very beautiful...", excerpt("This is a very beautiful morning", "very", options)) + assert_equal("This is...", excerpt("This is a very beautiful morning", "this", options)) + assert_equal("...beautiful morning", excerpt("This is a very beautiful morning", "morning", options)) + + options = { separator: "\n", radius: 0 } + assert_equal("...very long...", excerpt("my very\nvery\nvery long\nstring", "long", options)) + + options = { separator: "\n", radius: 1 } + assert_equal("...very\nvery long\nstring", excerpt("my very\nvery\nvery long\nstring", "long", options)) + + assert_equal excerpt("This is a beautiful morning", "a"), + excerpt("This is a beautiful morning", "a", separator: nil) + end + + def test_word_wrap + assert_equal("my very very\nvery long\nstring", word_wrap("my very very very long string", line_width: 15)) + end + + def test_word_wrap_with_extra_newlines + assert_equal("my very very\nvery long\nstring\n\nwith another\nline", word_wrap("my very very very long string\n\nwith another line", line_width: 15)) + end + + def test_word_wrap_with_leading_spaces + assert_equal(" This is a paragraph\nthat includes some\nindented lines:\n Like this sample\n blockquote", word_wrap(" This is a paragraph that includes some\nindented lines:\n Like this sample\n blockquote", line_width: 25)) + end + + def test_word_wrap_does_not_modify_the_options_hash + options = { line_width: 15 } + passed_options = options.dup + word_wrap("some text", passed_options) + assert_equal options, passed_options + end + + def test_word_wrap_with_custom_break_sequence + assert_equal("1234567890\r\n1234567890\r\n1234567890", word_wrap("1234567890 " * 3, line_width: 2, break_sequence: "\r\n")) + end + + def test_pluralization + assert_equal("1 count", pluralize(1, "count")) + assert_equal("2 counts", pluralize(2, "count")) + assert_equal("1 count", pluralize("1", "count")) + assert_equal("2 counts", pluralize("2", "count")) + assert_equal("1,066 counts", pluralize("1,066", "count")) + assert_equal("1.25 counts", pluralize("1.25", "count")) + assert_equal("1.0 count", pluralize("1.0", "count")) + assert_equal("1.00 count", pluralize("1.00", "count")) + assert_equal("2 counters", pluralize(2, "count", "counters")) + assert_equal("0 counters", pluralize(nil, "count", "counters")) + assert_equal("2 counters", pluralize(2, "count", plural: "counters")) + assert_equal("0 counters", pluralize(nil, "count", plural: "counters")) + assert_equal("2 people", pluralize(2, "person")) + assert_equal("10 buffaloes", pluralize(10, "buffalo")) + assert_equal("1 berry", pluralize(1, "berry")) + assert_equal("12 berries", pluralize(12, "berry")) + end + + def test_localized_pluralization + old_locale = I18n.locale + + begin + I18n.locale = :de + + ActiveSupport::Inflector.inflections(:de) do |inflect| + inflect.irregular "region", "regionen" + end + + assert_equal("1 region", pluralize(1, "region")) + assert_equal("2 regionen", pluralize(2, "region")) + assert_equal("2 regions", pluralize(2, "region", locale: :en)) + ensure + I18n.locale = old_locale + end + end + + def test_cycle_class + value = Cycle.new("one", 2, "3") + assert_equal("one", value.to_s) + assert_equal("2", value.to_s) + assert_equal("3", value.to_s) + assert_equal("one", value.to_s) + value.reset + assert_equal("one", value.to_s) + assert_equal("2", value.to_s) + assert_equal("3", value.to_s) + end + + def test_cycle_class_with_no_arguments + assert_raise(ArgumentError) { Cycle.new } + end + + def test_cycle + assert_equal("one", cycle("one", 2, "3")) + assert_equal("2", cycle("one", 2, "3")) + assert_equal("3", cycle("one", 2, "3")) + assert_equal("one", cycle("one", 2, "3")) + assert_equal("2", cycle("one", 2, "3")) + assert_equal("3", cycle("one", 2, "3")) + end + + def test_cycle_with_array + array = [1, 2, 3] + assert_equal("1", cycle(array)) + assert_equal("2", cycle(array)) + assert_equal("3", cycle(array)) + end + + def test_cycle_with_no_arguments + assert_raise(ArgumentError) { cycle } + end + + def test_cycle_resets_with_new_values + assert_equal("even", cycle("even", "odd")) + assert_equal("odd", cycle("even", "odd")) + assert_equal("even", cycle("even", "odd")) + assert_equal("1", cycle(1, 2, 3)) + assert_equal("2", cycle(1, 2, 3)) + assert_equal("3", cycle(1, 2, 3)) + assert_equal("1", cycle(1, 2, 3)) + end + + def test_named_cycles + assert_equal("1", cycle(1, 2, 3, name: "numbers")) + assert_equal("red", cycle("red", "blue", name: "colors")) + assert_equal("2", cycle(1, 2, 3, name: "numbers")) + assert_equal("blue", cycle("red", "blue", name: "colors")) + assert_equal("3", cycle(1, 2, 3, name: "numbers")) + assert_equal("red", cycle("red", "blue", name: "colors")) + end + + def test_current_cycle_with_default_name + cycle("even", "odd") + assert_equal "even", current_cycle + cycle("even", "odd") + assert_equal "odd", current_cycle + cycle("even", "odd") + assert_equal "even", current_cycle + end + + def test_current_cycle_with_named_cycles + cycle("red", "blue", name: "colors") + assert_equal "red", current_cycle("colors") + cycle("red", "blue", name: "colors") + assert_equal "blue", current_cycle("colors") + cycle("red", "blue", name: "colors") + assert_equal "red", current_cycle("colors") + end + + def test_current_cycle_safe_call + assert_nothing_raised { current_cycle } + assert_nothing_raised { current_cycle("colors") } + end + + def test_current_cycle_with_more_than_two_names + cycle(1, 2, 3) + assert_equal "1", current_cycle + cycle(1, 2, 3) + assert_equal "2", current_cycle + cycle(1, 2, 3) + assert_equal "3", current_cycle + cycle(1, 2, 3) + assert_equal "1", current_cycle + end + + def test_default_named_cycle + assert_equal("1", cycle(1, 2, 3)) + assert_equal("2", cycle(1, 2, 3, name: "default")) + assert_equal("3", cycle(1, 2, 3)) + end + + def test_reset_cycle + assert_equal("1", cycle(1, 2, 3)) + assert_equal("2", cycle(1, 2, 3)) + reset_cycle + assert_equal("1", cycle(1, 2, 3)) + end + + def test_reset_unknown_cycle + reset_cycle("colors") + end + + def test_reset_named_cycle + assert_equal("1", cycle(1, 2, 3, name: "numbers")) + assert_equal("red", cycle("red", "blue", name: "colors")) + reset_cycle("numbers") + assert_equal("1", cycle(1, 2, 3, name: "numbers")) + assert_equal("blue", cycle("red", "blue", name: "colors")) + assert_equal("2", cycle(1, 2, 3, name: "numbers")) + assert_equal("red", cycle("red", "blue", name: "colors")) + end + + def test_cycle_no_instance_variable_clashes + @cycles = %w{Specialized Fuji Giant} + assert_equal("red", cycle("red", "blue")) + assert_equal("blue", cycle("red", "blue")) + assert_equal("red", cycle("red", "blue")) + assert_equal(%w{Specialized Fuji Giant}, @cycles) + end +end diff --git a/actionview/test/template/text_test.rb b/actionview/test/template/text_test.rb new file mode 100644 index 0000000000..0c6470df21 --- /dev/null +++ b/actionview/test/template/text_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class TextTest < ActiveSupport::TestCase + test "formats always return :text" do + assert_equal [:text], ActionView::Template::Text.new("").formats + end + + test "identifier should return 'text template'" do + assert_equal "text template", ActionView::Template::Text.new("").identifier + end + + test "inspect should return 'text template'" do + assert_equal "text template", ActionView::Template::Text.new("").inspect + end + + test "to_str should return a given string" do + assert_equal "a cat", ActionView::Template::Text.new("a cat").to_str + end + + test "render should return a given string" do + assert_equal "a dog", ActionView::Template::Text.new("a dog").render + end +end diff --git a/actionview/test/template/translation_helper_test.rb b/actionview/test/template/translation_helper_test.rb new file mode 100644 index 0000000000..e756348938 --- /dev/null +++ b/actionview/test/template/translation_helper_test.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +require "abstract_unit" + +module I18n + class CustomExceptionHandler + def self.call(exception, locale, key, options) + "from CustomExceptionHandler" + end + end +end + +class TranslationHelperTest < ActiveSupport::TestCase + include ActionView::Helpers::TranslationHelper + + attr_reader :request, :view + + setup do + I18n.backend.store_translations(:en, + translations: { + templates: { + found: { foo: "Foo" }, + array: { foo: { bar: "Foo Bar" } }, + default: { foo: "Foo" } + }, + foo: "Foo", + hello: "<a>Hello World</a>", + html: "<a>Hello World</a>", + hello_html: "<a>Hello World</a>", + interpolated_html: "<a>Hello %{word}</a>", + array_html: %w(foo bar), + array: %w(foo bar), + count_html: { + one: "<a>One %{count}</a>", + other: "<a>Other %{count}</a>" + } + } + ) + @view = ::ActionView::Base.new(ActionController::Base.view_paths, {}) + end + + teardown do + I18n.backend.reload! + end + + def test_delegates_setting_to_i18n + assert_called_with(I18n, :translate, [:foo, locale: "en", raise: true], returns: "") do + translate :foo, locale: "en" + end + end + + def test_delegates_localize_to_i18n + @time = Time.utc(2008, 7, 8, 12, 18, 38) + assert_called_with(I18n, :localize, [@time]) do + localize @time + end + end + + def test_returns_missing_translation_message_without_span_wrap + old_value = ActionView::Base.debug_missing_translation + ActionView::Base.debug_missing_translation = false + + expected = "translation missing: en.translations.missing" + assert_equal expected, translate(:"translations.missing") + ensure + ActionView::Base.debug_missing_translation = old_value + end + + def test_returns_missing_translation_message_wrapped_into_span + expected = '<span class="translation_missing" title="translation missing: en.translations.missing">Missing</span>' + assert_equal expected, translate(:"translations.missing") + assert_equal true, translate(:"translations.missing").html_safe? + end + + def test_returns_missing_translation_message_with_unescaped_interpolation + expected = '<span class="translation_missing" title="translation missing: en.translations.missing, name: Kir, year: 2015, vulnerable: &quot; onclick=&quot;alert()&quot;">Missing</span>' + assert_equal expected, translate(:"translations.missing", name: "Kir", year: "2015", vulnerable: %{" onclick="alert()"}) + assert_predicate translate(:"translations.missing"), :html_safe? + end + + def test_returns_missing_translation_message_does_filters_out_i18n_options + expected = '<span class="translation_missing" title="translation missing: en.translations.missing, year: 2015">Missing</span>' + assert_equal expected, translate(:"translations.missing", year: "2015", default: []) + + expected = '<span class="translation_missing" title="translation missing: en.scoped.translations.missing, year: 2015">Missing</span>' + assert_equal expected, translate(:"translations.missing", year: "2015", scope: %i(scoped)) + end + + def test_raises_missing_translation_message_with_raise_config_option + ActionView::Base.raise_on_missing_translations = true + + assert_raise(I18n::MissingTranslationData) do + translate("translations.missing") + end + ensure + ActionView::Base.raise_on_missing_translations = false + end + + def test_raises_missing_translation_message_with_raise_option + assert_raise(I18n::MissingTranslationData) do + translate(:"translations.missing", raise: true) + end + end + + def test_uses_custom_exception_handler_when_specified + old_exception_handler = I18n.exception_handler + I18n.exception_handler = I18n::CustomExceptionHandler + assert_equal "from CustomExceptionHandler", translate(:"translations.missing", raise: false) + ensure + I18n.exception_handler = old_exception_handler + end + + def test_uses_custom_exception_handler_when_specified_for_html + old_exception_handler = I18n.exception_handler + I18n.exception_handler = I18n::CustomExceptionHandler + assert_equal "from CustomExceptionHandler", translate(:"translations.missing_html", raise: false) + ensure + I18n.exception_handler = old_exception_handler + end + + def test_translation_returning_an_array + expected = %w(foo bar) + assert_equal expected, translate(:"translations.array") + end + + def test_finds_translation_scoped_by_partial + assert_equal "Foo", view.render(file: "translations/templates/found").strip + end + + def test_finds_array_of_translations_scoped_by_partial + assert_equal "Foo Bar", @view.render(file: "translations/templates/array").strip + end + + def test_default_lookup_scoped_by_partial + assert_equal "Foo", view.render(file: "translations/templates/default").strip + end + + def test_missing_translation_scoped_by_partial + expected = '<span class="translation_missing" title="translation missing: en.translations.templates.missing.missing">Missing</span>' + assert_equal expected, view.render(file: "translations/templates/missing").strip + end + + def test_translate_does_not_mark_plain_text_as_safe_html + assert_equal false, translate(:'translations.hello').html_safe? + end + + def test_translate_marks_translations_named_html_as_safe_html + assert_predicate translate(:'translations.html'), :html_safe? + end + + def test_translate_marks_translations_with_a_html_suffix_as_safe_html + assert_predicate translate(:'translations.hello_html'), :html_safe? + end + + def test_translate_escapes_interpolations_in_translations_with_a_html_suffix + word_struct = Struct.new(:to_s) + assert_equal "<a>Hello <World></a>", translate(:'translations.interpolated_html', word: "<World>") + assert_equal "<a>Hello <World></a>", translate(:'translations.interpolated_html', word: word_struct.new("<World>")) + end + + def test_translate_with_html_count + assert_equal "<a>One 1</a>", translate(:'translations.count_html', count: 1) + assert_equal "<a>Other 2</a>", translate(:'translations.count_html', count: 2) + assert_equal "<a>Other <One></a>", translate(:'translations.count_html', count: "<One>") + end + + def test_translate_marks_array_of_translations_with_a_html_safe_suffix_as_safe_html + translate(:'translations.array_html').tap do |translated| + assert_equal %w( foo bar ), translated + assert translated.all?(&:html_safe?) + end + end + + def test_translate_with_default_named_html + translation = translate(:'translations.missing', default: :'translations.hello_html') + assert_equal "<a>Hello World</a>", translation + assert_equal true, translation.html_safe? + end + + def test_translate_with_missing_default + translation = translate(:'translations.missing', default: :'translations.missing_html') + expected = '<span class="translation_missing" title="translation missing: en.translations.missing_html">Missing Html</span>' + assert_equal expected, translation + assert_equal true, translation.html_safe? + end + + def test_translate_with_missing_default_and_raise_option + assert_raise(I18n::MissingTranslationData) do + translate(:'translations.missing', default: :'translations.missing_html', raise: true) + end + end + + def test_translate_with_two_defaults_named_html + translation = translate(:'translations.missing', default: [:'translations.missing_html', :'translations.hello_html']) + assert_equal "<a>Hello World</a>", translation + assert_equal true, translation.html_safe? + end + + def test_translate_with_last_default_named_html + translation = translate(:'translations.missing', default: [:'translations.missing', :'translations.hello_html']) + assert_equal "<a>Hello World</a>", translation + assert_equal true, translation.html_safe? + end + + def test_translate_with_last_default_not_named_html + translation = translate(:'translations.missing', default: [:'translations.missing_html', :'translations.foo']) + assert_equal "Foo", translation + assert_equal false, translation.html_safe? + end + + def test_translate_with_string_default + translation = translate(:'translations.missing', default: "A Generic String") + assert_equal "A Generic String", translation + end + + def test_translate_with_object_default + translation = translate(:'translations.missing', default: 123) + assert_equal 123, translation + end + + def test_translate_with_array_of_string_defaults + translation = translate(:'translations.missing', default: ["A Generic String", "Second generic string"]) + assert_equal "A Generic String", translation + end + + def test_translate_with_array_of_defaults_with_nil + translation = translate(:'translations.missing', default: [:'also_missing', nil, "A Generic String"]) + assert_equal "A Generic String", translation + end + + def test_translate_with_array_of_array_default + translation = translate(:'translations.missing', default: [[]]) + assert_equal [], translation + end + + def test_translate_does_not_change_options + options = {} + translate(:'translations.missing', options) + assert_equal({}, options) + end +end diff --git a/actionview/test/template/url_helper_test.rb b/actionview/test/template/url_helper_test.rb new file mode 100644 index 0000000000..1ab28e4749 --- /dev/null +++ b/actionview/test/template/url_helper_test.rb @@ -0,0 +1,1007 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class UrlHelperTest < ActiveSupport::TestCase + # In a few cases, the helper proxies to 'controller' + # or request. + # + # In those cases, we'll set up a simple mock + attr_accessor :controller, :request + + cattr_accessor :request_forgery, default: false + + routes = ActionDispatch::Routing::RouteSet.new + routes.draw do + get "/" => "foo#bar" + get "/other" => "foo#other" + get "/article/:id" => "foo#article", :as => :article + get "/category/:category" => "foo#category" + + scope :engine do + get "/" => "foo#bar" + end + end + + include ActionView::Helpers::UrlHelper + include routes.url_helpers + + include ActionView::Helpers::JavaScriptHelper + include Rails::Dom::Testing::Assertions::DomAssertions + include ActionView::Context + include RenderERBUtils + + setup :_prepare_context + + def hash_for(options = {}) + { controller: "foo", action: "bar" }.merge!(options) + end + alias url_hash hash_for + + def test_url_for_does_not_escape_urls + assert_equal "/?a=b&c=d", url_for(hash_for(a: :b, c: :d)) + end + + def test_url_for_does_not_include_empty_hashes + assert_equal "/", url_for(hash_for(a: {})) + end + + def test_url_for_with_back + referer = "http://www.example.com/referer" + @controller = Struct.new(:request).new(Struct.new(:env).new("HTTP_REFERER" => referer)) + + assert_equal "http://www.example.com/referer", url_for(:back) + end + + def test_url_for_with_back_and_no_referer + @controller = Struct.new(:request).new(Struct.new(:env).new({})) + assert_equal "javascript:history.back()", url_for(:back) + end + + def test_url_for_with_back_and_no_controller + @controller = nil + assert_equal "javascript:history.back()", url_for(:back) + end + + def test_url_for_with_back_and_javascript_referer + referer = "javascript:alert(document.cookie)" + @controller = Struct.new(:request).new(Struct.new(:env).new("HTTP_REFERER" => referer)) + assert_equal "javascript:history.back()", url_for(:back) + end + + def test_url_for_with_invalid_referer + referer = "THIS IS NOT A URL" + @controller = Struct.new(:request).new(Struct.new(:env).new("HTTP_REFERER" => referer)) + assert_equal "javascript:history.back()", url_for(:back) + end + + def test_url_for_with_array_defaults_to_only_path_true + assert_equal "/other", url_for([:other, { controller: "foo" }]) + end + + def test_url_for_with_array_and_only_path_set_to_false + default_url_options[:host] = "http://example.com" + assert_equal "http://example.com/other", url_for([:other, { controller: "foo", only_path: false }]) + end + + def test_to_form_params_with_hash + assert_equal( + [{ name: "name", value: "David" }, { name: "nationality", value: "Danish" }], + to_form_params(name: "David", nationality: "Danish") + ) + end + + def test_to_form_params_with_hash_having_symbol_and_string_keys + assert_equal( + [{ name: "name", value: "David" }, { name: "nationality", value: "Danish" }], + to_form_params("name" => "David", :nationality => "Danish") + ) + end + + def test_to_form_params_with_nested_hash + assert_equal( + [{ name: "country[name]", value: "Denmark" }], + to_form_params(country: { name: "Denmark" }) + ) + end + + def test_to_form_params_with_array_nested_in_hash + assert_equal( + [{ name: "countries[]", value: "Denmark" }, { name: "countries[]", value: "Sweden" }], + to_form_params(countries: ["Denmark", "Sweden"]) + ) + end + + def test_to_form_params_with_namespace + assert_equal( + [{ name: "country[name]", value: "Denmark" }], + to_form_params({ name: "Denmark" }, "country") + ) + end + + def test_button_to_with_straight_url + assert_dom_equal %{<form method="post" action="http://www.example.com" class="button_to"><input type="submit" value="Hello" /></form>}, button_to("Hello", "http://www.example.com") + end + + def test_button_to_with_path + assert_dom_equal( + %{<form method="post" action="/article/Hello" class="button_to"><input type="submit" value="Hello" /></form>}, + button_to("Hello", article_path("Hello")) + ) + end + + def test_button_to_with_straight_url_and_request_forgery + self.request_forgery = true + + assert_dom_equal( + %{<form method="post" action="http://www.example.com" class="button_to"><input type="submit" value="Hello" /><input name="form_token" type="hidden" value="secret" /></form>}, + button_to("Hello", "http://www.example.com") + ) + ensure + self.request_forgery = false + end + + def test_button_to_with_form_class + assert_dom_equal %{<form method="post" action="http://www.example.com" class="custom-class"><input type="submit" value="Hello" /></form>}, button_to("Hello", "http://www.example.com", form_class: "custom-class") + end + + def test_button_to_with_form_class_escapes + assert_dom_equal %{<form method="post" action="http://www.example.com" class="<script>evil_js</script>"><input type="submit" value="Hello" /></form>}, button_to("Hello", "http://www.example.com", form_class: "<script>evil_js</script>") + end + + def test_button_to_with_query + assert_dom_equal %{<form method="post" action="http://www.example.com/q1=v1&q2=v2" class="button_to"><input type="submit" value="Hello" /></form>}, button_to("Hello", "http://www.example.com/q1=v1&q2=v2") + end + + def test_button_to_with_html_safe_URL + assert_dom_equal %{<form method="post" action="http://www.example.com/q1=v1&q2=v2" class="button_to"><input type="submit" value="Hello" /></form>}, button_to("Hello", raw("http://www.example.com/q1=v1&q2=v2")) + end + + def test_button_to_with_query_and_no_name + assert_dom_equal %{<form method="post" action="http://www.example.com?q1=v1&q2=v2" class="button_to"><input type="submit" value="http://www.example.com?q1=v1&q2=v2" /></form>}, button_to(nil, "http://www.example.com?q1=v1&q2=v2") + end + + def test_button_to_with_javascript_confirm + assert_dom_equal( + %{<form method="post" action="http://www.example.com" class="button_to"><input data-confirm="Are you sure?" type="submit" value="Hello" /></form>}, + button_to("Hello", "http://www.example.com", data: { confirm: "Are you sure?" }) + ) + end + + def test_button_to_with_javascript_disable_with + assert_dom_equal( + %{<form method="post" action="http://www.example.com" class="button_to"><input data-disable-with="Greeting..." type="submit" value="Hello" /></form>}, + button_to("Hello", "http://www.example.com", data: { disable_with: "Greeting..." }) + ) + end + + def test_button_to_with_remote_and_form_options + assert_dom_equal( + %{<form method="post" action="http://www.example.com" class="custom-class" data-remote="true" data-type="json"><input type="submit" value="Hello" /></form>}, + button_to("Hello", "http://www.example.com", remote: true, form: { class: "custom-class", "data-type" => "json" }) + ) + end + + def test_button_to_with_remote_and_javascript_confirm + assert_dom_equal( + %{<form method="post" action="http://www.example.com" class="button_to" data-remote="true"><input data-confirm="Are you sure?" type="submit" value="Hello" /></form>}, + button_to("Hello", "http://www.example.com", remote: true, data: { confirm: "Are you sure?" }) + ) + end + + def test_button_to_with_remote_and_javascript_disable_with + assert_dom_equal( + %{<form method="post" action="http://www.example.com" class="button_to" data-remote="true"><input data-disable-with="Greeting..." type="submit" value="Hello" /></form>}, + button_to("Hello", "http://www.example.com", remote: true, data: { disable_with: "Greeting..." }) + ) + end + + def test_button_to_with_remote_false + assert_dom_equal( + %{<form method="post" action="http://www.example.com" class="button_to"><input type="submit" value="Hello" /></form>}, + button_to("Hello", "http://www.example.com", remote: false) + ) + end + + def test_button_to_enabled_disabled + assert_dom_equal( + %{<form method="post" action="http://www.example.com" class="button_to"><input type="submit" value="Hello" /></form>}, + button_to("Hello", "http://www.example.com", disabled: false) + ) + assert_dom_equal( + %{<form method="post" action="http://www.example.com" class="button_to"><input disabled="disabled" type="submit" value="Hello" /></form>}, + button_to("Hello", "http://www.example.com", disabled: true) + ) + end + + def test_button_to_with_method_delete + assert_dom_equal( + %{<form method="post" action="http://www.example.com" class="button_to"><input type="hidden" name="_method" value="delete" /><input type="submit" value="Hello" /></form>}, + button_to("Hello", "http://www.example.com", method: :delete) + ) + end + + def test_button_to_with_method_get + assert_dom_equal( + %{<form method="get" action="http://www.example.com" class="button_to"><input type="submit" value="Hello" /></form>}, + button_to("Hello", "http://www.example.com", method: :get) + ) + end + + def test_button_to_with_block + assert_dom_equal( + %{<form method="post" action="http://www.example.com" class="button_to"><button type="submit"><span>Hello</span></button></form>}, + button_to("http://www.example.com") { content_tag(:span, "Hello") } + ) + end + + def test_button_to_with_params + assert_dom_equal( + %{<form action="http://www.example.com" class="button_to" method="post"><input type="submit" value="Hello" /><input type="hidden" name="baz" value="quux" /><input type="hidden" name="foo" value="bar" /></form>}, + button_to("Hello", "http://www.example.com", params: { foo: :bar, baz: "quux" }) + ) + end + + class FakeParams + def initialize(permitted = true) + @permitted = permitted + end + + def permitted? + @permitted + end + + def to_h + if permitted? + { foo: :bar, baz: "quux" } + else + raise ArgumentError + end + end + end + + def test_button_to_with_permitted_strong_params + assert_dom_equal( + %{<form action="http://www.example.com" class="button_to" method="post"><input type="submit" value="Hello" /><input type="hidden" name="baz" value="quux" /><input type="hidden" name="foo" value="bar" /></form>}, + button_to("Hello", "http://www.example.com", params: FakeParams.new) + ) + end + + def test_button_to_with_unpermitted_strong_params + assert_raises(ArgumentError) do + button_to("Hello", "http://www.example.com", params: FakeParams.new(false)) + end + end + + def test_button_to_with_nested_hash_params + assert_dom_equal( + %{<form action="http://www.example.com" class="button_to" method="post"><input type="submit" value="Hello" /><input type="hidden" name="foo[bar]" value="baz" /></form>}, + button_to("Hello", "http://www.example.com", params: { foo: { bar: "baz" } }) + ) + end + + def test_button_to_with_nested_array_params + assert_dom_equal( + %{<form action="http://www.example.com" class="button_to" method="post"><input type="submit" value="Hello" /><input type="hidden" name="foo[]" value="bar" /></form>}, + button_to("Hello", "http://www.example.com", params: { foo: ["bar"] }) + ) + end + + def test_link_tag_with_straight_url + assert_dom_equal %{<a href="http://www.example.com">Hello</a>}, link_to("Hello", "http://www.example.com") + end + + def test_link_tag_without_host_option + assert_dom_equal(%{<a href="/">Test Link</a>}, link_to("Test Link", url_hash)) + end + + def test_link_tag_with_host_option + hash = hash_for(host: "www.example.com") + expected = %{<a href="http://www.example.com/">Test Link</a>} + assert_dom_equal(expected, link_to("Test Link", hash)) + end + + def test_link_tag_with_query + expected = %{<a href="http://www.example.com?q1=v1&q2=v2">Hello</a>} + assert_dom_equal expected, link_to("Hello", "http://www.example.com?q1=v1&q2=v2") + end + + def test_link_tag_with_query_and_no_name + expected = %{<a href="http://www.example.com?q1=v1&q2=v2">http://www.example.com?q1=v1&q2=v2</a>} + assert_dom_equal expected, link_to(nil, "http://www.example.com?q1=v1&q2=v2") + end + + def test_link_tag_with_back + env = { "HTTP_REFERER" => "http://www.example.com/referer" } + @controller = Struct.new(:request).new(Struct.new(:env).new(env)) + expected = %{<a href="#{env["HTTP_REFERER"]}">go back</a>} + assert_dom_equal expected, link_to("go back", :back) + end + + def test_link_tag_with_back_and_no_referer + @controller = Struct.new(:request).new(Struct.new(:env).new({})) + link = link_to("go back", :back) + assert_dom_equal %{<a href="javascript:history.back()">go back</a>}, link + end + + def test_link_tag_with_img + link = link_to(raw("<img src='/favicon.jpg' />"), "/") + expected = %{<a href="/"><img src='/favicon.jpg' /></a>} + assert_dom_equal expected, link + end + + def test_link_with_nil_html_options + link = link_to("Hello", url_hash, nil) + assert_dom_equal %{<a href="/">Hello</a>}, link + end + + def test_link_tag_with_custom_onclick + link = link_to("Hello", "http://www.example.com", onclick: "alert('yay!')") + expected = %{<a href="http://www.example.com" onclick="alert('yay!')">Hello</a>} + assert_dom_equal expected, link + end + + def test_link_tag_with_javascript_confirm + assert_dom_equal( + %{<a href="http://www.example.com" data-confirm="Are you sure?">Hello</a>}, + link_to("Hello", "http://www.example.com", data: { confirm: "Are you sure?" }) + ) + assert_dom_equal( + %{<a href="http://www.example.com" data-confirm="You cant possibly be sure, can you?">Hello</a>}, + link_to("Hello", "http://www.example.com", data: { confirm: "You cant possibly be sure, can you?" }) + ) + assert_dom_equal( + %{<a href="http://www.example.com" data-confirm="You cant possibly be sure,\n can you?">Hello</a>}, + link_to("Hello", "http://www.example.com", data: { confirm: "You cant possibly be sure,\n can you?" }) + ) + end + + def test_link_to_with_remote + assert_dom_equal( + %{<a href="http://www.example.com" data-remote="true">Hello</a>}, + link_to("Hello", "http://www.example.com", remote: true) + ) + end + + def test_link_to_with_remote_false + assert_dom_equal( + %{<a href="http://www.example.com">Hello</a>}, + link_to("Hello", "http://www.example.com", remote: false) + ) + end + + def test_link_to_with_symbolic_remote_in_non_html_options + assert_dom_equal( + %{<a href="/" data-remote="true">Hello</a>}, + link_to("Hello", hash_for(remote: true), {}) + ) + end + + def test_link_to_with_string_remote_in_non_html_options + assert_dom_equal( + %{<a href="/" data-remote="true">Hello</a>}, + link_to("Hello", hash_for("remote" => true), {}) + ) + end + + def test_link_tag_using_post_javascript + assert_dom_equal( + %{<a href="http://www.example.com" data-method="post" rel="nofollow">Hello</a>}, + link_to("Hello", "http://www.example.com", method: :post) + ) + end + + def test_link_tag_using_delete_javascript + assert_dom_equal( + %{<a href="http://www.example.com" rel="nofollow" data-method="delete">Destroy</a>}, + link_to("Destroy", "http://www.example.com", method: :delete) + ) + end + + def test_link_tag_using_delete_javascript_and_href + assert_dom_equal( + %{<a href="\#" rel="nofollow" data-method="delete">Destroy</a>}, + link_to("Destroy", "http://www.example.com", method: :delete, href: "#") + ) + end + + def test_link_tag_using_post_javascript_and_rel + assert_dom_equal( + %{<a href="http://www.example.com" data-method="post" rel="example nofollow">Hello</a>}, + link_to("Hello", "http://www.example.com", method: :post, rel: "example") + ) + end + + def test_link_tag_using_post_javascript_and_confirm + assert_dom_equal( + %{<a href="http://www.example.com" data-method="post" rel="nofollow" data-confirm="Are you serious?">Hello</a>}, + link_to("Hello", "http://www.example.com", method: :post, data: { confirm: "Are you serious?" }) + ) + end + + def test_link_tag_using_delete_javascript_and_href_and_confirm + assert_dom_equal( + %{<a href="\#" rel="nofollow" data-confirm="Are you serious?" data-method="delete">Destroy</a>}, + link_to("Destroy", "http://www.example.com", method: :delete, href: "#", data: { confirm: "Are you serious?" }) + ) + end + + def test_link_tag_with_block + assert_dom_equal %{<a href="/"><span>Example site</span></a>}, + link_to("/") { content_tag(:span, "Example site") } + end + + def test_link_tag_with_block_and_html_options + assert_dom_equal %{<a class="special" href="/"><span>Example site</span></a>}, + link_to("/", class: "special") { content_tag(:span, "Example site") } + end + + def test_link_tag_using_block_and_hash + assert_dom_equal( + %{<a href="/"><span>Example site</span></a>}, + link_to(url_hash) { content_tag(:span, "Example site") } + ) + end + + def test_link_tag_using_block_in_erb + out = render_erb %{<%= link_to('/') do %>Example site<% end %>} + assert_equal '<a href="/">Example site</a>', out + end + + def test_link_tag_with_html_safe_string + assert_dom_equal( + %{<a href="/article/Gerd_M%C3%BCller">Gerd Müller</a>}, + link_to("Gerd Müller", article_path("Gerd_Müller")) + ) + end + + def test_link_tag_escapes_content + assert_dom_equal %{<a href="/">Malicious <script>content</script></a>}, + link_to("Malicious <script>content</script>", "/") + end + + def test_link_tag_does_not_escape_html_safe_content + assert_dom_equal %{<a href="/">Malicious <script>content</script></a>}, + link_to(raw("Malicious <script>content</script>"), "/") + end + + def test_link_to_unless + assert_equal "Showing", link_to_unless(true, "Showing", url_hash) + + assert_dom_equal %{<a href="/">Listing</a>}, + link_to_unless(false, "Listing", url_hash) + + assert_equal "<strong>Showing</strong>", + link_to_unless(true, "Showing", url_hash) { |name| + raw "<strong>#{name}</strong>" + } + + assert_equal "test", + link_to_unless(true, "Showing", url_hash) { + "test" + } + + assert_equal %{<b>Showing</b>}, link_to_unless(true, "<b>Showing</b>", url_hash) + assert_equal %{<a href="/"><b>Showing</b></a>}, link_to_unless(false, "<b>Showing</b>", url_hash) + assert_equal %{<b>Showing</b>}, link_to_unless(true, raw("<b>Showing</b>"), url_hash) + assert_equal %{<a href="/"><b>Showing</b></a>}, link_to_unless(false, raw("<b>Showing</b>"), url_hash) + end + + def test_link_to_if + assert_equal "Showing", link_to_if(false, "Showing", url_hash) + assert_dom_equal %{<a href="/">Listing</a>}, link_to_if(true, "Listing", url_hash) + end + + def test_link_to_if_with_block + assert_equal "Fallback", link_to_if(false, "Showing", url_hash) { "Fallback" } + assert_dom_equal %{<a href="/">Listing</a>}, link_to_if(true, "Listing", url_hash) { "Fallback" } + end + + def request_for_url(url, opts = {}) + env = Rack::MockRequest.env_for("http://www.example.com#{url}", opts) + ActionDispatch::Request.new(env) + end + + def test_current_page_with_http_head_method + @request = request_for_url("/", method: :head) + assert current_page?(url_hash) + assert current_page?("http://www.example.com/") + end + + def test_current_page_with_simple_url + @request = request_for_url("/") + assert current_page?(url_hash) + assert current_page?("http://www.example.com/") + end + + def test_current_page_ignoring_params + @request = request_for_url("/?order=desc&page=1") + + assert current_page?(url_hash) + assert current_page?("http://www.example.com/") + end + + def test_current_page_considering_params + @request = request_for_url("/?order=desc&page=1") + + assert_not current_page?(url_hash, check_parameters: true) + assert_not current_page?(url_hash.merge(check_parameters: true)) + assert_not current_page?(ActionController::Parameters.new(url_hash.merge(check_parameters: true)).permit!) + assert_not current_page?("http://www.example.com/", check_parameters: true) + end + + def test_current_page_considering_params_when_options_does_not_respond_to_to_hash + @request = request_for_url("/?order=desc&page=1") + + assert_not current_page?(:back, check_parameters: false) + end + + def test_current_page_with_params_that_match + @request = request_for_url("/?order=desc&page=1") + + assert current_page?(hash_for(order: "desc", page: "1")) + assert current_page?("http://www.example.com/?order=desc&page=1") + end + + def test_current_page_with_scope_that_match + @request = request_for_url("/engine/") + + assert current_page?("/engine") + end + + def test_current_page_with_escaped_params + @request = request_for_url("/category/administra%c3%a7%c3%a3o") + + assert current_page?(controller: "foo", action: "category", category: "administração") + end + + def test_current_page_with_escaped_params_with_different_encoding + @request = request_for_url("/") + @request.stub(:path, (+"/category/administra%c3%a7%c3%a3o").force_encoding(Encoding::ASCII_8BIT)) do + assert current_page?(controller: "foo", action: "category", category: "administração") + assert current_page?("http://www.example.com/category/administra%c3%a7%c3%a3o") + end + end + + def test_current_page_with_double_escaped_params + @request = request_for_url("/category/administra%c3%a7%c3%a3o?callback_url=http%3a%2f%2fexample.com%2ffoo") + + assert current_page?(controller: "foo", action: "category", category: "administração", callback_url: "http://example.com/foo") + end + + def test_current_page_with_trailing_slash + @request = request_for_url("/posts") + + assert current_page?("/posts/") + end + + def test_current_page_with_not_get_verb + @request = request_for_url("/events", method: :post) + + assert_not current_page?("/events") + end + + def test_link_unless_current + @request = request_for_url("/") + + assert_equal "Showing", + link_to_unless_current("Showing", url_hash) + assert_equal "Showing", + link_to_unless_current("Showing", "http://www.example.com/") + + @request = request_for_url("/?order=desc") + + assert_equal "Showing", + link_to_unless_current("Showing", url_hash) + assert_equal "Showing", + link_to_unless_current("Showing", "http://www.example.com/") + + @request = request_for_url("/?order=desc&page=1") + + assert_equal "Showing", + link_to_unless_current("Showing", hash_for(order: "desc", page: "1")) + assert_equal "Showing", + link_to_unless_current("Showing", "http://www.example.com/?order=desc&page=1") + + @request = request_for_url("/?order=desc") + + assert_equal %{<a href="/?order=asc">Showing</a>}, + link_to_unless_current("Showing", hash_for(order: :asc)) + assert_equal %{<a href="http://www.example.com/?order=asc">Showing</a>}, + link_to_unless_current("Showing", "http://www.example.com/?order=asc") + + @request = request_for_url("/?order=desc") + assert_equal %{<a href="/?order=desc&page=2\">Showing</a>}, + link_to_unless_current("Showing", hash_for(order: "desc", page: 2)) + assert_equal %{<a href="http://www.example.com/?order=desc&page=2">Showing</a>}, + link_to_unless_current("Showing", "http://www.example.com/?order=desc&page=2") + + @request = request_for_url("/show") + + assert_equal %{<a href="/">Listing</a>}, + link_to_unless_current("Listing", url_hash) + assert_equal %{<a href="http://www.example.com/">Listing</a>}, + link_to_unless_current("Listing", "http://www.example.com/") + end + + def test_link_to_unless_with_block + assert_dom_equal %{<a href="/">Showing</a>}, link_to_unless(false, "Showing", url_hash) { "Fallback" } + assert_equal "Fallback", link_to_unless(true, "Listing", url_hash) { "Fallback" } + end + + def test_mail_to + assert_dom_equal %{<a href="mailto:david@loudthinking.com">david@loudthinking.com</a>}, mail_to("david@loudthinking.com") + assert_dom_equal %{<a href="mailto:david@loudthinking.com">David Heinemeier Hansson</a>}, mail_to("david@loudthinking.com", "David Heinemeier Hansson") + assert_dom_equal( + %{<a class="admin" href="mailto:david@loudthinking.com">David Heinemeier Hansson</a>}, + mail_to("david@loudthinking.com", "David Heinemeier Hansson", "class" => "admin") + ) + assert_equal mail_to("david@loudthinking.com", "David Heinemeier Hansson", "class" => "admin"), + mail_to("david@loudthinking.com", "David Heinemeier Hansson", class: "admin") + end + + def test_mail_to_with_special_characters + assert_dom_equal( + %{<a href="mailto:%23%21%24%25%26%27%2A%2B-%2F%3D%3F%5E_%60%7B%7D%7C@example.org">#!$%&'*+-/=?^_`{}|@example.org</a>}, + mail_to("#!$%&'*+-/=?^_`{}|@example.org") + ) + end + + def test_mail_with_options + assert_dom_equal( + %{<a href="mailto:me@example.com?cc=ccaddress%40example.com&bcc=bccaddress%40example.com&body=This%20is%20the%20body%20of%20the%20message.&subject=This%20is%20an%20example%20email&reply-to=foo%40bar.com">My email</a>}, + mail_to("me@example.com", "My email", cc: "ccaddress@example.com", bcc: "bccaddress@example.com", subject: "This is an example email", body: "This is the body of the message.", reply_to: "foo@bar.com") + ) + + assert_dom_equal( + %{<a href="mailto:me@example.com?body=This%20is%20the%20body%20of%20the%20message.&subject=This%20is%20an%20example%20email">My email</a>}, + mail_to("me@example.com", "My email", cc: "", bcc: "", subject: "This is an example email", body: "This is the body of the message.") + ) + end + + def test_mail_to_with_img + assert_dom_equal %{<a href="mailto:feedback@example.com"><img src="/feedback.png" /></a>}, + mail_to("feedback@example.com", raw('<img src="/feedback.png" />')) + end + + def test_mail_to_with_html_safe_string + assert_dom_equal( + %{<a href="mailto:david@loudthinking.com">david@loudthinking.com</a>}, + mail_to(raw("david@loudthinking.com")) + ) + end + + def test_mail_to_with_nil + assert_dom_equal( + %{<a href="mailto:"></a>}, + mail_to(nil) + ) + end + + def test_mail_to_returns_html_safe_string + assert_predicate mail_to("david@loudthinking.com"), :html_safe? + end + + def test_mail_to_with_block + assert_dom_equal %{<a href="mailto:me@example.com"><span>Email me</span></a>}, + mail_to("me@example.com") { content_tag(:span, "Email me") } + end + + def test_mail_to_with_block_and_options + assert_dom_equal %{<a class="special" href="mailto:me@example.com?cc=ccaddress%40example.com"><span>Email me</span></a>}, + mail_to("me@example.com", cc: "ccaddress@example.com", class: "special") { content_tag(:span, "Email me") } + end + + def test_mail_to_does_not_modify_html_options_hash + options = { class: "special" } + mail_to "me@example.com", "ME!", options + assert_equal({ class: "special" }, options) + end + + def protect_against_forgery? + request_forgery + end + + def form_authenticity_token(*args) + "secret" + end + + def request_forgery_protection_token + "form_token" + end +end + +class UrlHelperControllerTest < ActionController::TestCase + class UrlHelperController < ActionController::Base + ROUTES = test_routes do + get "url_helper_controller_test/url_helper/show/:id", + to: "url_helper_controller_test/url_helper#show", + as: :show + + get "url_helper_controller_test/url_helper/profile/:name", + to: "url_helper_controller_test/url_helper#show", + as: :profile + + get "url_helper_controller_test/url_helper/show_named_route", + to: "url_helper_controller_test/url_helper#show_named_route", + as: :show_named_route + + ActiveSupport::Deprecation.silence do + get "/:controller(/:action(/:id))" + end + + get "url_helper_controller_test/url_helper/normalize_recall_params", + to: UrlHelperController.action(:normalize_recall), + as: :normalize_recall_params + + get "/url_helper_controller_test/url_helper/override_url_helper/default", + to: "url_helper_controller_test/url_helper#override_url_helper", + as: :override_url_helper + end + + def show + if params[:name] + render inline: "ok" + else + redirect_to profile_path(params[:id]) + end + end + + def show_url_for + render inline: "<%= url_for controller: 'url_helper_controller_test/url_helper', action: 'show_url_for' %>" + end + + def show_named_route + render inline: "<%= show_named_route_#{params[:kind]} %>" + end + + def nil_url_for + render inline: "<%= url_for(nil) %>" + end + + def normalize_recall_params + render inline: "<%= normalize_recall_params_path %>" + end + + def recall_params_not_changed + render inline: "<%= url_for(action: :show_url_for) %>" + end + + def override_url_helper + render inline: "<%= override_url_helper_path %>" + end + + def override_url_helper_path + "/url_helper_controller_test/url_helper/override_url_helper/override" + end + helper_method :override_url_helper_path + end + + def setup + super + @routes = UrlHelperController::ROUTES + end + + tests UrlHelperController + + def test_url_for_shows_only_path + get :show_url_for + assert_equal "/url_helper_controller_test/url_helper/show_url_for", @response.body + end + + def test_named_route_url_shows_host_and_path + get :show_named_route, params: { kind: "url" } + assert_equal "http://test.host/url_helper_controller_test/url_helper/show_named_route", + @response.body + end + + def test_named_route_path_shows_only_path + get :show_named_route, params: { kind: "path" } + assert_equal "/url_helper_controller_test/url_helper/show_named_route", @response.body + end + + def test_url_for_nil_returns_current_path + get :nil_url_for + assert_equal "/url_helper_controller_test/url_helper/nil_url_for", @response.body + end + + def test_named_route_should_show_host_and_path_using_controller_default_url_options + class << @controller + def default_url_options + { host: "testtwo.host" } + end + end + + get :show_named_route, params: { kind: "url" } + assert_equal "http://testtwo.host/url_helper_controller_test/url_helper/show_named_route", @response.body + end + + def test_recall_params_should_be_normalized + get :normalize_recall_params + assert_equal "/url_helper_controller_test/url_helper/normalize_recall_params", @response.body + end + + def test_recall_params_should_not_be_changed + get :recall_params_not_changed + assert_equal "/url_helper_controller_test/url_helper/show_url_for", @response.body + end + + def test_recall_params_should_normalize_id + get :show, params: { id: "123" } + assert_equal 302, @response.status + assert_equal "http://test.host/url_helper_controller_test/url_helper/profile/123", @response.location + + get :show, params: { name: "123" } + assert_equal "ok", @response.body + end + + def test_url_helper_can_be_overridden + get :override_url_helper + assert_equal "/url_helper_controller_test/url_helper/override_url_helper/override", @response.body + end +end + +class TasksController < ActionController::Base + ROUTES = test_routes do + resources :tasks + end + + def index + render_default + end + + def show + render_default + end + + private + def render_default + render inline: "<%= link_to_unless_current('tasks', tasks_path) %>\n" \ + "<%= link_to_unless_current('tasks', tasks_url) %>" + end +end + +class LinkToUnlessCurrentWithControllerTest < ActionController::TestCase + tests TasksController + + def setup + super + @routes = TasksController::ROUTES + end + + def test_link_to_unless_current_to_current + get :index + assert_equal "tasks\ntasks", @response.body + end + + def test_link_to_unless_current_shows_link + get :show, params: { id: 1 } + assert_equal %{<a href="/tasks">tasks</a>\n} + + %{<a href="#{@request.protocol}#{@request.host_with_port}/tasks">tasks</a>}, + @response.body + end +end + +class Session + extend ActiveModel::Naming + include ActiveModel::Conversion + attr_accessor :id, :workshop_id + + def initialize(id) + @id = id + end + + def persisted? + id.present? + end + + def to_s + id.to_s + end +end + +class WorkshopsController < ActionController::Base + ROUTES = test_routes do + resources :workshops do + resources :sessions + end + end + + def index + @workshop = Workshop.new(nil) + render inline: "<%= url_for(@workshop) %>\n<%= link_to('Workshop', @workshop) %>" + end + + def show + @workshop = Workshop.new(params[:id]) + render inline: "<%= url_for(@workshop) %>\n<%= link_to('Workshop', @workshop) %>" + end + + def edit + @workshop = Workshop.new(params[:id]) + render inline: "<%= current_page?(@workshop) %>" + end +end + +class SessionsController < ActionController::Base + ROUTES = test_routes do + resources :workshops do + resources :sessions + end + end + + def index + @workshop = Workshop.new(params[:workshop_id]) + @session = Session.new(nil) + render inline: "<%= url_for([@workshop, @session]) %>\n<%= link_to('Session', [@workshop, @session]) %>" + end + + def show + @workshop = Workshop.new(params[:workshop_id]) + @session = Session.new(params[:id]) + render inline: "<%= url_for([@workshop, @session]) %>\n<%= link_to('Session', [@workshop, @session]) %>" + end + + def edit + @workshop = Workshop.new(params[:workshop_id]) + @session = Session.new(params[:id]) + @url = [@workshop, @session, format: params[:format]] + render inline: "<%= url_for(@url) %>\n<%= link_to('Session', @url) %>" + end +end + +class PolymorphicControllerTest < ActionController::TestCase + def setup + super + @routes = WorkshopsController::ROUTES + end + + def test_new_resource + @controller = WorkshopsController.new + + get :index + assert_equal %{/workshops\n<a href="/workshops">Workshop</a>}, @response.body + end + + def test_existing_resource + @controller = WorkshopsController.new + + get :show, params: { id: 1 } + assert_equal %{/workshops/1\n<a href="/workshops/1">Workshop</a>}, @response.body + end + + def test_current_page_when_options_does_not_respond_to_to_hash + @controller = WorkshopsController.new + + get :edit, params: { id: 1 } + assert_equal "false", @response.body + end +end + +class PolymorphicSessionsControllerTest < ActionController::TestCase + def setup + super + @routes = SessionsController::ROUTES + end + + def test_new_nested_resource + @controller = SessionsController.new + + get :index, params: { workshop_id: 1 } + assert_equal %{/workshops/1/sessions\n<a href="/workshops/1/sessions">Session</a>}, @response.body + end + + def test_existing_nested_resource + @controller = SessionsController.new + + get :show, params: { workshop_id: 1, id: 1 } + assert_equal %{/workshops/1/sessions/1\n<a href="/workshops/1/sessions/1">Session</a>}, @response.body + end + + def test_existing_nested_resource_with_params + @controller = SessionsController.new + + get :edit, params: { workshop_id: 1, id: 1, format: "json" } + assert_equal %{/workshops/1/sessions/1.json\n<a href="/workshops/1/sessions/1.json">Session</a>}, @response.body + end +end diff --git a/actionview/test/ujs/config.ru b/actionview/test/ujs/config.ru new file mode 100644 index 0000000000..7cd3a16acb --- /dev/null +++ b/actionview/test/ujs/config.ru @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift __dir__ +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-ajax.js b/actionview/test/ujs/public/test/call-ajax.js new file mode 100644 index 0000000000..4d0bfb0806 --- /dev/null +++ b/actionview/test/ujs/public/test/call-ajax.js @@ -0,0 +1,26 @@ +(function() { + +module('call-ajax', { + setup: function() { + $('#qunit-fixture') + .append($('<a />', { href: '#' })) + } +}) + +asyncTest('call ajax without "ajax:beforeSend"', 1, function() { + var link = $('#qunit-fixture a') + link.bindNative('click', function() { + Rails.ajax({ + type: 'get', + url: '/', + success: function() { + ok(true, 'calling request in ajax:success') + } + }) + }) + + link.triggerNative('click') + setTimeout(function() { start() }, 50) +}) + +})() 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..9c0c8cfb4b --- /dev/null +++ b/actionview/test/ujs/public/test/call-remote-callbacks.js @@ -0,0 +1,239 @@ +(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('iframe:loading') + } +}) + +function submit(fn) { + var form = $('form') + + if (fn) fn(form) + form.triggerNative('submit') + + setTimeout(function() { start() }, 13) +} + +asyncTest('modifying form fields with "ajax:before" sends modified data in request', 3, 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', 1, 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', 1, 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(e) { + ok(true, 'aborting request in ajax:beforeSend') + e.preventDefault() + }) + form.unbind('ajax:send').bindNative('ajax:send', function() { + ok(false, 'ajax:send should not run') + }) + form.bindNative('ajax:error', function(e, response, status, xhr) { + ok(false, 'ajax:error should not run') + }) + form.bindNative('ajax:complete', function() { + ok(false, 'ajax:complete should not run') + }) + }) +}) + +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(e) { + e.preventDefault() + }) + .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(e) { + ok(true, 'ajax:beforeSend observed with event delegation') + e.preventDefault() + }) + + submit(function(form) { + form.unbind('ajax:send').bindNative('ajax:send', function() { + ok(false, 'ajax:send should not run') + }) + form.bindNative('ajax:complete', function() { + ok(false, 'ajax:complete should not run') + }) + }) +}) + +asyncTest('"ajax:beforeSend", "ajax:send", "ajax:success" and "ajax:complete" are triggered', 8, 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') + }) + }) +}) + +asyncTest('"ajax:beforeSend", "ajax:send", "ajax:error" and "ajax:complete" are triggered on error', 8, 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, response, status, xhr) { + equal(response, '', 'first argument to ajax:error should be an HTTP status response') + equal(status, 'Forbidden', 'second argument to ajax:error should be a status string') + ok(xhr.getResponseHeader, 'third argument to "ajax:error" should be an XHR object') + // Opera returns "0" for HTTP code + equal(xhr.status, window.opera ? 0 : 403, 'status code should be 403') + }) + form.bindNative('ajax:complete', function(e, xhr, status) { + ok(xhr.getResponseHeader, 'first argument to "ajax:complete" should be an XHR object') + equal(status, 'Forbidden', 'second argument to ajax:complete should be a status string') + }) + }) +}) + +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:success', function() { + ok(true, 'ajax:success handler is triggered') + }) + .delegate('form[data-remote]', 'ajax:complete', function() { + ok(true, 'ajax:complete handler is triggered') + }) + $('form[data-remote]').triggerNative('submit') + + setTimeout(function() { start() }, 13) +}) + +})() 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..778dc1b09a --- /dev/null +++ b/actionview/test/ujs/public/test/call-remote.js @@ -0,0 +1,286 @@ +(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('ecmascript code should be executed', 1, function() { + buildForm({ method: 'post', 'data-type': 'script' }) + + $('form').append('<input type="text" name="content_type" value="application/ecmascript">') + $('form').append('<input type="text" name="content" value="ok(true, \'remote code should be run\')">') + + submit() +}) + +asyncTest('execution of JS code does not modify current DOM', 1, function() { + var docLength, newDocLength + function getDocLength() { + return document.documentElement.outerHTML.length + } + + 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="\'remote code should be run\'">') + + docLength = getDocLength() + + submit(function() { + newDocLength = getDocLength() + ok(docLength === newDocLength, 'executed JS should not present in the document') + }) +}) + +asyncTest('HTML content should be plain-text', 1, function() { + buildForm({ method: 'post', 'data-type': 'html' }) + + $('form').append('<input type="text" name="content_type" value="text/html">') + $('form').append('<input type="text" name="content" value="<p>hello</p>">') + + submit(function(e, data, status, xhr) { + ok(data === '<p>hello</p>', 'returned data should be a plain-text string') + }) +}) + +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(evt, 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. + evt.preventDefault() + }) + .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 + evt.preventDefault() + }) + .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 + evt.preventDefault() + }) + .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..1bd57b69ad --- /dev/null +++ b/actionview/test/ujs/public/test/data-confirm.js @@ -0,0 +1,342 @@ +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?' + })) + + $('#qunit-fixture').append($('<button />', { + type: 'submit', + form: 'confirm', + disabled: 'disabled', + '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(e) { + App.assertCallbackInvoked('confirm') + e.preventDefault() + }) + .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(e) { + App.assertCallbackInvoked('confirm') + e.preventDefault() + }) + .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(e) { + App.assertCallbackInvoked('confirm:complete') + e.preventDefault() + }) + .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(e) { + App.assertCallbackInvoked('confirm:complete') + e.preventDefault() + }) + .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') +}) + +asyncTest('clicking on the children of a disabled button should not trigger a confirm.', 1, function() { + var message + // auto-decline: + window.confirm = function(msg) { message = msg; return false } + + $('button[data-confirm][disabled]') + .html('<strong>Click me</strong>') + .bindNative('confirm', function() { + App.assertCallbackNotInvoked('confirm') + }) + .find('strong') + .bindNative('click', function() { + App.assertCallbackInvoked('click') + }) + .triggerNative('click') + + setTimeout(function() { + start() + }, 50) +}) + +asyncTest('clicking on a link with data-confirm attribute with custom confirm handler. Confirm yes.', 7, function() { + var message, element + // redefine confirm function so we can make sure it's not called + window.confirm = function(msg) { + ok(false, 'confirm dialog should not be called') + } + // custom auto-confirm: + Rails.confirm = function(msg, elem) { message = msg; element = elem; 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?') + equal(element, $('a[data-confirm]').get(0)) + start() + }) + .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..10b8870171 --- /dev/null +++ b/actionview/test/ujs/public/test/data-disable-with.js @@ -0,0 +1,434 @@ +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('a[data-remote][data-disable-with] within a form disables and re-enables', 6, function() { + var form = $('form:not([data-remote])'), + link = $('<a data-remote="true" data-disable-with="clicking...">Click me</a>') + form.append(link) + + 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') + link.remove() + start() + }, 15) + }) + .triggerNative('click') +}) + +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 = $('#qunit-fixture 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 = $('#qunit-fixture 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(e) { + App.checkDisabledState(link, 'clicking...') + e.preventDefault() + }) + .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(e) { + App.checkDisabledState(link, 'clicking...') + e.preventDefault() + }) + .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(e) { + e.preventDefault() + e.stopPropagation() + }) + .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 disable 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('right/mouse-wheel-clicking on a link does not disable the link', 10, function() { + var link = $('a[data-disable-with]') + + App.checkEnabledState(link, 'Click me') + + link.triggerNative('click', { button: 1 }) + App.checkEnabledState(link, 'Click me') + + link.triggerNative('click', { button: 1 }) + App.checkEnabledState(link, 'Click me') + + link.triggerNative('click', { button: 2 }) + App.checkEnabledState(link, 'Click me') + + link.triggerNative('click', { button: 2 }) + 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(e) { + App.checkDisabledState(button, 'clicking...') + e.preventDefault() + }) + .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(e) { + App.checkDisabledState(button, 'clicking...') + e.preventDefault() + }) + .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..9f84c4647e --- /dev/null +++ b/actionview/test/ujs/public/test/data-disable.js @@ -0,0 +1,358 @@ +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 = $('#qunit-fixture 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 = $('#qunit-fixture 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(e) { + App.checkDisabledState(link, 'Click me') + e.preventDefault() + }) + .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(e) { + App.checkDisabledState(link, 'Click me') + e.preventDefault() + }) + .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(e) { + e.preventDefault() + e.stopPropagation() + }) + .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('right/mouse-wheel-clicking on a link does not disable the link', 10, function() { + var link = $('a[data-disable]') + + App.checkEnabledState(link, 'Click me') + + link.triggerNative('click', { button: 1 }) + App.checkEnabledState(link, 'Click me') + + link.triggerNative('click', { button: 1 }) + App.checkEnabledState(link, 'Click me') + + link.triggerNative('click', { button: 2 }) + App.checkEnabledState(link, 'Click me') + + link.triggerNative('click', { button: 2 }) + 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(e) { + App.checkDisabledState(button, 'Click me') + e.preventDefault() + }) + .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(e) { + App.checkDisabledState(button, 'Click me') + e.preventDefault() + }) + .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) +}) + +asyncTest('do not enable elements for XHR redirects', 6, function() { + var link = $('a[data-disable]').attr('data-remote', true).attr('href', '/echo?with_xhr_redirect=true') + + App.checkEnabledState(link, 'Click me') + + link + .bindNative('ajax:send', function() { + App.checkDisabledState(link, 'Click me') + }) + .triggerNative('click') + + setTimeout(function() { + App.checkDisabledState(link, '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..55d39b0a52 --- /dev/null +++ b/actionview/test/ujs/public/test/data-remote.js @@ -0,0 +1,479 @@ +(function() { + +function buildSelect(attrs) { + attrs = $.extend({ + 'name': 'user_data', 'data-remote': 'true', 'data-url': '/echo', 'data-params': 'data1=value1' + }, attrs) + + $('#qunit-fixture').append( + $('<select />', attrs) + .append($('<option />', {value: 'optionValue1', text: 'option1'})) + .append($('<option />', {value: 'optionValue2', text: 'option2'})) + ) +} + +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('right/mouse-wheel-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', { button: 1 }) + link.triggerNative('click', { button: 2 }) + + 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') + 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('right/mouse-wheel-clicking on a button with data-remote attribute does not fire ajaxyness', 0, function() { + var button = $('button[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. + button + .removeAttr('data-params') + .bindNative('ajax:beforeSend', function() { + ok(false, 'ajax should not be triggered') + }) + + button.triggerNative('click', { button: 1 }) + button.triggerNative('click', { button: 2 }) + + setTimeout(function() { start() }, 13) +}) + +asyncTest('changing a select option with data-remote attribute', 5, function() { + buildSelect() + + $('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', 6, function() { + $('#qunit-fixture') + .append($('<input type="text" name="user_data" value="value1" form="my-remote-form">')) + .append($('<input type="text" name="user_email" value="from@example.com" disabled="disabled" 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') + equal(data.params.user_email, undefined, 'ajax arguments should not have disabled field') + 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(e) { + ok(true, 'binding handler is called') + e.preventDefault() + e.stopPropagation() + }) + .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(e) { + e.preventDefault() + }) + .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(e) { + e.preventDefault() + }) + .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(e) { + e.preventDefault() + }) + .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(e) { + e.preventDefault() + }) + .triggerNative('submit') + + setTimeout(function() { start() }, 20) +}) + +asyncTest('changing a select option with falsy "data-remote" attribute', 0, function() { + buildSelect({'data-remote': 'false'}) + + $('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') +}) + +asyncTest('form buttons should only be serialized when clicked', 4, function() { + $('form') + .append('<input type="submit" name="submit1" value="submit1" />') + .append('<button name="submit2" value="submit2" />') + .append('<button name="submit3" value="submit3" />') + .bindNative('ajax:success', function(e, data, status, xhr) { + equal(data.params.submit1, undefined) + equal(data.params.submit2, 'submit2') + equal(data.params.submit3, undefined) + equal(data['rack.request.form_vars'], 'user_name=john&submit2=submit2') + + start() + }) + .find('[name=submit2]').triggerNative('click') +}) + +asyncTest('changing a select option without "data-url" attribute still fires ajax request to current location', 1, function() { + var currentLocation, ajaxLocation + + buildSelect({'data-url': ''}) + + $('select[data-remote]') + .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 + } + + ajaxLocation = settings.url.replace(settings.data, '').replace(/&$/, '').replace(/\?$/, '') + equal(ajaxLocation, currentLocation, 'URL should be current page by default') + + e.preventDefault() + }) + .val('optionValue2') + .triggerNative('change') + + setTimeout(function() { start() }, 20) +}) + +})() diff --git a/actionview/test/ujs/public/test/override.js b/actionview/test/ujs/public/test/override.js new file mode 100644 index 0000000000..d73276ee4f --- /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) + e.preventDefault() + }) + .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) + e.preventDefault() + }) + .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 rails-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..682d044403 --- /dev/null +++ b/actionview/test/ujs/public/test/settings.js @@ -0,0 +1,122 @@ +var App = App || {} +var Turbolinks = Turbolinks || {} + +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, options.button, null) + return evt + } +} + +$.fn.extend({ + // trigger a 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 + + var el = this[0] + el.addEventListener(event, function(e) { + var args = [] + if (e.detail) { + args = e.detail.slice() + } + args.unshift(e) + return handler.apply(el, args) + }, false) + + return this + } +}) + +Turbolinks.clearCache = function() {} +Turbolinks.visit = function() {} diff --git a/actionview/test/ujs/public/vendor/jquery-2.2.0.js b/actionview/test/ujs/public/vendor/jquery-2.2.0.js new file mode 100644 index 0000000000..1e0ba99740 --- /dev/null +++ b/actionview/test/ujs/public/vendor/jquery-2.2.0.js @@ -0,0 +1,9831 @@ +/*! + * jQuery JavaScript Library v2.2.0 + * http://jquery.com/ + * + * Includes Sizzle.js + * http://sizzlejs.com/ + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license + * http://jquery.org/license + * + * Date: 2016-01-08T20:02Z + */ + +(function( global, factory ) { + + if ( typeof module === "object" && typeof module.exports === "object" ) { + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket #14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +}(typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Support: Firefox 18+ +// Can't be in strict mode, several libs including ASP.NET trace +// the stack via arguments.caller.callee and Firefox dies if +// you try to trace through "use strict" call chains. (#13335) +//"use strict"; +var arr = []; + +var document = window.document; + +var slice = arr.slice; + +var concat = arr.concat; + +var push = arr.push; + +var indexOf = arr.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var support = {}; + + + +var + version = "2.2.0", + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + + // The jQuery object is actually just the init constructor 'enhanced' + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); + }, + + // Support: Android<4.1 + // Make sure we trim BOM and NBSP + rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, + + // Matches dashed string for camelizing + rmsPrefix = /^-ms-/, + rdashAlpha = /-([\da-z])/gi, + + // Used by jQuery.camelCase as callback to replace() + fcamelCase = function( all, letter ) { + return letter.toUpperCase(); + }; + +jQuery.fn = jQuery.prototype = { + + // The current version of jQuery being used + jquery: version, + + constructor: jQuery, + + // Start with an empty selector + selector: "", + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + return num != null ? + + // Return just the one element from the set + ( num < 0 ? this[ num + this.length ] : this[ num ] ) : + + // Return all the elements in a clean array + slice.call( this ); + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + ret.context = this.context; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + each: function( callback ) { + return jQuery.each( this, callback ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { + return callback.call( elem, i, elem ); + } ) ); + }, + + slice: function() { + return this.pushStack( slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); + }, + + end: function() { + return this.prevObject || this.constructor(); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: push, + sort: arr.sort, + splice: arr.splice +}; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[ 0 ] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + + // Skip the boolean and the target + target = arguments[ i ] || {}; + i++; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !jQuery.isFunction( target ) ) { + target = {}; + } + + // Extend jQuery itself if only one argument is passed + if ( i === length ) { + target = this; + i--; + } + + for ( ; i < length; i++ ) { + + // Only deal with non-null/undefined values + if ( ( options = arguments[ i ] ) != null ) { + + // Extend the base object + for ( name in options ) { + src = target[ name ]; + copy = options[ name ]; + + // Prevent never-ending loop + if ( target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = jQuery.isArray( copy ) ) ) ) { + + if ( copyIsArray ) { + copyIsArray = false; + clone = src && jQuery.isArray( src ) ? src : []; + + } else { + clone = src && jQuery.isPlainObject( src ) ? src : {}; + } + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend( { + + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), + + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); + }, + + noop: function() {}, + + isFunction: function( obj ) { + return jQuery.type( obj ) === "function"; + }, + + isArray: Array.isArray, + + isWindow: function( obj ) { + return obj != null && obj === obj.window; + }, + + isNumeric: function( obj ) { + + // parseFloat NaNs numeric-cast false positives (null|true|false|"") + // ...but misinterprets leading-number strings, particularly hex literals ("0x...") + // subtraction forces infinities to NaN + // adding 1 corrects loss of precision from parseFloat (#15100) + var realStringObj = obj && obj.toString(); + return !jQuery.isArray( obj ) && ( realStringObj - parseFloat( realStringObj ) + 1 ) >= 0; + }, + + isPlainObject: function( obj ) { + + // Not plain objects: + // - Any object or value whose internal [[Class]] property is not "[object Object]" + // - DOM nodes + // - window + if ( jQuery.type( obj ) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) { + return false; + } + + if ( obj.constructor && + !hasOwn.call( obj.constructor.prototype, "isPrototypeOf" ) ) { + return false; + } + + // If the function hasn't returned already, we're confident that + // |obj| is a plain object, created by {} or constructed with new Object + return true; + }, + + isEmptyObject: function( obj ) { + var name; + for ( name in obj ) { + return false; + } + return true; + }, + + type: function( obj ) { + if ( obj == null ) { + return obj + ""; + } + + // Support: Android<4.0, iOS<6 (functionish RegExp) + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call( obj ) ] || "object" : + typeof obj; + }, + + // Evaluates a script in a global context + globalEval: function( code ) { + var script, + indirect = eval; + + code = jQuery.trim( code ); + + if ( code ) { + + // If the code includes a valid, prologue position + // strict mode pragma, execute code by injecting a + // script tag into the document. + if ( code.indexOf( "use strict" ) === 1 ) { + script = document.createElement( "script" ); + script.text = code; + document.head.appendChild( script ).parentNode.removeChild( script ); + } else { + + // Otherwise, avoid the DOM node creation, insertion + // and removal by using an indirect global eval + + indirect( code ); + } + } + }, + + // Convert dashed to camelCase; used by the css and data modules + // Support: IE9-11+ + // Microsoft forgot to hump their vendor prefix (#9572) + camelCase: function( string ) { + return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase ); + }, + + nodeName: function( elem, name ) { + return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase(); + }, + + each: function( obj, callback ) { + var length, i = 0; + + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } else { + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } + + return obj; + }, + + // Support: Android<4.1 + trim: function( text ) { + return text == null ? + "" : + ( text + "" ).replace( rtrim, "" ); + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArrayLike( Object( arr ) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + return arr == null ? -1 : indexOf.call( arr, elem, i ); + }, + + merge: function( first, second ) { + var len = +second.length, + j = 0, + i = first.length; + + for ( ; j < len; j++ ) { + first[ i++ ] = second[ j ]; + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], + i = 0, + length = elems.length, + callbackExpect = !invert; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); + } + } + + return matches; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var length, value, + i = 0, + ret = []; + + // Go through the array, translating each of the items to their new values + if ( isArrayLike( elems ) ) { + length = elems.length; + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + } + + // Flatten any nested arrays + return concat.apply( [], ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // Bind a function to a context, optionally partially applying any + // arguments. + proxy: function( fn, context ) { + var tmp, args, proxy; + + if ( typeof context === "string" ) { + tmp = fn[ context ]; + context = fn; + fn = tmp; + } + + // Quick check to determine if target is callable, in the spec + // this throws a TypeError, but we will just return undefined. + if ( !jQuery.isFunction( fn ) ) { + return undefined; + } + + // Simulated bind + args = slice.call( arguments, 2 ); + proxy = function() { + return fn.apply( context || this, args.concat( slice.call( arguments ) ) ); + }; + + // Set the guid of unique handler to the same of original handler, so it can be removed + proxy.guid = fn.guid = fn.guid || jQuery.guid++; + + return proxy; + }, + + now: Date.now, + + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +} ); + +// JSHint would error on this code due to the Symbol not being defined in ES5. +// Defining this global in .jshintrc would create a danger of using the global +// unguarded in another place, it seems safer to just disable JSHint for these +// three lines. +/* jshint ignore: start */ +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; +} +/* jshint ignore: end */ + +// Populate the class2type map +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), +function( i, name ) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); +} ); + +function isArrayLike( obj ) { + + // Support: iOS 8.2 (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, + type = jQuery.type( obj ); + + if ( type === "function" || jQuery.isWindow( obj ) ) { + return false; + } + + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; +} +var Sizzle = +/*! + * Sizzle CSS Selector Engine v2.2.1 + * http://sizzlejs.com/ + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license + * http://jquery.org/license + * + * Date: 2015-10-17 + */ +(function( window ) { + +var i, + support, + Expr, + getText, + isXML, + tokenize, + compile, + select, + outermostContext, + sortInput, + hasDuplicate, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + 1 * new Date(), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, + + // General-purpose constants + MAX_NEGATIVE = 1 << 31, + + // Instance methods + hasOwn = ({}).hasOwnProperty, + arr = [], + pop = arr.pop, + push_native = arr.push, + push = arr.push, + slice = arr.slice, + // Use a stripped-down indexOf as it's faster than native + // http://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { + var i = 0, + len = list.length; + for ( ; i < len; i++ ) { + if ( list[i] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + + // http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier + identifier = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+", + + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + // "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace + + "*\\]", + + pseudos = ":(" + identifier + ")(?:\\((" + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ), + + rattributeQuotes = new RegExp( "=" + whitespace + "*([^\\]'\"]*?)" + whitespace + "*\\]", "g" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace + + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace + + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + + whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rsibling = /[+~]/, + rescape = /'|\\/g, + + // CSS escapes http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ), + funescape = function( _, escaped, escapedWhitespace ) { + var high = "0x" + escaped - 0x10000; + // NaN means non-codepoint + // Support: Firefox<24 + // Workaround erroneous numeric interpretation of +"0x" + return high !== high || escapedWhitespace ? + escaped : + high < 0 ? + // BMP codepoint + String.fromCharCode( high + 0x10000 ) : + // Supplemental Plane codepoint (surrogate pair) + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); + }; + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + (arr = slice.call( preferredDoc.childNodes )), + preferredDoc.childNodes + ); + // Support: Android<4.0 + // Detect silently failing push.apply + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + push_native.apply( target, slice.call(els) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + // Can't trust NodeList.length + while ( (target[j++] = els[i++]) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var m, i, elem, nid, nidselect, match, groups, newSelector, + newContext = context && context.ownerDocument, + + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; + + results = results || []; + + // Return early from calls with invalid selector or context + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + + if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) { + setDocument( context ); + } + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) { + + // ID selector + if ( (m = match[1]) ) { + + // Document context + if ( nodeType === 9 ) { + if ( (elem = context.getElementById( m )) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && (elem = newContext.getElementById( m )) && + contains( context, elem ) && + elem.id === m ) { + + results.push( elem ); + return results; + } + } + + // Type selector + } else if ( match[2] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Class selector + } else if ( (m = match[3]) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !compilerCache[ selector + " " ] && + (!rbuggyQSA || !rbuggyQSA.test( selector )) ) { + + if ( nodeType !== 1 ) { + newContext = context; + newSelector = selector; + + // qSA looks outside Element context, which is not what we want + // Thanks to Andrew Dupont for this workaround technique + // Support: IE <=8 + // Exclude object elements + } else if ( context.nodeName.toLowerCase() !== "object" ) { + + // Capture the context ID, setting it first if necessary + if ( (nid = context.getAttribute( "id" )) ) { + nid = nid.replace( rescape, "\\$&" ); + } else { + context.setAttribute( "id", (nid = expando) ); + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + nidselect = ridentifier.test( nid ) ? "#" + nid : "[id='" + nid + "']"; + while ( i-- ) { + groups[i] = nidselect + " " + toSelector( groups[i] ); + } + newSelector = groups.join( "," ); + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + } + + if ( newSelector ) { + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {function(string, object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key + " " ) > Expr.cacheLength ) { + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return (cache[ key + " " ] = value); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created div and expects a boolean result + */ +function assert( fn ) { + var div = document.createElement("div"); + + try { + return !!fn( div ); + } catch (e) { + return false; + } finally { + // Remove from its parent by default + if ( div.parentNode ) { + div.parentNode.removeChild( div ); + } + // release memory in IE + div = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split("|"), + i = arr.length; + + while ( i-- ) { + Expr.attrHandle[ arr[i] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + ( ~b.sourceIndex || MAX_NEGATIVE ) - + ( ~a.sourceIndex || MAX_NEGATIVE ); + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( (cur = cur.nextSibling) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return (name === "input" || name === "button") && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction(function( argument ) { + argument = +argument; + return markFunction(function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ (j = matchIndexes[i]) ] ) { + seed[j] = !(matches[j] = seed[j]); + } + } + }); + }); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes + * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node + */ +isXML = Sizzle.isXML = function( elem ) { + // documentElement is verified for cases where it doesn't yet exist + // (such as loading iframes in IE - #4833) + var documentElement = elem && (elem.ownerDocument || elem).documentElement; + return documentElement ? documentElement.nodeName !== "HTML" : false; +}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var hasCompare, parent, + doc = node ? node.ownerDocument || node : preferredDoc; + + // Return early if doc is invalid or already selected + if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Update global variables + document = doc; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); + + // Support: IE 9-11, Edge + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + if ( (parent = document.defaultView) && parent.top !== parent ) { + // Support: IE 11 + if ( parent.addEventListener ) { + parent.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only + } else if ( parent.attachEvent ) { + parent.attachEvent( "onunload", unloadHandler ); + } + } + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert(function( div ) { + div.className = "i"; + return !div.getAttribute("className"); + }); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert(function( div ) { + div.appendChild( document.createComment("") ); + return !div.getElementsByTagName("*").length; + }); + + // Support: IE<9 + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert(function( div ) { + docElem.appendChild( div ).id = expando; + return !document.getElementsByName || !document.getElementsByName( expando ).length; + }); + + // ID find and filter + if ( support.getById ) { + Expr.find["ID"] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var m = context.getElementById( id ); + return m ? [ m ] : []; + } + }; + Expr.filter["ID"] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute("id") === attrId; + }; + }; + } else { + // Support: IE6/7 + // getElementById is not reliable as a find shortcut + delete Expr.find["ID"]; + + Expr.filter["ID"] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode("id"); + return node && node.value === attrId; + }; + }; + } + + // Tag + Expr.find["TAG"] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { + return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); + } + } : + + function( tag, context ) { + var elem, + tmp = [], + i = 0, + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( (elem = results[i++]) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See http://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( (support.qsa = rnative.test( document.querySelectorAll )) ) { + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert(function( div ) { + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // http://bugs.jquery.com/ticket/12359 + docElem.appendChild( div ).innerHTML = "<a id='" + expando + "'></a>" + + "<select id='" + expando + "-\r\\' msallowcapture=''>" + + "<option selected=''></option></select>"; + + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // http://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( div.querySelectorAll("[msallowcapture^='']").length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !div.querySelectorAll("[selected]").length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ + if ( !div.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push("~="); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !div.querySelectorAll(":checked").length ) { + rbuggyQSA.push(":checked"); + } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibing-combinator selector` fails + if ( !div.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push(".#.+[+~]"); + } + }); + + assert(function( div ) { + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = document.createElement("input"); + input.setAttribute( "type", "hidden" ); + div.appendChild( input ).setAttribute( "name", "D" ); + + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( div.querySelectorAll("[name=d]").length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( !div.querySelectorAll(":enabled").length ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Opera 10-11 does not throw on post-comma invalid pseudos + div.querySelectorAll("*,:x"); + rbuggyQSA.push(",.*:"); + }); + } + + if ( (support.matchesSelector = rnative.test( (matches = docElem.matches || + docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector) )) ) { + + assert(function( div ) { + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( div, "div" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + matches.call( div, "[s!='']:x" ); + rbuggyMatches.push( "!=", pseudos ); + }); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); + + // Element contains another + // Purposefully self-exclusive + // As in, an element does not contain itself + contains = hasCompare || rnative.test( docElem.contains ) ? + function( a, b ) { + var adown = a.nodeType === 9 ? a.documentElement : a, + bup = b && b.parentNode; + return a === bup || !!( bup && bup.nodeType === 1 && ( + adown.contains ? + adown.contains( bup ) : + a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16 + )); + } : + function( a, b ) { + if ( b ) { + while ( (b = b.parentNode) ) { + if ( b === a ) { + return true; + } + } + } + return false; + }; + + /* Sorting + ---------------------------------------------------------------------- */ + + // Document order sorting + sortOrder = hasCompare ? + function( a, b ) { + + // Flag for duplicate removal + if ( a === b ) { + hasDuplicate = true; + return 0; + } + + // Sort on method existence if only one input has compareDocumentPosition + var compare = !a.compareDocumentPosition - !b.compareDocumentPosition; + if ( compare ) { + return compare; + } + + // Calculate position if both inputs belong to the same document + compare = ( a.ownerDocument || a ) === ( b.ownerDocument || b ) ? + a.compareDocumentPosition( b ) : + + // Otherwise we know they are disconnected + 1; + + // Disconnected nodes + if ( compare & 1 || + (!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) { + + // Choose the first element that is related to our preferred document + if ( a === document || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) { + return -1; + } + if ( b === document || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) { + return 1; + } + + // Maintain original order + return sortInput ? + ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : + 0; + } + + return compare & 4 ? -1 : 1; + } : + function( a, b ) { + // Exit early if the nodes are identical + if ( a === b ) { + hasDuplicate = true; + return 0; + } + + var cur, + i = 0, + aup = a.parentNode, + bup = b.parentNode, + ap = [ a ], + bp = [ b ]; + + // Parentless nodes are either documents or disconnected + if ( !aup || !bup ) { + return a === document ? -1 : + b === document ? 1 : + aup ? -1 : + bup ? 1 : + sortInput ? + ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : + 0; + + // If the nodes are siblings, we can do a quick check + } else if ( aup === bup ) { + return siblingCheck( a, b ); + } + + // Otherwise we need full lists of their ancestors for comparison + cur = a; + while ( (cur = cur.parentNode) ) { + ap.unshift( cur ); + } + cur = b; + while ( (cur = cur.parentNode) ) { + bp.unshift( cur ); + } + + // Walk down the tree looking for a discrepancy + while ( ap[i] === bp[i] ) { + i++; + } + + return i ? + // Do a sibling check if the nodes have a common ancestor + siblingCheck( ap[i], bp[i] ) : + + // Otherwise nodes in our document sort first + ap[i] === preferredDoc ? -1 : + bp[i] === preferredDoc ? 1 : + 0; + }; + + return document; +}; + +Sizzle.matches = function( expr, elements ) { + return Sizzle( expr, null, null, elements ); +}; + +Sizzle.matchesSelector = function( elem, expr ) { + // Set document vars if needed + if ( ( elem.ownerDocument || elem ) !== document ) { + setDocument( elem ); + } + + // Make sure that attribute selectors are quoted + expr = expr.replace( rattributeQuotes, "='$1']" ); + + if ( support.matchesSelector && documentIsHTML && + !compilerCache[ expr + " " ] && + ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) && + ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) { + + try { + var ret = matches.call( elem, expr ); + + // IE 9's matchesSelector returns false on disconnected nodes + if ( ret || support.disconnectedMatch || + // As well, disconnected nodes are said to be in a document + // fragment in IE 9 + elem.document && elem.document.nodeType !== 11 ) { + return ret; + } + } catch (e) {} + } + + return Sizzle( expr, document, null, [ elem ] ).length > 0; +}; + +Sizzle.contains = function( context, elem ) { + // Set document vars if needed + if ( ( context.ownerDocument || context ) !== document ) { + setDocument( context ); + } + return contains( context, elem ); +}; + +Sizzle.attr = function( elem, name ) { + // Set document vars if needed + if ( ( elem.ownerDocument || elem ) !== document ) { + setDocument( elem ); + } + + var fn = Expr.attrHandle[ name.toLowerCase() ], + // Don't get fooled by Object.prototype properties (jQuery #13807) + val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ? + fn( elem, name, !documentIsHTML ) : + undefined; + + return val !== undefined ? + val : + support.attributes || !documentIsHTML ? + elem.getAttribute( name ) : + (val = elem.getAttributeNode(name)) && val.specified ? + val.value : + null; +}; + +Sizzle.error = function( msg ) { + throw new Error( "Syntax error, unrecognized expression: " + msg ); +}; + +/** + * Document sorting and removing duplicates + * @param {ArrayLike} results + */ +Sizzle.uniqueSort = function( results ) { + var elem, + duplicates = [], + j = 0, + i = 0; + + // Unless we *know* we can detect duplicates, assume their presence + hasDuplicate = !support.detectDuplicates; + sortInput = !support.sortStable && results.slice( 0 ); + results.sort( sortOrder ); + + if ( hasDuplicate ) { + while ( (elem = results[i++]) ) { + if ( elem === results[ i ] ) { + j = duplicates.push( i ); + } + } + while ( j-- ) { + results.splice( duplicates[ j ], 1 ); + } + } + + // Clear input after sorting to release objects + // See https://github.com/jquery/sizzle/pull/225 + sortInput = null; + + return results; +}; + +/** + * Utility function for retrieving the text value of an array of DOM nodes + * @param {Array|Element} elem + */ +getText = Sizzle.getText = function( elem ) { + var node, + ret = "", + i = 0, + nodeType = elem.nodeType; + + if ( !nodeType ) { + // If no nodeType, this is expected to be an array + while ( (node = elem[i++]) ) { + // Do not traverse comment nodes + ret += getText( node ); + } + } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) { + // Use textContent for elements + // innerText usage removed for consistency of new lines (jQuery #11153) + if ( typeof elem.textContent === "string" ) { + return elem.textContent; + } else { + // Traverse its children + for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { + ret += getText( elem ); + } + } + } else if ( nodeType === 3 || nodeType === 4 ) { + return elem.nodeValue; + } + // Do not include comment or processing instruction nodes + + return ret; +}; + +Expr = Sizzle.selectors = { + + // Can be adjusted by the user + cacheLength: 50, + + createPseudo: markFunction, + + match: matchExpr, + + attrHandle: {}, + + find: {}, + + relative: { + ">": { dir: "parentNode", first: true }, + " ": { dir: "parentNode" }, + "+": { dir: "previousSibling", first: true }, + "~": { dir: "previousSibling" } + }, + + preFilter: { + "ATTR": function( match ) { + match[1] = match[1].replace( runescape, funescape ); + + // Move the given value to match[3] whether quoted or unquoted + match[3] = ( match[3] || match[4] || match[5] || "" ).replace( runescape, funescape ); + + if ( match[2] === "~=" ) { + match[3] = " " + match[3] + " "; + } + + return match.slice( 0, 4 ); + }, + + "CHILD": function( match ) { + /* matches from matchExpr["CHILD"] + 1 type (only|nth|...) + 2 what (child|of-type) + 3 argument (even|odd|\d*|\d*n([+-]\d+)?|...) + 4 xn-component of xn+y argument ([+-]?\d*n|) + 5 sign of xn-component + 6 x of xn-component + 7 sign of y-component + 8 y of y-component + */ + match[1] = match[1].toLowerCase(); + + if ( match[1].slice( 0, 3 ) === "nth" ) { + // nth-* requires argument + if ( !match[3] ) { + Sizzle.error( match[0] ); + } + + // numeric x and y parameters for Expr.filter.CHILD + // remember that false/true cast respectively to 0/1 + match[4] = +( match[4] ? match[5] + (match[6] || 1) : 2 * ( match[3] === "even" || match[3] === "odd" ) ); + match[5] = +( ( match[7] + match[8] ) || match[3] === "odd" ); + + // other types prohibit arguments + } else if ( match[3] ) { + Sizzle.error( match[0] ); + } + + return match; + }, + + "PSEUDO": function( match ) { + var excess, + unquoted = !match[6] && match[2]; + + if ( matchExpr["CHILD"].test( match[0] ) ) { + return null; + } + + // Accept quoted arguments as-is + if ( match[3] ) { + match[2] = match[4] || match[5] || ""; + + // Strip excess characters from unquoted arguments + } else if ( unquoted && rpseudo.test( unquoted ) && + // Get excess from tokenize (recursively) + (excess = tokenize( unquoted, true )) && + // advance to the next closing parenthesis + (excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) { + + // excess is a negative index + match[0] = match[0].slice( 0, excess ); + match[2] = unquoted.slice( 0, excess ); + } + + // Return only captures needed by the pseudo filter method (type and argument) + return match.slice( 0, 3 ); + } + }, + + filter: { + + "TAG": function( nodeNameSelector ) { + var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase(); + return nodeNameSelector === "*" ? + function() { return true; } : + function( elem ) { + return elem.nodeName && elem.nodeName.toLowerCase() === nodeName; + }; + }, + + "CLASS": function( className ) { + var pattern = classCache[ className + " " ]; + + return pattern || + (pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) && + classCache( className, function( elem ) { + return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== "undefined" && elem.getAttribute("class") || "" ); + }); + }, + + "ATTR": function( name, operator, check ) { + return function( elem ) { + var result = Sizzle.attr( elem, name ); + + if ( result == null ) { + return operator === "!="; + } + if ( !operator ) { + return true; + } + + result += ""; + + return operator === "=" ? result === check : + operator === "!=" ? result !== check : + operator === "^=" ? check && result.indexOf( check ) === 0 : + operator === "*=" ? check && result.indexOf( check ) > -1 : + operator === "$=" ? check && result.slice( -check.length ) === check : + operator === "~=" ? ( " " + result.replace( rwhitespace, " " ) + " " ).indexOf( check ) > -1 : + operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" : + false; + }; + }, + + "CHILD": function( type, what, argument, first, last ) { + var simple = type.slice( 0, 3 ) !== "nth", + forward = type.slice( -4 ) !== "last", + ofType = what === "of-type"; + + return first === 1 && last === 0 ? + + // Shortcut for :nth-*(n) + function( elem ) { + return !!elem.parentNode; + } : + + function( elem, context, xml ) { + var cache, uniqueCache, outerCache, node, nodeIndex, start, + dir = simple !== forward ? "nextSibling" : "previousSibling", + parent = elem.parentNode, + name = ofType && elem.nodeName.toLowerCase(), + useCache = !xml && !ofType, + diff = false; + + if ( parent ) { + + // :(first|last|only)-(child|of-type) + if ( simple ) { + while ( dir ) { + node = elem; + while ( (node = node[ dir ]) ) { + if ( ofType ? + node.nodeName.toLowerCase() === name : + node.nodeType === 1 ) { + + return false; + } + } + // Reverse direction for :only-* (if we haven't yet done so) + start = dir = type === "only" && !start && "nextSibling"; + } + return true; + } + + start = [ forward ? parent.firstChild : parent.lastChild ]; + + // non-xml :nth-child(...) stores cache data on `parent` + if ( forward && useCache ) { + + // Seek `elem` from a previously-cached index + + // ...in a gzip-friendly way + node = parent; + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + cache = uniqueCache[ type ] || []; + nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ]; + diff = nodeIndex && cache[ 2 ]; + node = nodeIndex && parent.childNodes[ nodeIndex ]; + + while ( (node = ++nodeIndex && node && node[ dir ] || + + // Fallback to seeking `elem` from the start + (diff = nodeIndex = 0) || start.pop()) ) { + + // When found, cache indexes on `parent` and break + if ( node.nodeType === 1 && ++diff && node === elem ) { + uniqueCache[ type ] = [ dirruns, nodeIndex, diff ]; + break; + } + } + + } else { + // Use previously-cached element index if available + if ( useCache ) { + // ...in a gzip-friendly way + node = elem; + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + cache = uniqueCache[ type ] || []; + nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ]; + diff = nodeIndex; + } + + // xml :nth-child(...) + // or :nth-last-child(...) or :nth(-last)?-of-type(...) + if ( diff === false ) { + // Use the same loop as above to seek `elem` from the start + while ( (node = ++nodeIndex && node && node[ dir ] || + (diff = nodeIndex = 0) || start.pop()) ) { + + if ( ( ofType ? + node.nodeName.toLowerCase() === name : + node.nodeType === 1 ) && + ++diff ) { + + // Cache the index of each encountered element + if ( useCache ) { + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + uniqueCache[ type ] = [ dirruns, diff ]; + } + + if ( node === elem ) { + break; + } + } + } + } + } + + // Incorporate the offset, then check against cycle size + diff -= last; + return diff === first || ( diff % first === 0 && diff / first >= 0 ); + } + }; + }, + + "PSEUDO": function( pseudo, argument ) { + // pseudo-class names are case-insensitive + // http://www.w3.org/TR/selectors/#pseudo-classes + // Prioritize by case sensitivity in case custom pseudos are added with uppercase letters + // Remember that setFilters inherits from pseudos + var args, + fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] || + Sizzle.error( "unsupported pseudo: " + pseudo ); + + // The user may use createPseudo to indicate that + // arguments are needed to create the filter function + // just as Sizzle does + if ( fn[ expando ] ) { + return fn( argument ); + } + + // But maintain support for old signatures + if ( fn.length > 1 ) { + args = [ pseudo, pseudo, "", argument ]; + return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ? + markFunction(function( seed, matches ) { + var idx, + matched = fn( seed, argument ), + i = matched.length; + while ( i-- ) { + idx = indexOf( seed, matched[i] ); + seed[ idx ] = !( matches[ idx ] = matched[i] ); + } + }) : + function( elem ) { + return fn( elem, 0, args ); + }; + } + + return fn; + } + }, + + pseudos: { + // Potentially complex pseudos + "not": markFunction(function( selector ) { + // Trim the selector passed to compile + // to avoid treating leading and trailing + // spaces as combinators + var input = [], + results = [], + matcher = compile( selector.replace( rtrim, "$1" ) ); + + return matcher[ expando ] ? + markFunction(function( seed, matches, context, xml ) { + var elem, + unmatched = matcher( seed, null, xml, [] ), + i = seed.length; + + // Match elements unmatched by `matcher` + while ( i-- ) { + if ( (elem = unmatched[i]) ) { + seed[i] = !(matches[i] = elem); + } + } + }) : + function( elem, context, xml ) { + input[0] = elem; + matcher( input, null, xml, results ); + // Don't keep the element (issue #299) + input[0] = null; + return !results.pop(); + }; + }), + + "has": markFunction(function( selector ) { + return function( elem ) { + return Sizzle( selector, elem ).length > 0; + }; + }), + + "contains": markFunction(function( text ) { + text = text.replace( runescape, funescape ); + return function( elem ) { + return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1; + }; + }), + + // "Whether an element is represented by a :lang() selector + // is based solely on the element's language value + // being equal to the identifier C, + // or beginning with the identifier C immediately followed by "-". + // The matching of C against the element's language value is performed case-insensitively. + // The identifier C does not have to be a valid language name." + // http://www.w3.org/TR/selectors/#lang-pseudo + "lang": markFunction( function( lang ) { + // lang value must be a valid identifier + if ( !ridentifier.test(lang || "") ) { + Sizzle.error( "unsupported lang: " + lang ); + } + lang = lang.replace( runescape, funescape ).toLowerCase(); + return function( elem ) { + var elemLang; + do { + if ( (elemLang = documentIsHTML ? + elem.lang : + elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) { + + elemLang = elemLang.toLowerCase(); + return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0; + } + } while ( (elem = elem.parentNode) && elem.nodeType === 1 ); + return false; + }; + }), + + // Miscellaneous + "target": function( elem ) { + var hash = window.location && window.location.hash; + return hash && hash.slice( 1 ) === elem.id; + }, + + "root": function( elem ) { + return elem === docElem; + }, + + "focus": function( elem ) { + return elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex); + }, + + // Boolean properties + "enabled": function( elem ) { + return elem.disabled === false; + }, + + "disabled": function( elem ) { + return elem.disabled === true; + }, + + "checked": function( elem ) { + // In CSS3, :checked should return both checked and selected elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + var nodeName = elem.nodeName.toLowerCase(); + return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected); + }, + + "selected": function( elem ) { + // Accessing this property makes selected-by-default + // options in Safari work properly + if ( elem.parentNode ) { + elem.parentNode.selectedIndex; + } + + return elem.selected === true; + }, + + // Contents + "empty": function( elem ) { + // http://www.w3.org/TR/selectors/#empty-pseudo + // :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5), + // but not by others (comment: 8; processing instruction: 7; etc.) + // nodeType < 6 works because attributes (2) do not appear as children + for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { + if ( elem.nodeType < 6 ) { + return false; + } + } + return true; + }, + + "parent": function( elem ) { + return !Expr.pseudos["empty"]( elem ); + }, + + // Element/input types + "header": function( elem ) { + return rheader.test( elem.nodeName ); + }, + + "input": function( elem ) { + return rinputs.test( elem.nodeName ); + }, + + "button": function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === "button" || name === "button"; + }, + + "text": function( elem ) { + var attr; + return elem.nodeName.toLowerCase() === "input" && + elem.type === "text" && + + // Support: IE<8 + // New HTML5 attribute values (e.g., "search") appear with elem.type === "text" + ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === "text" ); + }, + + // Position-in-collection + "first": createPositionalPseudo(function() { + return [ 0 ]; + }), + + "last": createPositionalPseudo(function( matchIndexes, length ) { + return [ length - 1 ]; + }), + + "eq": createPositionalPseudo(function( matchIndexes, length, argument ) { + return [ argument < 0 ? argument + length : argument ]; + }), + + "even": createPositionalPseudo(function( matchIndexes, length ) { + var i = 0; + for ( ; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "odd": createPositionalPseudo(function( matchIndexes, length ) { + var i = 1; + for ( ; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "lt": createPositionalPseudo(function( matchIndexes, length, argument ) { + var i = argument < 0 ? argument + length : argument; + for ( ; --i >= 0; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "gt": createPositionalPseudo(function( matchIndexes, length, argument ) { + var i = argument < 0 ? argument + length : argument; + for ( ; ++i < length; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }) + } +}; + +Expr.pseudos["nth"] = Expr.pseudos["eq"]; + +// Add button/input type pseudos +for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) { + Expr.pseudos[ i ] = createInputPseudo( i ); +} +for ( i in { submit: true, reset: true } ) { + Expr.pseudos[ i ] = createButtonPseudo( i ); +} + +// Easy API for creating new setFilters +function setFilters() {} +setFilters.prototype = Expr.filters = Expr.pseudos; +Expr.setFilters = new setFilters(); + +tokenize = Sizzle.tokenize = function( selector, parseOnly ) { + var matched, match, tokens, type, + soFar, groups, preFilters, + cached = tokenCache[ selector + " " ]; + + if ( cached ) { + return parseOnly ? 0 : cached.slice( 0 ); + } + + soFar = selector; + groups = []; + preFilters = Expr.preFilter; + + while ( soFar ) { + + // Comma and first run + if ( !matched || (match = rcomma.exec( soFar )) ) { + if ( match ) { + // Don't consume trailing commas as valid + soFar = soFar.slice( match[0].length ) || soFar; + } + groups.push( (tokens = []) ); + } + + matched = false; + + // Combinators + if ( (match = rcombinators.exec( soFar )) ) { + matched = match.shift(); + tokens.push({ + value: matched, + // Cast descendant combinators to space + type: match[0].replace( rtrim, " " ) + }); + soFar = soFar.slice( matched.length ); + } + + // Filters + for ( type in Expr.filter ) { + if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] || + (match = preFilters[ type ]( match ))) ) { + matched = match.shift(); + tokens.push({ + value: matched, + type: type, + matches: match + }); + soFar = soFar.slice( matched.length ); + } + } + + if ( !matched ) { + break; + } + } + + // Return the length of the invalid excess + // if we're just parsing + // Otherwise, throw an error or return tokens + return parseOnly ? + soFar.length : + soFar ? + Sizzle.error( selector ) : + // Cache the tokens + tokenCache( selector, groups ).slice( 0 ); +}; + +function toSelector( tokens ) { + var i = 0, + len = tokens.length, + selector = ""; + for ( ; i < len; i++ ) { + selector += tokens[i].value; + } + return selector; +} + +function addCombinator( matcher, combinator, base ) { + var dir = combinator.dir, + checkNonElements = base && dir === "parentNode", + doneName = done++; + + return combinator.first ? + // Check against closest ancestor/preceding element + function( elem, context, xml ) { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + return matcher( elem, context, xml ); + } + } + } : + + // Check against all ancestor/preceding elements + function( elem, context, xml ) { + var oldCache, uniqueCache, outerCache, + newCache = [ dirruns, doneName ]; + + // We can't set arbitrary data on XML nodes, so they don't benefit from combinator caching + if ( xml ) { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + if ( matcher( elem, context, xml ) ) { + return true; + } + } + } + } else { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + outerCache = elem[ expando ] || (elem[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ elem.uniqueID ] || (outerCache[ elem.uniqueID ] = {}); + + if ( (oldCache = uniqueCache[ dir ]) && + oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) { + + // Assign to newCache so results back-propagate to previous elements + return (newCache[ 2 ] = oldCache[ 2 ]); + } else { + // Reuse newcache so results back-propagate to previous elements + uniqueCache[ dir ] = newCache; + + // A match means we're done; a fail means we have to keep checking + if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) { + return true; + } + } + } + } + } + }; +} + +function elementMatcher( matchers ) { + return matchers.length > 1 ? + function( elem, context, xml ) { + var i = matchers.length; + while ( i-- ) { + if ( !matchers[i]( elem, context, xml ) ) { + return false; + } + } + return true; + } : + matchers[0]; +} + +function multipleContexts( selector, contexts, results ) { + var i = 0, + len = contexts.length; + for ( ; i < len; i++ ) { + Sizzle( selector, contexts[i], results ); + } + return results; +} + +function condense( unmatched, map, filter, context, xml ) { + var elem, + newUnmatched = [], + i = 0, + len = unmatched.length, + mapped = map != null; + + for ( ; i < len; i++ ) { + if ( (elem = unmatched[i]) ) { + if ( !filter || filter( elem, context, xml ) ) { + newUnmatched.push( elem ); + if ( mapped ) { + map.push( i ); + } + } + } + } + + return newUnmatched; +} + +function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) { + if ( postFilter && !postFilter[ expando ] ) { + postFilter = setMatcher( postFilter ); + } + if ( postFinder && !postFinder[ expando ] ) { + postFinder = setMatcher( postFinder, postSelector ); + } + return markFunction(function( seed, results, context, xml ) { + var temp, i, elem, + preMap = [], + postMap = [], + preexisting = results.length, + + // Get initial elements from seed or context + elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ), + + // Prefilter to get matcher input, preserving a map for seed-results synchronization + matcherIn = preFilter && ( seed || !selector ) ? + condense( elems, preMap, preFilter, context, xml ) : + elems, + + matcherOut = matcher ? + // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results, + postFinder || ( seed ? preFilter : preexisting || postFilter ) ? + + // ...intermediate processing is necessary + [] : + + // ...otherwise use results directly + results : + matcherIn; + + // Find primary matches + if ( matcher ) { + matcher( matcherIn, matcherOut, context, xml ); + } + + // Apply postFilter + if ( postFilter ) { + temp = condense( matcherOut, postMap ); + postFilter( temp, [], context, xml ); + + // Un-match failing elements by moving them back to matcherIn + i = temp.length; + while ( i-- ) { + if ( (elem = temp[i]) ) { + matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem); + } + } + } + + if ( seed ) { + if ( postFinder || preFilter ) { + if ( postFinder ) { + // Get the final matcherOut by condensing this intermediate into postFinder contexts + temp = []; + i = matcherOut.length; + while ( i-- ) { + if ( (elem = matcherOut[i]) ) { + // Restore matcherIn since elem is not yet a final match + temp.push( (matcherIn[i] = elem) ); + } + } + postFinder( null, (matcherOut = []), temp, xml ); + } + + // Move matched elements from seed to results to keep them synchronized + i = matcherOut.length; + while ( i-- ) { + if ( (elem = matcherOut[i]) && + (temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) { + + seed[temp] = !(results[temp] = elem); + } + } + } + + // Add elements to results, through postFinder if defined + } else { + matcherOut = condense( + matcherOut === results ? + matcherOut.splice( preexisting, matcherOut.length ) : + matcherOut + ); + if ( postFinder ) { + postFinder( null, results, matcherOut, xml ); + } else { + push.apply( results, matcherOut ); + } + } + }); +} + +function matcherFromTokens( tokens ) { + var checkContext, matcher, j, + len = tokens.length, + leadingRelative = Expr.relative[ tokens[0].type ], + implicitRelative = leadingRelative || Expr.relative[" "], + i = leadingRelative ? 1 : 0, + + // The foundational matcher ensures that elements are reachable from top-level context(s) + matchContext = addCombinator( function( elem ) { + return elem === checkContext; + }, implicitRelative, true ), + matchAnyContext = addCombinator( function( elem ) { + return indexOf( checkContext, elem ) > -1; + }, implicitRelative, true ), + matchers = [ function( elem, context, xml ) { + var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || ( + (checkContext = context).nodeType ? + matchContext( elem, context, xml ) : + matchAnyContext( elem, context, xml ) ); + // Avoid hanging onto element (issue #299) + checkContext = null; + return ret; + } ]; + + for ( ; i < len; i++ ) { + if ( (matcher = Expr.relative[ tokens[i].type ]) ) { + matchers = [ addCombinator(elementMatcher( matchers ), matcher) ]; + } else { + matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches ); + + // Return special upon seeing a positional matcher + if ( matcher[ expando ] ) { + // Find the next relative operator (if any) for proper handling + j = ++i; + for ( ; j < len; j++ ) { + if ( Expr.relative[ tokens[j].type ] ) { + break; + } + } + return setMatcher( + i > 1 && elementMatcher( matchers ), + i > 1 && toSelector( + // If the preceding token was a descendant combinator, insert an implicit any-element `*` + tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" }) + ).replace( rtrim, "$1" ), + matcher, + i < j && matcherFromTokens( tokens.slice( i, j ) ), + j < len && matcherFromTokens( (tokens = tokens.slice( j )) ), + j < len && toSelector( tokens ) + ); + } + matchers.push( matcher ); + } + } + + return elementMatcher( matchers ); +} + +function matcherFromGroupMatchers( elementMatchers, setMatchers ) { + var bySet = setMatchers.length > 0, + byElement = elementMatchers.length > 0, + superMatcher = function( seed, context, xml, results, outermost ) { + var elem, j, matcher, + matchedCount = 0, + i = "0", + unmatched = seed && [], + setMatched = [], + contextBackup = outermostContext, + // We must always have either seed elements or outermost context + elems = seed || byElement && Expr.find["TAG"]( "*", outermost ), + // Use integer dirruns iff this is the outermost matcher + dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1), + len = elems.length; + + if ( outermost ) { + outermostContext = context === document || context || outermost; + } + + // Add elements passing elementMatchers directly to results + // Support: IE<9, Safari + // Tolerate NodeList properties (IE: "length"; Safari: <number>) matching elements by id + for ( ; i !== len && (elem = elems[i]) != null; i++ ) { + if ( byElement && elem ) { + j = 0; + if ( !context && elem.ownerDocument !== document ) { + setDocument( elem ); + xml = !documentIsHTML; + } + while ( (matcher = elementMatchers[j++]) ) { + if ( matcher( elem, context || document, xml) ) { + results.push( elem ); + break; + } + } + if ( outermost ) { + dirruns = dirrunsUnique; + } + } + + // Track unmatched elements for set filters + if ( bySet ) { + // They will have gone through all possible matchers + if ( (elem = !matcher && elem) ) { + matchedCount--; + } + + // Lengthen the array for every element, matched or not + if ( seed ) { + unmatched.push( elem ); + } + } + } + + // `i` is now the count of elements visited above, and adding it to `matchedCount` + // makes the latter nonnegative. + matchedCount += i; + + // Apply set filters to unmatched elements + // NOTE: This can be skipped if there are no unmatched elements (i.e., `matchedCount` + // equals `i`), unless we didn't visit _any_ elements in the above loop because we have + // no element matchers and no seed. + // Incrementing an initially-string "0" `i` allows `i` to remain a string only in that + // case, which will result in a "00" `matchedCount` that differs from `i` but is also + // numerically zero. + if ( bySet && i !== matchedCount ) { + j = 0; + while ( (matcher = setMatchers[j++]) ) { + matcher( unmatched, setMatched, context, xml ); + } + + if ( seed ) { + // Reintegrate element matches to eliminate the need for sorting + if ( matchedCount > 0 ) { + while ( i-- ) { + if ( !(unmatched[i] || setMatched[i]) ) { + setMatched[i] = pop.call( results ); + } + } + } + + // Discard index placeholder values to get only actual matches + setMatched = condense( setMatched ); + } + + // Add matches to results + push.apply( results, setMatched ); + + // Seedless set matches succeeding multiple successful matchers stipulate sorting + if ( outermost && !seed && setMatched.length > 0 && + ( matchedCount + setMatchers.length ) > 1 ) { + + Sizzle.uniqueSort( results ); + } + } + + // Override manipulation of globals by nested matchers + if ( outermost ) { + dirruns = dirrunsUnique; + outermostContext = contextBackup; + } + + return unmatched; + }; + + return bySet ? + markFunction( superMatcher ) : + superMatcher; +} + +compile = Sizzle.compile = function( selector, match /* Internal Use Only */ ) { + var i, + setMatchers = [], + elementMatchers = [], + cached = compilerCache[ selector + " " ]; + + if ( !cached ) { + // Generate a function of recursive functions that can be used to check each element + if ( !match ) { + match = tokenize( selector ); + } + i = match.length; + while ( i-- ) { + cached = matcherFromTokens( match[i] ); + if ( cached[ expando ] ) { + setMatchers.push( cached ); + } else { + elementMatchers.push( cached ); + } + } + + // Cache the compiled function + cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) ); + + // Save selector and tokenization + cached.selector = selector; + } + return cached; +}; + +/** + * A low-level selection function that works with Sizzle's compiled + * selector functions + * @param {String|Function} selector A selector or a pre-compiled + * selector function built with Sizzle.compile + * @param {Element} context + * @param {Array} [results] + * @param {Array} [seed] A set of elements to match against + */ +select = Sizzle.select = function( selector, context, results, seed ) { + var i, tokens, token, type, find, + compiled = typeof selector === "function" && selector, + match = !seed && tokenize( (selector = compiled.selector || selector) ); + + results = results || []; + + // Try to minimize operations if there is only one selector in the list and no seed + // (the latter of which guarantees us context) + if ( match.length === 1 ) { + + // Reduce context if the leading compound selector is an ID + tokens = match[0] = match[0].slice( 0 ); + if ( tokens.length > 2 && (token = tokens[0]).type === "ID" && + support.getById && context.nodeType === 9 && documentIsHTML && + Expr.relative[ tokens[1].type ] ) { + + context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0]; + if ( !context ) { + return results; + + // Precompiled matchers will still verify ancestry, so step up a level + } else if ( compiled ) { + context = context.parentNode; + } + + selector = selector.slice( tokens.shift().value.length ); + } + + // Fetch a seed set for right-to-left matching + i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length; + while ( i-- ) { + token = tokens[i]; + + // Abort if we hit a combinator + if ( Expr.relative[ (type = token.type) ] ) { + break; + } + if ( (find = Expr.find[ type ]) ) { + // Search, expanding context for leading sibling combinators + if ( (seed = find( + token.matches[0].replace( runescape, funescape ), + rsibling.test( tokens[0].type ) && testContext( context.parentNode ) || context + )) ) { + + // If seed is empty or no tokens remain, we can return early + tokens.splice( i, 1 ); + selector = seed.length && toSelector( tokens ); + if ( !selector ) { + push.apply( results, seed ); + return results; + } + + break; + } + } + } + } + + // Compile and execute a filtering function if one is not provided + // Provide `match` to avoid retokenization if we modified the selector above + ( compiled || compile( selector, match ) )( + seed, + context, + !documentIsHTML, + results, + !context || rsibling.test( selector ) && testContext( context.parentNode ) || context + ); + return results; +}; + +// One-time assignments + +// Sort stability +support.sortStable = expando.split("").sort( sortOrder ).join("") === expando; + +// Support: Chrome 14-35+ +// Always assume duplicates if they aren't passed to the comparison function +support.detectDuplicates = !!hasDuplicate; + +// Initialize against the default document +setDocument(); + +// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27) +// Detached nodes confoundingly follow *each other* +support.sortDetached = assert(function( div1 ) { + // Should return 1, but returns 4 (following) + return div1.compareDocumentPosition( document.createElement("div") ) & 1; +}); + +// Support: IE<8 +// Prevent attribute/property "interpolation" +// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx +if ( !assert(function( div ) { + div.innerHTML = "<a href='#'></a>"; + return div.firstChild.getAttribute("href") === "#" ; +}) ) { + addHandle( "type|href|height|width", function( elem, name, isXML ) { + if ( !isXML ) { + return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 ); + } + }); +} + +// Support: IE<9 +// Use defaultValue in place of getAttribute("value") +if ( !support.attributes || !assert(function( div ) { + div.innerHTML = "<input/>"; + div.firstChild.setAttribute( "value", "" ); + return div.firstChild.getAttribute( "value" ) === ""; +}) ) { + addHandle( "value", function( elem, name, isXML ) { + if ( !isXML && elem.nodeName.toLowerCase() === "input" ) { + return elem.defaultValue; + } + }); +} + +// Support: IE<9 +// Use getAttributeNode to fetch booleans when getAttribute lies +if ( !assert(function( div ) { + return div.getAttribute("disabled") == null; +}) ) { + addHandle( booleans, function( elem, name, isXML ) { + var val; + if ( !isXML ) { + return elem[ name ] === true ? name.toLowerCase() : + (val = elem.getAttributeNode( name )) && val.specified ? + val.value : + null; + } + }); +} + +return Sizzle; + +})( window ); + + + +jQuery.find = Sizzle; +jQuery.expr = Sizzle.selectors; +jQuery.expr[ ":" ] = jQuery.expr.pseudos; +jQuery.uniqueSort = jQuery.unique = Sizzle.uniqueSort; +jQuery.text = Sizzle.getText; +jQuery.isXMLDoc = Sizzle.isXML; +jQuery.contains = Sizzle.contains; + + + +var dir = function( elem, dir, until ) { + var matched = [], + truncate = until !== undefined; + + while ( ( elem = elem[ dir ] ) && elem.nodeType !== 9 ) { + if ( elem.nodeType === 1 ) { + if ( truncate && jQuery( elem ).is( until ) ) { + break; + } + matched.push( elem ); + } + } + return matched; +}; + + +var siblings = function( n, elem ) { + var matched = []; + + for ( ; n; n = n.nextSibling ) { + if ( n.nodeType === 1 && n !== elem ) { + matched.push( n ); + } + } + + return matched; +}; + + +var rneedsContext = jQuery.expr.match.needsContext; + +var rsingleTag = ( /^<([\w-]+)\s*\/?>(?:<\/\1>|)$/ ); + + + +var risSimple = /^.[^:#\[\.,]*$/; + +// Implement the identical functionality for filter and not +function winnow( elements, qualifier, not ) { + if ( jQuery.isFunction( qualifier ) ) { + return jQuery.grep( elements, function( elem, i ) { + /* jshint -W018 */ + return !!qualifier.call( elem, i, elem ) !== not; + } ); + + } + + if ( qualifier.nodeType ) { + return jQuery.grep( elements, function( elem ) { + return ( elem === qualifier ) !== not; + } ); + + } + + if ( typeof qualifier === "string" ) { + if ( risSimple.test( qualifier ) ) { + return jQuery.filter( qualifier, elements, not ); + } + + qualifier = jQuery.filter( qualifier, elements ); + } + + return jQuery.grep( elements, function( elem ) { + return ( indexOf.call( qualifier, elem ) > -1 ) !== not; + } ); +} + +jQuery.filter = function( expr, elems, not ) { + var elem = elems[ 0 ]; + + if ( not ) { + expr = ":not(" + expr + ")"; + } + + return elems.length === 1 && elem.nodeType === 1 ? + jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [] : + jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) { + return elem.nodeType === 1; + } ) ); +}; + +jQuery.fn.extend( { + find: function( selector ) { + var i, + len = this.length, + ret = [], + self = this; + + if ( typeof selector !== "string" ) { + return this.pushStack( jQuery( selector ).filter( function() { + for ( i = 0; i < len; i++ ) { + if ( jQuery.contains( self[ i ], this ) ) { + return true; + } + } + } ) ); + } + + for ( i = 0; i < len; i++ ) { + jQuery.find( selector, self[ i ], ret ); + } + + // Needed because $( selector, context ) becomes $( context ).find( selector ) + ret = this.pushStack( len > 1 ? jQuery.unique( ret ) : ret ); + ret.selector = this.selector ? this.selector + " " + selector : selector; + return ret; + }, + filter: function( selector ) { + return this.pushStack( winnow( this, selector || [], false ) ); + }, + not: function( selector ) { + return this.pushStack( winnow( this, selector || [], true ) ); + }, + is: function( selector ) { + return !!winnow( + this, + + // If this is a positional/relative selector, check membership in the returned set + // so $("p:first").is("p:last") won't return true for a doc with two "p". + typeof selector === "string" && rneedsContext.test( selector ) ? + jQuery( selector ) : + selector || [], + false + ).length; + } +} ); + + +// Initialize a jQuery object + + +// A central reference to the root jQuery(document) +var rootjQuery, + + // A simple way to check for HTML strings + // Prioritize #id over <tag> to avoid XSS via location.hash (#9521) + // Strict HTML recognition (#11290: must start with <) + rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/, + + init = jQuery.fn.init = function( selector, context, root ) { + var match, elem; + + // HANDLE: $(""), $(null), $(undefined), $(false) + if ( !selector ) { + return this; + } + + // Method init() accepts an alternate rootjQuery + // so migrate can support jQuery.sub (gh-2101) + root = root || rootjQuery; + + // Handle HTML strings + if ( typeof selector === "string" ) { + if ( selector[ 0 ] === "<" && + selector[ selector.length - 1 ] === ">" && + selector.length >= 3 ) { + + // Assume that strings that start and end with <> are HTML and skip the regex check + match = [ null, selector, null ]; + + } else { + match = rquickExpr.exec( selector ); + } + + // Match html or make sure no context is specified for #id + if ( match && ( match[ 1 ] || !context ) ) { + + // HANDLE: $(html) -> $(array) + if ( match[ 1 ] ) { + context = context instanceof jQuery ? context[ 0 ] : context; + + // Option to run scripts is true for back-compat + // Intentionally let the error be thrown if parseHTML is not present + jQuery.merge( this, jQuery.parseHTML( + match[ 1 ], + context && context.nodeType ? context.ownerDocument || context : document, + true + ) ); + + // HANDLE: $(html, props) + if ( rsingleTag.test( match[ 1 ] ) && jQuery.isPlainObject( context ) ) { + for ( match in context ) { + + // Properties of context are called as methods if possible + if ( jQuery.isFunction( this[ match ] ) ) { + this[ match ]( context[ match ] ); + + // ...and otherwise set as attributes + } else { + this.attr( match, context[ match ] ); + } + } + } + + return this; + + // HANDLE: $(#id) + } else { + elem = document.getElementById( match[ 2 ] ); + + // Support: Blackberry 4.6 + // gEBID returns nodes no longer in the document (#6963) + if ( elem && elem.parentNode ) { + + // Inject the element directly into the jQuery object + this.length = 1; + this[ 0 ] = elem; + } + + this.context = document; + this.selector = selector; + return this; + } + + // HANDLE: $(expr, $(...)) + } else if ( !context || context.jquery ) { + return ( context || root ).find( selector ); + + // HANDLE: $(expr, context) + // (which is just equivalent to: $(context).find(expr) + } else { + return this.constructor( context ).find( selector ); + } + + // HANDLE: $(DOMElement) + } else if ( selector.nodeType ) { + this.context = this[ 0 ] = selector; + this.length = 1; + return this; + + // HANDLE: $(function) + // Shortcut for document ready + } else if ( jQuery.isFunction( selector ) ) { + return root.ready !== undefined ? + root.ready( selector ) : + + // Execute immediately if ready is not present + selector( jQuery ); + } + + if ( selector.selector !== undefined ) { + this.selector = selector.selector; + this.context = selector.context; + } + + return jQuery.makeArray( selector, this ); + }; + +// Give the init function the jQuery prototype for later instantiation +init.prototype = jQuery.fn; + +// Initialize central reference +rootjQuery = jQuery( document ); + + +var rparentsprev = /^(?:parents|prev(?:Until|All))/, + + // Methods guaranteed to produce a unique set when starting from a unique set + guaranteedUnique = { + children: true, + contents: true, + next: true, + prev: true + }; + +jQuery.fn.extend( { + has: function( target ) { + var targets = jQuery( target, this ), + l = targets.length; + + return this.filter( function() { + var i = 0; + for ( ; i < l; i++ ) { + if ( jQuery.contains( this, targets[ i ] ) ) { + return true; + } + } + } ); + }, + + closest: function( selectors, context ) { + var cur, + i = 0, + l = this.length, + matched = [], + pos = rneedsContext.test( selectors ) || typeof selectors !== "string" ? + jQuery( selectors, context || this.context ) : + 0; + + for ( ; i < l; i++ ) { + for ( cur = this[ i ]; cur && cur !== context; cur = cur.parentNode ) { + + // Always skip document fragments + if ( cur.nodeType < 11 && ( pos ? + pos.index( cur ) > -1 : + + // Don't pass non-elements to Sizzle + cur.nodeType === 1 && + jQuery.find.matchesSelector( cur, selectors ) ) ) { + + matched.push( cur ); + break; + } + } + } + + return this.pushStack( matched.length > 1 ? jQuery.uniqueSort( matched ) : matched ); + }, + + // Determine the position of an element within the set + index: function( elem ) { + + // No argument, return index in parent + if ( !elem ) { + return ( this[ 0 ] && this[ 0 ].parentNode ) ? this.first().prevAll().length : -1; + } + + // Index in selector + if ( typeof elem === "string" ) { + return indexOf.call( jQuery( elem ), this[ 0 ] ); + } + + // Locate the position of the desired element + return indexOf.call( this, + + // If it receives a jQuery object, the first element is used + elem.jquery ? elem[ 0 ] : elem + ); + }, + + add: function( selector, context ) { + return this.pushStack( + jQuery.uniqueSort( + jQuery.merge( this.get(), jQuery( selector, context ) ) + ) + ); + }, + + addBack: function( selector ) { + return this.add( selector == null ? + this.prevObject : this.prevObject.filter( selector ) + ); + } +} ); + +function sibling( cur, dir ) { + while ( ( cur = cur[ dir ] ) && cur.nodeType !== 1 ) {} + return cur; +} + +jQuery.each( { + parent: function( elem ) { + var parent = elem.parentNode; + return parent && parent.nodeType !== 11 ? parent : null; + }, + parents: function( elem ) { + return dir( elem, "parentNode" ); + }, + parentsUntil: function( elem, i, until ) { + return dir( elem, "parentNode", until ); + }, + next: function( elem ) { + return sibling( elem, "nextSibling" ); + }, + prev: function( elem ) { + return sibling( elem, "previousSibling" ); + }, + nextAll: function( elem ) { + return dir( elem, "nextSibling" ); + }, + prevAll: function( elem ) { + return dir( elem, "previousSibling" ); + }, + nextUntil: function( elem, i, until ) { + return dir( elem, "nextSibling", until ); + }, + prevUntil: function( elem, i, until ) { + return dir( elem, "previousSibling", until ); + }, + siblings: function( elem ) { + return siblings( ( elem.parentNode || {} ).firstChild, elem ); + }, + children: function( elem ) { + return siblings( elem.firstChild ); + }, + contents: function( elem ) { + return elem.contentDocument || jQuery.merge( [], elem.childNodes ); + } +}, function( name, fn ) { + jQuery.fn[ name ] = function( until, selector ) { + var matched = jQuery.map( this, fn, until ); + + if ( name.slice( -5 ) !== "Until" ) { + selector = until; + } + + if ( selector && typeof selector === "string" ) { + matched = jQuery.filter( selector, matched ); + } + + if ( this.length > 1 ) { + + // Remove duplicates + if ( !guaranteedUnique[ name ] ) { + jQuery.uniqueSort( matched ); + } + + // Reverse order for parents* and prev-derivatives + if ( rparentsprev.test( name ) ) { + matched.reverse(); + } + } + + return this.pushStack( matched ); + }; +} ); +var rnotwhite = ( /\S+/g ); + + + +// Convert String-formatted options into Object-formatted ones +function createOptions( options ) { + var object = {}; + jQuery.each( options.match( rnotwhite ) || [], function( _, flag ) { + object[ flag ] = true; + } ); + return object; +} + +/* + * Create a callback list using the following parameters: + * + * options: an optional list of space-separated options that will change how + * the callback list behaves or a more traditional option object + * + * By default a callback list will act like an event callback list and can be + * "fired" multiple times. + * + * Possible options: + * + * once: will ensure the callback list can only be fired once (like a Deferred) + * + * memory: will keep track of previous values and will call any callback added + * after the list has been fired right away with the latest "memorized" + * values (like a Deferred) + * + * unique: will ensure a callback can only be added once (no duplicate in the list) + * + * stopOnFalse: interrupt callings when a callback returns false + * + */ +jQuery.Callbacks = function( options ) { + + // Convert options from String-formatted to Object-formatted if needed + // (we check in cache first) + options = typeof options === "string" ? + createOptions( options ) : + jQuery.extend( {}, options ); + + var // Flag to know if list is currently firing + firing, + + // Last fire value for non-forgettable lists + memory, + + // Flag to know if list was already fired + fired, + + // Flag to prevent firing + locked, + + // Actual callback list + list = [], + + // Queue of execution data for repeatable lists + queue = [], + + // Index of currently firing callback (modified by add/remove as needed) + firingIndex = -1, + + // Fire callbacks + fire = function() { + + // Enforce single-firing + locked = options.once; + + // Execute callbacks for all pending executions, + // respecting firingIndex overrides and runtime changes + fired = firing = true; + for ( ; queue.length; firingIndex = -1 ) { + memory = queue.shift(); + while ( ++firingIndex < list.length ) { + + // Run callback and check for early termination + if ( list[ firingIndex ].apply( memory[ 0 ], memory[ 1 ] ) === false && + options.stopOnFalse ) { + + // Jump to end and forget the data so .add doesn't re-fire + firingIndex = list.length; + memory = false; + } + } + } + + // Forget the data if we're done with it + if ( !options.memory ) { + memory = false; + } + + firing = false; + + // Clean up if we're done firing for good + if ( locked ) { + + // Keep an empty list if we have data for future add calls + if ( memory ) { + list = []; + + // Otherwise, this object is spent + } else { + list = ""; + } + } + }, + + // Actual Callbacks object + self = { + + // Add a callback or a collection of callbacks to the list + add: function() { + if ( list ) { + + // If we have memory from a past run, we should fire after adding + if ( memory && !firing ) { + firingIndex = list.length - 1; + queue.push( memory ); + } + + ( function add( args ) { + jQuery.each( args, function( _, arg ) { + if ( jQuery.isFunction( arg ) ) { + if ( !options.unique || !self.has( arg ) ) { + list.push( arg ); + } + } else if ( arg && arg.length && jQuery.type( arg ) !== "string" ) { + + // Inspect recursively + add( arg ); + } + } ); + } )( arguments ); + + if ( memory && !firing ) { + fire(); + } + } + return this; + }, + + // Remove a callback from the list + remove: function() { + jQuery.each( arguments, function( _, arg ) { + var index; + while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { + list.splice( index, 1 ); + + // Handle firing indexes + if ( index <= firingIndex ) { + firingIndex--; + } + } + } ); + return this; + }, + + // Check if a given callback is in the list. + // If no argument is given, return whether or not list has callbacks attached. + has: function( fn ) { + return fn ? + jQuery.inArray( fn, list ) > -1 : + list.length > 0; + }, + + // Remove all callbacks from the list + empty: function() { + if ( list ) { + list = []; + } + return this; + }, + + // Disable .fire and .add + // Abort any current/pending executions + // Clear all callbacks and values + disable: function() { + locked = queue = []; + list = memory = ""; + return this; + }, + disabled: function() { + return !list; + }, + + // Disable .fire + // Also disable .add unless we have memory (since it would have no effect) + // Abort any pending executions + lock: function() { + locked = queue = []; + if ( !memory ) { + list = memory = ""; + } + return this; + }, + locked: function() { + return !!locked; + }, + + // Call all callbacks with the given context and arguments + fireWith: function( context, args ) { + if ( !locked ) { + args = args || []; + args = [ context, args.slice ? args.slice() : args ]; + queue.push( args ); + if ( !firing ) { + fire(); + } + } + return this; + }, + + // Call all the callbacks with the given arguments + fire: function() { + self.fireWith( this, arguments ); + return this; + }, + + // To know if the callbacks have already been called at least once + fired: function() { + return !!fired; + } + }; + + return self; +}; + + +jQuery.extend( { + + Deferred: function( func ) { + var tuples = [ + + // action, add listener, listener list, final state + [ "resolve", "done", jQuery.Callbacks( "once memory" ), "resolved" ], + [ "reject", "fail", jQuery.Callbacks( "once memory" ), "rejected" ], + [ "notify", "progress", jQuery.Callbacks( "memory" ) ] + ], + state = "pending", + promise = { + state: function() { + return state; + }, + always: function() { + deferred.done( arguments ).fail( arguments ); + return this; + }, + then: function( /* fnDone, fnFail, fnProgress */ ) { + var fns = arguments; + return jQuery.Deferred( function( newDefer ) { + jQuery.each( tuples, function( i, tuple ) { + var fn = jQuery.isFunction( fns[ i ] ) && fns[ i ]; + + // deferred[ done | fail | progress ] for forwarding actions to newDefer + deferred[ tuple[ 1 ] ]( function() { + var returned = fn && fn.apply( this, arguments ); + if ( returned && jQuery.isFunction( returned.promise ) ) { + returned.promise() + .progress( newDefer.notify ) + .done( newDefer.resolve ) + .fail( newDefer.reject ); + } else { + newDefer[ tuple[ 0 ] + "With" ]( + this === promise ? newDefer.promise() : this, + fn ? [ returned ] : arguments + ); + } + } ); + } ); + fns = null; + } ).promise(); + }, + + // Get a promise for this deferred + // If obj is provided, the promise aspect is added to the object + promise: function( obj ) { + return obj != null ? jQuery.extend( obj, promise ) : promise; + } + }, + deferred = {}; + + // Keep pipe for back-compat + promise.pipe = promise.then; + + // Add list-specific methods + jQuery.each( tuples, function( i, tuple ) { + var list = tuple[ 2 ], + stateString = tuple[ 3 ]; + + // promise[ done | fail | progress ] = list.add + promise[ tuple[ 1 ] ] = list.add; + + // Handle state + if ( stateString ) { + list.add( function() { + + // state = [ resolved | rejected ] + state = stateString; + + // [ reject_list | resolve_list ].disable; progress_list.lock + }, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock ); + } + + // deferred[ resolve | reject | notify ] + deferred[ tuple[ 0 ] ] = function() { + deferred[ tuple[ 0 ] + "With" ]( this === deferred ? promise : this, arguments ); + return this; + }; + deferred[ tuple[ 0 ] + "With" ] = list.fireWith; + } ); + + // Make the deferred a promise + promise.promise( deferred ); + + // Call given func if any + if ( func ) { + func.call( deferred, deferred ); + } + + // All done! + return deferred; + }, + + // Deferred helper + when: function( subordinate /* , ..., subordinateN */ ) { + var i = 0, + resolveValues = slice.call( arguments ), + length = resolveValues.length, + + // the count of uncompleted subordinates + remaining = length !== 1 || + ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0, + + // the master Deferred. + // If resolveValues consist of only a single Deferred, just use that. + deferred = remaining === 1 ? subordinate : jQuery.Deferred(), + + // Update function for both resolve and progress values + updateFunc = function( i, contexts, values ) { + return function( value ) { + contexts[ i ] = this; + values[ i ] = arguments.length > 1 ? slice.call( arguments ) : value; + if ( values === progressValues ) { + deferred.notifyWith( contexts, values ); + } else if ( !( --remaining ) ) { + deferred.resolveWith( contexts, values ); + } + }; + }, + + progressValues, progressContexts, resolveContexts; + + // Add listeners to Deferred subordinates; treat others as resolved + if ( length > 1 ) { + progressValues = new Array( length ); + progressContexts = new Array( length ); + resolveContexts = new Array( length ); + for ( ; i < length; i++ ) { + if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) { + resolveValues[ i ].promise() + .progress( updateFunc( i, progressContexts, progressValues ) ) + .done( updateFunc( i, resolveContexts, resolveValues ) ) + .fail( deferred.reject ); + } else { + --remaining; + } + } + } + + // If we're not waiting on anything, resolve the master + if ( !remaining ) { + deferred.resolveWith( resolveContexts, resolveValues ); + } + + return deferred.promise(); + } +} ); + + +// The deferred used on DOM ready +var readyList; + +jQuery.fn.ready = function( fn ) { + + // Add the callback + jQuery.ready.promise().done( fn ); + + return this; +}; + +jQuery.extend( { + + // Is the DOM ready to be used? Set to true once it occurs. + isReady: false, + + // A counter to track how many items to wait for before + // the ready event fires. See #6781 + readyWait: 1, + + // Hold (or release) the ready event + holdReady: function( hold ) { + if ( hold ) { + jQuery.readyWait++; + } else { + jQuery.ready( true ); + } + }, + + // Handle when the DOM is ready + ready: function( wait ) { + + // Abort if there are pending holds or we're already ready + if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) { + return; + } + + // Remember that the DOM is ready + jQuery.isReady = true; + + // If a normal DOM Ready event fired, decrement, and wait if need be + if ( wait !== true && --jQuery.readyWait > 0 ) { + return; + } + + // If there are functions bound, to execute + readyList.resolveWith( document, [ jQuery ] ); + + // Trigger any bound ready events + if ( jQuery.fn.triggerHandler ) { + jQuery( document ).triggerHandler( "ready" ); + jQuery( document ).off( "ready" ); + } + } +} ); + +/** + * The ready event handler and self cleanup method + */ +function completed() { + document.removeEventListener( "DOMContentLoaded", completed ); + window.removeEventListener( "load", completed ); + jQuery.ready(); +} + +jQuery.ready.promise = function( obj ) { + if ( !readyList ) { + + readyList = jQuery.Deferred(); + + // Catch cases where $(document).ready() is called + // after the browser event has already occurred. + // Support: IE9-10 only + // Older IE sometimes signals "interactive" too soon + if ( document.readyState === "complete" || + ( document.readyState !== "loading" && !document.documentElement.doScroll ) ) { + + // Handle it asynchronously to allow scripts the opportunity to delay ready + window.setTimeout( jQuery.ready ); + + } else { + + // Use the handy event callback + document.addEventListener( "DOMContentLoaded", completed ); + + // A fallback to window.onload, that will always work + window.addEventListener( "load", completed ); + } + } + return readyList.promise( obj ); +}; + +// Kick off the DOM ready check even if the user does not +jQuery.ready.promise(); + + + + +// Multifunctional method to get and set values of a collection +// The value/s can optionally be executed if it's a function +var access = function( elems, fn, key, value, chainable, emptyGet, raw ) { + var i = 0, + len = elems.length, + bulk = key == null; + + // Sets many values + if ( jQuery.type( key ) === "object" ) { + chainable = true; + for ( i in key ) { + access( elems, fn, i, key[ i ], true, emptyGet, raw ); + } + + // Sets one value + } else if ( value !== undefined ) { + chainable = true; + + if ( !jQuery.isFunction( value ) ) { + raw = true; + } + + if ( bulk ) { + + // Bulk operations run against the entire set + if ( raw ) { + fn.call( elems, value ); + fn = null; + + // ...except when executing function values + } else { + bulk = fn; + fn = function( elem, key, value ) { + return bulk.call( jQuery( elem ), value ); + }; + } + } + + if ( fn ) { + for ( ; i < len; i++ ) { + fn( + elems[ i ], key, raw ? + value : + value.call( elems[ i ], i, fn( elems[ i ], key ) ) + ); + } + } + } + + return chainable ? + elems : + + // Gets + bulk ? + fn.call( elems ) : + len ? fn( elems[ 0 ], key ) : emptyGet; +}; +var acceptData = function( owner ) { + + // Accepts only: + // - Node + // - Node.ELEMENT_NODE + // - Node.DOCUMENT_NODE + // - Object + // - Any + /* jshint -W018 */ + return owner.nodeType === 1 || owner.nodeType === 9 || !( +owner.nodeType ); +}; + + + + +function Data() { + this.expando = jQuery.expando + Data.uid++; +} + +Data.uid = 1; + +Data.prototype = { + + register: function( owner, initial ) { + var value = initial || {}; + + // If it is a node unlikely to be stringify-ed or looped over + // use plain assignment + if ( owner.nodeType ) { + owner[ this.expando ] = value; + + // Otherwise secure it in a non-enumerable, non-writable property + // configurability must be true to allow the property to be + // deleted with the delete operator + } else { + Object.defineProperty( owner, this.expando, { + value: value, + writable: true, + configurable: true + } ); + } + return owner[ this.expando ]; + }, + cache: function( owner ) { + + // We can accept data for non-element nodes in modern browsers, + // but we should not, see #8335. + // Always return an empty object. + if ( !acceptData( owner ) ) { + return {}; + } + + // Check if the owner object already has a cache + var value = owner[ this.expando ]; + + // If not, create one + if ( !value ) { + value = {}; + + // We can accept data for non-element nodes in modern browsers, + // but we should not, see #8335. + // Always return an empty object. + if ( acceptData( owner ) ) { + + // If it is a node unlikely to be stringify-ed or looped over + // use plain assignment + if ( owner.nodeType ) { + owner[ this.expando ] = value; + + // Otherwise secure it in a non-enumerable property + // configurable must be true to allow the property to be + // deleted when data is removed + } else { + Object.defineProperty( owner, this.expando, { + value: value, + configurable: true + } ); + } + } + } + + return value; + }, + set: function( owner, data, value ) { + var prop, + cache = this.cache( owner ); + + // Handle: [ owner, key, value ] args + if ( typeof data === "string" ) { + cache[ data ] = value; + + // Handle: [ owner, { properties } ] args + } else { + + // Copy the properties one-by-one to the cache object + for ( prop in data ) { + cache[ prop ] = data[ prop ]; + } + } + return cache; + }, + get: function( owner, key ) { + return key === undefined ? + this.cache( owner ) : + owner[ this.expando ] && owner[ this.expando ][ key ]; + }, + access: function( owner, key, value ) { + var stored; + + // In cases where either: + // + // 1. No key was specified + // 2. A string key was specified, but no value provided + // + // Take the "read" path and allow the get method to determine + // which value to return, respectively either: + // + // 1. The entire cache object + // 2. The data stored at the key + // + if ( key === undefined || + ( ( key && typeof key === "string" ) && value === undefined ) ) { + + stored = this.get( owner, key ); + + return stored !== undefined ? + stored : this.get( owner, jQuery.camelCase( key ) ); + } + + // When the key is not a string, or both a key and value + // are specified, set or extend (existing objects) with either: + // + // 1. An object of properties + // 2. A key and value + // + this.set( owner, key, value ); + + // Since the "set" path can have two possible entry points + // return the expected data based on which path was taken[*] + return value !== undefined ? value : key; + }, + remove: function( owner, key ) { + var i, name, camel, + cache = owner[ this.expando ]; + + if ( cache === undefined ) { + return; + } + + if ( key === undefined ) { + this.register( owner ); + + } else { + + // Support array or space separated string of keys + if ( jQuery.isArray( key ) ) { + + // If "name" is an array of keys... + // When data is initially created, via ("key", "val") signature, + // keys will be converted to camelCase. + // Since there is no way to tell _how_ a key was added, remove + // both plain key and camelCase key. #12786 + // This will only penalize the array argument path. + name = key.concat( key.map( jQuery.camelCase ) ); + } else { + camel = jQuery.camelCase( key ); + + // Try the string as a key before any manipulation + if ( key in cache ) { + name = [ key, camel ]; + } else { + + // If a key with the spaces exists, use it. + // Otherwise, create an array by matching non-whitespace + name = camel; + name = name in cache ? + [ name ] : ( name.match( rnotwhite ) || [] ); + } + } + + i = name.length; + + while ( i-- ) { + delete cache[ name[ i ] ]; + } + } + + // Remove the expando if there's no more data + if ( key === undefined || jQuery.isEmptyObject( cache ) ) { + + // Support: Chrome <= 35-45+ + // Webkit & Blink performance suffers when deleting properties + // from DOM nodes, so set to undefined instead + // https://code.google.com/p/chromium/issues/detail?id=378607 + if ( owner.nodeType ) { + owner[ this.expando ] = undefined; + } else { + delete owner[ this.expando ]; + } + } + }, + hasData: function( owner ) { + var cache = owner[ this.expando ]; + return cache !== undefined && !jQuery.isEmptyObject( cache ); + } +}; +var dataPriv = new Data(); + +var dataUser = new Data(); + + + +// Implementation Summary +// +// 1. Enforce API surface and semantic compatibility with 1.9.x branch +// 2. Improve the module's maintainability by reducing the storage +// paths to a single mechanism. +// 3. Use the same single mechanism to support "private" and "user" data. +// 4. _Never_ expose "private" data to user code (TODO: Drop _data, _removeData) +// 5. Avoid exposing implementation details on user objects (eg. expando properties) +// 6. Provide a clear path for implementation upgrade to WeakMap in 2014 + +var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/, + rmultiDash = /[A-Z]/g; + +function dataAttr( elem, key, data ) { + var name; + + // If nothing was found internally, try to fetch any + // data from the HTML5 data-* attribute + if ( data === undefined && elem.nodeType === 1 ) { + name = "data-" + key.replace( rmultiDash, "-$&" ).toLowerCase(); + data = elem.getAttribute( name ); + + if ( typeof data === "string" ) { + try { + data = data === "true" ? true : + data === "false" ? false : + data === "null" ? null : + + // Only convert to a number if it doesn't change the string + +data + "" === data ? +data : + rbrace.test( data ) ? jQuery.parseJSON( data ) : + data; + } catch ( e ) {} + + // Make sure we set the data so it isn't changed later + dataUser.set( elem, key, data ); + } else { + data = undefined; + } + } + return data; +} + +jQuery.extend( { + hasData: function( elem ) { + return dataUser.hasData( elem ) || dataPriv.hasData( elem ); + }, + + data: function( elem, name, data ) { + return dataUser.access( elem, name, data ); + }, + + removeData: function( elem, name ) { + dataUser.remove( elem, name ); + }, + + // TODO: Now that all calls to _data and _removeData have been replaced + // with direct calls to dataPriv methods, these can be deprecated. + _data: function( elem, name, data ) { + return dataPriv.access( elem, name, data ); + }, + + _removeData: function( elem, name ) { + dataPriv.remove( elem, name ); + } +} ); + +jQuery.fn.extend( { + data: function( key, value ) { + var i, name, data, + elem = this[ 0 ], + attrs = elem && elem.attributes; + + // Gets all values + if ( key === undefined ) { + if ( this.length ) { + data = dataUser.get( elem ); + + if ( elem.nodeType === 1 && !dataPriv.get( elem, "hasDataAttrs" ) ) { + i = attrs.length; + while ( i-- ) { + + // Support: IE11+ + // The attrs elements can be null (#14894) + if ( attrs[ i ] ) { + name = attrs[ i ].name; + if ( name.indexOf( "data-" ) === 0 ) { + name = jQuery.camelCase( name.slice( 5 ) ); + dataAttr( elem, name, data[ name ] ); + } + } + } + dataPriv.set( elem, "hasDataAttrs", true ); + } + } + + return data; + } + + // Sets multiple values + if ( typeof key === "object" ) { + return this.each( function() { + dataUser.set( this, key ); + } ); + } + + return access( this, function( value ) { + var data, camelKey; + + // The calling jQuery object (element matches) is not empty + // (and therefore has an element appears at this[ 0 ]) and the + // `value` parameter was not undefined. An empty jQuery object + // will result in `undefined` for elem = this[ 0 ] which will + // throw an exception if an attempt to read a data cache is made. + if ( elem && value === undefined ) { + + // Attempt to get data from the cache + // with the key as-is + data = dataUser.get( elem, key ) || + + // Try to find dashed key if it exists (gh-2779) + // This is for 2.2.x only + dataUser.get( elem, key.replace( rmultiDash, "-$&" ).toLowerCase() ); + + if ( data !== undefined ) { + return data; + } + + camelKey = jQuery.camelCase( key ); + + // Attempt to get data from the cache + // with the key camelized + data = dataUser.get( elem, camelKey ); + if ( data !== undefined ) { + return data; + } + + // Attempt to "discover" the data in + // HTML5 custom data-* attrs + data = dataAttr( elem, camelKey, undefined ); + if ( data !== undefined ) { + return data; + } + + // We tried really hard, but the data doesn't exist. + return; + } + + // Set the data... + camelKey = jQuery.camelCase( key ); + this.each( function() { + + // First, attempt to store a copy or reference of any + // data that might've been store with a camelCased key. + var data = dataUser.get( this, camelKey ); + + // For HTML5 data-* attribute interop, we have to + // store property names with dashes in a camelCase form. + // This might not apply to all properties...* + dataUser.set( this, camelKey, value ); + + // *... In the case of properties that might _actually_ + // have dashes, we need to also store a copy of that + // unchanged property. + if ( key.indexOf( "-" ) > -1 && data !== undefined ) { + dataUser.set( this, key, value ); + } + } ); + }, null, value, arguments.length > 1, null, true ); + }, + + removeData: function( key ) { + return this.each( function() { + dataUser.remove( this, key ); + } ); + } +} ); + + +jQuery.extend( { + queue: function( elem, type, data ) { + var queue; + + if ( elem ) { + type = ( type || "fx" ) + "queue"; + queue = dataPriv.get( elem, type ); + + // Speed up dequeue by getting out quickly if this is just a lookup + if ( data ) { + if ( !queue || jQuery.isArray( data ) ) { + queue = dataPriv.access( elem, type, jQuery.makeArray( data ) ); + } else { + queue.push( data ); + } + } + return queue || []; + } + }, + + dequeue: function( elem, type ) { + type = type || "fx"; + + var queue = jQuery.queue( elem, type ), + startLength = queue.length, + fn = queue.shift(), + hooks = jQuery._queueHooks( elem, type ), + next = function() { + jQuery.dequeue( elem, type ); + }; + + // If the fx queue is dequeued, always remove the progress sentinel + if ( fn === "inprogress" ) { + fn = queue.shift(); + startLength--; + } + + if ( fn ) { + + // Add a progress sentinel to prevent the fx queue from being + // automatically dequeued + if ( type === "fx" ) { + queue.unshift( "inprogress" ); + } + + // Clear up the last queue stop function + delete hooks.stop; + fn.call( elem, next, hooks ); + } + + if ( !startLength && hooks ) { + hooks.empty.fire(); + } + }, + + // Not public - generate a queueHooks object, or return the current one + _queueHooks: function( elem, type ) { + var key = type + "queueHooks"; + return dataPriv.get( elem, key ) || dataPriv.access( elem, key, { + empty: jQuery.Callbacks( "once memory" ).add( function() { + dataPriv.remove( elem, [ type + "queue", key ] ); + } ) + } ); + } +} ); + +jQuery.fn.extend( { + queue: function( type, data ) { + var setter = 2; + + if ( typeof type !== "string" ) { + data = type; + type = "fx"; + setter--; + } + + if ( arguments.length < setter ) { + return jQuery.queue( this[ 0 ], type ); + } + + return data === undefined ? + this : + this.each( function() { + var queue = jQuery.queue( this, type, data ); + + // Ensure a hooks for this queue + jQuery._queueHooks( this, type ); + + if ( type === "fx" && queue[ 0 ] !== "inprogress" ) { + jQuery.dequeue( this, type ); + } + } ); + }, + dequeue: function( type ) { + return this.each( function() { + jQuery.dequeue( this, type ); + } ); + }, + clearQueue: function( type ) { + return this.queue( type || "fx", [] ); + }, + + // Get a promise resolved when queues of a certain type + // are emptied (fx is the type by default) + promise: function( type, obj ) { + var tmp, + count = 1, + defer = jQuery.Deferred(), + elements = this, + i = this.length, + resolve = function() { + if ( !( --count ) ) { + defer.resolveWith( elements, [ elements ] ); + } + }; + + if ( typeof type !== "string" ) { + obj = type; + type = undefined; + } + type = type || "fx"; + + while ( i-- ) { + tmp = dataPriv.get( elements[ i ], type + "queueHooks" ); + if ( tmp && tmp.empty ) { + count++; + tmp.empty.add( resolve ); + } + } + resolve(); + return defer.promise( obj ); + } +} ); +var pnum = ( /[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/ ).source; + +var rcssNum = new RegExp( "^(?:([+-])=|)(" + pnum + ")([a-z%]*)$", "i" ); + + +var cssExpand = [ "Top", "Right", "Bottom", "Left" ]; + +var isHidden = function( elem, el ) { + + // isHidden might be called from jQuery#filter function; + // in that case, element will be second argument + elem = el || elem; + return jQuery.css( elem, "display" ) === "none" || + !jQuery.contains( elem.ownerDocument, elem ); + }; + + + +function adjustCSS( elem, prop, valueParts, tween ) { + var adjusted, + scale = 1, + maxIterations = 20, + currentValue = tween ? + function() { return tween.cur(); } : + function() { return jQuery.css( elem, prop, "" ); }, + initial = currentValue(), + unit = valueParts && valueParts[ 3 ] || ( jQuery.cssNumber[ prop ] ? "" : "px" ), + + // Starting value computation is required for potential unit mismatches + initialInUnit = ( jQuery.cssNumber[ prop ] || unit !== "px" && +initial ) && + rcssNum.exec( jQuery.css( elem, prop ) ); + + if ( initialInUnit && initialInUnit[ 3 ] !== unit ) { + + // Trust units reported by jQuery.css + unit = unit || initialInUnit[ 3 ]; + + // Make sure we update the tween properties later on + valueParts = valueParts || []; + + // Iteratively approximate from a nonzero starting point + initialInUnit = +initial || 1; + + do { + + // If previous iteration zeroed out, double until we get *something*. + // Use string for doubling so we don't accidentally see scale as unchanged below + scale = scale || ".5"; + + // Adjust and apply + initialInUnit = initialInUnit / scale; + jQuery.style( elem, prop, initialInUnit + unit ); + + // Update scale, tolerating zero or NaN from tween.cur() + // Break the loop if scale is unchanged or perfect, or if we've just had enough. + } while ( + scale !== ( scale = currentValue() / initial ) && scale !== 1 && --maxIterations + ); + } + + if ( valueParts ) { + initialInUnit = +initialInUnit || +initial || 0; + + // Apply relative offset (+=/-=) if specified + adjusted = valueParts[ 1 ] ? + initialInUnit + ( valueParts[ 1 ] + 1 ) * valueParts[ 2 ] : + +valueParts[ 2 ]; + if ( tween ) { + tween.unit = unit; + tween.start = initialInUnit; + tween.end = adjusted; + } + } + return adjusted; +} +var rcheckableType = ( /^(?:checkbox|radio)$/i ); + +var rtagName = ( /<([\w:-]+)/ ); + +var rscriptType = ( /^$|\/(?:java|ecma)script/i ); + + + +// We have to close these tags to support XHTML (#13200) +var wrapMap = { + + // Support: IE9 + option: [ 1, "<select multiple='multiple'>", "</select>" ], + + // XHTML parsers do not magically insert elements in the + // same way that tag soup parsers do. So we cannot shorten + // this by omitting <tbody> or other required elements. + thead: [ 1, "<table>", "</table>" ], + col: [ 2, "<table><colgroup>", "</colgroup></table>" ], + tr: [ 2, "<table><tbody>", "</tbody></table>" ], + td: [ 3, "<table><tbody><tr>", "</tr></tbody></table>" ], + + _default: [ 0, "", "" ] +}; + +// Support: IE9 +wrapMap.optgroup = wrapMap.option; + +wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; +wrapMap.th = wrapMap.td; + + +function getAll( context, tag ) { + + // Support: IE9-11+ + // Use typeof to avoid zero-argument method invocation on host objects (#15151) + var ret = typeof context.getElementsByTagName !== "undefined" ? + context.getElementsByTagName( tag || "*" ) : + typeof context.querySelectorAll !== "undefined" ? + context.querySelectorAll( tag || "*" ) : + []; + + return tag === undefined || tag && jQuery.nodeName( context, tag ) ? + jQuery.merge( [ context ], ret ) : + ret; +} + + +// Mark scripts as having already been evaluated +function setGlobalEval( elems, refElements ) { + var i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + dataPriv.set( + elems[ i ], + "globalEval", + !refElements || dataPriv.get( refElements[ i ], "globalEval" ) + ); + } +} + + +var rhtml = /<|&#?\w+;/; + +function buildFragment( elems, context, scripts, selection, ignored ) { + var elem, tmp, tag, wrap, contains, j, + fragment = context.createDocumentFragment(), + nodes = [], + i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + elem = elems[ i ]; + + if ( elem || elem === 0 ) { + + // Add nodes directly + if ( jQuery.type( elem ) === "object" ) { + + // Support: Android<4.1, PhantomJS<2 + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem ); + + // Convert non-html into a text node + } else if ( !rhtml.test( elem ) ) { + nodes.push( context.createTextNode( elem ) ); + + // Convert html into DOM nodes + } else { + tmp = tmp || fragment.appendChild( context.createElement( "div" ) ); + + // Deserialize a standard representation + tag = ( rtagName.exec( elem ) || [ "", "" ] )[ 1 ].toLowerCase(); + wrap = wrapMap[ tag ] || wrapMap._default; + tmp.innerHTML = wrap[ 1 ] + jQuery.htmlPrefilter( elem ) + wrap[ 2 ]; + + // Descend through wrappers to the right content + j = wrap[ 0 ]; + while ( j-- ) { + tmp = tmp.lastChild; + } + + // Support: Android<4.1, PhantomJS<2 + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, tmp.childNodes ); + + // Remember the top-level container + tmp = fragment.firstChild; + + // Ensure the created nodes are orphaned (#12392) + tmp.textContent = ""; + } + } + } + + // Remove wrapper from fragment + fragment.textContent = ""; + + i = 0; + while ( ( elem = nodes[ i++ ] ) ) { + + // Skip elements already in the context collection (trac-4087) + if ( selection && jQuery.inArray( elem, selection ) > -1 ) { + if ( ignored ) { + ignored.push( elem ); + } + continue; + } + + contains = jQuery.contains( elem.ownerDocument, elem ); + + // Append to fragment + tmp = getAll( fragment.appendChild( elem ), "script" ); + + // Preserve script evaluation history + if ( contains ) { + setGlobalEval( tmp ); + } + + // Capture executables + if ( scripts ) { + j = 0; + while ( ( elem = tmp[ j++ ] ) ) { + if ( rscriptType.test( elem.type || "" ) ) { + scripts.push( elem ); + } + } + } + } + + return fragment; +} + + +( function() { + var fragment = document.createDocumentFragment(), + div = fragment.appendChild( document.createElement( "div" ) ), + input = document.createElement( "input" ); + + // Support: Android 4.0-4.3, Safari<=5.1 + // Check state lost if the name is set (#11217) + // Support: Windows Web Apps (WWA) + // `name` and `type` must use .setAttribute for WWA (#14901) + input.setAttribute( "type", "radio" ); + input.setAttribute( "checked", "checked" ); + input.setAttribute( "name", "t" ); + + div.appendChild( input ); + + // Support: Safari<=5.1, Android<4.2 + // Older WebKit doesn't clone checked state correctly in fragments + support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked; + + // Support: IE<=11+ + // Make sure textarea (and checkbox) defaultValue is properly cloned + div.innerHTML = "<textarea>x</textarea>"; + support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue; +} )(); + + +var + rkeyEvent = /^key/, + rmouseEvent = /^(?:mouse|pointer|contextmenu|drag|drop)|click/, + rtypenamespace = /^([^.]*)(?:\.(.+)|)/; + +function returnTrue() { + return true; +} + +function returnFalse() { + return false; +} + +// Support: IE9 +// See #13393 for more info +function safeActiveElement() { + try { + return document.activeElement; + } catch ( err ) { } +} + +function on( elem, types, selector, data, fn, one ) { + var origFn, type; + + // Types can be a map of types/handlers + if ( typeof types === "object" ) { + + // ( types-Object, selector, data ) + if ( typeof selector !== "string" ) { + + // ( types-Object, data ) + data = data || selector; + selector = undefined; + } + for ( type in types ) { + on( elem, type, selector, data, types[ type ], one ); + } + return elem; + } + + if ( data == null && fn == null ) { + + // ( types, fn ) + fn = selector; + data = selector = undefined; + } else if ( fn == null ) { + if ( typeof selector === "string" ) { + + // ( types, selector, fn ) + fn = data; + data = undefined; + } else { + + // ( types, data, fn ) + fn = data; + data = selector; + selector = undefined; + } + } + if ( fn === false ) { + fn = returnFalse; + } else if ( !fn ) { + return this; + } + + if ( one === 1 ) { + origFn = fn; + fn = function( event ) { + + // Can use an empty set, since event contains the info + jQuery().off( event ); + return origFn.apply( this, arguments ); + }; + + // Use same guid so caller can remove using origFn + fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); + } + return elem.each( function() { + jQuery.event.add( this, types, fn, data, selector ); + } ); +} + +/* + * Helper functions for managing events -- not part of the public interface. + * Props to Dean Edwards' addEvent library for many of the ideas. + */ +jQuery.event = { + + global: {}, + + add: function( elem, types, handler, data, selector ) { + + var handleObjIn, eventHandle, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = dataPriv.get( elem ); + + // Don't attach events to noData or text/comment nodes (but allow plain objects) + if ( !elemData ) { + return; + } + + // Caller can pass in an object of custom data in lieu of the handler + if ( handler.handler ) { + handleObjIn = handler; + handler = handleObjIn.handler; + selector = handleObjIn.selector; + } + + // Make sure that the handler has a unique ID, used to find/remove it later + if ( !handler.guid ) { + handler.guid = jQuery.guid++; + } + + // Init the element's event structure and main handler, if this is the first + if ( !( events = elemData.events ) ) { + events = elemData.events = {}; + } + if ( !( eventHandle = elemData.handle ) ) { + eventHandle = elemData.handle = function( e ) { + + // Discard the second event of a jQuery.event.trigger() and + // when an event is called after a page has unloaded + return typeof jQuery !== "undefined" && jQuery.event.triggered !== e.type ? + jQuery.event.dispatch.apply( elem, arguments ) : undefined; + }; + } + + // Handle multiple events separated by a space + types = ( types || "" ).match( rnotwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); + + // There *must* be a type, no attaching namespace-only handlers + if ( !type ) { + continue; + } + + // If event changes its type, use the special event handlers for the changed type + special = jQuery.event.special[ type ] || {}; + + // If selector defined, determine special event api type, otherwise given type + type = ( selector ? special.delegateType : special.bindType ) || type; + + // Update special based on newly reset type + special = jQuery.event.special[ type ] || {}; + + // handleObj is passed to all event handlers + handleObj = jQuery.extend( { + type: type, + origType: origType, + data: data, + handler: handler, + guid: handler.guid, + selector: selector, + needsContext: selector && jQuery.expr.match.needsContext.test( selector ), + namespace: namespaces.join( "." ) + }, handleObjIn ); + + // Init the event handler queue if we're the first + if ( !( handlers = events[ type ] ) ) { + handlers = events[ type ] = []; + handlers.delegateCount = 0; + + // Only use addEventListener if the special events handler returns false + if ( !special.setup || + special.setup.call( elem, data, namespaces, eventHandle ) === false ) { + + if ( elem.addEventListener ) { + elem.addEventListener( type, eventHandle ); + } + } + } + + if ( special.add ) { + special.add.call( elem, handleObj ); + + if ( !handleObj.handler.guid ) { + handleObj.handler.guid = handler.guid; + } + } + + // Add to the element's handler list, delegates in front + if ( selector ) { + handlers.splice( handlers.delegateCount++, 0, handleObj ); + } else { + handlers.push( handleObj ); + } + + // Keep track of which events have ever been used, for event optimization + jQuery.event.global[ type ] = true; + } + + }, + + // Detach an event or set of events from an element + remove: function( elem, types, handler, selector, mappedTypes ) { + + var j, origCount, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = dataPriv.hasData( elem ) && dataPriv.get( elem ); + + if ( !elemData || !( events = elemData.events ) ) { + return; + } + + // Once for each type.namespace in types; type may be omitted + types = ( types || "" ).match( rnotwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); + + // Unbind all events (on this namespace, if provided) for the element + if ( !type ) { + for ( type in events ) { + jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); + } + continue; + } + + special = jQuery.event.special[ type ] || {}; + type = ( selector ? special.delegateType : special.bindType ) || type; + handlers = events[ type ] || []; + tmp = tmp[ 2 ] && + new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" ); + + // Remove matching events + origCount = j = handlers.length; + while ( j-- ) { + handleObj = handlers[ j ]; + + if ( ( mappedTypes || origType === handleObj.origType ) && + ( !handler || handler.guid === handleObj.guid ) && + ( !tmp || tmp.test( handleObj.namespace ) ) && + ( !selector || selector === handleObj.selector || + selector === "**" && handleObj.selector ) ) { + handlers.splice( j, 1 ); + + if ( handleObj.selector ) { + handlers.delegateCount--; + } + if ( special.remove ) { + special.remove.call( elem, handleObj ); + } + } + } + + // Remove generic event handler if we removed something and no more handlers exist + // (avoids potential for endless recursion during removal of special event handlers) + if ( origCount && !handlers.length ) { + if ( !special.teardown || + special.teardown.call( elem, namespaces, elemData.handle ) === false ) { + + jQuery.removeEvent( elem, type, elemData.handle ); + } + + delete events[ type ]; + } + } + + // Remove data and the expando if it's no longer used + if ( jQuery.isEmptyObject( events ) ) { + dataPriv.remove( elem, "handle events" ); + } + }, + + dispatch: function( event ) { + + // Make a writable jQuery.Event from the native event object + event = jQuery.event.fix( event ); + + var i, j, ret, matched, handleObj, + handlerQueue = [], + args = slice.call( arguments ), + handlers = ( dataPriv.get( this, "events" ) || {} )[ event.type ] || [], + special = jQuery.event.special[ event.type ] || {}; + + // Use the fix-ed jQuery.Event rather than the (read-only) native event + args[ 0 ] = event; + event.delegateTarget = this; + + // Call the preDispatch hook for the mapped type, and let it bail if desired + if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { + return; + } + + // Determine handlers + handlerQueue = jQuery.event.handlers.call( this, event, handlers ); + + // Run delegates first; they may want to stop propagation beneath us + i = 0; + while ( ( matched = handlerQueue[ i++ ] ) && !event.isPropagationStopped() ) { + event.currentTarget = matched.elem; + + j = 0; + while ( ( handleObj = matched.handlers[ j++ ] ) && + !event.isImmediatePropagationStopped() ) { + + // Triggered event must either 1) have no namespace, or 2) have namespace(s) + // a subset or equal to those in the bound event (both can have no namespace). + if ( !event.rnamespace || event.rnamespace.test( handleObj.namespace ) ) { + + event.handleObj = handleObj; + event.data = handleObj.data; + + ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle || + handleObj.handler ).apply( matched.elem, args ); + + if ( ret !== undefined ) { + if ( ( event.result = ret ) === false ) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } + } + + // Call the postDispatch hook for the mapped type + if ( special.postDispatch ) { + special.postDispatch.call( this, event ); + } + + return event.result; + }, + + handlers: function( event, handlers ) { + var i, matches, sel, handleObj, + handlerQueue = [], + delegateCount = handlers.delegateCount, + cur = event.target; + + // Support (at least): Chrome, IE9 + // Find delegate handlers + // Black-hole SVG <use> instance trees (#13180) + // + // Support: Firefox<=42+ + // Avoid non-left-click in FF but don't block IE radio events (#3861, gh-2343) + if ( delegateCount && cur.nodeType && + ( event.type !== "click" || isNaN( event.button ) || event.button < 1 ) ) { + + for ( ; cur !== this; cur = cur.parentNode || this ) { + + // Don't check non-elements (#13208) + // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) + if ( cur.nodeType === 1 && ( cur.disabled !== true || event.type !== "click" ) ) { + matches = []; + for ( i = 0; i < delegateCount; i++ ) { + handleObj = handlers[ i ]; + + // Don't conflict with Object.prototype properties (#13203) + sel = handleObj.selector + " "; + + if ( matches[ sel ] === undefined ) { + matches[ sel ] = handleObj.needsContext ? + jQuery( sel, this ).index( cur ) > -1 : + jQuery.find( sel, this, null, [ cur ] ).length; + } + if ( matches[ sel ] ) { + matches.push( handleObj ); + } + } + if ( matches.length ) { + handlerQueue.push( { elem: cur, handlers: matches } ); + } + } + } + } + + // Add the remaining (directly-bound) handlers + if ( delegateCount < handlers.length ) { + handlerQueue.push( { elem: this, handlers: handlers.slice( delegateCount ) } ); + } + + return handlerQueue; + }, + + // Includes some event props shared by KeyEvent and MouseEvent + props: ( "altKey bubbles cancelable ctrlKey currentTarget detail eventPhase " + + "metaKey relatedTarget shiftKey target timeStamp view which" ).split( " " ), + + fixHooks: {}, + + keyHooks: { + props: "char charCode key keyCode".split( " " ), + filter: function( event, original ) { + + // Add which for key events + if ( event.which == null ) { + event.which = original.charCode != null ? original.charCode : original.keyCode; + } + + return event; + } + }, + + mouseHooks: { + props: ( "button buttons clientX clientY offsetX offsetY pageX pageY " + + "screenX screenY toElement" ).split( " " ), + filter: function( event, original ) { + var eventDoc, doc, body, + button = original.button; + + // Calculate pageX/Y if missing and clientX/Y available + if ( event.pageX == null && original.clientX != null ) { + eventDoc = event.target.ownerDocument || document; + doc = eventDoc.documentElement; + body = eventDoc.body; + + event.pageX = original.clientX + + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - + ( doc && doc.clientLeft || body && body.clientLeft || 0 ); + event.pageY = original.clientY + + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - + ( doc && doc.clientTop || body && body.clientTop || 0 ); + } + + // Add which for click: 1 === left; 2 === middle; 3 === right + // Note: button is not normalized, so don't use it + if ( !event.which && button !== undefined ) { + event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); + } + + return event; + } + }, + + fix: function( event ) { + if ( event[ jQuery.expando ] ) { + return event; + } + + // Create a writable copy of the event object and normalize some properties + var i, prop, copy, + type = event.type, + originalEvent = event, + fixHook = this.fixHooks[ type ]; + + if ( !fixHook ) { + this.fixHooks[ type ] = fixHook = + rmouseEvent.test( type ) ? this.mouseHooks : + rkeyEvent.test( type ) ? this.keyHooks : + {}; + } + copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props; + + event = new jQuery.Event( originalEvent ); + + i = copy.length; + while ( i-- ) { + prop = copy[ i ]; + event[ prop ] = originalEvent[ prop ]; + } + + // Support: Cordova 2.5 (WebKit) (#13255) + // All events should have a target; Cordova deviceready doesn't + if ( !event.target ) { + event.target = document; + } + + // Support: Safari 6.0+, Chrome<28 + // Target should not be a text node (#504, #13143) + if ( event.target.nodeType === 3 ) { + event.target = event.target.parentNode; + } + + return fixHook.filter ? fixHook.filter( event, originalEvent ) : event; + }, + + special: { + load: { + + // Prevent triggered image.load events from bubbling to window.load + noBubble: true + }, + focus: { + + // Fire native event if possible so blur/focus sequence is correct + trigger: function() { + if ( this !== safeActiveElement() && this.focus ) { + this.focus(); + return false; + } + }, + delegateType: "focusin" + }, + blur: { + trigger: function() { + if ( this === safeActiveElement() && this.blur ) { + this.blur(); + return false; + } + }, + delegateType: "focusout" + }, + click: { + + // For checkbox, fire native event so checked state will be right + trigger: function() { + if ( this.type === "checkbox" && this.click && jQuery.nodeName( this, "input" ) ) { + this.click(); + return false; + } + }, + + // For cross-browser consistency, don't fire native .click() on links + _default: function( event ) { + return jQuery.nodeName( event.target, "a" ); + } + }, + + beforeunload: { + postDispatch: function( event ) { + + // Support: Firefox 20+ + // Firefox doesn't alert if the returnValue field is not set. + if ( event.result !== undefined && event.originalEvent ) { + event.originalEvent.returnValue = event.result; + } + } + } + } +}; + +jQuery.removeEvent = function( elem, type, handle ) { + + // This "if" is needed for plain objects + if ( elem.removeEventListener ) { + elem.removeEventListener( type, handle ); + } +}; + +jQuery.Event = function( src, props ) { + + // Allow instantiation without the 'new' keyword + if ( !( this instanceof jQuery.Event ) ) { + return new jQuery.Event( src, props ); + } + + // Event object + if ( src && src.type ) { + this.originalEvent = src; + this.type = src.type; + + // Events bubbling up the document may have been marked as prevented + // by a handler lower down the tree; reflect the correct value. + this.isDefaultPrevented = src.defaultPrevented || + src.defaultPrevented === undefined && + + // Support: Android<4.0 + src.returnValue === false ? + returnTrue : + returnFalse; + + // Event type + } else { + this.type = src; + } + + // Put explicitly provided properties onto the event object + if ( props ) { + jQuery.extend( this, props ); + } + + // Create a timestamp if incoming event doesn't have one + this.timeStamp = src && src.timeStamp || jQuery.now(); + + // Mark it as fixed + this[ jQuery.expando ] = true; +}; + +// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding +// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html +jQuery.Event.prototype = { + constructor: jQuery.Event, + isDefaultPrevented: returnFalse, + isPropagationStopped: returnFalse, + isImmediatePropagationStopped: returnFalse, + + preventDefault: function() { + var e = this.originalEvent; + + this.isDefaultPrevented = returnTrue; + + if ( e ) { + e.preventDefault(); + } + }, + stopPropagation: function() { + var e = this.originalEvent; + + this.isPropagationStopped = returnTrue; + + if ( e ) { + e.stopPropagation(); + } + }, + stopImmediatePropagation: function() { + var e = this.originalEvent; + + this.isImmediatePropagationStopped = returnTrue; + + if ( e ) { + e.stopImmediatePropagation(); + } + + this.stopPropagation(); + } +}; + +// Create mouseenter/leave events using mouseover/out and event-time checks +// so that event delegation works in jQuery. +// Do the same for pointerenter/pointerleave and pointerover/pointerout +// +// Support: Safari 7 only +// Safari sends mouseenter too often; see: +// https://code.google.com/p/chromium/issues/detail?id=470258 +// for the description of the bug (it existed in older Chrome versions as well). +jQuery.each( { + mouseenter: "mouseover", + mouseleave: "mouseout", + pointerenter: "pointerover", + pointerleave: "pointerout" +}, function( orig, fix ) { + jQuery.event.special[ orig ] = { + delegateType: fix, + bindType: fix, + + handle: function( event ) { + var ret, + target = this, + related = event.relatedTarget, + handleObj = event.handleObj; + + // For mouseenter/leave call the handler if related is outside the target. + // NB: No relatedTarget if the mouse left/entered the browser window + if ( !related || ( related !== target && !jQuery.contains( target, related ) ) ) { + event.type = handleObj.origType; + ret = handleObj.handler.apply( this, arguments ); + event.type = fix; + } + return ret; + } + }; +} ); + +jQuery.fn.extend( { + on: function( types, selector, data, fn ) { + return on( this, types, selector, data, fn ); + }, + one: function( types, selector, data, fn ) { + return on( this, types, selector, data, fn, 1 ); + }, + off: function( types, selector, fn ) { + var handleObj, type; + if ( types && types.preventDefault && types.handleObj ) { + + // ( event ) dispatched jQuery.Event + handleObj = types.handleObj; + jQuery( types.delegateTarget ).off( + handleObj.namespace ? + handleObj.origType + "." + handleObj.namespace : + handleObj.origType, + handleObj.selector, + handleObj.handler + ); + return this; + } + if ( typeof types === "object" ) { + + // ( types-object [, selector] ) + for ( type in types ) { + this.off( type, selector, types[ type ] ); + } + return this; + } + if ( selector === false || typeof selector === "function" ) { + + // ( types [, fn] ) + fn = selector; + selector = undefined; + } + if ( fn === false ) { + fn = returnFalse; + } + return this.each( function() { + jQuery.event.remove( this, types, fn, selector ); + } ); + } +} ); + + +var + rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi, + + // Support: IE 10-11, Edge 10240+ + // In IE/Edge using regex groups here causes severe slowdowns. + // See https://connect.microsoft.com/IE/feedback/details/1736512/ + rnoInnerhtml = /<script|<style|<link/i, + + // checked="checked" or checked + rchecked = /checked\s*(?:[^=]|=\s*.checked.)/i, + rscriptTypeMasked = /^true\/(.*)/, + rcleanScript = /^\s*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g; + +function manipulationTarget( elem, content ) { + if ( jQuery.nodeName( elem, "table" ) && + jQuery.nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ) { + + return elem.getElementsByTagName( "tbody" )[ 0 ] || elem; + } + + return elem; +} + +// Replace/restore the type attribute of script elements for safe DOM manipulation +function disableScript( elem ) { + elem.type = ( elem.getAttribute( "type" ) !== null ) + "/" + elem.type; + return elem; +} +function restoreScript( elem ) { + var match = rscriptTypeMasked.exec( elem.type ); + + if ( match ) { + elem.type = match[ 1 ]; + } else { + elem.removeAttribute( "type" ); + } + + return elem; +} + +function cloneCopyEvent( src, dest ) { + var i, l, type, pdataOld, pdataCur, udataOld, udataCur, events; + + if ( dest.nodeType !== 1 ) { + return; + } + + // 1. Copy private data: events, handlers, etc. + if ( dataPriv.hasData( src ) ) { + pdataOld = dataPriv.access( src ); + pdataCur = dataPriv.set( dest, pdataOld ); + events = pdataOld.events; + + if ( events ) { + delete pdataCur.handle; + pdataCur.events = {}; + + for ( type in events ) { + for ( i = 0, l = events[ type ].length; i < l; i++ ) { + jQuery.event.add( dest, type, events[ type ][ i ] ); + } + } + } + } + + // 2. Copy user data + if ( dataUser.hasData( src ) ) { + udataOld = dataUser.access( src ); + udataCur = jQuery.extend( {}, udataOld ); + + dataUser.set( dest, udataCur ); + } +} + +// Fix IE bugs, see support tests +function fixInput( src, dest ) { + var nodeName = dest.nodeName.toLowerCase(); + + // Fails to persist the checked state of a cloned checkbox or radio button. + if ( nodeName === "input" && rcheckableType.test( src.type ) ) { + dest.checked = src.checked; + + // Fails to return the selected option to the default selected state when cloning options + } else if ( nodeName === "input" || nodeName === "textarea" ) { + dest.defaultValue = src.defaultValue; + } +} + +function domManip( collection, args, callback, ignored ) { + + // Flatten any nested arrays + args = concat.apply( [], args ); + + var fragment, first, scripts, hasScripts, node, doc, + i = 0, + l = collection.length, + iNoClone = l - 1, + value = args[ 0 ], + isFunction = jQuery.isFunction( value ); + + // We can't cloneNode fragments that contain checked, in WebKit + if ( isFunction || + ( l > 1 && typeof value === "string" && + !support.checkClone && rchecked.test( value ) ) ) { + return collection.each( function( index ) { + var self = collection.eq( index ); + if ( isFunction ) { + args[ 0 ] = value.call( this, index, self.html() ); + } + domManip( self, args, callback, ignored ); + } ); + } + + if ( l ) { + fragment = buildFragment( args, collection[ 0 ].ownerDocument, false, collection, ignored ); + first = fragment.firstChild; + + if ( fragment.childNodes.length === 1 ) { + fragment = first; + } + + // Require either new content or an interest in ignored elements to invoke the callback + if ( first || ignored ) { + scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); + hasScripts = scripts.length; + + // Use the original fragment for the last item + // instead of the first because it can end up + // being emptied incorrectly in certain situations (#8070). + for ( ; i < l; i++ ) { + node = fragment; + + if ( i !== iNoClone ) { + node = jQuery.clone( node, true, true ); + + // Keep references to cloned scripts for later restoration + if ( hasScripts ) { + + // Support: Android<4.1, PhantomJS<2 + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( scripts, getAll( node, "script" ) ); + } + } + + callback.call( collection[ i ], node, i ); + } + + if ( hasScripts ) { + doc = scripts[ scripts.length - 1 ].ownerDocument; + + // Reenable scripts + jQuery.map( scripts, restoreScript ); + + // Evaluate executable scripts on first document insertion + for ( i = 0; i < hasScripts; i++ ) { + node = scripts[ i ]; + if ( rscriptType.test( node.type || "" ) && + !dataPriv.access( node, "globalEval" ) && + jQuery.contains( doc, node ) ) { + + if ( node.src ) { + + // Optional AJAX dependency, but won't run scripts if not present + if ( jQuery._evalUrl ) { + jQuery._evalUrl( node.src ); + } + } else { + jQuery.globalEval( node.textContent.replace( rcleanScript, "" ) ); + } + } + } + } + } + } + + return collection; +} + +function remove( elem, selector, keepData ) { + var node, + nodes = selector ? jQuery.filter( selector, elem ) : elem, + i = 0; + + for ( ; ( node = nodes[ i ] ) != null; i++ ) { + if ( !keepData && node.nodeType === 1 ) { + jQuery.cleanData( getAll( node ) ); + } + + if ( node.parentNode ) { + if ( keepData && jQuery.contains( node.ownerDocument, node ) ) { + setGlobalEval( getAll( node, "script" ) ); + } + node.parentNode.removeChild( node ); + } + } + + return elem; +} + +jQuery.extend( { + htmlPrefilter: function( html ) { + return html.replace( rxhtmlTag, "<$1></$2>" ); + }, + + clone: function( elem, dataAndEvents, deepDataAndEvents ) { + var i, l, srcElements, destElements, + clone = elem.cloneNode( true ), + inPage = jQuery.contains( elem.ownerDocument, elem ); + + // Fix IE cloning issues + if ( !support.noCloneChecked && ( elem.nodeType === 1 || elem.nodeType === 11 ) && + !jQuery.isXMLDoc( elem ) ) { + + // We eschew Sizzle here for performance reasons: http://jsperf.com/getall-vs-sizzle/2 + destElements = getAll( clone ); + srcElements = getAll( elem ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + fixInput( srcElements[ i ], destElements[ i ] ); + } + } + + // Copy the events from the original to the clone + if ( dataAndEvents ) { + if ( deepDataAndEvents ) { + srcElements = srcElements || getAll( elem ); + destElements = destElements || getAll( clone ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + cloneCopyEvent( srcElements[ i ], destElements[ i ] ); + } + } else { + cloneCopyEvent( elem, clone ); + } + } + + // Preserve script evaluation history + destElements = getAll( clone, "script" ); + if ( destElements.length > 0 ) { + setGlobalEval( destElements, !inPage && getAll( elem, "script" ) ); + } + + // Return the cloned set + return clone; + }, + + cleanData: function( elems ) { + var data, elem, type, + special = jQuery.event.special, + i = 0; + + for ( ; ( elem = elems[ i ] ) !== undefined; i++ ) { + if ( acceptData( elem ) ) { + if ( ( data = elem[ dataPriv.expando ] ) ) { + if ( data.events ) { + for ( type in data.events ) { + if ( special[ type ] ) { + jQuery.event.remove( elem, type ); + + // This is a shortcut to avoid jQuery.event.remove's overhead + } else { + jQuery.removeEvent( elem, type, data.handle ); + } + } + } + + // Support: Chrome <= 35-45+ + // Assign undefined instead of using delete, see Data#remove + elem[ dataPriv.expando ] = undefined; + } + if ( elem[ dataUser.expando ] ) { + + // Support: Chrome <= 35-45+ + // Assign undefined instead of using delete, see Data#remove + elem[ dataUser.expando ] = undefined; + } + } + } + } +} ); + +jQuery.fn.extend( { + + // Keep domManip exposed until 3.0 (gh-2225) + domManip: domManip, + + detach: function( selector ) { + return remove( this, selector, true ); + }, + + remove: function( selector ) { + return remove( this, selector ); + }, + + text: function( value ) { + return access( this, function( value ) { + return value === undefined ? + jQuery.text( this ) : + this.empty().each( function() { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + this.textContent = value; + } + } ); + }, null, value, arguments.length ); + }, + + append: function() { + return domManip( this, arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.appendChild( elem ); + } + } ); + }, + + prepend: function() { + return domManip( this, arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.insertBefore( elem, target.firstChild ); + } + } ); + }, + + before: function() { + return domManip( this, arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this ); + } + } ); + }, + + after: function() { + return domManip( this, arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this.nextSibling ); + } + } ); + }, + + empty: function() { + var elem, + i = 0; + + for ( ; ( elem = this[ i ] ) != null; i++ ) { + if ( elem.nodeType === 1 ) { + + // Prevent memory leaks + jQuery.cleanData( getAll( elem, false ) ); + + // Remove any remaining nodes + elem.textContent = ""; + } + } + + return this; + }, + + clone: function( dataAndEvents, deepDataAndEvents ) { + dataAndEvents = dataAndEvents == null ? false : dataAndEvents; + deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; + + return this.map( function() { + return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); + } ); + }, + + html: function( value ) { + return access( this, function( value ) { + var elem = this[ 0 ] || {}, + i = 0, + l = this.length; + + if ( value === undefined && elem.nodeType === 1 ) { + return elem.innerHTML; + } + + // See if we can take a shortcut and just use innerHTML + if ( typeof value === "string" && !rnoInnerhtml.test( value ) && + !wrapMap[ ( rtagName.exec( value ) || [ "", "" ] )[ 1 ].toLowerCase() ] ) { + + value = jQuery.htmlPrefilter( value ); + + try { + for ( ; i < l; i++ ) { + elem = this[ i ] || {}; + + // Remove element nodes and prevent memory leaks + if ( elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem, false ) ); + elem.innerHTML = value; + } + } + + elem = 0; + + // If using innerHTML throws an exception, use the fallback method + } catch ( e ) {} + } + + if ( elem ) { + this.empty().append( value ); + } + }, null, value, arguments.length ); + }, + + replaceWith: function() { + var ignored = []; + + // Make the changes, replacing each non-ignored context element with the new content + return domManip( this, arguments, function( elem ) { + var parent = this.parentNode; + + if ( jQuery.inArray( this, ignored ) < 0 ) { + jQuery.cleanData( getAll( this ) ); + if ( parent ) { + parent.replaceChild( elem, this ); + } + } + + // Force callback invocation + }, ignored ); + } +} ); + +jQuery.each( { + appendTo: "append", + prependTo: "prepend", + insertBefore: "before", + insertAfter: "after", + replaceAll: "replaceWith" +}, function( name, original ) { + jQuery.fn[ name ] = function( selector ) { + var elems, + ret = [], + insert = jQuery( selector ), + last = insert.length - 1, + i = 0; + + for ( ; i <= last; i++ ) { + elems = i === last ? this : this.clone( true ); + jQuery( insert[ i ] )[ original ]( elems ); + + // Support: QtWebKit + // .get() because push.apply(_, arraylike) throws + push.apply( ret, elems.get() ); + } + + return this.pushStack( ret ); + }; +} ); + + +var iframe, + elemdisplay = { + + // Support: Firefox + // We have to pre-define these values for FF (#10227) + HTML: "block", + BODY: "block" + }; + +/** + * Retrieve the actual display of a element + * @param {String} name nodeName of the element + * @param {Object} doc Document object + */ + +// Called only from within defaultDisplay +function actualDisplay( name, doc ) { + var elem = jQuery( doc.createElement( name ) ).appendTo( doc.body ), + + display = jQuery.css( elem[ 0 ], "display" ); + + // We don't have any data stored on the element, + // so use "detach" method as fast way to get rid of the element + elem.detach(); + + return display; +} + +/** + * Try to determine the default display value of an element + * @param {String} nodeName + */ +function defaultDisplay( nodeName ) { + var doc = document, + display = elemdisplay[ nodeName ]; + + if ( !display ) { + display = actualDisplay( nodeName, doc ); + + // If the simple way fails, read from inside an iframe + if ( display === "none" || !display ) { + + // Use the already-created iframe if possible + iframe = ( iframe || jQuery( "<iframe frameborder='0' width='0' height='0'/>" ) ) + .appendTo( doc.documentElement ); + + // Always write a new HTML skeleton so Webkit and Firefox don't choke on reuse + doc = iframe[ 0 ].contentDocument; + + // Support: IE + doc.write(); + doc.close(); + + display = actualDisplay( nodeName, doc ); + iframe.detach(); + } + + // Store the correct default display + elemdisplay[ nodeName ] = display; + } + + return display; +} +var rmargin = ( /^margin/ ); + +var rnumnonpx = new RegExp( "^(" + pnum + ")(?!px)[a-z%]+$", "i" ); + +var getStyles = function( elem ) { + + // Support: IE<=11+, Firefox<=30+ (#15098, #14150) + // IE throws on elements created in popups + // FF meanwhile throws on frame elements through "defaultView.getComputedStyle" + var view = elem.ownerDocument.defaultView; + + if ( !view.opener ) { + view = window; + } + + return view.getComputedStyle( elem ); + }; + +var swap = function( elem, options, callback, args ) { + var ret, name, + old = {}; + + // Remember the old values, and insert the new ones + for ( name in options ) { + old[ name ] = elem.style[ name ]; + elem.style[ name ] = options[ name ]; + } + + ret = callback.apply( elem, args || [] ); + + // Revert the old values + for ( name in options ) { + elem.style[ name ] = old[ name ]; + } + + return ret; +}; + + +var documentElement = document.documentElement; + + + +( function() { + var pixelPositionVal, boxSizingReliableVal, pixelMarginRightVal, reliableMarginLeftVal, + container = document.createElement( "div" ), + div = document.createElement( "div" ); + + // Finish early in limited (non-browser) environments + if ( !div.style ) { + return; + } + + // Support: IE9-11+ + // Style of cloned element affects source element cloned (#8908) + div.style.backgroundClip = "content-box"; + div.cloneNode( true ).style.backgroundClip = ""; + support.clearCloneStyle = div.style.backgroundClip === "content-box"; + + container.style.cssText = "border:0;width:8px;height:0;top:0;left:-9999px;" + + "padding:0;margin-top:1px;position:absolute"; + container.appendChild( div ); + + // Executing both pixelPosition & boxSizingReliable tests require only one layout + // so they're executed at the same time to save the second computation. + function computeStyleTests() { + div.style.cssText = + + // Support: Firefox<29, Android 2.3 + // Vendor-prefix box-sizing + "-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;" + + "position:relative;display:block;" + + "margin:auto;border:1px;padding:1px;" + + "top:1%;width:50%"; + div.innerHTML = ""; + documentElement.appendChild( container ); + + var divStyle = window.getComputedStyle( div ); + pixelPositionVal = divStyle.top !== "1%"; + reliableMarginLeftVal = divStyle.marginLeft === "2px"; + boxSizingReliableVal = divStyle.width === "4px"; + + // Support: Android 4.0 - 4.3 only + // Some styles come back with percentage values, even though they shouldn't + div.style.marginRight = "50%"; + pixelMarginRightVal = divStyle.marginRight === "4px"; + + documentElement.removeChild( container ); + } + + jQuery.extend( support, { + pixelPosition: function() { + + // This test is executed only once but we still do memoizing + // since we can use the boxSizingReliable pre-computing. + // No need to check if the test was already performed, though. + computeStyleTests(); + return pixelPositionVal; + }, + boxSizingReliable: function() { + if ( boxSizingReliableVal == null ) { + computeStyleTests(); + } + return boxSizingReliableVal; + }, + pixelMarginRight: function() { + + // Support: Android 4.0-4.3 + // We're checking for boxSizingReliableVal here instead of pixelMarginRightVal + // since that compresses better and they're computed together anyway. + if ( boxSizingReliableVal == null ) { + computeStyleTests(); + } + return pixelMarginRightVal; + }, + reliableMarginLeft: function() { + + // Support: IE <=8 only, Android 4.0 - 4.3 only, Firefox <=3 - 37 + if ( boxSizingReliableVal == null ) { + computeStyleTests(); + } + return reliableMarginLeftVal; + }, + reliableMarginRight: function() { + + // Support: Android 2.3 + // Check if div with explicit width and no margin-right incorrectly + // gets computed margin-right based on width of container. (#3333) + // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right + // This support function is only executed once so no memoizing is needed. + var ret, + marginDiv = div.appendChild( document.createElement( "div" ) ); + + // Reset CSS: box-sizing; display; margin; border; padding + marginDiv.style.cssText = div.style.cssText = + + // Support: Android 2.3 + // Vendor-prefix box-sizing + "-webkit-box-sizing:content-box;box-sizing:content-box;" + + "display:block;margin:0;border:0;padding:0"; + marginDiv.style.marginRight = marginDiv.style.width = "0"; + div.style.width = "1px"; + documentElement.appendChild( container ); + + ret = !parseFloat( window.getComputedStyle( marginDiv ).marginRight ); + + documentElement.removeChild( container ); + div.removeChild( marginDiv ); + + return ret; + } + } ); +} )(); + + +function curCSS( elem, name, computed ) { + var width, minWidth, maxWidth, ret, + style = elem.style; + + computed = computed || getStyles( elem ); + + // Support: IE9 + // getPropertyValue is only needed for .css('filter') (#12537) + if ( computed ) { + ret = computed.getPropertyValue( name ) || computed[ name ]; + + if ( ret === "" && !jQuery.contains( elem.ownerDocument, elem ) ) { + ret = jQuery.style( elem, name ); + } + + // A tribute to the "awesome hack by Dean Edwards" + // Android Browser returns percentage for some values, + // but width seems to be reliably pixels. + // This is against the CSSOM draft spec: + // http://dev.w3.org/csswg/cssom/#resolved-values + if ( !support.pixelMarginRight() && rnumnonpx.test( ret ) && rmargin.test( name ) ) { + + // Remember the original values + width = style.width; + minWidth = style.minWidth; + maxWidth = style.maxWidth; + + // Put in the new values to get a computed value out + style.minWidth = style.maxWidth = style.width = ret; + ret = computed.width; + + // Revert the changed values + style.width = width; + style.minWidth = minWidth; + style.maxWidth = maxWidth; + } + } + + return ret !== undefined ? + + // Support: IE9-11+ + // IE returns zIndex value as an integer. + ret + "" : + ret; +} + + +function addGetHookIf( conditionFn, hookFn ) { + + // Define the hook, we'll check on the first run if it's really needed. + return { + get: function() { + if ( conditionFn() ) { + + // Hook not needed (or it's not possible to use it due + // to missing dependency), remove it. + delete this.get; + return; + } + + // Hook needed; redefine it so that the support test is not executed again. + return ( this.get = hookFn ).apply( this, arguments ); + } + }; +} + + +var + + // Swappable if display is none or starts with table + // except "table", "table-cell", or "table-caption" + // See here for display values: https://developer.mozilla.org/en-US/docs/CSS/display + rdisplayswap = /^(none|table(?!-c[ea]).+)/, + + cssShow = { position: "absolute", visibility: "hidden", display: "block" }, + cssNormalTransform = { + letterSpacing: "0", + fontWeight: "400" + }, + + cssPrefixes = [ "Webkit", "O", "Moz", "ms" ], + emptyStyle = document.createElement( "div" ).style; + +// Return a css property mapped to a potentially vendor prefixed property +function vendorPropName( name ) { + + // Shortcut for names that are not vendor prefixed + if ( name in emptyStyle ) { + return name; + } + + // Check for vendor prefixed names + var capName = name[ 0 ].toUpperCase() + name.slice( 1 ), + i = cssPrefixes.length; + + while ( i-- ) { + name = cssPrefixes[ i ] + capName; + if ( name in emptyStyle ) { + return name; + } + } +} + +function setPositiveNumber( elem, value, subtract ) { + + // Any relative (+/-) values have already been + // normalized at this point + var matches = rcssNum.exec( value ); + return matches ? + + // Guard against undefined "subtract", e.g., when used as in cssHooks + Math.max( 0, matches[ 2 ] - ( subtract || 0 ) ) + ( matches[ 3 ] || "px" ) : + value; +} + +function augmentWidthOrHeight( elem, name, extra, isBorderBox, styles ) { + var i = extra === ( isBorderBox ? "border" : "content" ) ? + + // If we already have the right measurement, avoid augmentation + 4 : + + // Otherwise initialize for horizontal or vertical properties + name === "width" ? 1 : 0, + + val = 0; + + for ( ; i < 4; i += 2 ) { + + // Both box models exclude margin, so add it if we want it + if ( extra === "margin" ) { + val += jQuery.css( elem, extra + cssExpand[ i ], true, styles ); + } + + if ( isBorderBox ) { + + // border-box includes padding, so remove it if we want content + if ( extra === "content" ) { + val -= jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); + } + + // At this point, extra isn't border nor margin, so remove border + if ( extra !== "margin" ) { + val -= jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + } + } else { + + // At this point, extra isn't content, so add padding + val += jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); + + // At this point, extra isn't content nor padding, so add border + if ( extra !== "padding" ) { + val += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + } + } + } + + return val; +} + +function getWidthOrHeight( elem, name, extra ) { + + // Start with offset property, which is equivalent to the border-box value + var valueIsBorderBox = true, + val = name === "width" ? elem.offsetWidth : elem.offsetHeight, + styles = getStyles( elem ), + isBorderBox = jQuery.css( elem, "boxSizing", false, styles ) === "border-box"; + + // Support: IE11 only + // In IE 11 fullscreen elements inside of an iframe have + // 100x too small dimensions (gh-1764). + if ( document.msFullscreenElement && window.top !== window ) { + + // Support: IE11 only + // Running getBoundingClientRect on a disconnected node + // in IE throws an error. + if ( elem.getClientRects().length ) { + val = Math.round( elem.getBoundingClientRect()[ name ] * 100 ); + } + } + + // Some non-html elements return undefined for offsetWidth, so check for null/undefined + // svg - https://bugzilla.mozilla.org/show_bug.cgi?id=649285 + // MathML - https://bugzilla.mozilla.org/show_bug.cgi?id=491668 + if ( val <= 0 || val == null ) { + + // Fall back to computed then uncomputed css if necessary + val = curCSS( elem, name, styles ); + if ( val < 0 || val == null ) { + val = elem.style[ name ]; + } + + // Computed unit is not pixels. Stop here and return. + if ( rnumnonpx.test( val ) ) { + return val; + } + + // Check for style in case a browser which returns unreliable values + // for getComputedStyle silently falls back to the reliable elem.style + valueIsBorderBox = isBorderBox && + ( support.boxSizingReliable() || val === elem.style[ name ] ); + + // Normalize "", auto, and prepare for extra + val = parseFloat( val ) || 0; + } + + // Use the active box-sizing model to add/subtract irrelevant styles + return ( val + + augmentWidthOrHeight( + elem, + name, + extra || ( isBorderBox ? "border" : "content" ), + valueIsBorderBox, + styles + ) + ) + "px"; +} + +function showHide( elements, show ) { + var display, elem, hidden, + values = [], + index = 0, + length = elements.length; + + for ( ; index < length; index++ ) { + elem = elements[ index ]; + if ( !elem.style ) { + continue; + } + + values[ index ] = dataPriv.get( elem, "olddisplay" ); + display = elem.style.display; + if ( show ) { + + // Reset the inline display of this element to learn if it is + // being hidden by cascaded rules or not + if ( !values[ index ] && display === "none" ) { + elem.style.display = ""; + } + + // Set elements which have been overridden with display: none + // in a stylesheet to whatever the default browser style is + // for such an element + if ( elem.style.display === "" && isHidden( elem ) ) { + values[ index ] = dataPriv.access( + elem, + "olddisplay", + defaultDisplay( elem.nodeName ) + ); + } + } else { + hidden = isHidden( elem ); + + if ( display !== "none" || !hidden ) { + dataPriv.set( + elem, + "olddisplay", + hidden ? display : jQuery.css( elem, "display" ) + ); + } + } + } + + // Set the display of most of the elements in a second loop + // to avoid the constant reflow + for ( index = 0; index < length; index++ ) { + elem = elements[ index ]; + if ( !elem.style ) { + continue; + } + if ( !show || elem.style.display === "none" || elem.style.display === "" ) { + elem.style.display = show ? values[ index ] || "" : "none"; + } + } + + return elements; +} + +jQuery.extend( { + + // Add in style property hooks for overriding the default + // behavior of getting and setting a style property + cssHooks: { + opacity: { + get: function( elem, computed ) { + if ( computed ) { + + // We should always get a number back from opacity + var ret = curCSS( elem, "opacity" ); + return ret === "" ? "1" : ret; + } + } + } + }, + + // Don't automatically add "px" to these possibly-unitless properties + cssNumber: { + "animationIterationCount": true, + "columnCount": true, + "fillOpacity": true, + "flexGrow": true, + "flexShrink": true, + "fontWeight": true, + "lineHeight": true, + "opacity": true, + "order": true, + "orphans": true, + "widows": true, + "zIndex": true, + "zoom": true + }, + + // Add in properties whose names you wish to fix before + // setting or getting the value + cssProps: { + "float": "cssFloat" + }, + + // Get and set the style property on a DOM Node + style: function( elem, name, value, extra ) { + + // Don't set styles on text and comment nodes + if ( !elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style ) { + return; + } + + // Make sure that we're working with the right name + var ret, type, hooks, + origName = jQuery.camelCase( name ), + style = elem.style; + + name = jQuery.cssProps[ origName ] || + ( jQuery.cssProps[ origName ] = vendorPropName( origName ) || origName ); + + // Gets hook for the prefixed version, then unprefixed version + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // Check if we're setting a value + if ( value !== undefined ) { + type = typeof value; + + // Convert "+=" or "-=" to relative numbers (#7345) + if ( type === "string" && ( ret = rcssNum.exec( value ) ) && ret[ 1 ] ) { + value = adjustCSS( elem, name, ret ); + + // Fixes bug #9237 + type = "number"; + } + + // Make sure that null and NaN values aren't set (#7116) + if ( value == null || value !== value ) { + return; + } + + // If a number was passed in, add the unit (except for certain CSS properties) + if ( type === "number" ) { + value += ret && ret[ 3 ] || ( jQuery.cssNumber[ origName ] ? "" : "px" ); + } + + // Support: IE9-11+ + // background-* props affect original clone's values + if ( !support.clearCloneStyle && value === "" && name.indexOf( "background" ) === 0 ) { + style[ name ] = "inherit"; + } + + // If a hook was provided, use that value, otherwise just set the specified value + if ( !hooks || !( "set" in hooks ) || + ( value = hooks.set( elem, value, extra ) ) !== undefined ) { + + style[ name ] = value; + } + + } else { + + // If a hook was provided get the non-computed value from there + if ( hooks && "get" in hooks && + ( ret = hooks.get( elem, false, extra ) ) !== undefined ) { + + return ret; + } + + // Otherwise just get the value from the style object + return style[ name ]; + } + }, + + css: function( elem, name, extra, styles ) { + var val, num, hooks, + origName = jQuery.camelCase( name ); + + // Make sure that we're working with the right name + name = jQuery.cssProps[ origName ] || + ( jQuery.cssProps[ origName ] = vendorPropName( origName ) || origName ); + + // Try prefixed name followed by the unprefixed name + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // If a hook was provided get the computed value from there + if ( hooks && "get" in hooks ) { + val = hooks.get( elem, true, extra ); + } + + // Otherwise, if a way to get the computed value exists, use that + if ( val === undefined ) { + val = curCSS( elem, name, styles ); + } + + // Convert "normal" to computed value + if ( val === "normal" && name in cssNormalTransform ) { + val = cssNormalTransform[ name ]; + } + + // Make numeric if forced or a qualifier was provided and val looks numeric + if ( extra === "" || extra ) { + num = parseFloat( val ); + return extra === true || isFinite( num ) ? num || 0 : val; + } + return val; + } +} ); + +jQuery.each( [ "height", "width" ], function( i, name ) { + jQuery.cssHooks[ name ] = { + get: function( elem, computed, extra ) { + if ( computed ) { + + // Certain elements can have dimension info if we invisibly show them + // but it must have a current display style that would benefit + return rdisplayswap.test( jQuery.css( elem, "display" ) ) && + elem.offsetWidth === 0 ? + swap( elem, cssShow, function() { + return getWidthOrHeight( elem, name, extra ); + } ) : + getWidthOrHeight( elem, name, extra ); + } + }, + + set: function( elem, value, extra ) { + var matches, + styles = extra && getStyles( elem ), + subtract = extra && augmentWidthOrHeight( + elem, + name, + extra, + jQuery.css( elem, "boxSizing", false, styles ) === "border-box", + styles + ); + + // Convert to pixels if value adjustment is needed + if ( subtract && ( matches = rcssNum.exec( value ) ) && + ( matches[ 3 ] || "px" ) !== "px" ) { + + elem.style[ name ] = value; + value = jQuery.css( elem, name ); + } + + return setPositiveNumber( elem, value, subtract ); + } + }; +} ); + +jQuery.cssHooks.marginLeft = addGetHookIf( support.reliableMarginLeft, + function( elem, computed ) { + if ( computed ) { + return ( parseFloat( curCSS( elem, "marginLeft" ) ) || + elem.getBoundingClientRect().left - + swap( elem, { marginLeft: 0 }, function() { + return elem.getBoundingClientRect().left; + } ) + ) + "px"; + } + } +); + +// Support: Android 2.3 +jQuery.cssHooks.marginRight = addGetHookIf( support.reliableMarginRight, + function( elem, computed ) { + if ( computed ) { + return swap( elem, { "display": "inline-block" }, + curCSS, [ elem, "marginRight" ] ); + } + } +); + +// These hooks are used by animate to expand properties +jQuery.each( { + margin: "", + padding: "", + border: "Width" +}, function( prefix, suffix ) { + jQuery.cssHooks[ prefix + suffix ] = { + expand: function( value ) { + var i = 0, + expanded = {}, + + // Assumes a single number if not a string + parts = typeof value === "string" ? value.split( " " ) : [ value ]; + + for ( ; i < 4; i++ ) { + expanded[ prefix + cssExpand[ i ] + suffix ] = + parts[ i ] || parts[ i - 2 ] || parts[ 0 ]; + } + + return expanded; + } + }; + + if ( !rmargin.test( prefix ) ) { + jQuery.cssHooks[ prefix + suffix ].set = setPositiveNumber; + } +} ); + +jQuery.fn.extend( { + css: function( name, value ) { + return access( this, function( elem, name, value ) { + var styles, len, + map = {}, + i = 0; + + if ( jQuery.isArray( name ) ) { + styles = getStyles( elem ); + len = name.length; + + for ( ; i < len; i++ ) { + map[ name[ i ] ] = jQuery.css( elem, name[ i ], false, styles ); + } + + return map; + } + + return value !== undefined ? + jQuery.style( elem, name, value ) : + jQuery.css( elem, name ); + }, name, value, arguments.length > 1 ); + }, + show: function() { + return showHide( this, true ); + }, + hide: function() { + return showHide( this ); + }, + toggle: function( state ) { + if ( typeof state === "boolean" ) { + return state ? this.show() : this.hide(); + } + + return this.each( function() { + if ( isHidden( this ) ) { + jQuery( this ).show(); + } else { + jQuery( this ).hide(); + } + } ); + } +} ); + + +function Tween( elem, options, prop, end, easing ) { + return new Tween.prototype.init( elem, options, prop, end, easing ); +} +jQuery.Tween = Tween; + +Tween.prototype = { + constructor: Tween, + init: function( elem, options, prop, end, easing, unit ) { + this.elem = elem; + this.prop = prop; + this.easing = easing || jQuery.easing._default; + this.options = options; + this.start = this.now = this.cur(); + this.end = end; + this.unit = unit || ( jQuery.cssNumber[ prop ] ? "" : "px" ); + }, + cur: function() { + var hooks = Tween.propHooks[ this.prop ]; + + return hooks && hooks.get ? + hooks.get( this ) : + Tween.propHooks._default.get( this ); + }, + run: function( percent ) { + var eased, + hooks = Tween.propHooks[ this.prop ]; + + if ( this.options.duration ) { + this.pos = eased = jQuery.easing[ this.easing ]( + percent, this.options.duration * percent, 0, 1, this.options.duration + ); + } else { + this.pos = eased = percent; + } + this.now = ( this.end - this.start ) * eased + this.start; + + if ( this.options.step ) { + this.options.step.call( this.elem, this.now, this ); + } + + if ( hooks && hooks.set ) { + hooks.set( this ); + } else { + Tween.propHooks._default.set( this ); + } + return this; + } +}; + +Tween.prototype.init.prototype = Tween.prototype; + +Tween.propHooks = { + _default: { + get: function( tween ) { + var result; + + // Use a property on the element directly when it is not a DOM element, + // or when there is no matching style property that exists. + if ( tween.elem.nodeType !== 1 || + tween.elem[ tween.prop ] != null && tween.elem.style[ tween.prop ] == null ) { + return tween.elem[ tween.prop ]; + } + + // Passing an empty string as a 3rd parameter to .css will automatically + // attempt a parseFloat and fallback to a string if the parse fails. + // Simple values such as "10px" are parsed to Float; + // complex values such as "rotate(1rad)" are returned as-is. + result = jQuery.css( tween.elem, tween.prop, "" ); + + // Empty strings, null, undefined and "auto" are converted to 0. + return !result || result === "auto" ? 0 : result; + }, + set: function( tween ) { + + // Use step hook for back compat. + // Use cssHook if its there. + // Use .style if available and use plain properties where available. + if ( jQuery.fx.step[ tween.prop ] ) { + jQuery.fx.step[ tween.prop ]( tween ); + } else if ( tween.elem.nodeType === 1 && + ( tween.elem.style[ jQuery.cssProps[ tween.prop ] ] != null || + jQuery.cssHooks[ tween.prop ] ) ) { + jQuery.style( tween.elem, tween.prop, tween.now + tween.unit ); + } else { + tween.elem[ tween.prop ] = tween.now; + } + } + } +}; + +// Support: IE9 +// Panic based approach to setting things on disconnected nodes +Tween.propHooks.scrollTop = Tween.propHooks.scrollLeft = { + set: function( tween ) { + if ( tween.elem.nodeType && tween.elem.parentNode ) { + tween.elem[ tween.prop ] = tween.now; + } + } +}; + +jQuery.easing = { + linear: function( p ) { + return p; + }, + swing: function( p ) { + return 0.5 - Math.cos( p * Math.PI ) / 2; + }, + _default: "swing" +}; + +jQuery.fx = Tween.prototype.init; + +// Back Compat <1.8 extension point +jQuery.fx.step = {}; + + + + +var + fxNow, timerId, + rfxtypes = /^(?:toggle|show|hide)$/, + rrun = /queueHooks$/; + +// Animations created synchronously will run synchronously +function createFxNow() { + window.setTimeout( function() { + fxNow = undefined; + } ); + return ( fxNow = jQuery.now() ); +} + +// Generate parameters to create a standard animation +function genFx( type, includeWidth ) { + var which, + i = 0, + attrs = { height: type }; + + // If we include width, step value is 1 to do all cssExpand values, + // otherwise step value is 2 to skip over Left and Right + includeWidth = includeWidth ? 1 : 0; + for ( ; i < 4 ; i += 2 - includeWidth ) { + which = cssExpand[ i ]; + attrs[ "margin" + which ] = attrs[ "padding" + which ] = type; + } + + if ( includeWidth ) { + attrs.opacity = attrs.width = type; + } + + return attrs; +} + +function createTween( value, prop, animation ) { + var tween, + collection = ( Animation.tweeners[ prop ] || [] ).concat( Animation.tweeners[ "*" ] ), + index = 0, + length = collection.length; + for ( ; index < length; index++ ) { + if ( ( tween = collection[ index ].call( animation, prop, value ) ) ) { + + // We're done with this property + return tween; + } + } +} + +function defaultPrefilter( elem, props, opts ) { + /* jshint validthis: true */ + var prop, value, toggle, tween, hooks, oldfire, display, checkDisplay, + anim = this, + orig = {}, + style = elem.style, + hidden = elem.nodeType && isHidden( elem ), + dataShow = dataPriv.get( elem, "fxshow" ); + + // Handle queue: false promises + if ( !opts.queue ) { + hooks = jQuery._queueHooks( elem, "fx" ); + if ( hooks.unqueued == null ) { + hooks.unqueued = 0; + oldfire = hooks.empty.fire; + hooks.empty.fire = function() { + if ( !hooks.unqueued ) { + oldfire(); + } + }; + } + hooks.unqueued++; + + anim.always( function() { + + // Ensure the complete handler is called before this completes + anim.always( function() { + hooks.unqueued--; + if ( !jQuery.queue( elem, "fx" ).length ) { + hooks.empty.fire(); + } + } ); + } ); + } + + // Height/width overflow pass + if ( elem.nodeType === 1 && ( "height" in props || "width" in props ) ) { + + // Make sure that nothing sneaks out + // Record all 3 overflow attributes because IE9-10 do not + // change the overflow attribute when overflowX and + // overflowY are set to the same value + opts.overflow = [ style.overflow, style.overflowX, style.overflowY ]; + + // Set display property to inline-block for height/width + // animations on inline elements that are having width/height animated + display = jQuery.css( elem, "display" ); + + // Test default display if display is currently "none" + checkDisplay = display === "none" ? + dataPriv.get( elem, "olddisplay" ) || defaultDisplay( elem.nodeName ) : display; + + if ( checkDisplay === "inline" && jQuery.css( elem, "float" ) === "none" ) { + style.display = "inline-block"; + } + } + + if ( opts.overflow ) { + style.overflow = "hidden"; + anim.always( function() { + style.overflow = opts.overflow[ 0 ]; + style.overflowX = opts.overflow[ 1 ]; + style.overflowY = opts.overflow[ 2 ]; + } ); + } + + // show/hide pass + for ( prop in props ) { + value = props[ prop ]; + if ( rfxtypes.exec( value ) ) { + delete props[ prop ]; + toggle = toggle || value === "toggle"; + if ( value === ( hidden ? "hide" : "show" ) ) { + + // If there is dataShow left over from a stopped hide or show + // and we are going to proceed with show, we should pretend to be hidden + if ( value === "show" && dataShow && dataShow[ prop ] !== undefined ) { + hidden = true; + } else { + continue; + } + } + orig[ prop ] = dataShow && dataShow[ prop ] || jQuery.style( elem, prop ); + + // Any non-fx value stops us from restoring the original display value + } else { + display = undefined; + } + } + + if ( !jQuery.isEmptyObject( orig ) ) { + if ( dataShow ) { + if ( "hidden" in dataShow ) { + hidden = dataShow.hidden; + } + } else { + dataShow = dataPriv.access( elem, "fxshow", {} ); + } + + // Store state if its toggle - enables .stop().toggle() to "reverse" + if ( toggle ) { + dataShow.hidden = !hidden; + } + if ( hidden ) { + jQuery( elem ).show(); + } else { + anim.done( function() { + jQuery( elem ).hide(); + } ); + } + anim.done( function() { + var prop; + + dataPriv.remove( elem, "fxshow" ); + for ( prop in orig ) { + jQuery.style( elem, prop, orig[ prop ] ); + } + } ); + for ( prop in orig ) { + tween = createTween( hidden ? dataShow[ prop ] : 0, prop, anim ); + + if ( !( prop in dataShow ) ) { + dataShow[ prop ] = tween.start; + if ( hidden ) { + tween.end = tween.start; + tween.start = prop === "width" || prop === "height" ? 1 : 0; + } + } + } + + // If this is a noop like .hide().hide(), restore an overwritten display value + } else if ( ( display === "none" ? defaultDisplay( elem.nodeName ) : display ) === "inline" ) { + style.display = display; + } +} + +function propFilter( props, specialEasing ) { + var index, name, easing, value, hooks; + + // camelCase, specialEasing and expand cssHook pass + for ( index in props ) { + name = jQuery.camelCase( index ); + easing = specialEasing[ name ]; + value = props[ index ]; + if ( jQuery.isArray( value ) ) { + easing = value[ 1 ]; + value = props[ index ] = value[ 0 ]; + } + + if ( index !== name ) { + props[ name ] = value; + delete props[ index ]; + } + + hooks = jQuery.cssHooks[ name ]; + if ( hooks && "expand" in hooks ) { + value = hooks.expand( value ); + delete props[ name ]; + + // Not quite $.extend, this won't overwrite existing keys. + // Reusing 'index' because we have the correct "name" + for ( index in value ) { + if ( !( index in props ) ) { + props[ index ] = value[ index ]; + specialEasing[ index ] = easing; + } + } + } else { + specialEasing[ name ] = easing; + } + } +} + +function Animation( elem, properties, options ) { + var result, + stopped, + index = 0, + length = Animation.prefilters.length, + deferred = jQuery.Deferred().always( function() { + + // Don't match elem in the :animated selector + delete tick.elem; + } ), + tick = function() { + if ( stopped ) { + return false; + } + var currentTime = fxNow || createFxNow(), + remaining = Math.max( 0, animation.startTime + animation.duration - currentTime ), + + // Support: Android 2.3 + // Archaic crash bug won't allow us to use `1 - ( 0.5 || 0 )` (#12497) + temp = remaining / animation.duration || 0, + percent = 1 - temp, + index = 0, + length = animation.tweens.length; + + for ( ; index < length ; index++ ) { + animation.tweens[ index ].run( percent ); + } + + deferred.notifyWith( elem, [ animation, percent, remaining ] ); + + if ( percent < 1 && length ) { + return remaining; + } else { + deferred.resolveWith( elem, [ animation ] ); + return false; + } + }, + animation = deferred.promise( { + elem: elem, + props: jQuery.extend( {}, properties ), + opts: jQuery.extend( true, { + specialEasing: {}, + easing: jQuery.easing._default + }, options ), + originalProperties: properties, + originalOptions: options, + startTime: fxNow || createFxNow(), + duration: options.duration, + tweens: [], + createTween: function( prop, end ) { + var tween = jQuery.Tween( elem, animation.opts, prop, end, + animation.opts.specialEasing[ prop ] || animation.opts.easing ); + animation.tweens.push( tween ); + return tween; + }, + stop: function( gotoEnd ) { + var index = 0, + + // If we are going to the end, we want to run all the tweens + // otherwise we skip this part + length = gotoEnd ? animation.tweens.length : 0; + if ( stopped ) { + return this; + } + stopped = true; + for ( ; index < length ; index++ ) { + animation.tweens[ index ].run( 1 ); + } + + // Resolve when we played the last frame; otherwise, reject + if ( gotoEnd ) { + deferred.notifyWith( elem, [ animation, 1, 0 ] ); + deferred.resolveWith( elem, [ animation, gotoEnd ] ); + } else { + deferred.rejectWith( elem, [ animation, gotoEnd ] ); + } + return this; + } + } ), + props = animation.props; + + propFilter( props, animation.opts.specialEasing ); + + for ( ; index < length ; index++ ) { + result = Animation.prefilters[ index ].call( animation, elem, props, animation.opts ); + if ( result ) { + if ( jQuery.isFunction( result.stop ) ) { + jQuery._queueHooks( animation.elem, animation.opts.queue ).stop = + jQuery.proxy( result.stop, result ); + } + return result; + } + } + + jQuery.map( props, createTween, animation ); + + if ( jQuery.isFunction( animation.opts.start ) ) { + animation.opts.start.call( elem, animation ); + } + + jQuery.fx.timer( + jQuery.extend( tick, { + elem: elem, + anim: animation, + queue: animation.opts.queue + } ) + ); + + // attach callbacks from options + return animation.progress( animation.opts.progress ) + .done( animation.opts.done, animation.opts.complete ) + .fail( animation.opts.fail ) + .always( animation.opts.always ); +} + +jQuery.Animation = jQuery.extend( Animation, { + tweeners: { + "*": [ function( prop, value ) { + var tween = this.createTween( prop, value ); + adjustCSS( tween.elem, prop, rcssNum.exec( value ), tween ); + return tween; + } ] + }, + + tweener: function( props, callback ) { + if ( jQuery.isFunction( props ) ) { + callback = props; + props = [ "*" ]; + } else { + props = props.match( rnotwhite ); + } + + var prop, + index = 0, + length = props.length; + + for ( ; index < length ; index++ ) { + prop = props[ index ]; + Animation.tweeners[ prop ] = Animation.tweeners[ prop ] || []; + Animation.tweeners[ prop ].unshift( callback ); + } + }, + + prefilters: [ defaultPrefilter ], + + prefilter: function( callback, prepend ) { + if ( prepend ) { + Animation.prefilters.unshift( callback ); + } else { + Animation.prefilters.push( callback ); + } + } +} ); + +jQuery.speed = function( speed, easing, fn ) { + var opt = speed && typeof speed === "object" ? jQuery.extend( {}, speed ) : { + complete: fn || !fn && easing || + jQuery.isFunction( speed ) && speed, + duration: speed, + easing: fn && easing || easing && !jQuery.isFunction( easing ) && easing + }; + + opt.duration = jQuery.fx.off ? 0 : typeof opt.duration === "number" ? + opt.duration : opt.duration in jQuery.fx.speeds ? + jQuery.fx.speeds[ opt.duration ] : jQuery.fx.speeds._default; + + // Normalize opt.queue - true/undefined/null -> "fx" + if ( opt.queue == null || opt.queue === true ) { + opt.queue = "fx"; + } + + // Queueing + opt.old = opt.complete; + + opt.complete = function() { + if ( jQuery.isFunction( opt.old ) ) { + opt.old.call( this ); + } + + if ( opt.queue ) { + jQuery.dequeue( this, opt.queue ); + } + }; + + return opt; +}; + +jQuery.fn.extend( { + fadeTo: function( speed, to, easing, callback ) { + + // Show any hidden elements after setting opacity to 0 + return this.filter( isHidden ).css( "opacity", 0 ).show() + + // Animate to the value specified + .end().animate( { opacity: to }, speed, easing, callback ); + }, + animate: function( prop, speed, easing, callback ) { + var empty = jQuery.isEmptyObject( prop ), + optall = jQuery.speed( speed, easing, callback ), + doAnimation = function() { + + // Operate on a copy of prop so per-property easing won't be lost + var anim = Animation( this, jQuery.extend( {}, prop ), optall ); + + // Empty animations, or finishing resolves immediately + if ( empty || dataPriv.get( this, "finish" ) ) { + anim.stop( true ); + } + }; + doAnimation.finish = doAnimation; + + return empty || optall.queue === false ? + this.each( doAnimation ) : + this.queue( optall.queue, doAnimation ); + }, + stop: function( type, clearQueue, gotoEnd ) { + var stopQueue = function( hooks ) { + var stop = hooks.stop; + delete hooks.stop; + stop( gotoEnd ); + }; + + if ( typeof type !== "string" ) { + gotoEnd = clearQueue; + clearQueue = type; + type = undefined; + } + if ( clearQueue && type !== false ) { + this.queue( type || "fx", [] ); + } + + return this.each( function() { + var dequeue = true, + index = type != null && type + "queueHooks", + timers = jQuery.timers, + data = dataPriv.get( this ); + + if ( index ) { + if ( data[ index ] && data[ index ].stop ) { + stopQueue( data[ index ] ); + } + } else { + for ( index in data ) { + if ( data[ index ] && data[ index ].stop && rrun.test( index ) ) { + stopQueue( data[ index ] ); + } + } + } + + for ( index = timers.length; index--; ) { + if ( timers[ index ].elem === this && + ( type == null || timers[ index ].queue === type ) ) { + + timers[ index ].anim.stop( gotoEnd ); + dequeue = false; + timers.splice( index, 1 ); + } + } + + // Start the next in the queue if the last step wasn't forced. + // Timers currently will call their complete callbacks, which + // will dequeue but only if they were gotoEnd. + if ( dequeue || !gotoEnd ) { + jQuery.dequeue( this, type ); + } + } ); + }, + finish: function( type ) { + if ( type !== false ) { + type = type || "fx"; + } + return this.each( function() { + var index, + data = dataPriv.get( this ), + queue = data[ type + "queue" ], + hooks = data[ type + "queueHooks" ], + timers = jQuery.timers, + length = queue ? queue.length : 0; + + // Enable finishing flag on private data + data.finish = true; + + // Empty the queue first + jQuery.queue( this, type, [] ); + + if ( hooks && hooks.stop ) { + hooks.stop.call( this, true ); + } + + // Look for any active animations, and finish them + for ( index = timers.length; index--; ) { + if ( timers[ index ].elem === this && timers[ index ].queue === type ) { + timers[ index ].anim.stop( true ); + timers.splice( index, 1 ); + } + } + + // Look for any animations in the old queue and finish them + for ( index = 0; index < length; index++ ) { + if ( queue[ index ] && queue[ index ].finish ) { + queue[ index ].finish.call( this ); + } + } + + // Turn off finishing flag + delete data.finish; + } ); + } +} ); + +jQuery.each( [ "toggle", "show", "hide" ], function( i, name ) { + var cssFn = jQuery.fn[ name ]; + jQuery.fn[ name ] = function( speed, easing, callback ) { + return speed == null || typeof speed === "boolean" ? + cssFn.apply( this, arguments ) : + this.animate( genFx( name, true ), speed, easing, callback ); + }; +} ); + +// Generate shortcuts for custom animations +jQuery.each( { + slideDown: genFx( "show" ), + slideUp: genFx( "hide" ), + slideToggle: genFx( "toggle" ), + fadeIn: { opacity: "show" }, + fadeOut: { opacity: "hide" }, + fadeToggle: { opacity: "toggle" } +}, function( name, props ) { + jQuery.fn[ name ] = function( speed, easing, callback ) { + return this.animate( props, speed, easing, callback ); + }; +} ); + +jQuery.timers = []; +jQuery.fx.tick = function() { + var timer, + i = 0, + timers = jQuery.timers; + + fxNow = jQuery.now(); + + for ( ; i < timers.length; i++ ) { + timer = timers[ i ]; + + // Checks the timer has not already been removed + if ( !timer() && timers[ i ] === timer ) { + timers.splice( i--, 1 ); + } + } + + if ( !timers.length ) { + jQuery.fx.stop(); + } + fxNow = undefined; +}; + +jQuery.fx.timer = function( timer ) { + jQuery.timers.push( timer ); + if ( timer() ) { + jQuery.fx.start(); + } else { + jQuery.timers.pop(); + } +}; + +jQuery.fx.interval = 13; +jQuery.fx.start = function() { + if ( !timerId ) { + timerId = window.setInterval( jQuery.fx.tick, jQuery.fx.interval ); + } +}; + +jQuery.fx.stop = function() { + window.clearInterval( timerId ); + + timerId = null; +}; + +jQuery.fx.speeds = { + slow: 600, + fast: 200, + + // Default speed + _default: 400 +}; + + +// Based off of the plugin by Clint Helfers, with permission. +// http://web.archive.org/web/20100324014747/http://blindsignals.com/index.php/2009/07/jquery-delay/ +jQuery.fn.delay = function( time, type ) { + time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time; + type = type || "fx"; + + return this.queue( type, function( next, hooks ) { + var timeout = window.setTimeout( next, time ); + hooks.stop = function() { + window.clearTimeout( timeout ); + }; + } ); +}; + + +( function() { + var input = document.createElement( "input" ), + select = document.createElement( "select" ), + opt = select.appendChild( document.createElement( "option" ) ); + + input.type = "checkbox"; + + // Support: iOS<=5.1, Android<=4.2+ + // Default value for a checkbox should be "on" + support.checkOn = input.value !== ""; + + // Support: IE<=11+ + // Must access selectedIndex to make default options select + support.optSelected = opt.selected; + + // Support: Android<=2.3 + // Options inside disabled selects are incorrectly marked as disabled + select.disabled = true; + support.optDisabled = !opt.disabled; + + // Support: IE<=11+ + // An input loses its value after becoming a radio + input = document.createElement( "input" ); + input.value = "t"; + input.type = "radio"; + support.radioValue = input.value === "t"; +} )(); + + +var boolHook, + attrHandle = jQuery.expr.attrHandle; + +jQuery.fn.extend( { + attr: function( name, value ) { + return access( this, jQuery.attr, name, value, arguments.length > 1 ); + }, + + removeAttr: function( name ) { + return this.each( function() { + jQuery.removeAttr( this, name ); + } ); + } +} ); + +jQuery.extend( { + attr: function( elem, name, value ) { + var ret, hooks, + nType = elem.nodeType; + + // Don't get/set attributes on text, comment and attribute nodes + if ( nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + // Fallback to prop when attributes are not supported + if ( typeof elem.getAttribute === "undefined" ) { + return jQuery.prop( elem, name, value ); + } + + // All attributes are lowercase + // Grab necessary hook if one is defined + if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { + name = name.toLowerCase(); + hooks = jQuery.attrHooks[ name ] || + ( jQuery.expr.match.bool.test( name ) ? boolHook : undefined ); + } + + if ( value !== undefined ) { + if ( value === null ) { + jQuery.removeAttr( elem, name ); + return; + } + + if ( hooks && "set" in hooks && + ( ret = hooks.set( elem, value, name ) ) !== undefined ) { + return ret; + } + + elem.setAttribute( name, value + "" ); + return value; + } + + if ( hooks && "get" in hooks && ( ret = hooks.get( elem, name ) ) !== null ) { + return ret; + } + + ret = jQuery.find.attr( elem, name ); + + // Non-existent attributes return null, we normalize to undefined + return ret == null ? undefined : ret; + }, + + attrHooks: { + type: { + set: function( elem, value ) { + if ( !support.radioValue && value === "radio" && + jQuery.nodeName( elem, "input" ) ) { + var val = elem.value; + elem.setAttribute( "type", value ); + if ( val ) { + elem.value = val; + } + return value; + } + } + } + }, + + removeAttr: function( elem, value ) { + var name, propName, + i = 0, + attrNames = value && value.match( rnotwhite ); + + if ( attrNames && elem.nodeType === 1 ) { + while ( ( name = attrNames[ i++ ] ) ) { + propName = jQuery.propFix[ name ] || name; + + // Boolean attributes get special treatment (#10870) + if ( jQuery.expr.match.bool.test( name ) ) { + + // Set corresponding property to false + elem[ propName ] = false; + } + + elem.removeAttribute( name ); + } + } + } +} ); + +// Hooks for boolean attributes +boolHook = { + set: function( elem, value, name ) { + if ( value === false ) { + + // Remove boolean attributes when set to false + jQuery.removeAttr( elem, name ); + } else { + elem.setAttribute( name, name ); + } + return name; + } +}; +jQuery.each( jQuery.expr.match.bool.source.match( /\w+/g ), function( i, name ) { + var getter = attrHandle[ name ] || jQuery.find.attr; + + attrHandle[ name ] = function( elem, name, isXML ) { + var ret, handle; + if ( !isXML ) { + + // Avoid an infinite loop by temporarily removing this function from the getter + handle = attrHandle[ name ]; + attrHandle[ name ] = ret; + ret = getter( elem, name, isXML ) != null ? + name.toLowerCase() : + null; + attrHandle[ name ] = handle; + } + return ret; + }; +} ); + + + + +var rfocusable = /^(?:input|select|textarea|button)$/i, + rclickable = /^(?:a|area)$/i; + +jQuery.fn.extend( { + prop: function( name, value ) { + return access( this, jQuery.prop, name, value, arguments.length > 1 ); + }, + + removeProp: function( name ) { + return this.each( function() { + delete this[ jQuery.propFix[ name ] || name ]; + } ); + } +} ); + +jQuery.extend( { + prop: function( elem, name, value ) { + var ret, hooks, + nType = elem.nodeType; + + // Don't get/set properties on text, comment and attribute nodes + if ( nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { + + // Fix name and attach hooks + name = jQuery.propFix[ name ] || name; + hooks = jQuery.propHooks[ name ]; + } + + if ( value !== undefined ) { + if ( hooks && "set" in hooks && + ( ret = hooks.set( elem, value, name ) ) !== undefined ) { + return ret; + } + + return ( elem[ name ] = value ); + } + + if ( hooks && "get" in hooks && ( ret = hooks.get( elem, name ) ) !== null ) { + return ret; + } + + return elem[ name ]; + }, + + propHooks: { + tabIndex: { + get: function( elem ) { + + // elem.tabIndex doesn't always return the + // correct value when it hasn't been explicitly set + // http://fluidproject.org/blog/2008/01/09/getting-setting-and-removing-tabindex-values-with-javascript/ + // Use proper attribute retrieval(#12072) + var tabindex = jQuery.find.attr( elem, "tabindex" ); + + return tabindex ? + parseInt( tabindex, 10 ) : + rfocusable.test( elem.nodeName ) || + rclickable.test( elem.nodeName ) && elem.href ? + 0 : + -1; + } + } + }, + + propFix: { + "for": "htmlFor", + "class": "className" + } +} ); + +if ( !support.optSelected ) { + jQuery.propHooks.selected = { + get: function( elem ) { + var parent = elem.parentNode; + if ( parent && parent.parentNode ) { + parent.parentNode.selectedIndex; + } + return null; + } + }; +} + +jQuery.each( [ + "tabIndex", + "readOnly", + "maxLength", + "cellSpacing", + "cellPadding", + "rowSpan", + "colSpan", + "useMap", + "frameBorder", + "contentEditable" +], function() { + jQuery.propFix[ this.toLowerCase() ] = this; +} ); + + + + +var rclass = /[\t\r\n\f]/g; + +function getClass( elem ) { + return elem.getAttribute && elem.getAttribute( "class" ) || ""; +} + +jQuery.fn.extend( { + addClass: function( value ) { + var classes, elem, cur, curValue, clazz, j, finalValue, + i = 0; + + if ( jQuery.isFunction( value ) ) { + return this.each( function( j ) { + jQuery( this ).addClass( value.call( this, j, getClass( this ) ) ); + } ); + } + + if ( typeof value === "string" && value ) { + classes = value.match( rnotwhite ) || []; + + while ( ( elem = this[ i++ ] ) ) { + curValue = getClass( elem ); + cur = elem.nodeType === 1 && + ( " " + curValue + " " ).replace( rclass, " " ); + + if ( cur ) { + j = 0; + while ( ( clazz = classes[ j++ ] ) ) { + if ( cur.indexOf( " " + clazz + " " ) < 0 ) { + cur += clazz + " "; + } + } + + // Only assign if different to avoid unneeded rendering. + finalValue = jQuery.trim( cur ); + if ( curValue !== finalValue ) { + elem.setAttribute( "class", finalValue ); + } + } + } + } + + return this; + }, + + removeClass: function( value ) { + var classes, elem, cur, curValue, clazz, j, finalValue, + i = 0; + + if ( jQuery.isFunction( value ) ) { + return this.each( function( j ) { + jQuery( this ).removeClass( value.call( this, j, getClass( this ) ) ); + } ); + } + + if ( !arguments.length ) { + return this.attr( "class", "" ); + } + + if ( typeof value === "string" && value ) { + classes = value.match( rnotwhite ) || []; + + while ( ( elem = this[ i++ ] ) ) { + curValue = getClass( elem ); + + // This expression is here for better compressibility (see addClass) + cur = elem.nodeType === 1 && + ( " " + curValue + " " ).replace( rclass, " " ); + + if ( cur ) { + j = 0; + while ( ( clazz = classes[ j++ ] ) ) { + + // Remove *all* instances + while ( cur.indexOf( " " + clazz + " " ) > -1 ) { + cur = cur.replace( " " + clazz + " ", " " ); + } + } + + // Only assign if different to avoid unneeded rendering. + finalValue = jQuery.trim( cur ); + if ( curValue !== finalValue ) { + elem.setAttribute( "class", finalValue ); + } + } + } + } + + return this; + }, + + toggleClass: function( value, stateVal ) { + var type = typeof value; + + if ( typeof stateVal === "boolean" && type === "string" ) { + return stateVal ? this.addClass( value ) : this.removeClass( value ); + } + + if ( jQuery.isFunction( value ) ) { + return this.each( function( i ) { + jQuery( this ).toggleClass( + value.call( this, i, getClass( this ), stateVal ), + stateVal + ); + } ); + } + + return this.each( function() { + var className, i, self, classNames; + + if ( type === "string" ) { + + // Toggle individual class names + i = 0; + self = jQuery( this ); + classNames = value.match( rnotwhite ) || []; + + while ( ( className = classNames[ i++ ] ) ) { + + // Check each className given, space separated list + if ( self.hasClass( className ) ) { + self.removeClass( className ); + } else { + self.addClass( className ); + } + } + + // Toggle whole class name + } else if ( value === undefined || type === "boolean" ) { + className = getClass( this ); + if ( className ) { + + // Store className if set + dataPriv.set( this, "__className__", className ); + } + + // If the element has a class name or if we're passed `false`, + // then remove the whole classname (if there was one, the above saved it). + // Otherwise bring back whatever was previously saved (if anything), + // falling back to the empty string if nothing was stored. + if ( this.setAttribute ) { + this.setAttribute( "class", + className || value === false ? + "" : + dataPriv.get( this, "__className__" ) || "" + ); + } + } + } ); + }, + + hasClass: function( selector ) { + var className, elem, + i = 0; + + className = " " + selector + " "; + while ( ( elem = this[ i++ ] ) ) { + if ( elem.nodeType === 1 && + ( " " + getClass( elem ) + " " ).replace( rclass, " " ) + .indexOf( className ) > -1 + ) { + return true; + } + } + + return false; + } +} ); + + + + +var rreturn = /\r/g; + +jQuery.fn.extend( { + val: function( value ) { + var hooks, ret, isFunction, + elem = this[ 0 ]; + + if ( !arguments.length ) { + if ( elem ) { + hooks = jQuery.valHooks[ elem.type ] || + jQuery.valHooks[ elem.nodeName.toLowerCase() ]; + + if ( hooks && + "get" in hooks && + ( ret = hooks.get( elem, "value" ) ) !== undefined + ) { + return ret; + } + + ret = elem.value; + + return typeof ret === "string" ? + + // Handle most common string cases + ret.replace( rreturn, "" ) : + + // Handle cases where value is null/undef or number + ret == null ? "" : ret; + } + + return; + } + + isFunction = jQuery.isFunction( value ); + + return this.each( function( i ) { + var val; + + if ( this.nodeType !== 1 ) { + return; + } + + if ( isFunction ) { + val = value.call( this, i, jQuery( this ).val() ); + } else { + val = value; + } + + // Treat null/undefined as ""; convert numbers to string + if ( val == null ) { + val = ""; + + } else if ( typeof val === "number" ) { + val += ""; + + } else if ( jQuery.isArray( val ) ) { + val = jQuery.map( val, function( value ) { + return value == null ? "" : value + ""; + } ); + } + + hooks = jQuery.valHooks[ this.type ] || jQuery.valHooks[ this.nodeName.toLowerCase() ]; + + // If set returns undefined, fall back to normal setting + if ( !hooks || !( "set" in hooks ) || hooks.set( this, val, "value" ) === undefined ) { + this.value = val; + } + } ); + } +} ); + +jQuery.extend( { + valHooks: { + option: { + get: function( elem ) { + + // Support: IE<11 + // option.value not trimmed (#14858) + return jQuery.trim( elem.value ); + } + }, + select: { + get: function( elem ) { + var value, option, + options = elem.options, + index = elem.selectedIndex, + one = elem.type === "select-one" || index < 0, + values = one ? null : [], + max = one ? index + 1 : options.length, + i = index < 0 ? + max : + one ? index : 0; + + // Loop through all the selected options + for ( ; i < max; i++ ) { + option = options[ i ]; + + // IE8-9 doesn't update selected after form reset (#2551) + if ( ( option.selected || i === index ) && + + // Don't return options that are disabled or in a disabled optgroup + ( support.optDisabled ? + !option.disabled : option.getAttribute( "disabled" ) === null ) && + ( !option.parentNode.disabled || + !jQuery.nodeName( option.parentNode, "optgroup" ) ) ) { + + // Get the specific value for the option + value = jQuery( option ).val(); + + // We don't need an array for one selects + if ( one ) { + return value; + } + + // Multi-Selects return an array + values.push( value ); + } + } + + return values; + }, + + set: function( elem, value ) { + var optionSet, option, + options = elem.options, + values = jQuery.makeArray( value ), + i = options.length; + + while ( i-- ) { + option = options[ i ]; + if ( option.selected = + jQuery.inArray( jQuery.valHooks.option.get( option ), values ) > -1 + ) { + optionSet = true; + } + } + + // Force browsers to behave consistently when non-matching value is set + if ( !optionSet ) { + elem.selectedIndex = -1; + } + return values; + } + } + } +} ); + +// Radios and checkboxes getter/setter +jQuery.each( [ "radio", "checkbox" ], function() { + jQuery.valHooks[ this ] = { + set: function( elem, value ) { + if ( jQuery.isArray( value ) ) { + return ( elem.checked = jQuery.inArray( jQuery( elem ).val(), value ) > -1 ); + } + } + }; + if ( !support.checkOn ) { + jQuery.valHooks[ this ].get = function( elem ) { + return elem.getAttribute( "value" ) === null ? "on" : elem.value; + }; + } +} ); + + + + +// Return jQuery for attributes-only inclusion + + +var rfocusMorph = /^(?:focusinfocus|focusoutblur)$/; + +jQuery.extend( jQuery.event, { + + trigger: function( event, data, elem, onlyHandlers ) { + + var i, cur, tmp, bubbleType, ontype, handle, special, + eventPath = [ elem || document ], + type = hasOwn.call( event, "type" ) ? event.type : event, + namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split( "." ) : []; + + cur = tmp = elem = elem || document; + + // Don't do events on text and comment nodes + if ( elem.nodeType === 3 || elem.nodeType === 8 ) { + return; + } + + // focus/blur morphs to focusin/out; ensure we're not firing them right now + if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { + return; + } + + if ( type.indexOf( "." ) > -1 ) { + + // Namespaced trigger; create a regexp to match event type in handle() + namespaces = type.split( "." ); + type = namespaces.shift(); + namespaces.sort(); + } + ontype = type.indexOf( ":" ) < 0 && "on" + type; + + // Caller can pass in a jQuery.Event object, Object, or just an event type string + event = event[ jQuery.expando ] ? + event : + new jQuery.Event( type, typeof event === "object" && event ); + + // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true) + event.isTrigger = onlyHandlers ? 2 : 3; + event.namespace = namespaces.join( "." ); + event.rnamespace = event.namespace ? + new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" ) : + null; + + // Clean up the event in case it is being reused + event.result = undefined; + if ( !event.target ) { + event.target = elem; + } + + // Clone any incoming data and prepend the event, creating the handler arg list + data = data == null ? + [ event ] : + jQuery.makeArray( data, [ event ] ); + + // Allow special events to draw outside the lines + special = jQuery.event.special[ type ] || {}; + if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { + return; + } + + // Determine event propagation path in advance, per W3C events spec (#9951) + // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) + if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { + + bubbleType = special.delegateType || type; + if ( !rfocusMorph.test( bubbleType + type ) ) { + cur = cur.parentNode; + } + for ( ; cur; cur = cur.parentNode ) { + eventPath.push( cur ); + tmp = cur; + } + + // Only add window if we got to document (e.g., not plain obj or detached DOM) + if ( tmp === ( elem.ownerDocument || document ) ) { + eventPath.push( tmp.defaultView || tmp.parentWindow || window ); + } + } + + // Fire handlers on the event path + i = 0; + while ( ( cur = eventPath[ i++ ] ) && !event.isPropagationStopped() ) { + + event.type = i > 1 ? + bubbleType : + special.bindType || type; + + // jQuery handler + handle = ( dataPriv.get( cur, "events" ) || {} )[ event.type ] && + dataPriv.get( cur, "handle" ); + if ( handle ) { + handle.apply( cur, data ); + } + + // Native handler + handle = ontype && cur[ ontype ]; + if ( handle && handle.apply && acceptData( cur ) ) { + event.result = handle.apply( cur, data ); + if ( event.result === false ) { + event.preventDefault(); + } + } + } + event.type = type; + + // If nobody prevented the default action, do it now + if ( !onlyHandlers && !event.isDefaultPrevented() ) { + + if ( ( !special._default || + special._default.apply( eventPath.pop(), data ) === false ) && + acceptData( elem ) ) { + + // Call a native DOM method on the target with the same name name as the event. + // Don't do default actions on window, that's where global variables be (#6170) + if ( ontype && jQuery.isFunction( elem[ type ] ) && !jQuery.isWindow( elem ) ) { + + // Don't re-trigger an onFOO event when we call its FOO() method + tmp = elem[ ontype ]; + + if ( tmp ) { + elem[ ontype ] = null; + } + + // Prevent re-triggering of the same event, since we already bubbled it above + jQuery.event.triggered = type; + elem[ type ](); + jQuery.event.triggered = undefined; + + if ( tmp ) { + elem[ ontype ] = tmp; + } + } + } + } + + return event.result; + }, + + // Piggyback on a donor event to simulate a different one + simulate: function( type, elem, event ) { + var e = jQuery.extend( + new jQuery.Event(), + event, + { + type: type, + isSimulated: true + + // Previously, `originalEvent: {}` was set here, so stopPropagation call + // would not be triggered on donor event, since in our own + // jQuery.event.stopPropagation function we had a check for existence of + // originalEvent.stopPropagation method, so, consequently it would be a noop. + // + // But now, this "simulate" function is used only for events + // for which stopPropagation() is noop, so there is no need for that anymore. + // + // For the compat branch though, guard for "click" and "submit" + // events is still used, but was moved to jQuery.event.stopPropagation function + // because `originalEvent` should point to the original event for the constancy + // with other events and for more focused logic + } + ); + + jQuery.event.trigger( e, null, elem ); + + if ( e.isDefaultPrevented() ) { + event.preventDefault(); + } + } + +} ); + +jQuery.fn.extend( { + + trigger: function( type, data ) { + return this.each( function() { + jQuery.event.trigger( type, data, this ); + } ); + }, + triggerHandler: function( type, data ) { + var elem = this[ 0 ]; + if ( elem ) { + return jQuery.event.trigger( type, data, elem, true ); + } + } +} ); + + +jQuery.each( ( "blur focus focusin focusout load resize scroll unload click dblclick " + + "mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave " + + "change select submit keydown keypress keyup error contextmenu" ).split( " " ), + function( i, name ) { + + // Handle event binding + jQuery.fn[ name ] = function( data, fn ) { + return arguments.length > 0 ? + this.on( name, null, data, fn ) : + this.trigger( name ); + }; +} ); + +jQuery.fn.extend( { + hover: function( fnOver, fnOut ) { + return this.mouseenter( fnOver ).mouseleave( fnOut || fnOver ); + } +} ); + + + + +support.focusin = "onfocusin" in window; + + +// Support: Firefox +// Firefox doesn't have focus(in | out) events +// Related ticket - https://bugzilla.mozilla.org/show_bug.cgi?id=687787 +// +// Support: Chrome, Safari +// focus(in | out) events fire after focus & blur events, +// which is spec violation - http://www.w3.org/TR/DOM-Level-3-Events/#events-focusevent-event-order +// Related ticket - https://code.google.com/p/chromium/issues/detail?id=449857 +if ( !support.focusin ) { + jQuery.each( { focus: "focusin", blur: "focusout" }, function( orig, fix ) { + + // Attach a single capturing handler on the document while someone wants focusin/focusout + var handler = function( event ) { + jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ) ); + }; + + jQuery.event.special[ fix ] = { + setup: function() { + var doc = this.ownerDocument || this, + attaches = dataPriv.access( doc, fix ); + + if ( !attaches ) { + doc.addEventListener( orig, handler, true ); + } + dataPriv.access( doc, fix, ( attaches || 0 ) + 1 ); + }, + teardown: function() { + var doc = this.ownerDocument || this, + attaches = dataPriv.access( doc, fix ) - 1; + + if ( !attaches ) { + doc.removeEventListener( orig, handler, true ); + dataPriv.remove( doc, fix ); + + } else { + dataPriv.access( doc, fix, attaches ); + } + } + }; + } ); +} +var location = window.location; + +var nonce = jQuery.now(); + +var rquery = ( /\?/ ); + + + +// Support: Android 2.3 +// Workaround failure to string-cast null input +jQuery.parseJSON = function( data ) { + return JSON.parse( data + "" ); +}; + + +// Cross-browser xml parsing +jQuery.parseXML = function( data ) { + var xml; + if ( !data || typeof data !== "string" ) { + return null; + } + + // Support: IE9 + try { + xml = ( new window.DOMParser() ).parseFromString( data, "text/xml" ); + } catch ( e ) { + xml = undefined; + } + + if ( !xml || xml.getElementsByTagName( "parsererror" ).length ) { + jQuery.error( "Invalid XML: " + data ); + } + return xml; +}; + + +var + rhash = /#.*$/, + rts = /([?&])_=[^&]*/, + rheaders = /^(.*?):[ \t]*([^\r\n]*)$/mg, + + // #7653, #8125, #8152: local protocol detection + rlocalProtocol = /^(?:about|app|app-storage|.+-extension|file|res|widget):$/, + rnoContent = /^(?:GET|HEAD)$/, + rprotocol = /^\/\//, + + /* Prefilters + * 1) They are useful to introduce custom dataTypes (see ajax/jsonp.js for an example) + * 2) These are called: + * - BEFORE asking for a transport + * - AFTER param serialization (s.data is a string if s.processData is true) + * 3) key is the dataType + * 4) the catchall symbol "*" can be used + * 5) execution will start with transport dataType and THEN continue down to "*" if needed + */ + prefilters = {}, + + /* Transports bindings + * 1) key is the dataType + * 2) the catchall symbol "*" can be used + * 3) selection will start with transport dataType and THEN go to "*" if needed + */ + transports = {}, + + // Avoid comment-prolog char sequence (#10098); must appease lint and evade compression + allTypes = "*/".concat( "*" ), + + // Anchor tag for parsing the document origin + originAnchor = document.createElement( "a" ); + originAnchor.href = location.href; + +// Base "constructor" for jQuery.ajaxPrefilter and jQuery.ajaxTransport +function addToPrefiltersOrTransports( structure ) { + + // dataTypeExpression is optional and defaults to "*" + return function( dataTypeExpression, func ) { + + if ( typeof dataTypeExpression !== "string" ) { + func = dataTypeExpression; + dataTypeExpression = "*"; + } + + var dataType, + i = 0, + dataTypes = dataTypeExpression.toLowerCase().match( rnotwhite ) || []; + + if ( jQuery.isFunction( func ) ) { + + // For each dataType in the dataTypeExpression + while ( ( dataType = dataTypes[ i++ ] ) ) { + + // Prepend if requested + if ( dataType[ 0 ] === "+" ) { + dataType = dataType.slice( 1 ) || "*"; + ( structure[ dataType ] = structure[ dataType ] || [] ).unshift( func ); + + // Otherwise append + } else { + ( structure[ dataType ] = structure[ dataType ] || [] ).push( func ); + } + } + } + }; +} + +// Base inspection function for prefilters and transports +function inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR ) { + + var inspected = {}, + seekingTransport = ( structure === transports ); + + function inspect( dataType ) { + var selected; + inspected[ dataType ] = true; + jQuery.each( structure[ dataType ] || [], function( _, prefilterOrFactory ) { + var dataTypeOrTransport = prefilterOrFactory( options, originalOptions, jqXHR ); + if ( typeof dataTypeOrTransport === "string" && + !seekingTransport && !inspected[ dataTypeOrTransport ] ) { + + options.dataTypes.unshift( dataTypeOrTransport ); + inspect( dataTypeOrTransport ); + return false; + } else if ( seekingTransport ) { + return !( selected = dataTypeOrTransport ); + } + } ); + return selected; + } + + return inspect( options.dataTypes[ 0 ] ) || !inspected[ "*" ] && inspect( "*" ); +} + +// A special extend for ajax options +// that takes "flat" options (not to be deep extended) +// Fixes #9887 +function ajaxExtend( target, src ) { + var key, deep, + flatOptions = jQuery.ajaxSettings.flatOptions || {}; + + for ( key in src ) { + if ( src[ key ] !== undefined ) { + ( flatOptions[ key ] ? target : ( deep || ( deep = {} ) ) )[ key ] = src[ key ]; + } + } + if ( deep ) { + jQuery.extend( true, target, deep ); + } + + return target; +} + +/* Handles responses to an ajax request: + * - finds the right dataType (mediates between content-type and expected dataType) + * - returns the corresponding response + */ +function ajaxHandleResponses( s, jqXHR, responses ) { + + var ct, type, finalDataType, firstDataType, + contents = s.contents, + dataTypes = s.dataTypes; + + // Remove auto dataType and get content-type in the process + while ( dataTypes[ 0 ] === "*" ) { + dataTypes.shift(); + if ( ct === undefined ) { + ct = s.mimeType || jqXHR.getResponseHeader( "Content-Type" ); + } + } + + // Check if we're dealing with a known content-type + if ( ct ) { + for ( type in contents ) { + if ( contents[ type ] && contents[ type ].test( ct ) ) { + dataTypes.unshift( type ); + break; + } + } + } + + // Check to see if we have a response for the expected dataType + if ( dataTypes[ 0 ] in responses ) { + finalDataType = dataTypes[ 0 ]; + } else { + + // Try convertible dataTypes + for ( type in responses ) { + if ( !dataTypes[ 0 ] || s.converters[ type + " " + dataTypes[ 0 ] ] ) { + finalDataType = type; + break; + } + if ( !firstDataType ) { + firstDataType = type; + } + } + + // Or just use first one + finalDataType = finalDataType || firstDataType; + } + + // If we found a dataType + // We add the dataType to the list if needed + // and return the corresponding response + if ( finalDataType ) { + if ( finalDataType !== dataTypes[ 0 ] ) { + dataTypes.unshift( finalDataType ); + } + return responses[ finalDataType ]; + } +} + +/* Chain conversions given the request and the original response + * Also sets the responseXXX fields on the jqXHR instance + */ +function ajaxConvert( s, response, jqXHR, isSuccess ) { + var conv2, current, conv, tmp, prev, + converters = {}, + + // Work with a copy of dataTypes in case we need to modify it for conversion + dataTypes = s.dataTypes.slice(); + + // Create converters map with lowercased keys + if ( dataTypes[ 1 ] ) { + for ( conv in s.converters ) { + converters[ conv.toLowerCase() ] = s.converters[ conv ]; + } + } + + current = dataTypes.shift(); + + // Convert to each sequential dataType + while ( current ) { + + if ( s.responseFields[ current ] ) { + jqXHR[ s.responseFields[ current ] ] = response; + } + + // Apply the dataFilter if provided + if ( !prev && isSuccess && s.dataFilter ) { + response = s.dataFilter( response, s.dataType ); + } + + prev = current; + current = dataTypes.shift(); + + if ( current ) { + + // There's only work to do if current dataType is non-auto + if ( current === "*" ) { + + current = prev; + + // Convert response if prev dataType is non-auto and differs from current + } else if ( prev !== "*" && prev !== current ) { + + // Seek a direct converter + conv = converters[ prev + " " + current ] || converters[ "* " + current ]; + + // If none found, seek a pair + if ( !conv ) { + for ( conv2 in converters ) { + + // If conv2 outputs current + tmp = conv2.split( " " ); + if ( tmp[ 1 ] === current ) { + + // If prev can be converted to accepted input + conv = converters[ prev + " " + tmp[ 0 ] ] || + converters[ "* " + tmp[ 0 ] ]; + if ( conv ) { + + // Condense equivalence converters + if ( conv === true ) { + conv = converters[ conv2 ]; + + // Otherwise, insert the intermediate dataType + } else if ( converters[ conv2 ] !== true ) { + current = tmp[ 0 ]; + dataTypes.unshift( tmp[ 1 ] ); + } + break; + } + } + } + } + + // Apply converter (if not an equivalence) + if ( conv !== true ) { + + // Unless errors are allowed to bubble, catch and return them + if ( conv && s.throws ) { + response = conv( response ); + } else { + try { + response = conv( response ); + } catch ( e ) { + return { + state: "parsererror", + error: conv ? e : "No conversion from " + prev + " to " + current + }; + } + } + } + } + } + } + + return { state: "success", data: response }; +} + +jQuery.extend( { + + // Counter for holding the number of active queries + active: 0, + + // Last-Modified header cache for next request + lastModified: {}, + etag: {}, + + ajaxSettings: { + url: location.href, + type: "GET", + isLocal: rlocalProtocol.test( location.protocol ), + global: true, + processData: true, + async: true, + contentType: "application/x-www-form-urlencoded; charset=UTF-8", + /* + timeout: 0, + data: null, + dataType: null, + username: null, + password: null, + cache: null, + throws: false, + traditional: false, + headers: {}, + */ + + accepts: { + "*": allTypes, + text: "text/plain", + html: "text/html", + xml: "application/xml, text/xml", + json: "application/json, text/javascript" + }, + + contents: { + xml: /\bxml\b/, + html: /\bhtml/, + json: /\bjson\b/ + }, + + responseFields: { + xml: "responseXML", + text: "responseText", + json: "responseJSON" + }, + + // Data converters + // Keys separate source (or catchall "*") and destination types with a single space + converters: { + + // Convert anything to text + "* text": String, + + // Text to html (true = no transformation) + "text html": true, + + // Evaluate text as a json expression + "text json": jQuery.parseJSON, + + // Parse text as xml + "text xml": jQuery.parseXML + }, + + // For options that shouldn't be deep extended: + // you can add your own custom options here if + // and when you create one that shouldn't be + // deep extended (see ajaxExtend) + flatOptions: { + url: true, + context: true + } + }, + + // Creates a full fledged settings object into target + // with both ajaxSettings and settings fields. + // If target is omitted, writes into ajaxSettings. + ajaxSetup: function( target, settings ) { + return settings ? + + // Building a settings object + ajaxExtend( ajaxExtend( target, jQuery.ajaxSettings ), settings ) : + + // Extending ajaxSettings + ajaxExtend( jQuery.ajaxSettings, target ); + }, + + ajaxPrefilter: addToPrefiltersOrTransports( prefilters ), + ajaxTransport: addToPrefiltersOrTransports( transports ), + + // Main method + ajax: function( url, options ) { + + // If url is an object, simulate pre-1.5 signature + if ( typeof url === "object" ) { + options = url; + url = undefined; + } + + // Force options to be an object + options = options || {}; + + var transport, + + // URL without anti-cache param + cacheURL, + + // Response headers + responseHeadersString, + responseHeaders, + + // timeout handle + timeoutTimer, + + // Url cleanup var + urlAnchor, + + // To know if global events are to be dispatched + fireGlobals, + + // Loop variable + i, + + // Create the final options object + s = jQuery.ajaxSetup( {}, options ), + + // Callbacks context + callbackContext = s.context || s, + + // Context for global events is callbackContext if it is a DOM node or jQuery collection + globalEventContext = s.context && + ( callbackContext.nodeType || callbackContext.jquery ) ? + jQuery( callbackContext ) : + jQuery.event, + + // Deferreds + deferred = jQuery.Deferred(), + completeDeferred = jQuery.Callbacks( "once memory" ), + + // Status-dependent callbacks + statusCode = s.statusCode || {}, + + // Headers (they are sent all at once) + requestHeaders = {}, + requestHeadersNames = {}, + + // The jqXHR state + state = 0, + + // Default abort message + strAbort = "canceled", + + // Fake xhr + jqXHR = { + readyState: 0, + + // Builds headers hashtable if needed + getResponseHeader: function( key ) { + var match; + if ( state === 2 ) { + if ( !responseHeaders ) { + responseHeaders = {}; + while ( ( match = rheaders.exec( responseHeadersString ) ) ) { + responseHeaders[ match[ 1 ].toLowerCase() ] = match[ 2 ]; + } + } + match = responseHeaders[ key.toLowerCase() ]; + } + return match == null ? null : match; + }, + + // Raw string + getAllResponseHeaders: function() { + return state === 2 ? responseHeadersString : null; + }, + + // Caches the header + setRequestHeader: function( name, value ) { + var lname = name.toLowerCase(); + if ( !state ) { + name = requestHeadersNames[ lname ] = requestHeadersNames[ lname ] || name; + requestHeaders[ name ] = value; + } + return this; + }, + + // Overrides response content-type header + overrideMimeType: function( type ) { + if ( !state ) { + s.mimeType = type; + } + return this; + }, + + // Status-dependent callbacks + statusCode: function( map ) { + var code; + if ( map ) { + if ( state < 2 ) { + for ( code in map ) { + + // Lazy-add the new callback in a way that preserves old ones + statusCode[ code ] = [ statusCode[ code ], map[ code ] ]; + } + } else { + + // Execute the appropriate callbacks + jqXHR.always( map[ jqXHR.status ] ); + } + } + return this; + }, + + // Cancel the request + abort: function( statusText ) { + var finalText = statusText || strAbort; + if ( transport ) { + transport.abort( finalText ); + } + done( 0, finalText ); + return this; + } + }; + + // Attach deferreds + deferred.promise( jqXHR ).complete = completeDeferred.add; + jqXHR.success = jqXHR.done; + jqXHR.error = jqXHR.fail; + + // Remove hash character (#7531: and string promotion) + // Add protocol if not provided (prefilters might expect it) + // Handle falsy url in the settings object (#10093: consistency with old signature) + // We also use the url parameter if available + s.url = ( ( url || s.url || location.href ) + "" ).replace( rhash, "" ) + .replace( rprotocol, location.protocol + "//" ); + + // Alias method option to type as per ticket #12004 + s.type = options.method || options.type || s.method || s.type; + + // Extract dataTypes list + s.dataTypes = jQuery.trim( s.dataType || "*" ).toLowerCase().match( rnotwhite ) || [ "" ]; + + // A cross-domain request is in order when the origin doesn't match the current origin. + if ( s.crossDomain == null ) { + urlAnchor = document.createElement( "a" ); + + // Support: IE8-11+ + // IE throws exception if url is malformed, e.g. http://example.com:80x/ + try { + urlAnchor.href = s.url; + + // Support: IE8-11+ + // Anchor's host property isn't correctly set when s.url is relative + urlAnchor.href = urlAnchor.href; + s.crossDomain = originAnchor.protocol + "//" + originAnchor.host !== + urlAnchor.protocol + "//" + urlAnchor.host; + } catch ( e ) { + + // If there is an error parsing the URL, assume it is crossDomain, + // it can be rejected by the transport if it is invalid + s.crossDomain = true; + } + } + + // Convert data if not already a string + if ( s.data && s.processData && typeof s.data !== "string" ) { + s.data = jQuery.param( s.data, s.traditional ); + } + + // Apply prefilters + inspectPrefiltersOrTransports( prefilters, s, options, jqXHR ); + + // If request was aborted inside a prefilter, stop there + if ( state === 2 ) { + return jqXHR; + } + + // We can fire global events as of now if asked to + // Don't fire events if jQuery.event is undefined in an AMD-usage scenario (#15118) + fireGlobals = jQuery.event && s.global; + + // Watch for a new set of requests + if ( fireGlobals && jQuery.active++ === 0 ) { + jQuery.event.trigger( "ajaxStart" ); + } + + // Uppercase the type + s.type = s.type.toUpperCase(); + + // Determine if request has content + s.hasContent = !rnoContent.test( s.type ); + + // Save the URL in case we're toying with the If-Modified-Since + // and/or If-None-Match header later on + cacheURL = s.url; + + // More options handling for requests with no content + if ( !s.hasContent ) { + + // If data is available, append data to url + if ( s.data ) { + cacheURL = ( s.url += ( rquery.test( cacheURL ) ? "&" : "?" ) + s.data ); + + // #9682: remove data so that it's not used in an eventual retry + delete s.data; + } + + // Add anti-cache in url if needed + if ( s.cache === false ) { + s.url = rts.test( cacheURL ) ? + + // If there is already a '_' parameter, set its value + cacheURL.replace( rts, "$1_=" + nonce++ ) : + + // Otherwise add one to the end + cacheURL + ( rquery.test( cacheURL ) ? "&" : "?" ) + "_=" + nonce++; + } + } + + // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. + if ( s.ifModified ) { + if ( jQuery.lastModified[ cacheURL ] ) { + jqXHR.setRequestHeader( "If-Modified-Since", jQuery.lastModified[ cacheURL ] ); + } + if ( jQuery.etag[ cacheURL ] ) { + jqXHR.setRequestHeader( "If-None-Match", jQuery.etag[ cacheURL ] ); + } + } + + // Set the correct header, if data is being sent + if ( s.data && s.hasContent && s.contentType !== false || options.contentType ) { + jqXHR.setRequestHeader( "Content-Type", s.contentType ); + } + + // Set the Accepts header for the server, depending on the dataType + jqXHR.setRequestHeader( + "Accept", + s.dataTypes[ 0 ] && s.accepts[ s.dataTypes[ 0 ] ] ? + s.accepts[ s.dataTypes[ 0 ] ] + + ( s.dataTypes[ 0 ] !== "*" ? ", " + allTypes + "; q=0.01" : "" ) : + s.accepts[ "*" ] + ); + + // Check for headers option + for ( i in s.headers ) { + jqXHR.setRequestHeader( i, s.headers[ i ] ); + } + + // Allow custom headers/mimetypes and early abort + if ( s.beforeSend && + ( s.beforeSend.call( callbackContext, jqXHR, s ) === false || state === 2 ) ) { + + // Abort if not done already and return + return jqXHR.abort(); + } + + // Aborting is no longer a cancellation + strAbort = "abort"; + + // Install callbacks on deferreds + for ( i in { success: 1, error: 1, complete: 1 } ) { + jqXHR[ i ]( s[ i ] ); + } + + // Get transport + transport = inspectPrefiltersOrTransports( transports, s, options, jqXHR ); + + // If no transport, we auto-abort + if ( !transport ) { + done( -1, "No Transport" ); + } else { + jqXHR.readyState = 1; + + // Send global event + if ( fireGlobals ) { + globalEventContext.trigger( "ajaxSend", [ jqXHR, s ] ); + } + + // If request was aborted inside ajaxSend, stop there + if ( state === 2 ) { + return jqXHR; + } + + // Timeout + if ( s.async && s.timeout > 0 ) { + timeoutTimer = window.setTimeout( function() { + jqXHR.abort( "timeout" ); + }, s.timeout ); + } + + try { + state = 1; + transport.send( requestHeaders, done ); + } catch ( e ) { + + // Propagate exception as error if not done + if ( state < 2 ) { + done( -1, e ); + + // Simply rethrow otherwise + } else { + throw e; + } + } + } + + // Callback for when everything is done + function done( status, nativeStatusText, responses, headers ) { + var isSuccess, success, error, response, modified, + statusText = nativeStatusText; + + // Called once + if ( state === 2 ) { + return; + } + + // State is "done" now + state = 2; + + // Clear timeout if it exists + if ( timeoutTimer ) { + window.clearTimeout( timeoutTimer ); + } + + // Dereference transport for early garbage collection + // (no matter how long the jqXHR object will be used) + transport = undefined; + + // Cache response headers + responseHeadersString = headers || ""; + + // Set readyState + jqXHR.readyState = status > 0 ? 4 : 0; + + // Determine if successful + isSuccess = status >= 200 && status < 300 || status === 304; + + // Get response data + if ( responses ) { + response = ajaxHandleResponses( s, jqXHR, responses ); + } + + // Convert no matter what (that way responseXXX fields are always set) + response = ajaxConvert( s, response, jqXHR, isSuccess ); + + // If successful, handle type chaining + if ( isSuccess ) { + + // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. + if ( s.ifModified ) { + modified = jqXHR.getResponseHeader( "Last-Modified" ); + if ( modified ) { + jQuery.lastModified[ cacheURL ] = modified; + } + modified = jqXHR.getResponseHeader( "etag" ); + if ( modified ) { + jQuery.etag[ cacheURL ] = modified; + } + } + + // if no content + if ( status === 204 || s.type === "HEAD" ) { + statusText = "nocontent"; + + // if not modified + } else if ( status === 304 ) { + statusText = "notmodified"; + + // If we have data, let's convert it + } else { + statusText = response.state; + success = response.data; + error = response.error; + isSuccess = !error; + } + } else { + + // Extract error from statusText and normalize for non-aborts + error = statusText; + if ( status || !statusText ) { + statusText = "error"; + if ( status < 0 ) { + status = 0; + } + } + } + + // Set data for the fake xhr object + jqXHR.status = status; + jqXHR.statusText = ( nativeStatusText || statusText ) + ""; + + // Success/Error + if ( isSuccess ) { + deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] ); + } else { + deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] ); + } + + // Status-dependent callbacks + jqXHR.statusCode( statusCode ); + statusCode = undefined; + + if ( fireGlobals ) { + globalEventContext.trigger( isSuccess ? "ajaxSuccess" : "ajaxError", + [ jqXHR, s, isSuccess ? success : error ] ); + } + + // Complete + completeDeferred.fireWith( callbackContext, [ jqXHR, statusText ] ); + + if ( fireGlobals ) { + globalEventContext.trigger( "ajaxComplete", [ jqXHR, s ] ); + + // Handle the global AJAX counter + if ( !( --jQuery.active ) ) { + jQuery.event.trigger( "ajaxStop" ); + } + } + } + + return jqXHR; + }, + + getJSON: function( url, data, callback ) { + return jQuery.get( url, data, callback, "json" ); + }, + + getScript: function( url, callback ) { + return jQuery.get( url, undefined, callback, "script" ); + } +} ); + +jQuery.each( [ "get", "post" ], function( i, method ) { + jQuery[ method ] = function( url, data, callback, type ) { + + // Shift arguments if data argument was omitted + if ( jQuery.isFunction( data ) ) { + type = type || callback; + callback = data; + data = undefined; + } + + // The url can be an options object (which then must have .url) + return jQuery.ajax( jQuery.extend( { + url: url, + type: method, + dataType: type, + data: data, + success: callback + }, jQuery.isPlainObject( url ) && url ) ); + }; +} ); + + +jQuery._evalUrl = function( url ) { + return jQuery.ajax( { + url: url, + + // Make this explicit, since user can override this through ajaxSetup (#11264) + type: "GET", + dataType: "script", + async: false, + global: false, + "throws": true + } ); +}; + + +jQuery.fn.extend( { + wrapAll: function( html ) { + var wrap; + + if ( jQuery.isFunction( html ) ) { + return this.each( function( i ) { + jQuery( this ).wrapAll( html.call( this, i ) ); + } ); + } + + if ( this[ 0 ] ) { + + // The elements to wrap the target around + wrap = jQuery( html, this[ 0 ].ownerDocument ).eq( 0 ).clone( true ); + + if ( this[ 0 ].parentNode ) { + wrap.insertBefore( this[ 0 ] ); + } + + wrap.map( function() { + var elem = this; + + while ( elem.firstElementChild ) { + elem = elem.firstElementChild; + } + + return elem; + } ).append( this ); + } + + return this; + }, + + wrapInner: function( html ) { + if ( jQuery.isFunction( html ) ) { + return this.each( function( i ) { + jQuery( this ).wrapInner( html.call( this, i ) ); + } ); + } + + return this.each( function() { + var self = jQuery( this ), + contents = self.contents(); + + if ( contents.length ) { + contents.wrapAll( html ); + + } else { + self.append( html ); + } + } ); + }, + + wrap: function( html ) { + var isFunction = jQuery.isFunction( html ); + + return this.each( function( i ) { + jQuery( this ).wrapAll( isFunction ? html.call( this, i ) : html ); + } ); + }, + + unwrap: function() { + return this.parent().each( function() { + if ( !jQuery.nodeName( this, "body" ) ) { + jQuery( this ).replaceWith( this.childNodes ); + } + } ).end(); + } +} ); + + +jQuery.expr.filters.hidden = function( elem ) { + return !jQuery.expr.filters.visible( elem ); +}; +jQuery.expr.filters.visible = function( elem ) { + + // Support: Opera <= 12.12 + // Opera reports offsetWidths and offsetHeights less than zero on some elements + // Use OR instead of AND as the element is not visible if either is true + // See tickets #10406 and #13132 + return elem.offsetWidth > 0 || elem.offsetHeight > 0 || elem.getClientRects().length > 0; +}; + + + + +var r20 = /%20/g, + rbracket = /\[\]$/, + rCRLF = /\r?\n/g, + rsubmitterTypes = /^(?:submit|button|image|reset|file)$/i, + rsubmittable = /^(?:input|select|textarea|keygen)/i; + +function buildParams( prefix, obj, traditional, add ) { + var name; + + if ( jQuery.isArray( obj ) ) { + + // Serialize array item. + jQuery.each( obj, function( i, v ) { + if ( traditional || rbracket.test( prefix ) ) { + + // Treat each array item as a scalar. + add( prefix, v ); + + } else { + + // Item is non-scalar (array or object), encode its numeric index. + buildParams( + prefix + "[" + ( typeof v === "object" && v != null ? i : "" ) + "]", + v, + traditional, + add + ); + } + } ); + + } else if ( !traditional && jQuery.type( obj ) === "object" ) { + + // Serialize object item. + for ( name in obj ) { + buildParams( prefix + "[" + name + "]", obj[ name ], traditional, add ); + } + + } else { + + // Serialize scalar item. + add( prefix, obj ); + } +} + +// Serialize an array of form elements or a set of +// key/values into a query string +jQuery.param = function( a, traditional ) { + var prefix, + s = [], + add = function( key, value ) { + + // If value is a function, invoke it and return its value + value = jQuery.isFunction( value ) ? value() : ( value == null ? "" : value ); + s[ s.length ] = encodeURIComponent( key ) + "=" + encodeURIComponent( value ); + }; + + // Set traditional to true for jQuery <= 1.3.2 behavior. + if ( traditional === undefined ) { + traditional = jQuery.ajaxSettings && jQuery.ajaxSettings.traditional; + } + + // If an array was passed in, assume that it is an array of form elements. + if ( jQuery.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) { + + // Serialize the form elements + jQuery.each( a, function() { + add( this.name, this.value ); + } ); + + } else { + + // If traditional, encode the "old" way (the way 1.3.2 or older + // did it), otherwise encode params recursively. + for ( prefix in a ) { + buildParams( prefix, a[ prefix ], traditional, add ); + } + } + + // Return the resulting serialization + return s.join( "&" ).replace( r20, "+" ); +}; + +jQuery.fn.extend( { + serialize: function() { + return jQuery.param( this.serializeArray() ); + }, + serializeArray: function() { + return this.map( function() { + + // Can add propHook for "elements" to filter or add form elements + var elements = jQuery.prop( this, "elements" ); + return elements ? jQuery.makeArray( elements ) : this; + } ) + .filter( function() { + var type = this.type; + + // Use .is( ":disabled" ) so that fieldset[disabled] works + return this.name && !jQuery( this ).is( ":disabled" ) && + rsubmittable.test( this.nodeName ) && !rsubmitterTypes.test( type ) && + ( this.checked || !rcheckableType.test( type ) ); + } ) + .map( function( i, elem ) { + var val = jQuery( this ).val(); + + return val == null ? + null : + jQuery.isArray( val ) ? + jQuery.map( val, function( val ) { + return { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; + } ) : + { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; + } ).get(); + } +} ); + + +jQuery.ajaxSettings.xhr = function() { + try { + return new window.XMLHttpRequest(); + } catch ( e ) {} +}; + +var xhrSuccessStatus = { + + // File protocol always yields status code 0, assume 200 + 0: 200, + + // Support: IE9 + // #1450: sometimes IE returns 1223 when it should be 204 + 1223: 204 + }, + xhrSupported = jQuery.ajaxSettings.xhr(); + +support.cors = !!xhrSupported && ( "withCredentials" in xhrSupported ); +support.ajax = xhrSupported = !!xhrSupported; + +jQuery.ajaxTransport( function( options ) { + var callback, errorCallback; + + // Cross domain only allowed if supported through XMLHttpRequest + if ( support.cors || xhrSupported && !options.crossDomain ) { + return { + send: function( headers, complete ) { + var i, + xhr = options.xhr(); + + xhr.open( + options.type, + options.url, + options.async, + options.username, + options.password + ); + + // Apply custom fields if provided + if ( options.xhrFields ) { + for ( i in options.xhrFields ) { + xhr[ i ] = options.xhrFields[ i ]; + } + } + + // Override mime type if needed + if ( options.mimeType && xhr.overrideMimeType ) { + xhr.overrideMimeType( options.mimeType ); + } + + // X-Requested-With header + // For cross-domain requests, seeing as conditions for a preflight are + // akin to a jigsaw puzzle, we simply never set it to be sure. + // (it can always be set on a per-request basis or even using ajaxSetup) + // For same-domain requests, won't change header if already provided. + if ( !options.crossDomain && !headers[ "X-Requested-With" ] ) { + headers[ "X-Requested-With" ] = "XMLHttpRequest"; + } + + // Set headers + for ( i in headers ) { + xhr.setRequestHeader( i, headers[ i ] ); + } + + // Callback + callback = function( type ) { + return function() { + if ( callback ) { + callback = errorCallback = xhr.onload = + xhr.onerror = xhr.onabort = xhr.onreadystatechange = null; + + if ( type === "abort" ) { + xhr.abort(); + } else if ( type === "error" ) { + + // Support: IE9 + // On a manual native abort, IE9 throws + // errors on any property access that is not readyState + if ( typeof xhr.status !== "number" ) { + complete( 0, "error" ); + } else { + complete( + + // File: protocol always yields status 0; see #8605, #14207 + xhr.status, + xhr.statusText + ); + } + } else { + complete( + xhrSuccessStatus[ xhr.status ] || xhr.status, + xhr.statusText, + + // Support: IE9 only + // IE9 has no XHR2 but throws on binary (trac-11426) + // For XHR2 non-text, let the caller handle it (gh-2498) + ( xhr.responseType || "text" ) !== "text" || + typeof xhr.responseText !== "string" ? + { binary: xhr.response } : + { text: xhr.responseText }, + xhr.getAllResponseHeaders() + ); + } + } + }; + }; + + // Listen to events + xhr.onload = callback(); + errorCallback = xhr.onerror = callback( "error" ); + + // Support: IE9 + // Use onreadystatechange to replace onabort + // to handle uncaught aborts + if ( xhr.onabort !== undefined ) { + xhr.onabort = errorCallback; + } else { + xhr.onreadystatechange = function() { + + // Check readyState before timeout as it changes + if ( xhr.readyState === 4 ) { + + // Allow onerror to be called first, + // but that will not handle a native abort + // Also, save errorCallback to a variable + // as xhr.onerror cannot be accessed + window.setTimeout( function() { + if ( callback ) { + errorCallback(); + } + } ); + } + }; + } + + // Create the abort callback + callback = callback( "abort" ); + + try { + + // Do send the request (this may raise an exception) + xhr.send( options.hasContent && options.data || null ); + } catch ( e ) { + + // #14683: Only rethrow if this hasn't been notified as an error yet + if ( callback ) { + throw e; + } + } + }, + + abort: function() { + if ( callback ) { + callback(); + } + } + }; + } +} ); + + + + +// Install script dataType +jQuery.ajaxSetup( { + accepts: { + script: "text/javascript, application/javascript, " + + "application/ecmascript, application/x-ecmascript" + }, + contents: { + script: /\b(?:java|ecma)script\b/ + }, + converters: { + "text script": function( text ) { + jQuery.globalEval( text ); + return text; + } + } +} ); + +// Handle cache's special case and crossDomain +jQuery.ajaxPrefilter( "script", function( s ) { + if ( s.cache === undefined ) { + s.cache = false; + } + if ( s.crossDomain ) { + s.type = "GET"; + } +} ); + +// Bind script tag hack transport +jQuery.ajaxTransport( "script", function( s ) { + + // This transport only deals with cross domain requests + if ( s.crossDomain ) { + var script, callback; + return { + send: function( _, complete ) { + script = jQuery( "<script>" ).prop( { + charset: s.scriptCharset, + src: s.url + } ).on( + "load error", + callback = function( evt ) { + script.remove(); + callback = null; + if ( evt ) { + complete( evt.type === "error" ? 404 : 200, evt.type ); + } + } + ); + + // Use native DOM manipulation to avoid our domManip AJAX trickery + document.head.appendChild( script[ 0 ] ); + }, + abort: function() { + if ( callback ) { + callback(); + } + } + }; + } +} ); + + + + +var oldCallbacks = [], + rjsonp = /(=)\?(?=&|$)|\?\?/; + +// Default jsonp settings +jQuery.ajaxSetup( { + jsonp: "callback", + jsonpCallback: function() { + var callback = oldCallbacks.pop() || ( jQuery.expando + "_" + ( nonce++ ) ); + this[ callback ] = true; + return callback; + } +} ); + +// Detect, normalize options and install callbacks for jsonp requests +jQuery.ajaxPrefilter( "json jsonp", function( s, originalSettings, jqXHR ) { + + var callbackName, overwritten, responseContainer, + jsonProp = s.jsonp !== false && ( rjsonp.test( s.url ) ? + "url" : + typeof s.data === "string" && + ( s.contentType || "" ) + .indexOf( "application/x-www-form-urlencoded" ) === 0 && + rjsonp.test( s.data ) && "data" + ); + + // Handle iff the expected data type is "jsonp" or we have a parameter to set + if ( jsonProp || s.dataTypes[ 0 ] === "jsonp" ) { + + // Get callback name, remembering preexisting value associated with it + callbackName = s.jsonpCallback = jQuery.isFunction( s.jsonpCallback ) ? + s.jsonpCallback() : + s.jsonpCallback; + + // Insert callback into url or form data + if ( jsonProp ) { + s[ jsonProp ] = s[ jsonProp ].replace( rjsonp, "$1" + callbackName ); + } else if ( s.jsonp !== false ) { + s.url += ( rquery.test( s.url ) ? "&" : "?" ) + s.jsonp + "=" + callbackName; + } + + // Use data converter to retrieve json after script execution + s.converters[ "script json" ] = function() { + if ( !responseContainer ) { + jQuery.error( callbackName + " was not called" ); + } + return responseContainer[ 0 ]; + }; + + // Force json dataType + s.dataTypes[ 0 ] = "json"; + + // Install callback + overwritten = window[ callbackName ]; + window[ callbackName ] = function() { + responseContainer = arguments; + }; + + // Clean-up function (fires after converters) + jqXHR.always( function() { + + // If previous value didn't exist - remove it + if ( overwritten === undefined ) { + jQuery( window ).removeProp( callbackName ); + + // Otherwise restore preexisting value + } else { + window[ callbackName ] = overwritten; + } + + // Save back as free + if ( s[ callbackName ] ) { + + // Make sure that re-using the options doesn't screw things around + s.jsonpCallback = originalSettings.jsonpCallback; + + // Save the callback name for future use + oldCallbacks.push( callbackName ); + } + + // Call if it was a function and we have a response + if ( responseContainer && jQuery.isFunction( overwritten ) ) { + overwritten( responseContainer[ 0 ] ); + } + + responseContainer = overwritten = undefined; + } ); + + // Delegate to script + return "script"; + } +} ); + + + + +// Support: Safari 8+ +// In Safari 8 documents created via document.implementation.createHTMLDocument +// collapse sibling forms: the second one becomes a child of the first one. +// Because of that, this security measure has to be disabled in Safari 8. +// https://bugs.webkit.org/show_bug.cgi?id=137337 +support.createHTMLDocument = ( function() { + var body = document.implementation.createHTMLDocument( "" ).body; + body.innerHTML = "<form></form><form></form>"; + return body.childNodes.length === 2; +} )(); + + +// Argument "data" should be string of html +// context (optional): If specified, the fragment will be created in this context, +// defaults to document +// keepScripts (optional): If true, will include scripts passed in the html string +jQuery.parseHTML = function( data, context, keepScripts ) { + if ( !data || typeof data !== "string" ) { + return null; + } + if ( typeof context === "boolean" ) { + keepScripts = context; + context = false; + } + + // Stop scripts or inline event handlers from being executed immediately + // by using document.implementation + context = context || ( support.createHTMLDocument ? + document.implementation.createHTMLDocument( "" ) : + document ); + + var parsed = rsingleTag.exec( data ), + scripts = !keepScripts && []; + + // Single tag + if ( parsed ) { + return [ context.createElement( parsed[ 1 ] ) ]; + } + + parsed = buildFragment( [ data ], context, scripts ); + + if ( scripts && scripts.length ) { + jQuery( scripts ).remove(); + } + + return jQuery.merge( [], parsed.childNodes ); +}; + + +// Keep a copy of the old load method +var _load = jQuery.fn.load; + +/** + * Load a url into a page + */ +jQuery.fn.load = function( url, params, callback ) { + if ( typeof url !== "string" && _load ) { + return _load.apply( this, arguments ); + } + + var selector, type, response, + self = this, + off = url.indexOf( " " ); + + if ( off > -1 ) { + selector = jQuery.trim( url.slice( off ) ); + url = url.slice( 0, off ); + } + + // If it's a function + if ( jQuery.isFunction( params ) ) { + + // We assume that it's the callback + callback = params; + params = undefined; + + // Otherwise, build a param string + } else if ( params && typeof params === "object" ) { + type = "POST"; + } + + // If we have elements to modify, make the request + if ( self.length > 0 ) { + jQuery.ajax( { + url: url, + + // If "type" variable is undefined, then "GET" method will be used. + // Make value of this field explicit since + // user can override it through ajaxSetup method + type: type || "GET", + dataType: "html", + data: params + } ).done( function( responseText ) { + + // Save response for use in complete callback + response = arguments; + + self.html( selector ? + + // If a selector was specified, locate the right elements in a dummy div + // Exclude scripts to avoid IE 'Permission Denied' errors + jQuery( "<div>" ).append( jQuery.parseHTML( responseText ) ).find( selector ) : + + // Otherwise use the full result + responseText ); + + // If the request succeeds, this function gets "data", "status", "jqXHR" + // but they are ignored because response was set above. + // If it fails, this function gets "jqXHR", "status", "error" + } ).always( callback && function( jqXHR, status ) { + self.each( function() { + callback.apply( self, response || [ jqXHR.responseText, status, jqXHR ] ); + } ); + } ); + } + + return this; +}; + + + + +// Attach a bunch of functions for handling common AJAX events +jQuery.each( [ + "ajaxStart", + "ajaxStop", + "ajaxComplete", + "ajaxError", + "ajaxSuccess", + "ajaxSend" +], function( i, type ) { + jQuery.fn[ type ] = function( fn ) { + return this.on( type, fn ); + }; +} ); + + + + +jQuery.expr.filters.animated = function( elem ) { + return jQuery.grep( jQuery.timers, function( fn ) { + return elem === fn.elem; + } ).length; +}; + + + + +/** + * Gets a window from an element + */ +function getWindow( elem ) { + return jQuery.isWindow( elem ) ? elem : elem.nodeType === 9 && elem.defaultView; +} + +jQuery.offset = { + setOffset: function( elem, options, i ) { + var curPosition, curLeft, curCSSTop, curTop, curOffset, curCSSLeft, calculatePosition, + position = jQuery.css( elem, "position" ), + curElem = jQuery( elem ), + props = {}; + + // Set position first, in-case top/left are set even on static elem + if ( position === "static" ) { + elem.style.position = "relative"; + } + + curOffset = curElem.offset(); + curCSSTop = jQuery.css( elem, "top" ); + curCSSLeft = jQuery.css( elem, "left" ); + calculatePosition = ( position === "absolute" || position === "fixed" ) && + ( curCSSTop + curCSSLeft ).indexOf( "auto" ) > -1; + + // Need to be able to calculate position if either + // top or left is auto and position is either absolute or fixed + if ( calculatePosition ) { + curPosition = curElem.position(); + curTop = curPosition.top; + curLeft = curPosition.left; + + } else { + curTop = parseFloat( curCSSTop ) || 0; + curLeft = parseFloat( curCSSLeft ) || 0; + } + + if ( jQuery.isFunction( options ) ) { + + // Use jQuery.extend here to allow modification of coordinates argument (gh-1848) + options = options.call( elem, i, jQuery.extend( {}, curOffset ) ); + } + + if ( options.top != null ) { + props.top = ( options.top - curOffset.top ) + curTop; + } + if ( options.left != null ) { + props.left = ( options.left - curOffset.left ) + curLeft; + } + + if ( "using" in options ) { + options.using.call( elem, props ); + + } else { + curElem.css( props ); + } + } +}; + +jQuery.fn.extend( { + offset: function( options ) { + if ( arguments.length ) { + return options === undefined ? + this : + this.each( function( i ) { + jQuery.offset.setOffset( this, options, i ); + } ); + } + + var docElem, win, + elem = this[ 0 ], + box = { top: 0, left: 0 }, + doc = elem && elem.ownerDocument; + + if ( !doc ) { + return; + } + + docElem = doc.documentElement; + + // Make sure it's not a disconnected DOM node + if ( !jQuery.contains( docElem, elem ) ) { + return box; + } + + box = elem.getBoundingClientRect(); + win = getWindow( doc ); + return { + top: box.top + win.pageYOffset - docElem.clientTop, + left: box.left + win.pageXOffset - docElem.clientLeft + }; + }, + + position: function() { + if ( !this[ 0 ] ) { + return; + } + + var offsetParent, offset, + elem = this[ 0 ], + parentOffset = { top: 0, left: 0 }; + + // Fixed elements are offset from window (parentOffset = {top:0, left: 0}, + // because it is its only offset parent + if ( jQuery.css( elem, "position" ) === "fixed" ) { + + // Assume getBoundingClientRect is there when computed position is fixed + offset = elem.getBoundingClientRect(); + + } else { + + // Get *real* offsetParent + offsetParent = this.offsetParent(); + + // Get correct offsets + offset = this.offset(); + if ( !jQuery.nodeName( offsetParent[ 0 ], "html" ) ) { + parentOffset = offsetParent.offset(); + } + + // Add offsetParent borders + // Subtract offsetParent scroll positions + parentOffset.top += jQuery.css( offsetParent[ 0 ], "borderTopWidth", true ) - + offsetParent.scrollTop(); + parentOffset.left += jQuery.css( offsetParent[ 0 ], "borderLeftWidth", true ) - + offsetParent.scrollLeft(); + } + + // Subtract parent offsets and element margins + return { + top: offset.top - parentOffset.top - jQuery.css( elem, "marginTop", true ), + left: offset.left - parentOffset.left - jQuery.css( elem, "marginLeft", true ) + }; + }, + + // This method will return documentElement in the following cases: + // 1) For the element inside the iframe without offsetParent, this method will return + // documentElement of the parent window + // 2) For the hidden or detached element + // 3) For body or html element, i.e. in case of the html node - it will return itself + // + // but those exceptions were never presented as a real life use-cases + // and might be considered as more preferable results. + // + // This logic, however, is not guaranteed and can change at any point in the future + offsetParent: function() { + return this.map( function() { + var offsetParent = this.offsetParent; + + while ( offsetParent && jQuery.css( offsetParent, "position" ) === "static" ) { + offsetParent = offsetParent.offsetParent; + } + + return offsetParent || documentElement; + } ); + } +} ); + +// Create scrollLeft and scrollTop methods +jQuery.each( { scrollLeft: "pageXOffset", scrollTop: "pageYOffset" }, function( method, prop ) { + var top = "pageYOffset" === prop; + + jQuery.fn[ method ] = function( val ) { + return access( this, function( elem, method, val ) { + var win = getWindow( elem ); + + if ( val === undefined ) { + return win ? win[ prop ] : elem[ method ]; + } + + if ( win ) { + win.scrollTo( + !top ? val : win.pageXOffset, + top ? val : win.pageYOffset + ); + + } else { + elem[ method ] = val; + } + }, method, val, arguments.length ); + }; +} ); + +// Support: Safari<7-8+, Chrome<37-44+ +// Add the top/left cssHooks using jQuery.fn.position +// Webkit bug: https://bugs.webkit.org/show_bug.cgi?id=29084 +// Blink bug: https://code.google.com/p/chromium/issues/detail?id=229280 +// getComputedStyle returns percent when specified for top/left/bottom/right; +// rather than make the css module depend on the offset module, just check for it here +jQuery.each( [ "top", "left" ], function( i, prop ) { + jQuery.cssHooks[ prop ] = addGetHookIf( support.pixelPosition, + function( elem, computed ) { + if ( computed ) { + computed = curCSS( elem, prop ); + + // If curCSS returns percentage, fallback to offset + return rnumnonpx.test( computed ) ? + jQuery( elem ).position()[ prop ] + "px" : + computed; + } + } + ); +} ); + + +// Create innerHeight, innerWidth, height, width, outerHeight and outerWidth methods +jQuery.each( { Height: "height", Width: "width" }, function( name, type ) { + jQuery.each( { padding: "inner" + name, content: type, "": "outer" + name }, + function( defaultExtra, funcName ) { + + // Margin is only for outerHeight, outerWidth + jQuery.fn[ funcName ] = function( margin, value ) { + var chainable = arguments.length && ( defaultExtra || typeof margin !== "boolean" ), + extra = defaultExtra || ( margin === true || value === true ? "margin" : "border" ); + + return access( this, function( elem, type, value ) { + var doc; + + if ( jQuery.isWindow( elem ) ) { + + // As of 5/8/2012 this will yield incorrect results for Mobile Safari, but there + // isn't a whole lot we can do. See pull request at this URL for discussion: + // https://github.com/jquery/jquery/pull/764 + return elem.document.documentElement[ "client" + name ]; + } + + // Get document width or height + if ( elem.nodeType === 9 ) { + doc = elem.documentElement; + + // Either scroll[Width/Height] or offset[Width/Height] or client[Width/Height], + // whichever is greatest + return Math.max( + elem.body[ "scroll" + name ], doc[ "scroll" + name ], + elem.body[ "offset" + name ], doc[ "offset" + name ], + doc[ "client" + name ] + ); + } + + return value === undefined ? + + // Get width or height on the element, requesting but not forcing parseFloat + jQuery.css( elem, type, extra ) : + + // Set width or height on the element + jQuery.style( elem, type, value, extra ); + }, type, chainable ? margin : undefined, chainable, null ); + }; + } ); +} ); + + +jQuery.fn.extend( { + + bind: function( types, data, fn ) { + return this.on( types, null, data, fn ); + }, + unbind: function( types, fn ) { + return this.off( types, null, fn ); + }, + + delegate: function( selector, types, data, fn ) { + return this.on( types, selector, data, fn ); + }, + undelegate: function( selector, types, fn ) { + + // ( namespace ) or ( selector, types [, fn] ) + return arguments.length === 1 ? + this.off( selector, "**" ) : + this.off( types, selector || "**", fn ); + }, + size: function() { + return this.length; + } +} ); + +jQuery.fn.andSelf = jQuery.fn.addBack; + + + + +// Register as a named AMD module, since jQuery can be concatenated with other +// files that may use define, but not via a proper concatenation script that +// understands anonymous AMD modules. A named AMD is safest and most robust +// way to register. Lowercase jquery is used because AMD module names are +// derived from file names, and jQuery is normally delivered in a lowercase +// file name. Do this after creating the global so that if an AMD module wants +// to call noConflict to hide this version of jQuery, it will work. + +// Note that for maximum portability, libraries that are not jQuery should +// declare themselves as anonymous modules, and avoid setting a global if an +// AMD loader is present. jQuery is a special case. For more information, see +// https://github.com/jrburke/requirejs/wiki/Updating-existing-libraries#wiki-anon + +if ( typeof define === "function" && define.amd ) { + define( "jquery", [], function() { + return jQuery; + } ); +} + + + +var + + // Map over jQuery in case of overwrite + _jQuery = window.jQuery, + + // Map over the $ in case of overwrite + _$ = window.$; + +jQuery.noConflict = function( deep ) { + if ( window.$ === jQuery ) { + window.$ = _$; + } + + if ( deep && window.jQuery === jQuery ) { + window.jQuery = _jQuery; + } + + return jQuery; +}; + +// Expose jQuery and $ identifiers, even in AMD +// (#7102#comment:10, https://github.com/jquery/jquery/pull/557) +// and CommonJS for browser emulators (#13566) +if ( !noGlobal ) { + window.jQuery = window.$ = jQuery; +} + +return jQuery; +})); 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..5b5253cdf7 --- /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 containing 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..50a9e6455d --- /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 https://es5.github.io/#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..56f436c8b8 --- /dev/null +++ b/actionview/test/ujs/server.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "rack" +require "rails" +require "action_controller/railtie" +require "action_view/railtie" +require "blade" +require "json" + +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 + + config.content_security_policy do |policy| + policy.default_src :self, :https + policy.font_src :self, :https, :data + policy.img_src :self, :https, :data + policy.object_src :none + policy.script_src :self, :https + policy.style_src :self, :https + end + + config.content_security_policy_nonce_generator = ->(req) { SecureRandom.base64(16) } + end +end + +module TestsHelper + def test_to(*names) + names = names.map { |name| "/test/#{name}.js" } + names = %w[/vendor/qunit.js /test/settings.js] + names + + capture do + names.each do |name| + concat(javascript_include_tag(name)) + end + end + end +end + +class TestsController < ActionController::Base + helper TestsHelper + layout "application" + + def index + render :index + end + + def echo + data = { params: params.to_unsafe_h }.update(request.env) + + if params[:content_type] && params[:content] + render inline: params[:content], content_type: params[:content_type] + elsif request.xhr? + if params[:with_xhr_redirect] + response.set_header("X-Xhr-Redirect", "http://example.com/") + render inline: %{Turbolinks.clearCache()\nTurbolinks.visit("http://example.com/", {"action":"replace"})} + else + render json: JSON.generate(data) + end + elsif params[:iframe] + payload = JSON.generate(data).gsub("<", "<").gsub(">", ">") + html = <<-HTML + <script nonce="#{request.content_security_policy_nonce}"> + 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..8f6f6fc17f --- /dev/null +++ b/actionview/test/ujs/views/layouts/application.html.erb @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html id="html"> + <head> + <title><%= @title %></title> + <%= csp_meta_tag %> + <link href="/vendor/qunit.css" media="screen" rel="stylesheet" type="text/css" media="screen, projection" /> + <script src="/vendor/jquery-2.2.0.js" type="text/javascript"></script> + <%= javascript_tag nonce: true do %> + // This is for test in override.js. + // Must go before rails-ujs. + document.addEventListener('rails:attachBindings', function() { + window.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 + window.Rails.delegate(document, '#qunit-fixture a', 'click', function(e) { + e.preventDefault(); + }); + }); + <% end %> + <%= javascript_include_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..6b16535216 --- /dev/null +++ b/actionview/test/ujs/views/tests/index.html.erb @@ -0,0 +1,11 @@ +<% @title = "rails-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', 'call-ajax' %> + +<h1 id="qunit-header"><%= @title %></h1> +<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> |