diff --git a/.travis.yml b/.travis.yml index cc09511..b72bae5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,14 @@ language: ruby sudo: false cache: bundler +before_install: + - gem update --system + - rvm @global do gem uninstall bundler -a -x + - rvm @global do gem install bundler -v 1.13.7 script: 'bundle exec rake test:coverage --trace && bundle exec rubocop' rvm: - 2.3.3 + - 2.4.0 - ruby-head - jruby-9.1.6.0 - jruby-head diff --git a/CHANGELOG.md b/CHANGELOG.md index 134eee8..3b2c5bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Hanami::Helpers View helpers for Ruby web applications +## v1.0.0.beta1 (unreleased) +### Added +- [Luca Guidi] Official support for Ruby: MRI 2.4 +- [Marion Duprey] Introduced Form helper `fields_for_collection` to support arrays of nested fields + +## Fixed +- [Ksenia Zalesnaya] Ensure radio buttons and selects to coerce the value to boolean before to decide if they should be checked or not. +- [Anton Davydov] Escape form values to prevent XSS attacks + ## v0.5.1 - 2016-12-19 ### Fixed - [Alex Coles] Ensure `#form_for`'s `values:` to accept `Hanami::Entity` instances diff --git a/lib/hanami/helpers/form_helper/form_builder.rb b/lib/hanami/helpers/form_helper/form_builder.rb index 8356056..9bc90ee 100644 --- a/lib/hanami/helpers/form_helper/form_builder.rb +++ b/lib/hanami/helpers/form_helper/form_builder.rb @@ -198,7 +198,49 @@ def to_s def fields_for(name) current_name = @name @name = _input_name(name) - yield + yield(name) + ensure + @name = current_name + end + + # Nested collections + # + # Supports nesting for collections, with infinite + # levels of nesting. + # + # @param name [Symbol] the nested name, it's used to generate input + # names, ids, and to lookup params to fill values. + # + # @example Basic usage + # <%= + # form_for :delivery, routes.deliveries_path do + # text_field :customer_name + # + # fields_for_collection :addresses do + # text_field :street + # end + # + # submit 'Create' + # end + # %> + # + # Output: + # #
+ # # + # # + # # + # # + # # + # #
+ # + def fields_for_collection(name, &block) + current_name = @name + base_value = _value(name) + @name = _input_name(name) + + base_value.count.times do |index| + fields_for(index, &block) + end ensure @name = current_name end @@ -678,7 +720,7 @@ def text_field(name, attributes = {}) # # def radio_button(name, value, attributes = {}) attributes = { type: :radio, name: _input_name(name), value: value }.merge(attributes) - attributes[:checked] = CHECKED if _value(name) == value + attributes[:checked] = CHECKED if _value(name).to_s == value.to_s input(attributes) end @@ -957,10 +999,14 @@ def csrf_token # @api private # @since 0.2.0 def _attributes(type, name, attributes) - { type: type, name: _input_name(name), id: _input_id(name), value: _value(name) }.merge(attributes) + attrs = { type: type, name: _displayed_input_name(name), id: _input_id(name), value: _value(name) } + attrs.merge!(attributes) + attrs[:value] = Hanami::Utils::Escape.html(attrs[:value]) + attrs end - # Input name HTML attribute + # Full input name, used to construct the input + # attributes. # # @api private # @since 0.2.0 @@ -968,6 +1014,14 @@ def _input_name(name) "#{@name}[#{name}]" end + # Input name HTML attribute + # + # @api private + # @since 1.0.0.beta1 + def _displayed_input_name(name) + _input_name(name).gsub(/\[\d+\]/, '[]') + end + # Input id HTML attribute # # @api private @@ -1047,7 +1101,7 @@ def _select_input_name(name, multiple) # rubocop:disable Metrics/PerceivedComplexity def _select_option_selected?(value, selected, input_value, multiple) value == selected || (multiple && (selected.is_a?(Array) && selected.include?(value))) || - value == input_value || (multiple && (input_value.is_a?(Array) && input_value.include?(value))) + value.to_s == input_value.to_s || (multiple && (input_value.is_a?(Array) && input_value.include?(value))) end # rubocop:enable Metrics/PerceivedComplexity # rubocop:enable Metrics/CyclomaticComplexity diff --git a/lib/hanami/helpers/form_helper/values.rb b/lib/hanami/helpers/form_helper/values.rb index a7d4863..91535b6 100644 --- a/lib/hanami/helpers/form_helper/values.rb +++ b/lib/hanami/helpers/form_helper/values.rb @@ -36,6 +36,7 @@ def get(*keys) # @since 0.5.0 # @api private def _get_from_params(*keys) + keys.map! { |key| key.to_s =~ /\A\d+\z/ ? key.to_s.to_i : key } @params.dig(*keys) end @@ -47,15 +48,21 @@ def _get_from_values(*keys) tail.each do |k| break if result.nil? - - result = case result - when Utils::Hash, ::Hash then result[k] - when ->(r) { r.respond_to?(k) } then result.public_send(k) - end + result = _dig(result, k) end result end + + # @since 1.0.0.beta1 + # @api private + def _dig(base, key) + case base + when Utils::Hash, ::Hash then base[key] + when Array then base[key.to_s.to_i] + when ->(r) { r.respond_to?(key) } then base.public_send(key) + end + end end end end diff --git a/test/fixtures.rb b/test/fixtures.rb index c222020..0e64c18 100644 --- a/test/fixtures.rb +++ b/test/fixtures.rb @@ -384,6 +384,15 @@ def initialize(attributes = {}) end end +class Bill + attr_reader :id, :addresses + + def initialize(attributes = {}) + @id = attributes[:id] + @addresses = attributes[:addresses] + end +end + class DeliveryParams < Hanami::Action::Params params do required(:delivery).schema do @@ -395,6 +404,18 @@ class DeliveryParams < Hanami::Action::Params end end +class BillParams < Hanami::Action::Params + params do + required(:bill).schema do + required(:addresses).each do + schema do + required(:street, :string).filled + end + end + end + end +end + class Session def initialize(values) @values = values.to_h @@ -423,6 +444,10 @@ def delivery_path(attrs = {}) _escape "/deliveries/#{attrs.fetch(:id)}" end + def bill_path(attrs = {}) + _escape "/bills/#{attrs.fetch(:id)}" + end + private def _escape(string) @@ -494,6 +519,21 @@ def submit_label end end end + + module Bills + class Edit + include TestView + template 'bills/edit' + + def form + Form.new(:bill, routes.bill_path(id: bill.id), { bill: bill }, method: :patch) + end + + def submit_label + 'Update' + end + end + end end end diff --git a/test/fixtures/templates/bills/edit.html.erb b/test/fixtures/templates/bills/edit.html.erb new file mode 100644 index 0000000..aa1063a --- /dev/null +++ b/test/fixtures/templates/bills/edit.html.erb @@ -0,0 +1,18 @@ +<%= + form_for(form, class: 'form-horizontal') do + fieldset do + legend 'Addresses' + + fields_for_collection :addresses do |i| + div class: 'form-group' do + label :street + input_text :street, class: 'form-control', placeholder: 'Street', 'data-funky': "id-#{i}" + end + end + + label :ensure_names + end + + submit submit_label, class: 'btn btn-default' + end +%> diff --git a/test/form_helper_test.rb b/test/form_helper_test.rb index 994ebe4..a330061 100644 --- a/test/form_helper_test.rb +++ b/test/form_helper_test.rb @@ -311,6 +311,18 @@ actual.must_include %() end end + + describe 'checked_value is boolean' do + let(:params) { Hash[book: { free_shipping: 'true' }] } + + it "renders with 'checked' attribute" do + actual = view.form_for(:book, action) do + check_box :free_shipping, checked_value: true + end.to_s + + actual.must_include %() + end + end end describe 'automatic values' do @@ -1011,6 +1023,27 @@ end end + describe 'without values' do + let(:book) { Book.new(title: val) } + let(:val) { '"DDD" Book' } + + it 'renders with value' do + actual = view.form_for(:book, action) do + text_field :title + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + text_field :title, value: book.title + end.to_s + + actual.must_include %() + end + end + describe 'with filled params' do let(:params) { Hash[book: { percent_read: val }] } let(:val) { 95 } @@ -1398,16 +1431,32 @@ end describe 'with filled params' do - let(:params) { Hash[book: { category: val }] } - let(:val) { 'Non-Fiction' } + describe 'string value' do + let(:params) { Hash[book: { category: val }] } + let(:val) { 'Non-Fiction' } - it 'renders with value' do - actual = view.form_for(:book, action) do - radio_button :category, 'Fiction' - radio_button :category, 'Non-Fiction' - end.to_s + it 'renders with value' do + actual = view.form_for(:book, action) do + radio_button :category, 'Fiction' + radio_button :category, 'Non-Fiction' + end.to_s - actual.must_include %(\n) + actual.must_include %(\n) + end + end + + describe 'decimal value' do + let(:params) { Hash[book: { price: val }] } + let(:val) { '20.0' } + + it 'renders with value' do + actual = view.form_for(:book, action) do + radio_button :price, 10.0 + radio_button :price, 20.0 + end.to_s + + actual.must_include %(\n) + end end end end @@ -1580,15 +1629,31 @@ end describe 'with filled params' do - let(:params) { Hash[book: { store: val }] } - let(:val) { 'it' } + describe 'string values' do + let(:params) { Hash[book: { store: val }] } + let(:val) { 'it' } - it 'renders with value' do - actual = view.form_for(:book, action) do - select :store, option_values, options: { prompt: 'Select a store' } - end.to_s + it 'renders with value' do + actual = view.form_for(:book, action) do + select :store, option_values, options: { prompt: 'Select a store' } + end.to_s - actual.must_include %() + actual.must_include %() + end + end + + describe 'integer values' do + let(:values) { Hash['Brave new world' => 1, 'Solaris' => 2] } + let(:params) { Hash[bookshelf: { book: val }] } + let(:val) { '1' } + + it 'renders' do + actual = view.form_for(:bookshelf, action) do + select :book, values + end.to_s + + actual.must_include %() + end end end end diff --git a/test/html_helper/html_builder_test.rb b/test/html_helper/html_builder_test.rb index 333c4cc..8f5f1b2 100644 --- a/test/html_helper/html_builder_test.rb +++ b/test/html_helper/html_builder_test.rb @@ -206,8 +206,8 @@ end it 'renders multiple attributes' do - result = @builder.input('required' => true, 'something' => 'bar').to_s - result.must_equal('') + result = @builder.input('required' => true, 'value' => 'Title "book"', 'something' => 'bar').to_s + result.must_equal('') end end diff --git a/test/integration/form_helper_test.rb b/test/integration/form_helper_test.rb index 9fa0ea2..d13285b 100644 --- a/test/integration/form_helper_test.rb +++ b/test/integration/form_helper_test.rb @@ -89,4 +89,38 @@ end end end + + describe 'form with nested structures' do + describe 'first page load' do + before do + @address1 = Address.new(street: '5th Ave') + @address2 = Address.new(street: '4th Ave') + @bill = Bill.new(id: 1, addresses: [@address1, @address2]) + @params = BillParams.new({}) + @session = Session.new(_csrf_token: 's14') + + @actual = FullStack::Views::Bills::Edit.render(format: :html, bill: @bill, params: @params, session: @session) + end + + it 'renders the form' do + @actual.must_include %(
\n\n\n
\nAddresses\n
\n\n\n
\n
\n\n\n
\n\n
\n\n
\n) + end + end + + describe 'after a failed submission' do + before do + @address1 = Address.new(street: '5th Ave') + @address2 = Address.new(street: '4th Ave') + @bill = Bill.new(id: 1, addresses: [@address1, @address2]) + @params = BillParams.new(bill: { addresses: [{ street: 'Mulholland Drive' }, { street: 'Quaint Edge' }] }) + @session = Session.new(_csrf_token: 's14') + + @actual = FullStack::Views::Bills::Edit.render(format: :html, bill: @bill, params: @params, session: @session) + end + + it 'renders the form' do + @actual.must_include %(
\n\n\n
\nAddresses\n
\n\n\n
\n
\n\n\n
\n\n
\n\n
\n) + end + end + end end