archivesspace/archivesspace

View on GitHub
frontend/app/helpers/aspace_form_helper.rb

Summary

Maintainability
F
1 wk
Test Coverage
require 'mixed_content_parser'

module AspaceFormHelper

  COMBOBOX_MIN_LIMIT = 50 # if a <select> has equal or more options than this value, output a combobox

  class FormHelpers
    include ActionView::Helpers::TagHelper
    include ActionView::Helpers::TextHelper
    include ActionView::Helpers::FormTagHelper
    include ActionView::Helpers::FormOptionsHelper
  end

  class FormContext

    def initialize(name, values_from, parent)
      values = values_from.is_a?(JSONModelType) ? values_from.to_hash(:raw) : values_from

      @forms = FormHelpers.new
      @parent = parent
      @context = [[name, values]]
    end


    def h(str)
      ERB::Util.html_escape(str)
    end


    def clean_mixed_content(content, root_url)
      content = content.to_s
      return content if content.blank?

      MixedContentParser::parse(content, root_url, { :wrap_blocks => false } ).to_s.html_safe
    end


    def readonly?
      false
    end


    def set_index(template, idx)
      template.gsub(/\[\]$/, "[#{idx}]")
    end

    # ANW-617:
    # TODO: Ideally, this method should generate a full URL, with the value from AppConfig[:public_url], so the link is fully actionable.
    # Ran into a a bug (with AS? or deeper?) where the value of AppConfig[:public_url] was being changed at runtime simply by getting it's value, with code like base_url = AppConfig[:public_url].
    # For now, this method generates a relative URL, like '\resources\Resource A' to avoid this.
    def slug_url_field(name, repo_slug = nil, generate_url_with_repo_slug = nil)
      url = ""
      html = ""

      case obj['jsonmodel_type']
      when 'resource'
        scope = :repo
        route = "resources"
      when 'accession'
        scope = :repo
        route = "accessions"
      when 'classification'
        scope = :repo
        route = "classifications"
      when 'classification_term'
        scope = :repo
        route = "classification_terms"
      when 'digital_object'
        scope = :repo
        route = "digital_objects"
      when 'repository'
        scope = :global
        route = "repositories"
      when 'agent_person'
        scope = :global
        route = "agents"
      when 'agent_family'
        scope = :global
        route = "agents"
      when 'agent_software'
        scope = :global
        route = "agents"
      when 'agent_corporate_entity'
        scope = :global
        route = "agents"
      when 'subject'
        scope = :global
        route = "subjects"
      when 'archival_object'
        scope = :repo
        route = "archival_objects"
      when 'digital_object_component'
        scope = :repo
        route = "digital_object_components"
      end

      # For repo scoped objects,
      # if we have access to the repo slug in the session and the repo scoped URLs are enabled
      # generate link with repo slug
      if !obj['slug'].nil? &&
         !obj['slug'].empty? &&
         AppConfig[:use_human_readable_urls]

        if scope == :repo
          if generate_url_with_repo_slug && repo_slug
            url << "/" + "repositories" + "/"
            url << repo_slug
          end
        end

        url << "/" + route + "/" + obj['slug']
      else
        url = obj['uri']
      end

      url.to_s
    end

    # renders a list of form element sets from a template. Each item will be re-orderable.
    # Objects should be an array.
    def list_for(objects, context_name, &block)
      objects ||= []
      result = ""

      objects.each_with_index do |object, idx|
        push(set_index(context_name, idx), object) do
          result << "<li id=\"#{current_id}\" class=\"subrecord-form-wrapper\" data-index=\"#{idx}\" data-object-name=\"#{context_name.gsub(/\[\]/, "").singularize}\">"
          result << hidden_input("lock_version") if obj.respond_to?(:has_key?) && obj.has_key?("lock_version")
          result << @parent.capture(object, idx, &block)
          result << "</li>"
        end
      end

      ("<ul data-name-path=\"#{set_index(self.path(context_name), '${index}')}\" " +
       " data-id-path=\"#{id_for(set_index(self.path(context_name), '${index}'), false)}\" " +
       " class=\"subrecord-form-list\">#{result}</ul>").html_safe
    end


    # renders a single template containing form elements.
    def fields_for(object, context_name, &block)
      result = ""

      push(context_name, object) do
        result << hidden_input("lock_version", object["lock_version"]) if object
        result << @parent.capture(object, &block)
      end

      extra_classes = ""

      # ANW-429: Add class to top level div so element can be switched out by JS based on user form input
      # TODO: refactor
      extra_classes += "sdl-subrecord-form" if context_name == "structured_date_range" || context_name == "structured_date_single"


      ("<div data-name-path=\"#{set_index(self.path(context_name), '${index}')}\" " +
        " data-id-path=\"#{id_for(set_index(self.path(context_name), '${index}'), false)}\" " +
        " class=\"subrecord-form-fields-for #{extra_classes}\">#{result}</div>").html_safe
    end


    def form_top
      @context[0].first
    end


    def id
      "form_#{form_top}"
    end


    # Turn a name like my[nested][object][0][title] into the equivalent JSON
    # path (my/nested/object/0/title)
    def name_to_json_path(name)
      name.gsub(/[\[\]]+/, "/").gsub(/\/+$/, "").gsub(/^\/+/, "")
    end


    def path(name = nil)
      names = @context.map(&:first)
      tail = names.drop(1)
      tail += [name] if name

      path = tail.map {|e|
        if e =~ /(.*?)\[([0-9]+)?\]$/
          "[#{$1}][#{$2}]"
        else
          "[#{e}]"
        end
      }.join("")

      "#{names.first}#{path}"
    end


    def help_path_for(name)
      names = @context.map(&:first)
      return "#{names[-1].to_s.gsub(/\[.*\]/, "").singularize}_#{name}" if names.length > 0
      name
    end


    def parent_context
      form_top
    end


    def current_context
      @context.last
    end


    def obj
      @context.last.second
    end


    def [](key)
      obj[key]
    end


    def push(name, values_from = {})
      path(name) # populate the i18n mapping
      @context.push([name, values_from])
      yield(self)
      @context.pop
    end


    # ignore_form_context will return a translation divorced from the active template
    # or form in which it appears.
    def i18n_for(name, ignore_form_context = false)
      if ignore_form_context
        "#{name.to_s.gsub(/\[\]$/, "")}"
      else
        "#{@active_template or form_top}.#{name.to_s.gsub(/\[\]$/, "")}"
      end
    end


    def exceptions_for_js(exceptions)
      result = {}
      [:errors, :warnings].each do |condition|
        if exceptions[condition]
          result[condition] = exceptions[condition].keys.map {|property|
            id_for_javascript(property)
          }
        end
      end

      result.to_json.html_safe
    end


    def id_for_javascript(name)
      path = name.split("/").collect {|a| "[#{a}]"}.join
      "#{form_top}#{path}".gsub(/[\[\]\/]/, "_")
    end


    def current_id
      path(nil).gsub(/[\[\]]/, '_')
    end

    def id_for(name, qualify = true)
      name = path(name) if qualify

      name.gsub(/[\[\]]/, '_')
    end

    def label_and_textfield(name, opts = {})
      label_with_field(name, textfield(name, obj[name], opts[:field_opts] || {}), opts)
    end

    def label_and_date(name, opts = {})
      field_opts = (opts[:field_opts] || {}).merge({
          :class => "date-field form-control",
          :"data-format" => "yyyy-mm-dd",
          :"data-date" => Date.today.strftime('%Y-%m-%d'),
          :"data-autoclose" => true,
          :"data-force-parse" => false,
          :"data-label" => I18n.t("actions.date_picker_toggle")
      })

      if obj[name].blank? && opts[:default]
        value = opts[:default]
      else
        value = obj[name]
      end

      opts[:col_size] = 4

      date_input = textfield(name, value, field_opts)

      label_with_field(name, date_input, opts)
    end

    def label_and_disabled_checkbox(name)
      html = ""

      html << "<div class='form-group'>"

      html << "<label class='col-sm-2 control-label'>#{name}</label>"
      html << "<div class='col-sm-1'>"
      html << "<input type='checkbox' name='disabled' disabled>"
      html << "</div>"
      html << "</div>"

      return html.html_safe
    end

    def label_and_textarea(name, opts = {})
      label_with_field(name, textarea(name, obj[name] || opts[:default], opts[:field_opts] || {}), opts)
    end


    def label_and_select(name, options, opts = {})
      options = ([""] + options) if opts[:nodefault]
      opts[:field_opts] ||= {}

      opts[:col_size] = 9
      widget = options.length < COMBOBOX_MIN_LIMIT ? select(name, options, opts[:field_opts] || {}) : combobox(name, options, opts[:field_opts] || {})
      label_with_field(name, widget, opts)
    end


    def label_and_password(name, opts = {})
      label_with_field(name, password(name, obj[name], opts[:field_opts] || {}), opts)
    end


    def label_and_boolean(name, opts = {}, default = false, force_checked = false)
      opts[:col_size] = 1
      opts[:controls_class] = "checkbox"
      label_with_field(name, checkbox(name, opts, default, force_checked), opts)
    end

    def label_and_readonly(name, default = "", opts = {})
      value = obj[name]
      if !(value.is_a? String)
        value = value.to_s
      end

      begin
        jsonmodel_type = obj["jsonmodel_type"]
        prefix = opts[:plugin] ? 'plugins.' : ''
        schema = JSONModel(jsonmodel_type).schema
        if (schema["properties"][name].has_key?('dynamic_enum'))
          value = I18n.t({:enumeration => schema["properties"][name]["dynamic_enum"], :value => value}, :default => value)
        elsif schema["properties"][name].has_key?("enum")
          value = I18n.t("#{prefix}#{jsonmodel_type.to_s}.#{property}_#{value}", :default => value)
        end
      rescue
      end
      if opts.has_key? :controls_class
        opts[:controls_class] << " label-only"
      else
        opts[:controls_class] = " label-only"
      end

      label_with_field(name, value.blank? ? default : value , opts)
    end

    def label_and_merge_select(name, default = "", opts = {})
      value = obj[name]
      begin
        jsonmodel_type = obj["jsonmodel_type"]
        prefix = opts[:plugin] ? 'plugins.' : ''
        schema = JSONModel(jsonmodel_type).schema
        if (schema["properties"][name].has_key?('dynamic_enum'))
          value = I18n.t({:enumeration => schema["properties"][name]["dynamic_enum"], :value => value}, :default => value)
        elsif schema["properties"][name].has_key?("enum")
          value = I18n.t("#{prefix}#{jsonmodel_type.to_s}.#{property}_#{value}", :default => value)
        end
      rescue
      end
      if opts.has_key? :controls_class
        opts[:controls_class] << " label-only"
      else
        opts[:controls_class] = " label-only"
      end
      if value.blank?
        label_with_field(name, value.blank? ? default : value , opts)
      else
        label_with_field(name, merge_select(name, value, opts), opts)
      end
    end

    # ANW-429: Modified this method so that a disable_replace can be passed in, which will skip the creation of the "replace" checkboxes.
    # This can be used to not render them in case a replace in inappropriate, e.g., the target record has nothing to replace with.
    def merge_select(name, value, opts)
      unless opts[:disable_replace] == true
        value += "<label class='subreplace-control'>".html_safe
        value += merge_checkbox("#{name}", {
          :class => "merge-toggle"}, false, false)
        value += "&#160;<small>".html_safe
        value += I18n.t("actions.merge_replace")
        value += "</small></label>".html_safe
      else
        value += ""
      end
    end

    def combobox(name, options, opts = {})
      select(name, options, opts.merge({ :"data-combobox" => true,
                                         :id => id_for(name) }))
    end


    def select(name, options, opts = {})
      if opts.has_key? :class
        opts[:class] << " form-control"
      else
        opts[:class] = "form-control"
      end
      if opts.has_key? :"data-combobox"
        opts[:role] = "listbox"
        opts[:"aria-label"] = I18n.t(i18n_for(name))
      end
      selection = obj[name]
      selection = selection[0...-4] if selection.is_a? String and selection.end_with?("_REQ")
      @forms.select_tag(path(name), @forms.options_for_select(options, selection || default_for(name) || opts[:default]), {:id => id_for(name)}.merge!(opts))
    end

    def textarea(name = nil, value = "", opts = {})
      value = value[0...-4] if value.is_a? String and value.end_with?("_REQ")
      value = nil if value === "REQ"
      options = {:id => id_for(name), :rows => 3}

      placeholder = I18n.t("#{i18n_for(name)}_placeholder", :default => '')
      options[:placeholder] = placeholder if not placeholder.empty?
      options[:class] = "form-control"

      @forms.text_area_tag(path(name), h(value), options.merge(opts))
    end

    def textarea_ro(name = nil, value = "", opts = {})
      return "" if value.blank?
      opts[:escape] = true unless opts[:escape] == false
      opts[:base_url] ||= "/"
      value = clean_mixed_content(value, opts[:base_url]) if opts[:clean] == true
      value = @parent.preserve_newlines(value) if opts[:clean] == true
      value = CGI::escapeHTML(value) if opts[:escape]
      value.html_safe
    end

    def textfield(name = nil, value = nil, opts = {})
      value ||= obj[name] if !name.nil?

      value = value[0...-4] if value.is_a? String and value.end_with?("_REQ")
      value = nil if value === "REQ"

      options = {:id => id_for(name), :type => "text", :value => h(value), :name => path(name)}

      placeholder = I18n.t("#{i18n_for(name)}_placeholder", :default => '')
      options[:placeholder] = placeholder if not placeholder.empty?
      options[:class] = "form-control"

      value = @forms.tag("input", options.merge(opts),
                 false, false)

      if opts[:automatable]
        by_default = default_for("#{name}_auto_generate") || false
        value << "<label>".html_safe
        value << checkbox("#{name}_auto_generate", {
          :class => "automate-field-toggle", :display_text_when_checked => I18n.t("states.auto_generated")
          }, by_default, false)
        value << "&#160;<small>".html_safe
        value << I18n.t("actions.automate")
        value << "</small></label>".html_safe
      end

      inline_help = I18n.t("#{i18n_for(name)}_inline_help", :default => '')
      if !inline_help.empty?
        value << "<span class=\"help-inline\">#{inline_help}</span>".html_safe
      end

      value
    end


    def password(name = nil, value = "", opts = {})
      @forms.tag("input", {:id => id_for(name), :type => "password", :value => h(value), :name => path(name)}.merge(opts),
                 false, false)
    end

    def hidden_input(name, value = nil, field_opts = {})
      value = obj[name] if value.nil?

      full_name = path(name)

      if value && value.is_a?(Hash) && value.has_key?('ref')
        full_name += '[ref]'
        value = value['ref']
      end

      @forms.tag("input", {:id => id_for(name), :type => "hidden", :value => h(value), :name => full_name}.merge(field_opts),
                 false, false)
    end

    def emit_template(name, *args)
      if !@parent.templates[name]
        raise "No such template: #{name.inspect}"
      end

      old = @active_template
      @active_template = name
      @parent.templates[name][:block].call(self, *args)
      @active_template = old
    end

    def label_and_fourpartid
      field_html =  textfield("id_0", obj["id_0"], :class => "id_0 form-control", :size => 10)
      field_html << textfield("id_1", obj["id_1"], :class => "id_1 form-control", :size => 10, :disabled => obj["id_0"].blank? && obj["id_1"].blank?, :'aria-label' => "id_1")
      field_html << textfield("id_2", obj["id_2"], :class => "id_2 form-control", :size => 10, :disabled => obj["id_1"].blank? && obj["id_2"].blank?, :'aria-label' => "id_2")
      field_html << textfield("id_3", obj["id_3"], :class => "id_3 form-control", :size => 10, :disabled => obj["id_2"].blank? && obj["id_3"].blank?, :'aria-label' => "id_3")
      @forms.content_tag(:div, (I18n.t(i18n_for("id_0")) + field_html).html_safe, :class=> "identifier-fields")
      label_with_field("id_0", field_html, :control_class => "identifier-fields")
    end


    def label(name, opts = {}, classes = [])
      prefix = ''
      prefix << "#{opts[:contextual]}." if opts[:contextual]
      prefix << 'plugins.' if opts[:plugin]

      classes << 'control-label'

      options = {:class => classes.join(' '), :for => id_for(name)}

      unless (tooltip = tooltip(name, prefix)).empty?
        add_tooltip_options(tooltip, options)
      end

      attr_string = options.merge(opts || {})
                      .map {|k, v| '%s="%s"' % [CGI::escapeHTML(k.to_s),
                                                CGI::escapeHTML(v.to_s)]}
                      .join(' ')
      content = CGI::escapeHTML(I18n.t(prefix + i18n_for(name, opts[:ignore_form_context])))
      "<label #{attr_string}>#{content}</label>".html_safe
    end

    def add_tooltip_options(tooltip, options)
      options[:title] = tooltip
      options['data-placement'] = 'bottom'
      options['data-html'] = true
      options['data-delay'] = 500
      options['data-trigger'] = 'manual'
      options['data-template'] = '<div class="tooltip archivesspace-help"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>'
      options[:class] ||= ''
      options[:class] += ' has-tooltip'
      options
    end

    def button_with_tooltip(tooltip, content, div_classes = [], button_classes = [], use_default_btn_classes = true)
      div_classes = div_classes + ["btn-with-tooltip"]

      button_classes = use_default_btn_classes ? button_classes + ["btn", "btn-small"] : button_classes

      div_options = {:class => div_classes.join(' ')}
      add_tooltip_options(tooltip, div_options)

      button_options = {:class => button_classes.join(' ')}

      div_attr_string = div_options.map {|k, v| '%s="%s"' % [CGI::escapeHTML(k.to_s),
                                                     CGI::escapeHTML(v.to_s)]}
                           .join(' ')

      button_attr_string = button_options.map {|k, v| '%s="%s"' % [CGI::escapeHTML(k.to_s),
                                                     CGI::escapeHTML(v.to_s)]}
                           .join(' ')

      "<div #{div_attr_string}><button #{button_attr_string}>#{content}</button></div>".html_safe
    end

    def tooltip(name, prefix = '')
      I18n.t_raw("#{prefix}#{i18n_for(name)}_tooltip", :default => '')
    end

    def checkbox(name, opts = {}, default = true, force_checked = false)
      options = {:id => "#{id_for(name)}", :type => "checkbox", :name => path(name), :value => 1}
      options[:checked] = "checked" if force_checked or (obj[name] === true) or (obj[name].is_a? String and obj[name].start_with?("true")) or (obj[name] === "1") or (obj[name].nil? and default)

      @forms.tag("input", options.merge(opts), false, false)
    end

    # takes a JSON representation of the current options selected and the list of archival_record_level enums
    # returns HTML for a set of checkboxes representing current selected and deselected sets for OAI export
    def checkboxes_for_oai_sets(set_json, value_list)
      return "" if value_list == nil
      # when called by #new, set_json will be nil.
      if set_json
        set_arry = JSON::parse(set_json)
      else
        set_arry = []
      end

      html = ""

      html << "<div class='form-group'>"
      html << label("oai_sets_available", {}, ["control-label", "col-sm-2"])
      html << "<div class='col-sm-9'>"
      html << "<ul class='checkbox-list'>"
      value_list['enumeration_values'].each do |v|
        # if we have an empty list of checkboxes, assume all sets are enabled.
        # otherwise, a checkbox is on if it's the in the list we get from the backend.
        checked = set_arry.include?(v['id'].to_s) || set_arry.length == 0

        html << "<li class='list-group-item'>"
        html << "<div class='checkbox'>"
        html << "<label>"
        html << "<input id=\"#{v['id']}\" name=\"sets[#{v['id']}]\" type=\"checkbox\" "
        if checked
          html << "checked=\"checked\" "
        end

        if readonly?
          html << "disabled />"
        else
          html << "/>"
        end # of checkbox tag

        html << "#{v['value']}"
        html << "</label>"
        html << "</div>"
        html << "</li>"
      end
      html << "</ul>"
      html << "</div>" #col-sm-9
      html << "</div>" #form-group

      return html.html_safe
    end

    def oai_config_repo_set_codes_field(set_json, repositories)
      #label_and_textfield(name, opts)
      set_arry = JSON::parse(set_json)

      html = ""

      html << "<div class='form-group'>"
      html << label("repo_set_section", {}, ["control-label", "col-sm-2"])
      html << "<div class='col-sm-9'>"
      html << "<ul class='checkbox-list'>"
      repositories.each do |r|
        # a checkbox is on if it's the in the list we get from the backend.
        checked = set_arry.include?(r['repo_code'].to_s)

        html << "<li class='list-group-item'>"
        html << "<div class='checkbox'>"
        html << "<label>"
        html << "<input id=\"#{r['repo_code']}\" name=\"repo_set_codes[#{r['repo_code']}]\" type=\"checkbox\" "
        if checked
          html << "checked=\"checked\" "
        end

        html << "/>"

        html << "#{r['repo_code']}"
        html << "</label>"
        html << "</div>"
        html << "</li>"
      end
      html << "</ul>"
      html << "</div>" #col-sm-9
      html << "</div>" #form-group

      return html.html_safe
    end

    def oai_config_sponsor_set_names_field(set_json, opts = {})
      # turn array from DB into a comma delimited list for UI
      set_arry = JSON::parse(set_json)
      value = set_arry.join("|")

      html = ""

      html << "<div class='form-group'>"
      html << label("sponsor_set_names", {}, ["control-label", "col-sm-2"])
      html << "<div class='col-sm-9'>"
      html << "<input id='oai_config_sponsor_set_names_' type='text' value='#{value}' name='oai_config[sponsor_set_names]' class='form-control js-taggable' datarole='tagsinput'>"
      html << "</div>"
      html << "</div>"

      return html.html_safe
    end

    def merge_checkbox(name, opts = {}, default = false, force_checked = false)
      options = {:id => "#{id_for(name)}", :type => "checkbox", :name => path(name), :value => "REPLACE"}
      options[:checked] = "checked" if force_checked or (obj[name] === true) or (obj[name].is_a? String and obj[name].start_with?("true")) or (obj[name] === "REPLACE") or (obj[name].nil? and default)

      @forms.tag("input", options.merge(opts), false, false)
    end

    def radio(name, value, opts = {})
      options = {:id => "#{id_for(name)}", :type => "radio", :name => path(name), :value => value}
      options[:checked] = "checked" if obj[name] == value

      @forms.tag("input", options.merge(opts), false, false)
    end


    def required?(name)
      if @active_template && @parent.templates[@active_template]
        @parent.templates[@active_template][:definition].required?(name)
      else
        false
      end
    end


    def default_for(name)
      if @active_template && @parent.templates[@active_template]
        @parent.templates[@active_template][:definition].default_for(name)
      else
        nil
      end
    end


    def allowable_types_for(name)
      if @active_template && @parent.templates[@active_template]
        @parent.templates[@active_template][:definition].allowable_types_for(name)
      else
        []
      end
    end


    def possible_options_for(name, add_empty_options = false, opts = {})
      if @active_template && @parent.templates[@active_template]
        @parent.templates[@active_template][:definition].options_for(self, name, add_empty_options, opts)
      else
        []
      end
    end


    def label_with_field(name, field_html, opts = {})
      opts[:label_opts] ||= {}
      opts[:label_opts][:plugin] = opts[:plugin]
      opts[:col_size] ||= 9

      control_group_classes,
      label_classes,
      controls_classes = %w(form-group), [], []

      unless opts[:layout] && opts[:layout] == 'stacked'
        label_classes << "col-sm-#{opts[:label_opts].fetch(:col_size, 2)}"
        controls_classes << "col-sm-#{opts[:col_size]}"
      end
      # There must be a better way to say this...
      # The value of the 'required' option wins out if set to either true or false
      # if not specified, we take the value of required?
      required = [:required, 'required'].map {|r| opts[r]}.compact.first
      if required.nil?
        required = required?(name)
      end

      # additional admin-defined requirements
      unless required || @required_fields.nil?
        type = @record_type || @context.last[1]["jsonmodel_type"]
        # ideally we would send along the property as well,
        # and be really sure that this field is required on
        # this type of record in such and such context. A possible
        # refactor would be to have all or some of  the marking up
        # of required fields happen on demand (in JavaScript).
        required = @required_fields.required?(nil, type, name)
      end

      control_group_classes << "required" if required == true

      control_group_classes << "conditionally-required" if required == :conditionally

      control_group_classes << "#{opts[:control_class]}" if opts.has_key? :control_class

      #TODO: refactor this. We don't need a separate method for each extra special class to be added below. Probably the thing to is to use the opts param.
      # ANW-617: add JS classes to slug fields
      control_group_classes << "js-slug_textfield" if name == "slug"
      control_group_classes << "js-slug_auto_checkbox" if name == "is_slug_auto"

      # ANW-429: add JS classes to structured date fields
      control_group_classes << "js-structured_date_select" if name == "date_type_structured"

      controls_classes << "#{opts[:controls_class]}" if opts.has_key? :controls_class

      control_group = "<div class=\"#{control_group_classes.join(' ')}\">"
      control_group << label(name, opts[:label_opts], label_classes)
      control_group << "<div class=\"#{controls_classes.join(' ')}\">"
      control_group << field_html
      control_group << "</div>"
      control_group << "</div>"

      # ANW-429
      # TODO: Refactor to the JS files, ideally so this is run when the "Add Date" button is clicked. This is a tricky one since the select field this JS needs to be run on doesn't exist until the callbacks that run after the button is clicked run. Putting it here means that it runs as part of the html, and is always included in the right context.
      control_group << "<script>selectStructuredDateSubform();</script>" if name == "date_type_structured"

      control_group.html_safe
    end

    # ANW-429
    # Generates HTML for a very stripped down summary of a note for use in the agents merge preview.
    # TODO: Eventually we'll want to use the notes partials in place of this code. This code was created because the current notes show takes up a lot of space, and work needs to be done to figure out the exact setup/context needed to get those views to render properly.
    # T
    def notes_preview(notes_index = "notes", content_index = "content")
      content_label = I18n.t("note._frontend.preview.content")
      html = ""

      if obj[notes_index] && obj[notes_index].length > 0

        html << "<div class='subrecord-form-container'>"
        html << "<h4 class='subrecord-form-heading'>#{I18n.t("subsections.notes")}</h4>"

        obj[notes_index].each_with_index do |o, i|
          notes_heading = I18n.t("note.#{o['jsonmodel_type'].to_s}")

          if o[content_index].is_a?(Array)
            notes_content = o[content_index].join(" : ")
          else
            notes_content = o[content_index]
          end

          html << "<section>"
          html << "<h5>#{notes_heading}</h5>"
          html << "<div class='panel panel-default'>"
          html << "<div class=\"form-group\">"
          html << "<label class='control-label col-sm-2'>#{content_label}</label>"
          html << "<div class='col-sm-9 label-only'>"
          html << "#{notes_content}"
          html << "</div>"
          html << "</div>"
          html << "</div>"
          html << "</section>"
        end

        html << "</div>"

        html.html_safe

      end
    end

    # Same as above, but intended for use with an agents top level notes record instead of a subrecord, in the merge selector form.
    # Needed because emitting templates as usual breaks the merge selector interface
    def notes_preview_single(obj)
      content_label = I18n.t("note._frontend.preview.content")
      html = ""

      notes_content = ""

      obj['subnotes'].each do |subnote|
        if subnote['jsonmodel_type'] == "note_text"
          notes_content << subnote["content"] if subnote["content"]
        end
      end

      html << "<br />"
      html << "<section>"
      html << "<div class='panel panel-default'>"
      html << "<div class=\"form-group\">"
      html << "<label class='control-label col-sm-2'>#{content_label}</label>"
      html << "<div class='col-sm-9 label-only'>"
      html << "#{notes_content}"
      html << "</div>"
      html << "</div>"
      html << "</div>"
      html << "</section>"

      html.html_safe
    end

    # ANW-429
    # outputs HTML for checkboxes for record-level add and replace for agents merge
    def record_level_merge_controls(form, name = "undefined", controls = true, replace = true, append = true)
      html = ""

      html << '<h4 class="subrecord-form-heading">'
      html << I18n.t("#{name}._singular").to_s

      if controls
        if replace
          html << '<label class="replace-control">'
          html << form.merge_checkbox('replace')
          html << '<small>'
          html << I18n.t("actions.merge_replace").to_s
          html << '</small>'
          html << '</label>'
        end

        if append
          html << '<label class="append-control">'
          html << form.merge_checkbox('append')
          html << '<small>'
          html << I18n.t("actions.merge_add").to_s
          html << '</small>'
          html << '</label>'
        end
      end

      html << '</h4>'

      return html.html_safe
    end

  end #of FormContext

  def merge_victim_view(hash, opts = {})
    jsonmodel_type = hash["jsonmodel_type"]
    schema = JSONModel(jsonmodel_type).schema
    prefix = opts[:plugin] ? 'plugins.' : ''
    html = "<div class='form-horizontal'>"

    hash.reject {|k, v| PROPERTIES_TO_EXCLUDE_FROM_READ_ONLY_VIEW.include?(k)}.each do |property, value|
      if schema and schema["properties"].has_key?(property)
        if (schema["properties"][property].has_key?('dynamic_enum'))
          value = I18n.t({:enumeration => schema["properties"][property]["dynamic_enum"], :value => value}, :default => value)
        elsif schema["properties"][property].has_key?("enum")
          value = I18n.t("#{prefix}#{jsonmodel_type.to_s}.#{property}_#{value}", :default => value)
        elsif schema["properties"][property]["type"] === "boolean"
          value = value === true ? "True" : "False"
        elsif schema["properties"][property]["type"] === "date"
          value = value.blank? ? "" : Date.strptime(value, "%Y-%m-%d")
        elsif schema["properties"][property]["type"] === "array"
          # this view doesn't support arrays
          next
        elsif value.is_a? Hash
          # can't display an object either
          next
        end
      end
      html << "<div class='form-group'>"
      html << "<div class='control-label col-sm-2'>"
      html << I18n.t("#{prefix}#{jsonmodel_type.to_s}.#{property}")
      html << "</div>"
      html << "<div class='label-only col-sm-8'>#{value}</div>"
      html << "</div>"
    end
    html << "</div>"
    html.html_safe
  end

  class ReadOnlyContext < FormContext

    def readonly?
      true
    end

    def select(name, options, opts = {})
      return nil if obj[name].blank?

      # Attempt a match in the options to give dynamic enums a chance.
      match = options.find {|label, value| value == obj[name]}

      if match
        match[0]
      else
        I18n.t("#{i18n_for(name)}_#{obj[name]}", :default => obj[name])
      end
    end

    def textfield(name = nil, value = "", opts = {})
      return "" if value.blank?
      opts[:escape] = true unless opts[:escape] == false
      opts[:base_url] ||= "/"
      value = clean_mixed_content(value, opts[:base_url]) if opts[:clean] == true
      value = @parent.preserve_newlines(value) if opts[:clean] == true
      value = CGI::escapeHTML(value) if opts[:escape]
      value.html_safe
    end

    def textarea(name = nil, value = "", opts = {})
      return "" if value.blank?
      opts[:escape] = true unless opts[:escape] == false
      opts[:base_url] ||= "/"
      value = clean_mixed_content(value, opts[:base_url]) if opts[:clean] == true
      value = @parent.preserve_newlines(value) if opts[:clean] == true
      value = value.to_s if value.is_a? Integer
      value = CGI::escapeHTML(value) if opts[:escape]
      value.html_safe
    end

    def checkbox(name, opts = {}, default = true, force_checked = false)
      true_i18n = I18n.t("#{i18n_for(name)}_true", :default => I18n.t('boolean.true'))
      false_i18n = I18n.t("#{i18n_for(name)}_false", :default => I18n.t('boolean.false'))
      ((obj[name] === true) || obj[name] === "true") ? true_i18n : false_i18n
    end

    def label_with_field(name, field_html, opts = {})
      return "" if field_html.blank?
      super(name, field_html, opts.merge({:controls_class => "label-only"}))
    end

    def label_and_fourpartid
      fourpart_html = "<div class='identifier-display'>"+
                        "<span class='identifier-display-part'>#{obj["id_0"]}</span>" +
                        "<span class='identifier-display-part'>#{obj["id_1"]}</span>" +
                        "<span class='identifier-display-part'>#{obj["id_2"]}</span>" +
                        "<span class='identifier-display-part'>#{obj["id_3"]}</span>" +
                      "</div>"

      label_with_field("id_0", fourpart_html)
    end

    def label_and_date(name, opts = {})
      label_with_field(name, "#{obj[name]}")
    end
  end


  def form_context(name, values_from = {}, &body)
    context = FormContext.new(name, values_from, self)

    env = self.request.env
    env['form_context_depth'] ||= 0
    context.instance_variable_set(:@form_context_depth, env['form_context_depth'])
    # Only fetch required values at the top-level
    if env['form_context_depth'] == 0
      begin
        required_fields = RequiredFields.get(values_from.jsonmodel_type)
        context.instance_variable_set(:@required_fields, required_fields)
      rescue
      end
    end

    s = "<div class=\"form-context\" id=\"form_#{name}\">".html_safe
    s << context.hidden_input("lock_version", values_from["lock_version"])

    env['form_context_depth'] += 1
    s << capture(context, &body)
    env['form_context_depth'] -= 1

    if env['form_context_depth'] == 0
      # Only emit the JS templates at the top-level
      s << templates_for_js(values_from["jsonmodel_type"])
    end
    s << "</div>".html_safe

    s
  rescue
    Rails.logger.error("Failure generating templates for JS: #{$!}")
    Rails.logger.error("Stacktrace:\n%s" % [$@.join("\n")])

    raise $!
  end


  def templates
    @templates ||= {}
    @templates
  end


  class BaseDefinition
    def required?(name)
      false
    end

    def record_type
      nil
    end
  end


  def jsonmodel_definition(type, root = nil)
    JSONModelDefinition.new(JSONModel(type), root)
  end


  class JSONModelDefinition < BaseDefinition
    def initialize(jsonmodel, root)
      @jsonmodel = jsonmodel
      @root = root
    end


    def required?(name)
      ((jsonmodel_schema_definition(name) &&
       jsonmodel_schema_definition(name)['ifmissing'] === 'error'))
    end


    def default_for(name)
      if jsonmodel_schema_definition(name)
        if jsonmodel_schema_definition(name).has_key?('dynamic_enum')
          if jsonmodel_schema_definition(name)['default']
            Rails.logger.warn("Superfluous default value at: #{@jsonmodel}.#{name} ")
          end
          JSONModel.enum_default_value(jsonmodel_schema_definition(name)['dynamic_enum'])
        else
          jsonmodel_schema_definition(name)['default']
        end
      else
        nil
      end
    end


    def allowable_types_for(name)
      defn = jsonmodel_schema_definition(name)

      if defn
        ASUtils.extract_nested_strings(defn).map {|s|
          ref = JSONModel.parse_jsonmodel_ref(s)
          ref.first.to_s if ref
        }.compact
      else
        []
      end
    end


    def options_for(context, property, add_empty_options = false, opts = {})
      options = []
      options.push([(opts[:empty_label] || ""), ""]) if add_empty_options

      defn = jsonmodel_schema_definition(property)

      jsonmodel_enum_for(property).each do |v|
        if opts[:include] && !opts[:include].include?(v)
          next
        end
        if opts[:exclude] && opts[:exclude].include?(v)
          next
        end
        if opts.has_key?(:i18n_path_for) && opts[:i18n_path_for].has_key?(v)
          i18n_path = opts[:i18n_path_for][v]
        elsif opts.has_key?(:i18n_prefix)
          i18n_path = "#{opts[:i18n_prefix]}.#{v}"
        elsif defn.has_key?('dynamic_enum')
          i18n_path = {
            :enumeration =>  defn['dynamic_enum'],
            :value => v
          }
        else
          i18n_path = context.i18n_for("#{Array(property).last}_#{v}")
        end
        options.push([I18n.t(i18n_path, :default => v), v])
      end
      options
    end

    def record_type
      @jsonmodel.record_type
    end

    private

    def jsonmodel_enum_for(property)
      defn = jsonmodel_schema_definition(property)

      if defn["enum"]
        defn["enum"]
      elsif defn["dynamic_enum"]
        JSONModel.enum_values(defn['dynamic_enum'])
      else
        raise "No enum found for #{property}"
      end
    end


    def jsonmodel_schema_definition(property)
      schema = @jsonmodel.schema
      properties = Array(property).clone

      if @root
        properties = [@root] + properties
      end

      while !properties.empty?
        if schema['type'] == 'object'
          schema = schema['properties']
        elsif schema['type'] == 'array'
          schema = schema['items']
        else
          property = properties.shift

          if properties.empty?
            return schema[property]
          else
            schema = schema[property]
          end
        end
      end

      nil
    end

  end

  # we expect the template to be defined in a view context
  # that will have the @required_fields object if applicable.
  # We add it to the template hash because the object will
  # be out of scope when the JS templates are emitted.
  def define_template(name, definition = nil, &block)
    @templates ||= {}
    @templates[name] = {
      :block => block,
      :definition => (definition || BaseDefinition.new),
      :requirements => @required_fields
    }
  end


  def templates_for_js(jsonmodel_type = nil)
    @delivering_js_templates = true

    result = ""
    return result if @templates.blank?

    obj = {}
    obj['jsonmodel_type'] = jsonmodel_type if jsonmodel_type

    templates_to_process = @templates.clone
    templates_processed = []
    # As processing a template may register further templates that hadn't been
    # registered previously, keep looping until we have no more templates to
    # process.
    #
    # Because infinite loops are terrifying and a pain to debug, let us reign
    # in the fear with a 100-loop-count-get-out-of-here-alive limit.
    i = 0
    while (true)
      templates_to_process.each do |name, template|
        context = FormContext.new("${path}", obj, self)

        def context.id_for(name, qualify = true)
          name = path(name) if qualify

          name.gsub(/[\[\]]/, '_').gsub('${path}', '${id_path}')
        end

        context.instance_eval do
          @active_template = name
          @record_type = template[:definition].record_type
          @required_fields = template[:requirements]
        end

        result << "<div id=\"template_#{name}\"><!--"
        result << capture(context, &template[:block])
        result << "--></div>"

        templates_processed << name
      end

      if templates_processed.length < @templates.length
        # some new templates were defined while outputing the js templates
        templates_to_process = @templates.reject {|name, _| templates_processed.include?(name)}
      else
        # we've got them all
        break
      end

      i += 1

      if i > 100
        Rails.logger.error("templates_for_js has looped out more that 100 times")
        break
      end
    end

    result.html_safe
  end

  def readonly_context(name, values_from = {}, &body)
    context = ReadOnlyContext.new(name, values_from, self)

    # Not feeling great about this, but we render the form twice: the first pass
    # sets up the mapping from form input names to i18n keys, while the second
    # actually uses that map to set the labels correctly.
    capture(context, &body)

    s = "<div class=\"readonly-context form-horizontal\">".html_safe
    s << capture(context, &body)
    s << "</div>".html_safe

    s
  end

  PROPERTIES_TO_EXCLUDE_FROM_READ_ONLY_VIEW = ["jsonmodel_type", "lock_version", "_resolved", "uri", "ref", "create_time", "system_mtime", "user_mtime", "created_by", "last_modified_by", "sort_name_auto_generate", "suppressed", "display_string", "file_uri", "agent_person_id", "agent_software_id", "agent_family_id", "agent_corporate_entity_id", "id"]

  def read_only_view(hash, opts = {})
    jsonmodel_type = hash["jsonmodel_type"]
    schema = JSONModel(jsonmodel_type).schema
    prefix = opts[:plugin] ? 'plugins.' : ''
    html = "<div class='form-horizontal'>"

    # in some cases, we want to not display certain fields for some records, but not for others.
    # e.g., we don't want to display published for subjects (they are always published), but we do for other records types.
    if opts[:exclude]
      props_to_exclude = PROPERTIES_TO_EXCLUDE_FROM_READ_ONLY_VIEW + opts[:exclude]
    else
      props_to_exclude = PROPERTIES_TO_EXCLUDE_FROM_READ_ONLY_VIEW

    end

    hash.reject {|k, v| props_to_exclude.include?(k)}.each do |property, value|

      if schema and schema["properties"].has_key?(property)
        if (schema["properties"][property].has_key?('dynamic_enum'))
          value = I18n.t({:enumeration => schema["properties"][property]["dynamic_enum"], :value => value}, :default => value)
        elsif schema["properties"][property].has_key?("enum")
          value = I18n.t("#{prefix}#{jsonmodel_type.to_s}.#{property}_#{value}", :default => value)
        elsif schema["properties"][property]["type"] === "boolean"
          value = value === true ? I18n.t('boolean.true') : I18n.t('boolean.false')
        elsif schema["properties"][property]["type"] === "date"
          value = value.blank? ? "" : Date.strptime(value, "%Y-%m-%d")
        elsif schema["properties"][property]["type"] === "integer"
          value = value.blank? ? "" : value.to_s
        elsif schema["properties"][property]["type"] === "array"
          # this view doesn't support arrays
          next
        elsif value.is_a? Hash
          # can't display an object either
          next
        end
      end

      html << "<div class='form-group'>"
      html << "<div class='control-label col-sm-2'>"
      html << I18n.t("#{prefix}#{jsonmodel_type.to_s}.#{property}")
      html << "</div>"
      html << "<div class='label-only col-sm-8'>#{value}</div>"
      html << "</div>"
    end

    html << "</div>"

    html.html_safe
  end

  def preserve_newlines(string)
    string.gsub(/\n/, '<br>')
  end


  def update_monitor_params(record)
    {
      :"data-update-monitor" => true,
      :"data-update-monitor-url" => url_for(:controller => :update_monitor, :action => :poll),
      :"data-update-monitor-record-uri" => record.uri,
      :"data-update-monitor-record-is-stale" => !!@record_is_stale,
      :"data-update-monitor-lock_version" => record.lock_version
    }
  end


  def error_params(exceptions)
    {
      :"data-form-errors" => (exceptions && exceptions.keys[0])
    }
  end

  def custom_report_template_limit_options
    [100, 500, 1000, 5000, 10000, 50000]
  end

  def array_for_textarea(values)
    return values unless values.is_a?(Array)
    values.join("\n")
  end
end