require 'kiss/form/field'; # Part Hash, part Array... it's a Hashay. class Hashay < Hash _attr_accessor :keys, :values def initialize(*args, &block) super @_keys = [] @_values = [] end def each_key(&block) @_keys.each &block end def each_value(&block) @_values.each &block end alias_method :each, :each_value def []=(key, value) unless has_key?(key) @_keys << key @_values << value end super end def [](key) key.is_a?(Numeric) ? @_values[key] : super end include Enumerable end class Kiss class Form _attr_accessor :fields, :params, :submitted, :has_field_errors, :has_required_fields, :delegate, :controller, :components, :form, :new_object_index dsl_accessor :name, :url, :action, :method, :enctype, :errors, :cancel, :mark_required, :id, :class, :style, :html, :error_class, :field_error_class, :objects_save_order, :object, :prepend_html, :append_html, :year, :timezone @@component_types = { :text => TextField, :hidden => HiddenField, :textarea => TextAreaField, :password => PasswordField, :boolean => BooleanField, :file => FileField, :select => SelectField, :radio => RadioField, :checkbox => CheckboxField, :multiselect => MultiSelectField, :submit => SubmitField } # Create DSL methods for component types self.class_eval( @@component_types.keys.map do |type| "def #{type}(*args, &block); add_component(:#{type}, *args, &block); end; " end.join ) def add_component(type, name, *args, &block) attrs = args.to_attrs add_field({ :type => type, :name => name }.merge(attrs), &block) end def debug(*args) @_delegate.request.debug(args.first, Kernel.caller[0]) end def method_missing(method, *args, &block) delegate.send method, *args, &block end def unique(*val) if val.empty? @_unique else @_unique << val end end # Creates a new form object with specified attributes. def initialize(*args, &block) @_attrs = args.to_attrs _instance_variables_set_from_attrs(@_attrs) @_method ||= 'post' @_components = [] @_fields = Hashay.new @_object_fields = {} @_default_values ||= {} @_params = {} @_with = nil @_field_name_prefix = '' @_objects_add_order = [] @_objects_save_order = [] @_new_object_index = -1 @_object_fields = {} @_unique = [] @_prepend_html = '' @_append_html = '' (@_attrs[:fields] || []).each do |field| # create field here add_field(field) end @_errors = [] @_field_errors = {} import_instance_variables(@_delegate) if @_delegate instance_eval(&block) if block_given? raise "form name required" unless @_name raise "form delegate required" unless @_delegate end def context @_context ||= begin @_timezone ||= (fields['timezone'] && (params['timezone'] || (object && object[:timezone]))) || nil { :timezone => @_timezone, :year => @_year } end end # Creates and adds a field to the form, according to specified attributes. def add_field(attrs = {}, &block) attrs = @_with.merge(attrs) if @_with name = attrs[:name].to_s key = (attrs[:key] || name).to_sym name = @_field_name_prefix + name unless @_field_name_prefix.empty? type = attrs[:type] ? attrs[:type].to_sym : :text raise "invalid field type '#{type}'" unless @@component_types.has_key?(type) field = @@component_types[type].new(self, attrs.merge( :name => name, :key => key, :type => type ), &block) field.object = @_object if @_object && !field.object obj = field.object # must hash @_object_fields by Ruby object id; if by object, weird lookup errors result, # even when lookup object has same Ruby object id as the hash key object! ruby_obj_id = obj.object_id unless @_object_fields[ruby_obj_id] @_object_fields[ruby_obj_id] = {} @_objects_add_order << object end if @_object_fields[ruby_obj_id][field.name] raise "duplicate form field name '#{attrs[:name]}'#{" on #{obj.class.name} object" if obj}" end @_object_fields[ruby_obj_id][field.name] = field @_fields[name] = field @_components << field while true other_field = field.other_field break unless other_field other_field.form = self @_other_field = @_form.create_field( { :name => @_name + '.other' }.merge(@_other) ) end @_enctype = 'multipart/form-data' if field.type == :file field end alias_method :create_field, :add_field # Creates and adds set of submit buttons to the form, per specified attributes. def submit(*args, &block) if args.size > 0 raise 'submit already defined' if @_submit attrs = { :type => :submit, :name => 'submit', :save => false, :cancel => 'Cancel' } attrs.merge!(args.pop) if args.last.is_a?(Hash) attrs[:options] = args if args.size > 0 attrs = @_with.merge(attrs) if @_with @_submit = @@component_types[:submit].new(self, attrs, &block) else @_submit end end alias_method :add_submit, :submit def reset @_params = {} @_fields.each {|field| field.reset } end def field(name) @_fields[name] end def object_field(obj, name) @_object_fields[obj.object_id][name.to_s] end # Gets hash of form values. def values @_values ||= begin hash = {} @_fields.each {|field| hash[field.name] = field.value } hash['submit'] = @_submit.value = params[@_submit.name.to_s] hash end end # Add error message to be rendered with form. # If multiple args given, first arg is field name, and second arg (error message) # will render next to specified field. def add_error(*args) case args.size when 0 raise 'at least one argument required' when 1 arg = args.first if arg.is_a?(Hash) field = arg.field || arg.field_name message = arg.message else @_errors << arg return end when 2 field, message = args else raise 'too many arguments' end field = @_fields[field] if field.is_a?(String) field.add_error(message) end # Validates form values against fields' format and required attributes, # and returns values unless form has errors. def validate return nil unless submitted @_fields.each {|field| field.validate } @_unique.each {|field_set| validate_uniqueness_of(field_set) } has_errors? ? nil : values end def validate_uniqueness_of(column_set) column_set_labels_values = column_set.map do |column_name| (field = fields[column_name.to_s]) ? [field.label, field.value] : [column_name.to_s.sub(/_id\Z/, '').titlecase, object[column_name]] end conditions = column_set.zip column_set_labels_values.map {|lv| lv[1] } dataset = @_object.model.filter(conditions) unless (@_object.new? ? dataset : dataset.exclude(@_object.pk_hash)).empty? labels = column_set_labels_values.map {|lv| lv[0] } message = "There is already another #{@_object.model.name.singularize.gsub('_', ' ')} with the same #{labels.conjoin}." return ( (column_set.length == 1) && (field_name = column_set[0].to_str) && fields[field_name] ) ? add_error(field_name, message) : add_error(message) end end # Checks whether form was submitted and accepted by user and, if so, # whether form validates. # If form validates, saves form values to form's Sequel::Model object # and returns true. Otherwise, returns false. def process(*objs) return false unless submitted if accepted validate return false if has_errors? save(*objs) end return true end def process_or_render self.render unless self.process end private def extract_objects_from_args(objs) objs = args.first if objs.first.is_a?(Array) if objs.size == 0 objects_save_order.push(@_object) if @_object && !objects_save_order.include?(@_object) objs = objects_save_order end objs end public def set_object_data(obj = @_object) @_object_fields[obj.object_id].values.each do |field| # ignore fields whose name starts with underscore next if field.name =~ /\A\_/ # don't save 'ignore' fields to the database next if field.ignore || !field.save # ignore file fields next if field.type == :file key = field.key.to_sym value = (field.value != nil || obj.class.db_schema[key].allow_null) ? field.value : (obj.class.db_schema[key][:default] ||= field.format.default) if field.digest value = Digest.const_get(field.digest.to_sym).hexdigest(value) end obj[key] = value if field.save end end def set_objects_data(*args) extract_objects_from_args(args).each do |obj| next unless obj set_object_data(obj) end end # Saves form input data associated with specified objects, or # all form objects if objects are not specified here. def save(*args) db.transaction do extract_objects_from_args(args).each do |obj| set_object_data(obj) obj.save end end end def require_values(*names) names.each {|name| fields[name.to_s].require_value } end def with(*args, &block) if block_given? attrs = args.to_attrs old_with = @_with @_with = (@_with || {}).merge(attrs) instance_eval(&block) @_with = old_with end end def object(obj = nil, options = {}, &block) if block_given? raise 'missing object arg' unless obj if obj.is_a?(Symbol) parent = @_object raise 'missing parent object for object symbol reference' unless parent obj = parent.send(obj) #|| #parent.set_associated_object(parent.class.association_reflection(object).associated_class.new) end raise 'object parameter must reference a model object' unless obj.is_a?(Kiss::Model) # if new, save early to give object a primary key, needed to save assoc children @_objects_save_order << obj if obj.new? prefix = options[:prefix] old_obj = @_object old_prefix = @_field_name_prefix @_object = obj @_field_name_prefix = (@_field_name_prefix + prefix.to_s + '.') if prefix instance_eval(&block) @_object = old_obj @_field_name_prefix = old_prefix # save again to pick up any assoc ids from children @_objects_save_order << obj obj elsif obj @_object = obj else @_object end end # Returns true if form has errors. def has_errors? (@_errors.size > 0 || @_has_field_errors) end # Returns true if user submitted form with non-cancel submit button # (non-nil submit param, not equal to cancel submit button value). def accepted raise 'form missing submit field' unless @_submit return params[@_submit.name] != @_submit.cancel end # Renders error HTML block for top of form, and returns as string. def errors_html return nil unless has_errors? @_errors << "Please correct the #{@_errors.empty? ? '' : 'other '}errors highlighted below." if @_has_field_errors if @_errors.size == 1 content = @_errors[0] else content = "<ul>" + @_errors.map {|e| "<li>#{e}</li>"}.join + "</ul>" end @_errors.pop if @_has_field_errors plural = @_errors.size > 1 || @_has_field_errors ? 's' : nil %Q(<table class="kiss_error"><tr><td>#{content}</td></tr></table>) end # Renders current action using form's HTML as action render content. def render(options = {}) @_delegate.render options.merge(:content => html) end def form_name_hidden_tag_html %Q(<input type=hidden name="form" value="#{@_name}">) end # Renders beginning of form (form open tag and form/field/error styles). def html_open @_error_class ||= 'kiss_form_error_message' @_field_error_class ||= @_error_class # form tag form_attrs = ['id', 'method', 'enctype', 'class', 'style'].map do |attr| next if (value = send attr).blank? "#{attr}=\"#{send attr}\"" end if @_html @_html.each_pair do |k, v| form_attrs.push("#{k}=\"#{v}\"") end end form_tag = %Q(<form action="#{@_action}" #{form_attrs.join(' ')}>#{form_name_hidden_tag_html}) # style tag styles = [] styles.push( <<-EOT table.kiss_form { margin-bottom: 6px; } .kiss_form td { padding: 2px 4px; vertical-align: middle; } .kiss_form .kiss_error { margin-bottom: 4px; } .kiss_form .kiss_error td { background-color: #ff8; padding: 2px 4px; line-height: 135%; color: #900; border: 1px solid #ea4; } .kiss_form .kiss_error td ul { padding-left: 16px; margin: 0; } .kiss_form .kiss_help { padding: 0px 3px 0 6px; font-size: 90%; color: #555; } .kiss_required { color: #a21; font-size: 150%; line-height: 50%; position: relative; top: 4px; } .kiss_form td.kiss_label { text-align: right; white-space: nowrap; } .kiss_form td.kiss_label.error { color: #b10; } .kiss_form tr.kiss_prompt td { padding: 8px 3px 0px 4px; } .kiss_form tr.kiss_submit td.kiss_submit { padding: 6px 3px; } .kiss_form input[type="text"], .kiss_form input[type=password], .kiss_form textarea { width: 250px; margin-left: 0; margin-right: 0; } .kiss_form table.kiss_field_columns { border-spacing: 0; border-collapse: collapse; width: 100%; } .kiss_form table.kiss_field_columns td { padding: 0 16px 2px 0; vertical-align: top; } .kiss_form .kiss_checkbox .kiss_label { vertical-align: top; padding-top: 3px; } .kiss_form .kiss_radio .kiss_label { vertical-align: top; padding-top: 4px; } .kiss_form .kiss_textarea .kiss_label { vertical-align: top; padding-top: 3px; } EOT ) styles.push( <<-EOT .kiss_form_error_message { padding: 1px 4px; border: 1px solid #edc; border-top: 0; border-bottom: 1px solid #db4; background-color: #ff8; color: #900; font-size: 90%; line-height: 135%; display: block; float: left; margin-bottom: 2px; } .kiss_form_error_message div { color: #c00; font-weight: bold; } .kiss_form_error_message ul { padding-left: 16px; margin: 0; } tr.kiss_form_error_row td { padding-top: 0; } tr.kiss_form_error_row .kiss_form_error_message { position: relative; top: -2px; margin-bottom: 0; } EOT ) if @_error_class == 'kiss_form_error_message' style_tag = styles.size == 0 ? '' : "<style>" + styles.join('') + "</style>" # combine return %Q(#{@_prepend_html}#{form_tag}#{style_tag}<div class="kiss_form">#{errors_html}) end # Renders end of form (form close tag). def html_close "</div></form>#{@_append_html}" end # Renders form fields HTML. def components_html @_components.map do |component| component_html(component) end.join end alias_method :fields_html, :components_html # Renders open of form table. def table_html_open %Q(<table class="kiss_form" border=0 cellspacing=0>) end def required_legend_html (@_has_required_fields && @_mark_required) ? %Q( <tr><td></td><td class="kiss_help">Required fields marked by <span class="kiss_required">*</span></td></tr> ) : '' end # Renders close of form table. def table_html_close '</table>' end # Renders form submit buttons. def submit_html @_submit ? field_html(@_submit) : '' end # Renders complete form HTML. def html return [ html_open, table_html_open, required_legend_html, components_html, submit_html, table_html_close, html_close ].flatten.join end # Renders HTML for specified form field. def component_html(field) field = fields[field.to_s] if (field.is_a?(Symbol) || field.is_a?(String)) return field.element_html if field.type == :hidden type = field.type prompt = field.prompt label = field.label errors = field.errors_html required = field.required ? %Q(<span class="kiss_required">#{@_mark_required}</span> ) : '' ([ prompt ? %Q(<tr class="kiss_prompt"><td class="kiss_label">#{required}</td><td>#{prompt.to_s}</td></tr>) : '', %Q(<tr class="kiss_#{type}"><td class="kiss_label#{errors ? ' error' : ''}">), !prompt ? (required + (label.blank? ? '' : label.to_s + ':' )) : '', %Q(</td><td class="kiss_#{type}">), field.element_html, "</td></tr>" ] + (errors ? [ '<tr class="kiss_form_error_row"><td class="kiss_required"></td><td>', errors, '</td></tr>' ] : [])).join end alias_method :field_html, :component_html end end