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..c2d9126 --- /dev/null +++ b/lib/lotus/helpers/form_helper.rb @@ -0,0 +1,249 @@ +require 'lotus/helpers/form_helper/form_builder' + +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(id: 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: + # #
+ # # + # # + # # + # # + # #
+ # + # @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) + 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 +end 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..7d8dc73 --- /dev/null +++ b/lib/lotus/helpers/form_helper/form_builder.rb @@ -0,0 +1,629 @@ +require 'lotus/helpers/form_helper/html_node' +require 'lotus/helpers/html_helper/html_builder' +require 'lotus/utils/string' + +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 + # + # @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 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, values, attributes = {}, &blk) + super() + + @name = name + @params = params + @values = values + @attributes = attributes + @blk = blk + 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! + form(@blk, @attributes) + end + + 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) + yield + ensure + @name = current_name + end + + # 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) + 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) + + 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) + + 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 + + # 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 + 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, values: @values, 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 + + if BROWSER_METHODS.include?(verb) + @attributes[:method] = verb + else + @attributes[:method] = DEFAULT_METHOD + @verb = verb + 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:]]\-]*)\]/, 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:]]*)\]/, INPUT_VALUE_REPLACEMENT) + @values.get(name) || @params.get(name) + end + + # Input for HTML attribute + # + # @api private + # @since x.x.x + 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..972120b --- /dev/null +++ b/lib/lotus/helpers/form_helper/html_node.rb @@ -0,0 +1,63 @@ +require 'lotus/helpers/html_helper/html_node' + +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), + options.fetch(:values) + ) + @verb = options.fetch(:verb, nil) + 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? + + verb = @verb + @builder.resolve do + input(type: :hidden, name: :_method, value: verb) + end + end + 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..cee2a69 100644 --- a/lib/lotus/helpers/html_helper/html_node.rb +++ b/lib/lotus/helpers/html_helper/html_node.rb @@ -15,9 +15,10 @@ 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) + 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..687948e 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,55 @@ 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 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 + 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_path + '/deliveries' + end + + def self.delivery_path(attrs = {}) + "/deliveries/#{ attrs.fetch(:id) }" + end end module Views @@ -214,6 +259,34 @@ def routing_helper_path end end end + + 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 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/_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 new file mode 100644 index 0000000..c6f94b7 --- /dev/null +++ b/test/fixtures/templates/deliveries/new.html.erb @@ -0,0 +1 @@ +<%= render partial: 'deliveries/form' %> 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..959813a --- /dev/null +++ b/test/integration/form_helper_test.rb @@ -0,0 +1,44 @@ +require 'test_helper' + +describe 'Form helper' do + 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
) + 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 + + 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' do + @actual.must_include %(
\n\n
\n\n\n\n
\n
\nAddress\n
\n\n\n
\n
\n\n
) + end + end + end +end