Module: NestedFormsHelper

Defined in:
app/helpers/nested_forms_helper.rb

Overview

View helper: nested forms.

Instance Method Summary collapse

Instance Method Details

#button_to_stimulus_nested_form(name, options = {}) ⇒ ActiveSupport::SafeBuffer

Stimulus-based nested forms using stimulus-components/rails-nested-form
Stimulus-based nested forms — preferred for new code

Parameters:

  • name (String)

    button label text

  • options (Hash) (defaults to: {})

    button options

Options Hash (options):

  • :class (String)

    CSS class(es), replacing the default
    btn btn-outline-primary

  • :skip_icon (Boolean)

    render plain text without the
    circle-plus icon

  • :controller (String)

    Stimulus controller name handling
    the add action (default 'nested-form')

  • :data (Hash)

    extra data attributes merged into the button

  • :id (String)

    DOM id of the button

  • :style (String)

    inline style of the button

Returns:

  • (ActiveSupport::SafeBuffer)

    the "add" button



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'app/helpers/nested_forms_helper.rb', line 182

def button_to_stimulus_nested_form(name, options = {})
  css_classes = []
  if options[:class].present?
    css_classes << options[:class]
  else
    css_classes += %w[btn btn-outline-primary]
  end

  button_label = if block_given?
                   yield
                 elsif options[:skip_icon].to_b
                   name
                 else
                   fa_icon('circle-plus', text: name)
                 end

  # Use the controller name from options or default to 'nested-form'
  controller_name = options[:controller] || 'nested-form'

  data_hsh = {
    action: "#{controller_name}#add"
  }.merge(options[:data] || {})

  button_tag button_label.html_safe,
             id: options[:id],
             type: 'button',
             class: css_classes.compact.uniq.join(' '),
             style: options[:style],
             data: data_hsh
end

#generate_html(form_builder, method, options = {}) ⇒ String

Render the fields partial for a new (unsaved) nested-form record.

Parameters:

  • form_builder (ActionView::Helpers::FormBuilder)

    parent form builder

  • method (Symbol)

    association name to build a new record for

  • options (Hash) (defaults to: {})

    rendering options

Options Hash (options):

  • :object (ActiveRecord::Base)

    pre-built child record
    (default: a new instance of the association class)

  • :partial (String)

    partial name (default: singularized
    association name)

  • :form_builder_local (Symbol, String)

    local variable name
    the child builder is exposed as in the partial (default :f)

  • :partial_locals (Hash)

    extra locals merged into the
    partial render

  • :child_index (String, Integer)

    child index for
    fields_for (default 'NEW_RECORD')

Returns:

  • (String)

    rendered partial HTML



20
21
22
23
24
25
26
27
28
29
30
31
# File 'app/helpers/nested_forms_helper.rb', line 20

def generate_html(form_builder, method, options = {})
  options[:object] ||= form_builder.object.class.reflect_on_association(method).klass.new
  options[:partial] ||= method.to_s.singularize
  options[:form_builder_local] ||= :f
  options[:partial_locals] ||= {}
  child_index = options[:child_index] || 'NEW_RECORD'
  builder_method = form_builder.respond_to?(:simple_fields_for) ? :simple_fields_for : :fields_for
  form_builder.send(builder_method, method, options[:object], child_index: child_index) do |f|
    render(partial: options[:partial],
           locals: { options[:form_builder_local] => f }.merge(options[:partial_locals]))
  end
end

Legacy nested forms (to be migrated to Stimulus).
Pass use_stimulus_append: true to omit .new-nested-form and use .rma-add-items-nested-trigger
(rma-add-items Stimulus controller). Pass stimulus_nested_trigger_class: 'my-class' for a
page-specific trigger (rma-receive-items, etc.); that controller must handle the click.

Parameters:

  • name (String)

    link label text

  • form_builder (ActionView::Helpers::FormBuilder)

    parent form builder

  • method (Symbol)

    association name to build a new record for

  • options (Hash) (defaults to: {})

    rendering/link options

  • instance_defaults (Hash, nil) (defaults to: nil)

    attribute defaults for the new record

Options Hash (options):

  • :defaults (Hash)

    attribute defaults for the new record
    (used when instance_defaults is nil)

  • :object (ActiveRecord::Base)

    pre-built child record

  • :partial (String)

    partial name (default: singularized
    association name)

  • :form_builder_local (Symbol, String)

    local variable name
    the child builder is exposed as in the partial (default :f)

  • :element_id (String)

    target container id (default:
    association name)

  • :partial_locals (Hash)

    extra locals merged into the
    partial render

  • :child_index (String, Integer)

    child index for
    fields_for (default 'NEW_RECORD')

  • :use_stimulus_append (Boolean)

    use the
    rma-add-items-nested-trigger class instead of new-nested-form

  • :stimulus_nested_trigger_class (String)

    page-specific
    Stimulus trigger class (takes precedence over :use_stimulus_append)

  • :class (String)

    CSS class(es), replacing the default
    btn btn-outline-primary

  • :skip_icon (Boolean)

    render plain text without the
    circle-plus icon

  • :trigger (String)

    value for data-trigger

  • :data (Hash)

    extra data attributes merged into the link

  • :id (String)

    DOM id of the link

  • :style (String)

    inline style of the link

Returns:

  • (ActiveSupport::SafeBuffer)

    the "add" link



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'app/helpers/nested_forms_helper.rb', line 118

def link_to_new_nested_form(name, form_builder, method, options = {}, instance_defaults = nil)
  instance_defaults ||= options[:defaults] || {}
  options[:object] ||= form_builder.object.class.reflect_on_association(method).klass.new(instance_defaults)
  options[:partial] ||= method.to_s.singularize
  options[:form_builder_local] ||= :f
  options[:element_id] ||= method.to_s
  options[:partial_locals] ||= {}
  html = generate_html(form_builder,
                       method,
                       object: options[:object],
                       partial: options[:partial],
                       form_builder_local: options[:form_builder_local],
                       partial_locals: options[:partial_locals],
                       child_index: options[:child_index])
  use_stimulus_append = options[:use_stimulus_append]
  stimulus_trigger_class = options[:stimulus_nested_trigger_class].to_s.presence
  css_classes = []
  if options[:class].present?
    css_classes << options[:class]
  else
    css_classes += %w[btn btn-outline-primary]
  end
  css_classes << if stimulus_trigger_class
                   stimulus_trigger_class
                 elsif use_stimulus_append
                   'rma-add-items-nested-trigger'
                 else
                   'new-nested-form'
                 end

  link_label = if block_given?
                 yield
               elsif options[:skip_icon].to_b
                 name
               else
                 fa_icon('circle-plus', text: name)
               end
  data_hsh = {
    'element-id': options[:element_id],
    partial: h(html.to_s),
    trigger: options[:trigger]
  }.merge(options[:data] || {})

  link_to link_label.html_safe, '#',
          id: options[:id],
          class: css_classes.compact.uniq.join(' '),
          style: options[:style],
          data: data_hsh
end

#nested_form_delete_icon(f, selector, label_text = nil, **opts) ⇒ ActiveSupport::SafeBuffer

Keyword options (all optional): :icon, :icon_options (passed to fa_icon), :link_class, :title, :aria_label

Non-persisted branch: +icon_args+ is built from +icon_base+ (+icon_options+ dup). If both +label_text+
and +:text+ in +icon_options+ are present, +label_text+ wins (written into +icon_args[:text]+) and
Rails.logger.warn records the conflict.

Parameters:

  • f (ActionView::Helpers::FormBuilder)

    nested form builder

  • selector (String)

    CSS class of the element to remove on click

  • label_text (String, nil) (defaults to: nil)

    optional visible label next to the icon

  • opts (Hash)

    keyword options

Options Hash (**opts):

  • :icon (String, Symbol)

    Font Awesome icon name (default 'trash')

  • :icon_options (Hash)

    options passed through to fa_icon
    (its :text key is replaced by label_text when both are present)

  • :link_class (String)

    extra CSS class(es) on the remove link

  • :title (String)

    tooltip on the remove link

  • :aria_label (String)

    aria-label on the remove link

Returns:

  • (ActiveSupport::SafeBuffer)

    delete checkbox label or remove link



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'app/helpers/nested_forms_helper.rb', line 49

def nested_form_delete_icon(f, selector, label_text = nil, **opts)
  icon_name = (opts[:icon] || 'trash').to_s
  icon_base = (opts[:icon_options] || {}).dup
  extra_link_class = opts[:link_class].presence
  title = opts[:title]
  aria_label = opts[:aria_label]

  if f.object.present? && f.object.persisted?
    f.label '_destroy', class: 'p-0 m-0' do
      inner = fa_icon(icon_name, **icon_base) + f.check_box('_destroy')
      inner += tag.span(label_text, class: 'ms-1') if label_text.present?
      inner
    end
  else
    icon_args = icon_base.dup
    if label_text.present? && (icon_args.key?(:text) || icon_args.key?('text'))
      Rails.logger.warn(
        '[nested_form_delete_icon] label_text and icon_options[:text] were both set; ' \
        "label_text wins and replaces icon_args[:text]. label_text=#{label_text.inspect}, " \
        "icon_options=#{opts[:icon_options].inspect}, icon_base keys=#{icon_base.keys.inspect}"
      )
    end
    if label_text.present?
      icon_args.delete('text')
      icon_args[:text] = label_text
    end
    link_classes = ['btn', 'trash-remove', 'p-0', 'm-0', extra_link_class].compact.join(' ')
    link_opts = { class: link_classes, 'data-class-to-remove': selector }
    link_opts[:title] = title if title.present?
    link_opts[:'aria-label'] = aria_label if aria_label.present?
    link_to fa_icon(icon_name, **icon_args), '#', **link_opts
  end
end