From b580e46c97c1ab8f9a1e90e4508bc6949709561b Mon Sep 17 00:00:00 2001 From: Luca Guidi Date: Sun, 22 Feb 2015 11:01:17 +0100 Subject: [PATCH 1/6] Introduced Lotus::Helpers::FormHelper --- Gemfile | 5 +- lib/lotus/helpers.rb | 2 + lib/lotus/helpers/form_helper.rb | 186 +++++ lib/lotus/helpers/html_helper/html_builder.rb | 13 +- lib/lotus/helpers/html_helper/html_node.rb | 2 +- test/fixtures.rb | 30 + test/fixtures/templates/albums/new.html.erb | 1 + .../templates/deliveries/new.html.erb | 23 + test/form_helper_test.rb | 758 ++++++++++++++++++ test/integration/form_helper_test.rb | 27 + 10 files changed, 1042 insertions(+), 5 deletions(-) create mode 100644 lib/lotus/helpers/form_helper.rb create mode 100644 test/fixtures/templates/albums/new.html.erb create mode 100644 test/fixtures/templates/deliveries/new.html.erb create mode 100644 test/form_helper_test.rb create mode 100644 test/integration/form_helper_test.rb diff --git a/Gemfile b/Gemfile index b14f471..6a278f9 100644 --- a/Gemfile +++ b/Gemfile @@ -7,8 +7,9 @@ unless ENV['TRAVIS'] gem 'yard', require: false end -gem 'lotus-utils', github: 'lotus/utils', branch: 'master' # FIXME use a stable branch -gem 'lotus-view', github: 'lotus/view', branch: '0.3.x' +gem 'lotus-utils', github: 'lotus/utils', branch: 'master' # FIXME use a stable branch +gem 'lotus-controller', github: 'lotus/controller', branch: 'master' # FIXME use a stable branch +gem 'lotus-view', github: 'lotus/view', branch: '0.3.x' gem 'simplecov', require: false gem 'coveralls', require: false diff --git a/lib/lotus/helpers.rb b/lib/lotus/helpers.rb index c5ea520..a369d65 100644 --- a/lib/lotus/helpers.rb +++ b/lib/lotus/helpers.rb @@ -2,6 +2,7 @@ require 'lotus/helpers/html_helper' require 'lotus/helpers/escape_helper' require 'lotus/helpers/routing_helper' +require 'lotus/helpers/form_helper' module Lotus # View helpers for Ruby applications @@ -21,6 +22,7 @@ def self.included(base) include Lotus::Helpers::HtmlHelper include Lotus::Helpers::EscapeHelper include Lotus::Helpers::RoutingHelper + include Lotus::Helpers::FormHelper end end end diff --git a/lib/lotus/helpers/form_helper.rb b/lib/lotus/helpers/form_helper.rb new file mode 100644 index 0000000..090edd3 --- /dev/null +++ b/lib/lotus/helpers/form_helper.rb @@ -0,0 +1,186 @@ +require 'lotus/helpers/html_helper/html_builder' +require 'lotus/helpers/html_helper/html_node' +require 'lotus/utils/string' + +module Lotus + module Helpers + module FormHelper + DEFAULT_METHOD = 'POST'.freeze + BROWSER_METHODS = ['GET', 'POST'].freeze + + CHECKED = 'checked'.freeze + SELECTED = 'selected'.freeze + ACCEPT_SEPARATOR = ','.freeze + # ENCTYPE_MULTIPART = 'multipart/form-data'.freeze + + class HtmlNode < ::Lotus::Helpers::HtmlHelper::HtmlNode + def initialize(name, content, attributes, options) + super + @builder = FormBuilder.new(options.fetch(:form_name), options.fetch(:params)) + @verb = options.fetch(:verb, nil) + end + + private + def content + _method_override! + super + end + + def _method_override! + return if @verb.nil? + + verb = @verb + @builder.resolve do + input(type: :hidden, name: :_method, value: verb) + end + end + end + + class FormBuilder < ::Lotus::Helpers::HtmlHelper::HtmlBuilder + self.html_node = ::Lotus::Helpers::FormHelper::HtmlNode + + def initialize(name, params, attributes = {}, &blk) + super() + + @name = name + @params = params + @attributes = attributes + @blk = blk + end + + def options + Hash[form_name: @name, params: @params, verb: @verb] + end + + def to_s + if toplevel? + _method_override! + form(@blk, @attributes) + end + + super + end + + def fields_for(name) + current_name = @name + @name = _input_name(name) + yield + ensure + @name = current_name + end + + def label(content, attributes = {}, &blk) + attributes = { for: _for(content, attributes.delete(:for)) }.merge(attributes) + content = Utils::String.new(content).titleize + + super(content, attributes, &blk) + end + + def color_field(name, attributes = {}) + input _attributes(:color, name, attributes) + end + + def date_field(name, attributes = {}) + input _attributes(:date, name, attributes) + end + + def datetime_field(name, attributes = {}) + input _attributes(:datetime, name, attributes) + end + + def datetime_local_field(name, attributes = {}) + input _attributes(:'datetime-local', name, attributes) + end + + def email_field(name, attributes = {}) + input _attributes(:email, name, attributes) + end + + def hidden_field(name, attributes = {}) + input _attributes(:hidden, name, attributes) + end + + def file_field(name, attributes = {}) + attributes[:accept] = Array(attributes[:accept]).join(ACCEPT_SEPARATOR) if attributes.key?(:accept) + attributes = { type: :file, name: _input_name(name), id: _input_id(name) }.merge(attributes) + + input(attributes) + end + + def text_field(name, attributes = {}) + input _attributes(:text, name, attributes) + end + alias_method :input_text, :text_field + + def radio_button(name, value, attributes = {}) + attributes = { type: :radio, name: _input_name(name), value: value }.merge(attributes) + attributes[:checked] = CHECKED if _value(name) == value + input(attributes) + end + + def select(name, values, attributes = {}) + options = attributes.delete(:options) || {} + attributes = { name: _input_name(name), id: _input_id(name) }.merge(attributes) + + super(attributes) do + values.each do |value, content| + if _value(name) == value + option(content, {value: value, selected: SELECTED}.merge(options)) + else + option(content, {value: value}.merge(options)) + end + end + end + end + + def submit(content, attributes = {}) + attributes = { type: :submit }.merge(attributes) + button(content, attributes) + end + + private + def toplevel? + @attributes.any? + end + + def _method_override! + verb = (@attributes.fetch(:method) { DEFAULT_METHOD }).to_s.upcase + + if BROWSER_METHODS.include?(verb) + @attributes[:method] = verb + else + @attributes[:method] = DEFAULT_METHOD + @verb = verb + end + end + + def _attributes(type, name, attributes) + { type: type, name: _input_name(name), id: _input_id(name), value: _value(name) }.merge(attributes) + end + + def _input_name(name) + "#{ @name }[#{ name }]" + end + + def _input_id(name) + name = _input_name(name).gsub(/\[(?[[[:word:]]\-]*)\]/, '-\k') + Utils::String.new(name).dasherize + end + + def _value(name) + name = _input_name(name).gsub(/\[(?[[:word:]]*)\]/, '.\k') + @params.get(name) + end + + def _for(content, name) + _input_id(name || content) + end + end + + def form_for(name, url, attributes = {}, &blk) + attributes = { action: url, id: "#{ name }-form", method: DEFAULT_METHOD }.merge(attributes) + FormBuilder.new(name, params, attributes, &blk) + end + end + end +end diff --git a/lib/lotus/helpers/html_helper/html_builder.rb b/lib/lotus/helpers/html_helper/html_builder.rb index 17214d8..ce6c942 100644 --- a/lib/lotus/helpers/html_helper/html_builder.rb +++ b/lib/lotus/helpers/html_helper/html_builder.rb @@ -1,4 +1,5 @@ require 'lotus/utils' # RUBY_VERSION >= '2.2' +require 'lotus/utils/class_attribute' require 'lotus/utils/escape' require 'lotus/helpers/html_helper/empty_html_node' require 'lotus/helpers/html_helper/html_node' @@ -149,7 +150,7 @@ class HtmlBuilder CONTENT_TAGS.each do |tag| class_eval %{ def #{ tag }(content = nil, attributes = nil, &blk) - @nodes << HtmlNode.new(:#{ tag }, blk || content, attributes || content) + @nodes << self.class.html_node.new(:#{ tag }, blk || content, attributes || content, options) self end } @@ -164,6 +165,11 @@ def #{ tag }(attributes = nil) } end + include Utils::ClassAttribute + + class_attribute :html_node + self.html_node = ::Lotus::Helpers::HtmlHelper::HtmlNode + # Initialize a new builder # # @return [Lotus::Helpers::HtmlHelper::HtmlBuilder] the builder @@ -174,6 +180,9 @@ def initialize @nodes = [] end + def options + end + # Define a custom tag # # @param name [Symbol,String] the name of the tag @@ -217,7 +226,7 @@ def initialize # # hello # # def tag(name, content = nil, attributes = nil, &blk) - @nodes << HtmlNode.new(name, blk || content, attributes || content) + @nodes << HtmlNode.new(name, blk || content, attributes || content, options) self end diff --git a/lib/lotus/helpers/html_helper/html_node.rb b/lib/lotus/helpers/html_helper/html_node.rb index 28eea35..c1d016f 100644 --- a/lib/lotus/helpers/html_helper/html_node.rb +++ b/lib/lotus/helpers/html_helper/html_node.rb @@ -17,7 +17,7 @@ class HtmlNode < EmptyHtmlNode # @param attributes [Hash,NilClass] the optional tag attributes # # @return [Lotus::Helpers::HtmlHelper::HtmlNode] - def initialize(name, content, attributes) + def initialize(name, content, attributes, options = {}) @builder = HtmlBuilder.new @name = name @content = case content diff --git a/test/fixtures.rb b/test/fixtures.rb index e8c1fb7..f0ea8bb 100644 --- a/test/fixtures.rb +++ b/test/fixtures.rb @@ -1,4 +1,5 @@ require 'lotus/view' +require 'lotus/controller' require 'lotus/helpers/html_helper' require 'lotus/helpers/escape_helper' @@ -195,11 +196,33 @@ def details end end +class FormHelperView + include Lotus::Helpers::FormHelper + attr_reader :params + + def initialize(params) + @params = Lotus::Action::Params.new(params) + end +end + +class DeliveryParams < Lotus::Action::Params + param :delivery do + param :customer_id, type: Integer, presence: true + param :address do + param :street, type: String, presence: true + end + end +end + module FullStack class Routes def self.path(name) "/#{ name }" end + + def self.deliveries + '/deliveries' + end end module Views @@ -214,6 +237,13 @@ def routing_helper_path end end end + + module Deliveries + class New + include TestView + template 'deliveries/new' + end + end end end diff --git a/test/fixtures/templates/albums/new.html.erb b/test/fixtures/templates/albums/new.html.erb new file mode 100644 index 0000000..1cb5582 --- /dev/null +++ b/test/fixtures/templates/albums/new.html.erb @@ -0,0 +1 @@ +<%= album_form %> diff --git a/test/fixtures/templates/deliveries/new.html.erb b/test/fixtures/templates/deliveries/new.html.erb new file mode 100644 index 0000000..176f242 --- /dev/null +++ b/test/fixtures/templates/deliveries/new.html.erb @@ -0,0 +1,23 @@ +<%= + form_for(:delivery, routes.deliveries, class: 'form-horizontal') do + div class: 'form-group' do + label :customer + input_text :customer, class: 'form-control', placeholder: 'Customer', name: nil + + hidden_field :customer_id + end + + fieldset do + legend 'Address' + + fields_for :address do + div class: 'form-group' do + label :street + input_text :street, class: 'form-control', placeholder: 'Street' + end + end + end + + submit 'Create', class: 'btn btn-default' + end +%> diff --git a/test/form_helper_test.rb b/test/form_helper_test.rb new file mode 100644 index 0000000..80c6050 --- /dev/null +++ b/test/form_helper_test.rb @@ -0,0 +1,758 @@ +require 'test_helper' + +describe Lotus::Helpers::FormHelper do + let(:view) { FormHelperView.new(params) } + let(:params) { Hash[] } + let(:action) { '/books' } + + # + # FORM + # + + describe '#form_for' do + it 'renders' do + actual = view.form_for(:book, action).to_s + actual.must_equal %(
) + end + + it "allows to override 'id' attribute" do + actual = view.form_for(:book, action, id: 'books').to_s + actual.must_equal %(
) + end + + it "allows to override 'method' attribute (get)" do + actual = view.form_for(:book, action, method: 'get').to_s + actual.must_equal %(
) + end + + it "allows to override 'method' attribute (:get)" do + actual = view.form_for(:book, action, method: :get).to_s + actual.must_equal %(
) + end + + it "allows to override 'method' attribute (GET)" do + actual = view.form_for(:book, action, method: 'GET').to_s + actual.must_equal %(
) + end + + [:patch, :put, :delete].each do |verb| + it "allows to override 'method' attribute (#{ verb })" do + actual = view.form_for(:book, action, method: verb) do + text_field :title + end.to_s + + actual.must_equal %(
\n\n\n
) + end + end + + it "allows to override 'action' attribute" do + actual = view.form_for(:book, action, action: '/b').to_s + actual.must_equal %(
) + end + + it "allows to specify HTML attributes" do + actual = view.form_for(:book, action, class: 'form-horizonal').to_s + actual.must_equal %(
) + end + end + + # + # NESTED FIELDS + # + + describe '#fields_for' do + it "renders" do + actual = view.form_for(:book, action) do + fields_for :categories do + text_field :name + + fields_for :subcategories do + text_field :name + end + + text_field :name2 + end + + text_field :title + end.to_s + + actual.must_equal %(
\n\n\n\n\n
) + end + + describe "with filled params" do + let(:params) { Hash[book: { title: 'TDD', categories: { name: 'foo', name2: 'bar', subcategories: { name: 'sub' } } }] } + + it "renders" do + actual = view.form_for(:book, action) do + fields_for :categories do + text_field :name + + fields_for :subcategories do + text_field :name + end + + text_field :name2 + end + + text_field :title + end.to_s + + actual.must_equal %(
\n\n\n\n\n
) + end + end + end + + # + # INPUT FIELDS + # + + # describe '#check_box' do + # it 'renders' do + # actual = view.form_for(:book, action) do + # check_box :free_shipping + # end.to_s + + # actual.must_include %() + # end + # end + + describe "#color_field" do + it "renders" do + actual = view.form_for(:book, action) do + color_field :cover + end.to_s + + actual.must_include %() + end + + it "allows to override 'id' attribute" do + actual = view.form_for(:book, action) do + color_field :cover, id: 'b-cover' + end.to_s + + actual.must_include %() + end + + it "allows to override 'name' attribute" do + actual = view.form_for(:book, action) do + color_field :cover, name: 'cover' + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + color_field :cover, value: '#ffffff' + end.to_s + + actual.must_include %() + end + + it "allows to specify HTML attributes" do + actual = view.form_for(:book, action) do + color_field :cover, class: 'form-control' + end.to_s + + actual.must_include %() + end + + describe "with filled params" do + let(:params) { Hash[book: { cover: value }] } + let(:value) { '#d3397e' } + + it "renders with value" do + actual = view.form_for(:book, action) do + color_field :cover + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + color_field :cover, value: '#000000' + end.to_s + + actual.must_include %() + end + end + end + + describe "#date_field" do + it "renders" do + actual = view.form_for(:book, action) do + date_field :release_date + end.to_s + + actual.must_include %() + end + + it "allows to override 'id' attribute" do + actual = view.form_for(:book, action) do + date_field :release_date, id: 'release-date' + end.to_s + + actual.must_include %() + end + + it "allows to override 'name' attribute" do + actual = view.form_for(:book, action) do + date_field :release_date, name: 'release_date' + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + date_field :release_date, value: '2015-02-19' + end.to_s + + actual.must_include %() + end + + it "allows to specify HTML attributes" do + actual = view.form_for(:book, action) do + date_field :release_date, class: 'form-control' + end.to_s + + actual.must_include %() + end + + describe "with filled params" do + let(:params) { Hash[book: { release_date: value }] } + let(:value) { '2014-06-23' } + + it "renders with value" do + actual = view.form_for(:book, action) do + date_field :release_date + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + date_field :release_date, value: '2015-03-23' + end.to_s + + actual.must_include %() + end + end + end + + describe "#datetime_field" do + it "renders" do + actual = view.form_for(:book, action) do + datetime_field :published_at + end.to_s + + actual.must_include %() + end + + it "allows to override 'id' attribute" do + actual = view.form_for(:book, action) do + datetime_field :published_at, id: 'published-timestamp' + end.to_s + + actual.must_include %() + end + + it "allows to override 'name' attribute" do + actual = view.form_for(:book, action) do + datetime_field :published_at, name: 'book[published][timestamp]' + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + datetime_field :published_at, value: '2015-02-19T12:50:36Z' + end.to_s + + actual.must_include %() + end + + it "allows to specify HTML attributes" do + actual = view.form_for(:book, action) do + datetime_field :published_at, class: 'form-control' + end.to_s + + actual.must_include %() + end + + describe "with filled params" do + let(:params) { Hash[book: { published_at: value }] } + let(:value) { '2015-02-19T12:56:31Z' } + + it "renders with value" do + actual = view.form_for(:book, action) do + datetime_field :published_at + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + datetime_field :published_at, value: '2015-02-19T12:50:36Z' + end.to_s + + actual.must_include %() + end + end + end + + describe "#datetime_local_field" do + it "renders" do + actual = view.form_for(:book, action) do + datetime_local_field :released_at + end.to_s + + actual.must_include %() + end + + it "allows to override 'id' attribute" do + actual = view.form_for(:book, action) do + datetime_local_field :released_at, id: 'local-release-timestamp' + end.to_s + + actual.must_include %() + end + + it "allows to override 'name' attribute" do + actual = view.form_for(:book, action) do + datetime_local_field :released_at, name: 'book[release-timestamp]' + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + datetime_local_field :released_at, value: '2015-02-19T14:01:28+01:00' + end.to_s + + actual.must_include %() + end + + it "allows to specify HTML attributes" do + actual = view.form_for(:book, action) do + datetime_local_field :released_at, class: 'form-control' + end.to_s + + actual.must_include %() + end + + describe "with filled params" do + let(:params) { Hash[book: { released_at: value }] } + let(:value) { '2015-02-19T14:11:19+01:00' } + + it "renders with value" do + actual = view.form_for(:book, action) do + datetime_local_field :released_at + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + datetime_local_field :released_at, value: '2015-02-19T14:01:28+01:00' + end.to_s + + actual.must_include %() + end + end + end + + describe "#email_field" do + it "renders" do + actual = view.form_for(:book, action) do + email_field :publisher_email + end.to_s + + actual.must_include %() + end + + it "allows to override 'id' attribute" do + actual = view.form_for(:book, action) do + email_field :publisher_email, id: 'publisher-email' + end.to_s + + actual.must_include %() + end + + it "allows to override 'name' attribute" do + actual = view.form_for(:book, action) do + email_field :publisher_email, name: 'book[email]' + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + email_field :publisher_email, value: 'publisher@example.org' + end.to_s + + actual.must_include %() + end + + it "allows to specify 'multiple' attribute" do + actual = view.form_for(:book, action) do + email_field :publisher_email, multiple: true + end.to_s + + actual.must_include %() + end + + it "allows to specify HTML attributes" do + actual = view.form_for(:book, action) do + email_field :publisher_email, class: 'form-control' + end.to_s + + actual.must_include %() + end + + describe "with filled params" do + let(:params) { Hash[book: { publisher_email: value }] } + let(:value) { 'maria@publisher.org' } + + it "renders with value" do + actual = view.form_for(:book, action) do + email_field :publisher_email + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + email_field :publisher_email, value: 'publisher@example.org' + end.to_s + + actual.must_include %() + end + end + end + + describe "#file_field" do + it "renders" do + actual = view.form_for(:book, action) do + file_field :image_cover + end.to_s + + actual.must_include %() + end + + it "sets 'enctype' attribute to the form" + # it "sets 'enctype' attribute to the form" do + # actual = view.form_for(:book, action) do + # file_field :image_cover + # end.to_s + + # actual.must_include %(
) + # end + + it "sets 'enctype' attribute to the form when there are nested fields" + # it "sets 'enctype' attribute to the form when there are nested fields" do + # actual = view.form_for(:book, action) do + # fields_for :images do + # file_field :cover + # end + # end.to_s + + # actual.must_include %() + # end + + it "allows to override 'id' attribute" do + actual = view.form_for(:book, action) do + file_field :image_cover, id: 'book-cover' + end.to_s + + actual.must_include %() + end + + it "allows to override 'name' attribute" do + actual = view.form_for(:book, action) do + file_field :image_cover, name: 'book[cover]' + end.to_s + + actual.must_include %() + end + + it "allows to specify 'multiple' attribute" do + actual = view.form_for(:book, action) do + file_field :image_cover, multiple: true + end.to_s + + actual.must_include %() + end + + it "allows to specify single value for 'accept' attribute" do + actual = view.form_for(:book, action) do + file_field :image_cover, accept: 'application/pdf' + end.to_s + + actual.must_include %() + end + + it "allows to specify multiple values for 'accept' attribute" do + actual = view.form_for(:book, action) do + file_field :image_cover, accept: 'image/png,image/jpg' + end.to_s + + actual.must_include %() + end + + it "allows to specify multiple values (array) for 'accept' attribute" do + actual = view.form_for(:book, action) do + file_field :image_cover, accept: ['image/png', 'image/jpg'] + end.to_s + + actual.must_include %() + end + + describe "with filled params" do + let(:params) { Hash[book: { image_cover: value }] } + let(:value) { 'image' } + + it "ignores value" do + actual = view.form_for(:book, action) do + file_field :image_cover + end.to_s + + actual.must_include %() + end + end + end + + describe "#hidden_field" do + it "renders" do + actual = view.form_for(:book, action) do + hidden_field :author_id + end.to_s + + actual.must_include %() + end + + it "allows to override 'id' attribute" do + actual = view.form_for(:book, action) do + hidden_field :author_id, id: 'author-id' + end.to_s + + actual.must_include %() + end + + it "allows to override 'name' attribute" do + actual = view.form_for(:book, action) do + hidden_field :author_id, name: 'book[author]' + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + hidden_field :author_id, value: '23' + end.to_s + + actual.must_include %() + end + + it "allows to specify HTML attributes" do + actual = view.form_for(:book, action) do + hidden_field :author_id, class: 'form-details' + end.to_s + + actual.must_include %() + end + + describe "with filled params" do + let(:params) { Hash[book: { author_id: value }] } + let(:value) { '1' } + + it "renders with value" do + actual = view.form_for(:book, action) do + hidden_field :author_id + end.to_s + + actual.must_include %() + end + + it "allows to override 'value' attribute" do + actual = view.form_for(:book, action) do + hidden_field :author_id, value: '23' + end.to_s + + actual.must_include %() + end + end + end + + describe "#text_field" do + it "renders" do + actual = view.form_for(:book, action) do + text_field :title + end.to_s + + actual.must_include %() + end + + it "allows to override 'id' attribute" do + actual = view.form_for(:book, action) do + text_field :title, id: 'book-short-title' + end.to_s + + actual.must_include %() + end + + it "allows to override 'name' attribute" do + actual = view.form_for(:book, action) do + text_field :title, name: 'book[short_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: 'Refactoring' + end.to_s + + actual.must_include %() + end + + it "allows to specify HTML attributes" do + actual = view.form_for(:book, action) do + text_field :title, class: 'form-control' + end.to_s + + actual.must_include %() + end + + describe "with filled params" do + let(:params) { Hash[book: { title: value }] } + let(:value) { 'PPoEA' } + + 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: 'DDD' + end.to_s + + actual.must_include %() + end + end + end + + describe "#radio_button" do + it "renders" do + actual = view.form_for(:book, action) do + radio_button :category, 'Fiction' + radio_button :category, 'Non-Fiction' + end.to_s + + actual.must_include %(\n) + end + + it "allows to override 'name' attribute" do + actual = view.form_for(:book, action) do + radio_button :category, 'Fiction', name: 'category_name' + radio_button :category, 'Non-Fiction', name: 'category_name' + end.to_s + + actual.must_include %(\n) + end + + it "allows to specify HTML attributes" do + actual = view.form_for(:book, action) do + radio_button :category, 'Fiction', class: 'form-control' + radio_button :category, 'Non-Fiction', class: 'radio-button' + end.to_s + + actual.must_include %(\n) + end + + describe "with filled params" do + let(:params) { Hash[book: { category: value }] } + let(:value) { '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 + + actual.must_include %(\n) + end + end + end + + describe "#select" do + let(:values) { Hash['it' => 'Italy', 'us' => 'United States'] } + + it "renders" do + actual = view.form_for(:book, action) do + select :store, values + end.to_s + + actual.must_include %() + end + + it "allows to override 'id' attribute" do + actual = view.form_for(:book, action) do + select :store, values, id: 'store' + end.to_s + + actual.must_include %() + end + + it "allows to override 'name' attribute" do + actual = view.form_for(:book, action) do + select :store, values, name: 'store' + end.to_s + + actual.must_include %() + end + + it "allows to specify HTML attributes" do + actual = view.form_for(:book, action) do + select :store, values, class: 'form-control' + end.to_s + + actual.must_include %() + end + + it "allows to specify HTML attributes for options" do + actual = view.form_for(:book, action) do + select :store, values, options: { class: 'form-option' } + end.to_s + + actual.must_include %() + end + + describe "with filled params" do + let(:params) { Hash[book: { store: value }] } + let(:value) { 'it' } + + it "renders with value" do + actual = view.form_for(:book, action) do + select :store, values + end.to_s + + actual.must_include %() + end + end + end +end diff --git a/test/integration/form_helper_test.rb b/test/integration/form_helper_test.rb new file mode 100644 index 0000000..0fce0b3 --- /dev/null +++ b/test/integration/form_helper_test.rb @@ -0,0 +1,27 @@ +require 'test_helper' + +describe 'Form helper' do + describe 'first page load' do + before do + @params = DeliveryParams.new({}) + @actual = FullStack::Views::Deliveries::New.render(format: :html, params: @params) + end + + it 'renders the form' do + @actual.must_include %(\n
\n\n\n\n
\n
\nAddress\n
\n\n\n
\n
\n\n
) + end + end + + describe 'after a failed form submission' do + before do + @params = DeliveryParams.new({ delivery: { address: { street: '5th Ave' }}}) + @params.valid? # trigger validations + + @actual = FullStack::Views::Deliveries::New.render(format: :html, params: @params) + end + + it 'renders the form with previous values' do + @actual.must_include %(
\n
\n\n\n\n
\n
\nAddress\n
\n\n\n
\n
\n\n
) + end + end +end From 00bd9f8081cd989c6a72225a3172a304919064d9 Mon Sep 17 00:00:00 2001 From: Luca Guidi Date: Mon, 23 Feb 2015 10:34:56 +0100 Subject: [PATCH 2/6] Form helper: split into separated files --- lib/lotus/helpers/form_helper.rb | 176 +----------------- lib/lotus/helpers/form_helper/form_builder.rb | 158 ++++++++++++++++ lib/lotus/helpers/form_helper/html_node.rb | 30 +++ 3 files changed, 190 insertions(+), 174 deletions(-) create mode 100644 lib/lotus/helpers/form_helper/form_builder.rb create mode 100644 lib/lotus/helpers/form_helper/html_node.rb diff --git a/lib/lotus/helpers/form_helper.rb b/lib/lotus/helpers/form_helper.rb index 090edd3..17d080e 100644 --- a/lib/lotus/helpers/form_helper.rb +++ b/lib/lotus/helpers/form_helper.rb @@ -1,181 +1,9 @@ -require 'lotus/helpers/html_helper/html_builder' -require 'lotus/helpers/html_helper/html_node' -require 'lotus/utils/string' +require 'lotus/helpers/form_helper/form_builder' module Lotus module Helpers module FormHelper - DEFAULT_METHOD = 'POST'.freeze - BROWSER_METHODS = ['GET', 'POST'].freeze - - CHECKED = 'checked'.freeze - SELECTED = 'selected'.freeze - ACCEPT_SEPARATOR = ','.freeze - # ENCTYPE_MULTIPART = 'multipart/form-data'.freeze - - class HtmlNode < ::Lotus::Helpers::HtmlHelper::HtmlNode - def initialize(name, content, attributes, options) - super - @builder = FormBuilder.new(options.fetch(:form_name), options.fetch(:params)) - @verb = options.fetch(:verb, nil) - end - - private - def content - _method_override! - super - end - - def _method_override! - return if @verb.nil? - - verb = @verb - @builder.resolve do - input(type: :hidden, name: :_method, value: verb) - end - end - end - - class FormBuilder < ::Lotus::Helpers::HtmlHelper::HtmlBuilder - self.html_node = ::Lotus::Helpers::FormHelper::HtmlNode - - def initialize(name, params, attributes = {}, &blk) - super() - - @name = name - @params = params - @attributes = attributes - @blk = blk - end - - def options - Hash[form_name: @name, params: @params, verb: @verb] - end - - def to_s - if toplevel? - _method_override! - form(@blk, @attributes) - end - - super - end - - def fields_for(name) - current_name = @name - @name = _input_name(name) - yield - ensure - @name = current_name - end - - def label(content, attributes = {}, &blk) - attributes = { for: _for(content, attributes.delete(:for)) }.merge(attributes) - content = Utils::String.new(content).titleize - - super(content, attributes, &blk) - end - - def color_field(name, attributes = {}) - input _attributes(:color, name, attributes) - end - - def date_field(name, attributes = {}) - input _attributes(:date, name, attributes) - end - - def datetime_field(name, attributes = {}) - input _attributes(:datetime, name, attributes) - end - - def datetime_local_field(name, attributes = {}) - input _attributes(:'datetime-local', name, attributes) - end - - def email_field(name, attributes = {}) - input _attributes(:email, name, attributes) - end - - def hidden_field(name, attributes = {}) - input _attributes(:hidden, name, attributes) - end - - def file_field(name, attributes = {}) - attributes[:accept] = Array(attributes[:accept]).join(ACCEPT_SEPARATOR) if attributes.key?(:accept) - attributes = { type: :file, name: _input_name(name), id: _input_id(name) }.merge(attributes) - - input(attributes) - end - - def text_field(name, attributes = {}) - input _attributes(:text, name, attributes) - end - alias_method :input_text, :text_field - - def radio_button(name, value, attributes = {}) - attributes = { type: :radio, name: _input_name(name), value: value }.merge(attributes) - attributes[:checked] = CHECKED if _value(name) == value - input(attributes) - end - - def select(name, values, attributes = {}) - options = attributes.delete(:options) || {} - attributes = { name: _input_name(name), id: _input_id(name) }.merge(attributes) - - super(attributes) do - values.each do |value, content| - if _value(name) == value - option(content, {value: value, selected: SELECTED}.merge(options)) - else - option(content, {value: value}.merge(options)) - end - end - end - end - - def submit(content, attributes = {}) - attributes = { type: :submit }.merge(attributes) - button(content, attributes) - end - - private - def toplevel? - @attributes.any? - end - - def _method_override! - verb = (@attributes.fetch(:method) { DEFAULT_METHOD }).to_s.upcase - - if BROWSER_METHODS.include?(verb) - @attributes[:method] = verb - else - @attributes[:method] = DEFAULT_METHOD - @verb = verb - end - end - - def _attributes(type, name, attributes) - { type: type, name: _input_name(name), id: _input_id(name), value: _value(name) }.merge(attributes) - end - - def _input_name(name) - "#{ @name }[#{ name }]" - end - - def _input_id(name) - name = _input_name(name).gsub(/\[(?[[[:word:]]\-]*)\]/, '-\k') - Utils::String.new(name).dasherize - end - - def _value(name) - name = _input_name(name).gsub(/\[(?[[:word:]]*)\]/, '.\k') - @params.get(name) - end - - def _for(content, name) - _input_id(name || content) - end - end + DEFAULT_METHOD = 'POST'.freeze def form_for(name, url, attributes = {}, &blk) attributes = { action: url, id: "#{ name }-form", method: DEFAULT_METHOD }.merge(attributes) diff --git a/lib/lotus/helpers/form_helper/form_builder.rb b/lib/lotus/helpers/form_helper/form_builder.rb new file mode 100644 index 0000000..668e63e --- /dev/null +++ b/lib/lotus/helpers/form_helper/form_builder.rb @@ -0,0 +1,158 @@ +require 'lotus/helpers/form_helper/html_node' +require 'lotus/helpers/html_helper/html_builder' +require 'lotus/utils/string' + +module Lotus + module Helpers + module FormHelper + class FormBuilder < ::Lotus::Helpers::HtmlHelper::HtmlBuilder + BROWSER_METHODS = ['GET', 'POST'].freeze + + CHECKED = 'checked'.freeze + SELECTED = 'selected'.freeze + ACCEPT_SEPARATOR = ','.freeze + # ENCTYPE_MULTIPART = 'multipart/form-data'.freeze + + self.html_node = ::Lotus::Helpers::FormHelper::HtmlNode + + def initialize(name, params, attributes = {}, &blk) + super() + + @name = name + @params = params + @attributes = attributes + @blk = blk + end + + def options + Hash[form_name: @name, params: @params, verb: @verb] + end + + def to_s + if toplevel? + _method_override! + form(@blk, @attributes) + end + + super + end + + def fields_for(name) + current_name = @name + @name = _input_name(name) + yield + ensure + @name = current_name + end + + def label(content, attributes = {}, &blk) + attributes = { for: _for(content, attributes.delete(:for)) }.merge(attributes) + content = Utils::String.new(content).titleize + + super(content, attributes, &blk) + end + + def color_field(name, attributes = {}) + input _attributes(:color, name, attributes) + end + + def date_field(name, attributes = {}) + input _attributes(:date, name, attributes) + end + + def datetime_field(name, attributes = {}) + input _attributes(:datetime, name, attributes) + end + + def datetime_local_field(name, attributes = {}) + input _attributes(:'datetime-local', name, attributes) + end + + def email_field(name, attributes = {}) + input _attributes(:email, name, attributes) + end + + def hidden_field(name, attributes = {}) + input _attributes(:hidden, name, attributes) + end + + def file_field(name, attributes = {}) + attributes[:accept] = Array(attributes[:accept]).join(ACCEPT_SEPARATOR) if attributes.key?(:accept) + attributes = { type: :file, name: _input_name(name), id: _input_id(name) }.merge(attributes) + + input(attributes) + end + + def text_field(name, attributes = {}) + input _attributes(:text, name, attributes) + end + alias_method :input_text, :text_field + + def radio_button(name, value, attributes = {}) + attributes = { type: :radio, name: _input_name(name), value: value }.merge(attributes) + attributes[:checked] = CHECKED if _value(name) == value + input(attributes) + end + + def select(name, values, attributes = {}) + options = attributes.delete(:options) || {} + attributes = { name: _input_name(name), id: _input_id(name) }.merge(attributes) + + super(attributes) do + values.each do |value, content| + if _value(name) == value + option(content, {value: value, selected: SELECTED}.merge(options)) + else + option(content, {value: value}.merge(options)) + end + end + end + end + + def submit(content, attributes = {}) + attributes = { type: :submit }.merge(attributes) + button(content, attributes) + end + + private + def toplevel? + @attributes.any? + end + + def _method_override! + verb = (@attributes.fetch(:method) { DEFAULT_METHOD }).to_s.upcase + + if BROWSER_METHODS.include?(verb) + @attributes[:method] = verb + else + @attributes[:method] = DEFAULT_METHOD + @verb = verb + end + end + + def _attributes(type, name, attributes) + { type: type, name: _input_name(name), id: _input_id(name), value: _value(name) }.merge(attributes) + end + + def _input_name(name) + "#{ @name }[#{ name }]" + end + + def _input_id(name) + name = _input_name(name).gsub(/\[(?[[[:word:]]\-]*)\]/, '-\k') + Utils::String.new(name).dasherize + end + + def _value(name) + name = _input_name(name).gsub(/\[(?[[:word:]]*)\]/, '.\k') + @params.get(name) + end + + def _for(content, name) + _input_id(name || content) + end + end + end + end +end + diff --git a/lib/lotus/helpers/form_helper/html_node.rb b/lib/lotus/helpers/form_helper/html_node.rb new file mode 100644 index 0000000..7f166fb --- /dev/null +++ b/lib/lotus/helpers/form_helper/html_node.rb @@ -0,0 +1,30 @@ +require 'lotus/helpers/html_helper/html_node' + +module Lotus + module Helpers + module FormHelper + class HtmlNode < ::Lotus::Helpers::HtmlHelper::HtmlNode + def initialize(name, content, attributes, options) + super + @builder = FormBuilder.new(options.fetch(:form_name), options.fetch(:params)) + @verb = options.fetch(:verb, nil) + end + + private + def content + _method_override! + super + end + + def _method_override! + return if @verb.nil? + + verb = @verb + @builder.resolve do + input(type: :hidden, name: :_method, value: verb) + end + end + end + end + end +end From e995a8db3276992d8a5fef6f9f605c656cf81432 Mon Sep 17 00:00:00 2001 From: Luca Guidi Date: Mon, 23 Feb 2015 11:16:30 +0100 Subject: [PATCH 3/6] [ci skip] API docs for Lotus::Helpers::FormHelper --- lib/lotus/helpers/form_helper.rb | 149 +++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/lib/lotus/helpers/form_helper.rb b/lib/lotus/helpers/form_helper.rb index 17d080e..e0ec69d 100644 --- a/lib/lotus/helpers/form_helper.rb +++ b/lib/lotus/helpers/form_helper.rb @@ -2,9 +2,158 @@ module Lotus module Helpers + # Form builder + # + # By including Lotus::Helpers::FormHelper it will inject one public method: form_for. + # This is a HTML5 form builder. + # + # To understand the general HTML5 builder syntax of this framework, please + # consider to have a look at Lotus::Helpers::HtmlHelper documentation. + # + # This builder is independent from any template engine. + # This was hard to achieve without a compromise: the form helper should be + # used in one output block in a template or as a method in a view (see the examples below). + # + # Features: + # + # * Support for complex markup without the need of concatenation + # * Auto closing HTML5 tags + # * Support for view local variables + # * Method override support (PUT/PATCH/DELETE HTTP verbs aren't understood by browsers) + # * Automatic generation of HTML attributes for inputs: id, name, value + # * Allow to override HTML attributes + # * Extract values from request params and fill value attributes + # * Automatic selection of current value for radio button and select inputs + # * Infinite nested fields + # + # Supported tags and inputs: + # + # * color_field + # * date_field + # * datetime_field + # * datetime_local_field + # * email_field + # * hidden_field + # * file_field + # * fields_for + # * form_for + # * label + # * text_field + # * radio_button + # * select + # * submit + # + # @since x.x.x + # + # @see Lotus::Helpers::FormHelper#form_for + # @see Lotus::Helpers::HtmlHelper + # + # @example One output block (template) + # <%= + # form_for :book, routes.books_path do + # text_field :title + # + # submit 'Create' + # end + # %> + # + # @example Method (view) + # require 'lotus/helpers' + # + # class MyView + # include Lotus::Helpers::FormHelper + # + # def my_form + # form_for :book, routes.books_path do + # text_field :title + # end + # end + # end + # + # # Corresponding template: + # # + # # <%= my_form %> module FormHelper + # Default HTTP method for form + # + # @since x.x.x + # @api private DEFAULT_METHOD = 'POST'.freeze + # Instantiate a HTML5 form builder + # + # @param name [Symbol] the toplevel name of the form, it's used to generate + # input names, ids, and to lookup params to fill values. + # @param url [String] the form action URL + # @param attributes [Hash] HTML attributes to pass to the form tag + # @param blk [Proc] A block that describes the contents of the form + # + # @return [Lotus::Helpers::FormHelper::FormBuilder] the form builder + # + # @since x.x.x + # + # @see Lotus::Helpers::FormHelper + # @see Lotus::Helpers::FormHelper::FormBuilder + # + # @example Basic usage + # <%= + # form_for :book, routes.books_path, class: 'form-horizontal' do + # div do + # label :title + # text_field :title, class: 'form-control' + # end + # + # submit 'Create' + # end + # %> + # + # Output: + # #
+ # #
+ # # + # # + # #
+ # # + # # + # #
+ # + # @example Method override + # <%= + # form_for :book, routes.book_path(book.id), method: :put do + # text_field :title + # + # submit 'Update' + # end + # %> + # + # Output: + # #
+ # # + # # + # # + # # + # #
+ # + # @example Nested fields + # <%= + # form_for :delivery, routes.deliveries_path do + # text_field :customer_name + # + # fields_for :address do + # text_field :city + # end + # + # submit 'Create' + # end + # %> + # + # Output: + # #
+ # # + # # + # # + # # + # #
def form_for(name, url, attributes = {}, &blk) attributes = { action: url, id: "#{ name }-form", method: DEFAULT_METHOD }.merge(attributes) FormBuilder.new(name, params, attributes, &blk) From 2c96a18a9be75d7603f382a39f6efb5bd7069ede Mon Sep 17 00:00:00 2001 From: Luca Guidi Date: Mon, 23 Feb 2015 12:17:31 +0100 Subject: [PATCH 4/6] Form builder: minor changes and API docs --- lib/lotus/helpers/form_helper/form_builder.rb | 453 +++++++++++++++++- 1 file changed, 445 insertions(+), 8 deletions(-) diff --git a/lib/lotus/helpers/form_helper/form_builder.rb b/lib/lotus/helpers/form_helper/form_builder.rb index 668e63e..95743f6 100644 --- a/lib/lotus/helpers/form_helper/form_builder.rb +++ b/lib/lotus/helpers/form_helper/form_builder.rb @@ -5,16 +5,73 @@ module Lotus module Helpers module FormHelper + # Form builder + # + # @since x.x.x + # + # @see Lotus::Helpers::HtmlHelper::HtmlBuilder class FormBuilder < ::Lotus::Helpers::HtmlHelper::HtmlBuilder + # Set of HTTP methods that are understood by web browsers + # + # @since x.x.x + # @api private BROWSER_METHODS = ['GET', 'POST'].freeze + # Checked attribute value + # + # @since x.x.x + # @api private + # + # @see Lotus::Helpers::FormHelper::FormBuilder#radio_button CHECKED = 'checked'.freeze + + # Selected attribute value for option + # + # @since x.x.x + # @api private + # + # @see Lotus::Helpers::FormHelper::FormBuilder#select SELECTED = 'selected'.freeze + + # Separator for accept attribute of file input + # + # @since x.x.x + # @api private + # + # @see Lotus::Helpers::FormHelper::FormBuilder#file_input ACCEPT_SEPARATOR = ','.freeze + + # Replacement for input id interpolation + # + # @since x.x.x + # @api private + # + # @see Lotus::Helpers::FormHelper::FormBuilder#_input_id + INPUT_ID_REPLACEMENT = '-\k'.freeze + + # Replacement for input value interpolation + # + # @since x.x.x + # @api private + # + # @see Lotus::Helpers::FormHelper::FormBuilder#_value + INPUT_VALUE_REPLACEMENT = '.\k'.freeze + # ENCTYPE_MULTIPART = 'multipart/form-data'.freeze self.html_node = ::Lotus::Helpers::FormHelper::HtmlNode + # Instantiate a form builder + # + # @param name [Symbol] the toplevel name of the form, it's used to generate + # input names, ids, and to lookup params to fill values. + # @param params [Lotus::Action::Params] the params of the request + # @param attributes [Hash] HTML attributes to pass to the form tag + # @param blk [Proc] A block that describes the contents of the form + # + # @return [Lotus::Helpers::FormHelper::FormBuilder] the form builder + # + # @since x.x.x def initialize(name, params, attributes = {}, &blk) super() @@ -24,10 +81,15 @@ def initialize(name, params, attributes = {}, &blk) @blk = blk end - def options - Hash[form_name: @name, params: @params, verb: @verb] - end - + # Resolves all the nodes and generates the markup + # + # @return [Lotus::Utils::Escape::SafeString] the output + # + # @since x.x.x + # @api private + # + # @see Lotus::Helpers::HtmlHelper::HtmlBuilder#to_s + # @see http://www.rubydoc.info/gems/lotus-utils/Lotus/Utils/Escape/SafeString def to_s if toplevel? _method_override! @@ -37,6 +99,64 @@ def to_s super end + # Nested fields + # + # The inputs generated by the wrapped block will be prefixed with the given name + # It supports 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. + # + # @since x.x.x + # + # @example Basic usage + # <%= + # form_for :delivery, routes.deliveries_path do + # text_field :customer_name + # + # fields_for :address do + # text_field :street + # end + # + # submit 'Create' + # end + # %> + # + # Output: + # #
+ # # + # # + # # + # # + # #
+ # + # @example Multiple levels of nesting + # <%= + # form_for :delivery, routes.deliveries_path do + # text_field :customer_name + # + # fields_for :address do + # text_field :street + # + # fields_for :location do + # text_field :city + # text_field :country + # end + # end + # + # submit 'Create' + # end + # %> + # + # Output: + # #
+ # # + # # + # # + # # + # # + # # + # #
def fields_for(name) current_name = @name @name = _input_name(name) @@ -45,37 +165,204 @@ def fields_for(name) @name = current_name end - def label(content, attributes = {}, &blk) + # Label tag + # + # The first param content can be a Symbol that represents + # the target field (Eg. :extended_title), or a String + # which is used as it is. + # + # @param content [Symbol,String] the field name or a content string + # @param attributes [Hash] HTML attributes to pass to the label tag + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # label :extended_title + # %> + # + # # Output: + # # + # + # @example Custom content + # <%= + # # ... + # label 'Title', for: :extended_title + # %> + # + # # Output: + # # + # + # @example Nested fields usage + # <%= + # # ... + # fields_for :address do + # label :city + # text_field :city + # end + # %> + # + # # Output: + # # + # # + def label(content, attributes = {}) attributes = { for: _for(content, attributes.delete(:for)) }.merge(attributes) content = Utils::String.new(content).titleize - super(content, attributes, &blk) + super(content, attributes) end + # Color input + # + # @param name [Symbol] the input name + # @param attributes [Hash] HTML attributes to pass to the input tag + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # color_field :background + # %> + # + # # Output: + # # def color_field(name, attributes = {}) input _attributes(:color, name, attributes) end + # Date input + # + # @param name [Symbol] the input name + # @param attributes [Hash] HTML attributes to pass to the input tag + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # date_field :birth_date + # %> + # + # # Output: + # # def date_field(name, attributes = {}) input _attributes(:date, name, attributes) end + # Datetime input + # + # @param name [Symbol] the input name + # @param attributes [Hash] HTML attributes to pass to the input tag + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # datetime_field :delivered_at + # %> + # + # # Output: + # # def datetime_field(name, attributes = {}) input _attributes(:datetime, name, attributes) end + # Datetime Local input + # + # @param name [Symbol] the input name + # @param attributes [Hash] HTML attributes to pass to the input tag + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # datetime_local_field :delivered_at + # %> + # + # # Output: + # # def datetime_local_field(name, attributes = {}) input _attributes(:'datetime-local', name, attributes) end + # Email input + # + # @param name [Symbol] the input name + # @param attributes [Hash] HTML attributes to pass to the input tag + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # email_field :email + # %> + # + # # Output: + # # def email_field(name, attributes = {}) input _attributes(:email, name, attributes) end + # Hidden input + # + # @param name [Symbol] the input name + # @param attributes [Hash] HTML attributes to pass to the input tag + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # hidden_field :customer_id + # %> + # + # # Output: + # # def hidden_field(name, attributes = {}) input _attributes(:hidden, name, attributes) end + # File input + # + # PLEASE REMEMBER TO ADD enctype: 'multipart/form-data' ATTRIBUTE TO THE FORM + # + # @param name [Symbol] the input name + # @param attributes [Hash] HTML attributes to pass to the input tag + # @option attributes [String,Array] :accept Optional set of accepted MIME Types + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # file_field :avatar + # %> + # + # # Output: + # # + # + # @example Accepted mime types + # <%= + # # ... + # file_field :resume, accept: 'application/pdf,application/ms-word' + # %> + # + # # Output: + # # + # + # @example Accepted mime types (as array) + # <%= + # # ... + # file_field :resume, accept: ['application/pdf', 'application/ms-word'] + # %> + # + # # Output: + # # def file_field(name, attributes = {}) attributes[:accept] = Array(attributes[:accept]).join(ACCEPT_SEPARATOR) if attributes.key?(:accept) attributes = { type: :file, name: _input_name(name), id: _input_id(name) }.merge(attributes) @@ -83,17 +370,115 @@ def file_field(name, attributes = {}) input(attributes) end + # Text input + # + # @param name [Symbol] the input name + # @param attributes [Hash] HTML attributes to pass to the input tag + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # text_field :first_name + # %> + # + # # Output: + # # def text_field(name, attributes = {}) input _attributes(:text, name, attributes) end alias_method :input_text, :text_field + # Radio input + # + # If request params have a value that corresponds to the given value, + # it automatically sets the checked attribute. + # This Lotus::Controller integration happens without any developer intervention. + # + # @param name [Symbol] the input name + # @param value [String] the input value + # @param attributes [Hash] HTML attributes to pass to the input tag + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # radio_button :category, 'Fiction' + # radio_button :category, 'Non-Fiction' + # %> + # + # # Output: + # # + # # + # + # @example Automatic checked value + # # Given the following params: + # # + # # book: { + # # category: 'Non-Fiction' + # # } + # + # <%= + # # ... + # radio_button :category, 'Fiction' + # radio_button :category, 'Non-Fiction' + # %> + # + # # Output: + # # + # # def radio_button(name, value, attributes = {}) attributes = { type: :radio, name: _input_name(name), value: value }.merge(attributes) attributes[:checked] = CHECKED if _value(name) == value input(attributes) end + # Select input + # + # @param name [Symbol] the input name + # @param values [Hash] a Hash to generate tags. + # Keys correspond to value and values correspond to the content. + # @param attributes [Hash] HTML attributes to pass to the input tag + # + # If request params have a value that corresponds to one of the given values, + # it automatically sets the selected attribute on the tag. + # This Lotus::Controller integration happens without any developer intervention. + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # values = Hash['it' => 'Italy', 'us' => 'United States'] + # select :stores, values + # %> + # + # # Output: + # # + # + # @example Automatic selected option + # # Given the following params: + # # + # # book: { + # # store: 'it' + # # } + # + # <%= + # # ... + # values = Hash['it' => 'Italy', 'us' => 'United States'] + # select :stores, values + # %> + # + # # Output: + # # def select(name, values, attributes = {}) options = attributes.delete(:options) || {} attributes = { name: _input_name(name), id: _input_id(name) }.merge(attributes) @@ -109,16 +494,48 @@ def select(name, values, attributes = {}) end end + # Submit button + # + # @param content [String] The content + # @param attributes [Hash] HTML attributes to pass to the button tag + # + # @since x.x.x + # + # @example Basic usage + # <%= + # # ... + # submit 'Create' + # %> + # + # # Output: + # # def submit(content, attributes = {}) attributes = { type: :submit }.merge(attributes) button(content, attributes) end + protected + # A set of options to pass to the sub form helpers. + # + # @api private + # @since x.x.x + def options + Hash[form_name: @name, params: @params, verb: @verb] + end + private + # Check the current builder is top-level + # + # @api private + # @since x.x.x def toplevel? @attributes.any? end + # Prepare for method override + # + # @api private + # @since x.x.x def _method_override! verb = (@attributes.fetch(:method) { DEFAULT_METHOD }).to_s.upcase @@ -130,24 +547,44 @@ def _method_override! end end + # Return a set of default HTML attributes + # + # @api private + # @since x.x.x def _attributes(type, name, attributes) { type: type, name: _input_name(name), id: _input_id(name), value: _value(name) }.merge(attributes) end + # Input name HTML attribute + # + # @api private + # @since x.x.x def _input_name(name) "#{ @name }[#{ name }]" end + # Input id HTML attribute + # + # @api private + # @since x.x.x def _input_id(name) - name = _input_name(name).gsub(/\[(?[[[:word:]]\-]*)\]/, '-\k') + name = _input_name(name).gsub(/\[(?[[[:word:]]\-]*)\]/, INPUT_ID_REPLACEMENT) Utils::String.new(name).dasherize end + # Input value HTML attribute + # + # @api private + # @since x.x.x def _value(name) - name = _input_name(name).gsub(/\[(?[[:word:]]*)\]/, '.\k') + name = _input_name(name).gsub(/\[(?[[:word:]]*)\]/, INPUT_VALUE_REPLACEMENT) @params.get(name) end + # Input for HTML attribute + # + # @api private + # @since x.x.x def _for(content, name) _input_id(name || content) end From c1ea318e18ac6dade5dc62aa8479d613c2dcc066 Mon Sep 17 00:00:00 2001 From: Luca Guidi Date: Mon, 23 Feb 2015 12:21:38 +0100 Subject: [PATCH 5/6] [ci skip] Done with API docs for form helper --- lib/lotus/helpers/form_helper/html_node.rb | 29 ++++++++++++++++++++++ lib/lotus/helpers/html_helper/html_node.rb | 1 + 2 files changed, 30 insertions(+) diff --git a/lib/lotus/helpers/form_helper/html_node.rb b/lib/lotus/helpers/form_helper/html_node.rb index 7f166fb..80a2e48 100644 --- a/lib/lotus/helpers/form_helper/html_node.rb +++ b/lib/lotus/helpers/form_helper/html_node.rb @@ -3,7 +3,24 @@ module Lotus module Helpers module FormHelper + # HTML form node + # + # @since x.x.x + # @api private + # + # @see Lotus::Helpers::HtmlHelper::HtmlNode class HtmlNode < ::Lotus::Helpers::HtmlHelper::HtmlNode + # Initialize a new HTML form node + # + # @param name [Symbol,String] the name of the tag + # @param content [String,Proc,Lotus::Helpers::HtmlHelper::FormBuilder,NilClass] the optional content + # @param attributes [Hash,NilClass] the optional tag attributes + # @param options [Hash] a set of data + # + # @return [Lotus::Helpers::FormHelper::HtmlNode] + # + # @since x.x.x + # @api private def initialize(name, content, attributes, options) super @builder = FormBuilder.new(options.fetch(:form_name), options.fetch(:params)) @@ -11,11 +28,23 @@ def initialize(name, content, attributes, options) end private + # Resolve the (nested) content + # + # @return [String] the content + # + # @since x.x.x + # @api private + # + # @see Lotus::Helpers::HtmlHelper::HtmlNode#content def content _method_override! super end + # Inject a hidden field to make Method Override possible + # + # @since x.x.x + # @api private def _method_override! return if @verb.nil? diff --git a/lib/lotus/helpers/html_helper/html_node.rb b/lib/lotus/helpers/html_helper/html_node.rb index c1d016f..cee2a69 100644 --- a/lib/lotus/helpers/html_helper/html_node.rb +++ b/lib/lotus/helpers/html_helper/html_node.rb @@ -15,6 +15,7 @@ class HtmlNode < EmptyHtmlNode # @param name [Symbol,String] the name of the tag # @param content [String,Proc,Lotus::Helpers::HtmlHelper::HtmlBuilder,NilClass] the optional content # @param attributes [Hash,NilClass] the optional tag attributes + # @param options [Hash] a optional set of data # # @return [Lotus::Helpers::HtmlHelper::HtmlNode] def initialize(name, content, attributes, options = {}) From 19df61b74e38d2101872a169426c48822d016562 Mon Sep 17 00:00:00 2001 From: Luca Guidi Date: Mon, 23 Feb 2015 17:25:37 +0100 Subject: [PATCH 6/6] Form helpers: add support for update resources --- lib/lotus/helpers/form_helper.rb | 92 ++++++++++++++++++- lib/lotus/helpers/form_helper/form_builder.rb | 46 ++++++++-- lib/lotus/helpers/form_helper/html_node.rb | 6 +- test/fixtures.rb | 45 ++++++++- .../templates/deliveries/_form.html.erb | 23 +++++ .../templates/deliveries/edit.html.erb | 1 + .../templates/deliveries/new.html.erb | 24 +---- test/integration/form_helper_test.rb | 47 +++++++--- 8 files changed, 235 insertions(+), 49 deletions(-) create mode 100644 test/fixtures/templates/deliveries/_form.html.erb create mode 100644 test/fixtures/templates/deliveries/edit.html.erb diff --git a/lib/lotus/helpers/form_helper.rb b/lib/lotus/helpers/form_helper.rb index e0ec69d..c2d9126 100644 --- a/lib/lotus/helpers/form_helper.rb +++ b/lib/lotus/helpers/form_helper.rb @@ -119,7 +119,7 @@ module FormHelper # # @example Method override # <%= - # form_for :book, routes.book_path(book.id), method: :put do + # form_for :book, routes.book_path(id: book.id), method: :put do # text_field :title # # submit 'Update' @@ -154,9 +154,95 @@ module FormHelper # # # # # # + # + # @example Form to create a new resource + # <%= + # form_for :book, routes.books_path do + # text_field :title + # + # submit 'Create' + # end + # %> + # + # Output: + # #
+ # # + # # + # # + # #
+ # + # @example Form to update an existing resource + # <%= + # form_for { book: book }, routes.book_path(id: book.id) do + # text_field :title + # + # submit 'Update' + # end + # %> + # + # Output: + # #
+ # # + # # + # # + # # + # #
+ # + # @example Share markup betweek new and update forms + # module Deliveries + # class New + # include Lotus::View + # include Lotus::Helpers + # + # def form_values + # :delivery + # end + # + # def form_action + # routes.deliveries_path + # end + # end + # + # class Edit + # include Lotus::View + # include Lotus::Helpers + # + # def form_values + # { delivery: delivery } + # end + # + # def form_action + # routes.delivery_path(id: delivery.id) + # end + # end + # end + # + # # deliveries/_form.html.erb + # <%= + # form_for form_values, form_action do + # text_field :title + # + # submit update? ? 'Update' : 'Create' + # end + # %> + # + # # deliveries/new.html.erb + # <%= render partial: 'deliveries/form' %> + # + # # deliveries/edit.html.erb + # <%= render partial: 'deliveries/form' %> def form_for(name, url, attributes = {}, &blk) - attributes = { action: url, id: "#{ name }-form", method: DEFAULT_METHOD }.merge(attributes) - FormBuilder.new(name, params, attributes, &blk) + name, values = case name + when Hash + [name.first.first, name] + else + [name, {}] + end + + verb = :patch if values.any? + attributes = { action: url, id: "#{ name }-form", method: verb || DEFAULT_METHOD }.merge(attributes) + + FormBuilder.new(name, params, Values.new(values), attributes, &blk) end end end diff --git a/lib/lotus/helpers/form_helper/form_builder.rb b/lib/lotus/helpers/form_helper/form_builder.rb index 95743f6..7d8dc73 100644 --- a/lib/lotus/helpers/form_helper/form_builder.rb +++ b/lib/lotus/helpers/form_helper/form_builder.rb @@ -5,6 +5,34 @@ module Lotus module Helpers module FormHelper + # FIXME Don't inherit from OpenStruct + # TODO unify values with params + require 'ostruct' + class Values < OpenStruct + GET_SEPARATOR = '.'.freeze + + def get(key) + key, *keys = key.to_s.split(GET_SEPARATOR) + result = self[key] + + Array(keys).each do |k| + break if result.nil? + + result = if result.respond_to?(k) + result.public_send(k) + else + nil + end + end + + result + end + + def update? + to_h.keys.count > 0 + end + end + # Form builder # # @since x.x.x @@ -23,7 +51,7 @@ class FormBuilder < ::Lotus::Helpers::HtmlHelper::HtmlBuilder # @api private # # @see Lotus::Helpers::FormHelper::FormBuilder#radio_button - CHECKED = 'checked'.freeze + CHECKED = 'checked'.freeze # Selected attribute value for option # @@ -31,7 +59,7 @@ class FormBuilder < ::Lotus::Helpers::HtmlHelper::HtmlBuilder # @api private # # @see Lotus::Helpers::FormHelper::FormBuilder#select - SELECTED = 'selected'.freeze + SELECTED = 'selected'.freeze # Separator for accept attribute of file input # @@ -39,7 +67,7 @@ class FormBuilder < ::Lotus::Helpers::HtmlHelper::HtmlBuilder # @api private # # @see Lotus::Helpers::FormHelper::FormBuilder#file_input - ACCEPT_SEPARATOR = ','.freeze + ACCEPT_SEPARATOR = ','.freeze # Replacement for input id interpolation # @@ -66,17 +94,19 @@ class FormBuilder < ::Lotus::Helpers::HtmlHelper::HtmlBuilder # @param name [Symbol] the toplevel name of the form, it's used to generate # input names, ids, and to lookup params to fill values. # @param params [Lotus::Action::Params] the params of the request + # @param values [Hash] A set of values # @param attributes [Hash] HTML attributes to pass to the form tag # @param blk [Proc] A block that describes the contents of the form # # @return [Lotus::Helpers::FormHelper::FormBuilder] the form builder # # @since x.x.x - def initialize(name, params, attributes = {}, &blk) + def initialize(name, params, values, attributes = {}, &blk) super() @name = name @params = params + @values = values @attributes = attributes @blk = blk end @@ -515,12 +545,16 @@ def submit(content, attributes = {}) end protected + def update? + @values.update? + end + # A set of options to pass to the sub form helpers. # # @api private # @since x.x.x def options - Hash[form_name: @name, params: @params, verb: @verb] + Hash[form_name: @name, params: @params, values: @values, verb: @verb] end private @@ -578,7 +612,7 @@ def _input_id(name) # @since x.x.x def _value(name) name = _input_name(name).gsub(/\[(?[[:word:]]*)\]/, INPUT_VALUE_REPLACEMENT) - @params.get(name) + @values.get(name) || @params.get(name) end # Input for HTML attribute diff --git a/lib/lotus/helpers/form_helper/html_node.rb b/lib/lotus/helpers/form_helper/html_node.rb index 80a2e48..972120b 100644 --- a/lib/lotus/helpers/form_helper/html_node.rb +++ b/lib/lotus/helpers/form_helper/html_node.rb @@ -23,7 +23,11 @@ class HtmlNode < ::Lotus::Helpers::HtmlHelper::HtmlNode # @api private def initialize(name, content, attributes, options) super - @builder = FormBuilder.new(options.fetch(:form_name), options.fetch(:params)) + @builder = FormBuilder.new( + options.fetch(:form_name), + options.fetch(:params), + options.fetch(:values) + ) @verb = options.fetch(:verb, nil) end diff --git a/test/fixtures.rb b/test/fixtures.rb index f0ea8bb..687948e 100644 --- a/test/fixtures.rb +++ b/test/fixtures.rb @@ -205,6 +205,24 @@ def initialize(params) end end +class Address + attr_reader :street + + def initialize(attributes = {}) + @street = attributes[:street] + end +end + +class Delivery + attr_reader :id, :customer_id, :address + + def initialize(attributes = {}) + @id = attributes[:id] + @customer_id = attributes[:customer_id] + @address = attributes[:address] + end +end + class DeliveryParams < Lotus::Action::Params param :delivery do param :customer_id, type: Integer, presence: true @@ -220,9 +238,13 @@ def self.path(name) "/#{ name }" end - def self.deliveries + def self.deliveries_path '/deliveries' end + + def self.delivery_path(attrs = {}) + "/deliveries/#{ attrs.fetch(:id) }" + end end module Views @@ -242,6 +264,27 @@ module Deliveries class New include TestView template 'deliveries/new' + + def form_values + :delivery + end + + def form_action + routes.deliveries_path + end + end + + class Edit + include TestView + template 'deliveries/edit' + + def form_values + Hash[delivery: delivery] + end + + def form_action + routes.delivery_path(id: delivery.id) + end end end end diff --git a/test/fixtures/templates/deliveries/_form.html.erb b/test/fixtures/templates/deliveries/_form.html.erb new file mode 100644 index 0000000..496c6da --- /dev/null +++ b/test/fixtures/templates/deliveries/_form.html.erb @@ -0,0 +1,23 @@ +<%= + form_for(form_values, form_action, class: 'form-horizontal') do + div class: 'form-group' do + label :customer + input_text :customer, class: 'form-control', placeholder: 'Customer', name: nil + + hidden_field :customer_id + end + + fieldset do + legend 'Address' + + fields_for :address do + div class: 'form-group' do + label :street + input_text :street, class: 'form-control', placeholder: 'Street' + end + end + end + + submit update? ? 'Update' : 'Create', class: 'btn btn-default' + end +%> diff --git a/test/fixtures/templates/deliveries/edit.html.erb b/test/fixtures/templates/deliveries/edit.html.erb new file mode 100644 index 0000000..c6f94b7 --- /dev/null +++ b/test/fixtures/templates/deliveries/edit.html.erb @@ -0,0 +1 @@ +<%= render partial: 'deliveries/form' %> diff --git a/test/fixtures/templates/deliveries/new.html.erb b/test/fixtures/templates/deliveries/new.html.erb index 176f242..c6f94b7 100644 --- a/test/fixtures/templates/deliveries/new.html.erb +++ b/test/fixtures/templates/deliveries/new.html.erb @@ -1,23 +1 @@ -<%= - form_for(:delivery, routes.deliveries, class: 'form-horizontal') do - div class: 'form-group' do - label :customer - input_text :customer, class: 'form-control', placeholder: 'Customer', name: nil - - hidden_field :customer_id - end - - fieldset do - legend 'Address' - - fields_for :address do - div class: 'form-group' do - label :street - input_text :street, class: 'form-control', placeholder: 'Street' - end - end - end - - submit 'Create', class: 'btn btn-default' - end -%> +<%= render partial: 'deliveries/form' %> diff --git a/test/integration/form_helper_test.rb b/test/integration/form_helper_test.rb index 0fce0b3..959813a 100644 --- a/test/integration/form_helper_test.rb +++ b/test/integration/form_helper_test.rb @@ -1,27 +1,44 @@ require 'test_helper' describe 'Form helper' do - describe 'first page load' do - before do - @params = DeliveryParams.new({}) - @actual = FullStack::Views::Deliveries::New.render(format: :html, params: @params) - end + describe 'form to create a new resource' do + describe 'first page load' do + before do + @params = DeliveryParams.new({}) + @actual = FullStack::Views::Deliveries::New.render(format: :html, params: @params) + end - it 'renders the form' do - @actual.must_include %(
\n
\n\n\n\n
\n
\nAddress\n
\n\n\n
\n
\n\n
) + it 'renders the form' do + @actual.must_include %(
\n
\n\n\n\n
\n
\nAddress\n
\n\n\n
\n
\n\n
) + end end - end - describe 'after a failed form submission' do - before do - @params = DeliveryParams.new({ delivery: { address: { street: '5th Ave' }}}) - @params.valid? # trigger validations + describe 'after a failed form submission' do + before do + @params = DeliveryParams.new({ delivery: { address: { street: '5th Ave' }}}) + @params.valid? # trigger validations + + @actual = FullStack::Views::Deliveries::New.render(format: :html, params: @params) + end - @actual = FullStack::Views::Deliveries::New.render(format: :html, params: @params) + it 'renders the form with previous values' do + @actual.must_include %(
\n
\n\n\n\n
\n
\nAddress\n
\n\n\n
\n
\n\n
) + end end + end + + describe 'form to update a resource' do + describe 'first page load' do + before do + @address = Address.new(street: '5th Ave') + @delivery = Delivery.new(id: 1, customer_id: 23, address: @address) + @params = DeliveryParams.new({}) + @actual = FullStack::Views::Deliveries::Edit.render(format: :html, delivery: @delivery, params: @params) + end - it 'renders the form with previous values' do - @actual.must_include %(
\n
\n\n\n\n
\n
\nAddress\n
\n\n\n
\n
\n\n
) + it 'renders the form' do + @actual.must_include %(
\n\n
\n\n\n\n
\n
\nAddress\n
\n\n\n
\n
\n\n
) + end end end end