Skip to content

Instantly share code, notes, and snippets.

@mdchaney
Created September 28, 2023 04:02
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mdchaney/f25b2afd8f1253d07bd234a1b3f00a93 to your computer and use it in GitHub Desktop.
Save mdchaney/f25b2afd8f1253d07bd234a1b3f00a93 to your computer and use it in GitHub Desktop.
Stimulus-based subform
<!--
Note that when showing this line if the object is marked_for_destruction? it
should be hidden.
-->
<tr class="line_item subform fields" id="line_item_<%= f.object.id %>">
<td>
<%= f.text_field :field_1, required: true %>
</td>
<td>
<%= f.text_field :field_2, required: true %>
</td>
<td>
<%= remove_child_link 'Remove', f %>
</td>
</tr>
<!--
Note that this belongs in a form where "f" is the form variable.
In this case, we are dealing with a child model called LineItem.
Make careful note of the pluralization of "line_item" below - some
must be plural and others singular. See the _line_item.html.erb
file for an example of the actual subform. Also note that you
must allow for "id" and "_destroy" fields in your strong parameters
for line_items_attributes.
-->
<fieldset>
<legend>Line Items</legend>
<table id='line_items_tbl'>
<thead>
<tr><th>Field 1</th><th>Field 2</th><th>Actions</th></tr>
</thead>
<tbody id="line_items">
<%= f.fields_for :line_items do |li_form|%>
<%= render partial: "line_item", locals: { f: li_form } %>
<% end %>
</tbody>
</table>
<%= add_child_button 'Add A Line Item', f, :line_items, partial: 'line_item' %>
</fieldset>
module ApplicationHelper
# These methods handle child forms where there's a
# one-to-x relationship.
def remove_child_link(name, f, options = {})
_remove_child_link_with_class(name, f, 'remove-child-link', options)
end
def remove_child_button(name, f, options = {})
_remove_child_link_with_class(name, f, 'btn btn-secondary btn-sm remove-child-button', options)
end
def _remove_child_link_with_class(name, f, class_str, options = {})
confirm = options.delete(:confirm)
confirm = confirm.nil? ? true : confirm
f.hidden_field(:_destroy) + link_to(name, '', data: {action: 'dynamic-forms#removeFromForm', dynamic_forms_confirm_param: (confirm ? 'Do you really want to remove this item?' : nil)}, class: 'remove-child-link ' + class_str)
end
def add_child_link(name, f, method, options = {})
_add_child_link_with_class(name, f, method, '', options)
end
def add_child_button(name, f, method, options = {})
_add_child_link_with_class(name, f, method, 'btn btn-secondary btn-sm', options)
end
# In the following method, we use "h('' + item)" in order to remove
# the "html_safe" flag from the string and cause it to always be
# escaped.
def _add_child_link_with_class(name, f, method, class_str, options = {})
fields = new_child_fields(f, method, options)
link_to(name, '#', data: { action: 'dynamic-forms#addToForm', dynamic_forms_method_param: method, dynamic_forms_template_param: h('' + fields) }, class: 'add-child-link ' + class_str)
end
def new_child_fields(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
form_builder.fields_for(method, options[:object], :child_index => "___new_#{method}___") do |f|
render(:partial => options[:partial], :locals => { options[:form_builder_local] => f })
end
end
end
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="dynamic-forms"
export default class extends Controller {
static targets = [ "destination" ];
static params = [ "method", "template", "confirm" ];
connect() {
this.hideRemovedRecords();
}
// If dataset.dynamicFormsNewItem is set, then this is a new item that was
// just added. It can be simply removed from the DOM completely. Otherwise,
// the _destroy field needs to be set to "true" and the item hidden.
removeFromForm(ev) {
ev.stopPropagation();
ev.preventDefault();
// Check for confirmation if requested
if (ev.params.confirm) {
if (!window.confirm(ev.params.confirm)) {
return;
}
}
// Need to determine what the "main" element is that we're removing. It will
// have the class "fields".
const fields_el = ev.target.closest('.fields');
// If the item has the dataset.dynamicFormsNewItem attribute, then it was
// just added and can be removed from the DOM completely. Otherwise, it
// needs to be hidden and the _destroy field set to "true" and all
// required fields be set to not required.
if (this.isNewItem(fields_el)) {
fields_el.remove();
} else {
const field_parent = ev.target.parentNode;
const destroy_field = field_parent.querySelector('input[type=hidden][name*="[_destroy]"]');
destroy_field.value = 'true';
this.hideRecord(fields_el);
}
}
// Find all the items that have been marked for removal and hide them.
hideRemovedRecords() {
const _this = this;
this.destinationTarget.querySelectorAll('input[type=hidden][value=true][name$="[_destroy]"], input[type=checkbox][name$="[_destroy]"]:checked').forEach(function(destroy_field) {
const fields_el = destroy_field.closest('.fields');
_this.hideRecord(fields_el);
});
}
// Hide the item and set all required fields to not required. Also,
// move the item to the end of the list so it won't mess up striping.
hideRecord(fields_el) {
fields_el.style.display = 'none';
fields_el.querySelectorAll(':required').forEach((field) => field.required = false);
// this moves the row to the end so it won't mess up striping
this.destinationTarget.insertBefore(fields_el, null);
}
// This is called when the "Add" button is clicked. It will add a new item
// to the form. The "method" and "template" parameters are passed in from
// the data attributes on the button.
//
// When a new item is added:
// 1. Determine the next serial number to use for the item.
// 2. Replace the "___new_method___" string in the template with the new
// serial number.
// 3. Create a new element from the template.
// 4. Mark the new element as a new item.
// 5. Add the new element to the form.
addToForm(ev) {
ev.stopPropagation();
ev.preventDefault();
const item = ev.currentTarget;
const method = ev.params.method
let template = ev.params.template
const new_id = this.getNextId(this.destinationTarget, method);
template = this.replaceFieldNamesAndIds(template, method, new_id);
const new_element = this.getNewElementFromTemplate(template);
this.markNewItem(new_element);
this.destinationTarget.append(new_element);
}
getNewElementFromTemplate(template) {
const tag = this.getTagFromTemplate(template);
let new_template = null;
if (tag == 'tr') {
// A tr tag needs to be in a table tbody.
new_template = `<table><tbody>${template}</tbody></table>`;
} else if (tag == 'td') {
// A td tag needs to be in a table tbody tr.
new_template = `<table><tbody><tr>${template}</tr></tbody></table>`;
} else {
new_template = template;
}
const dom_parser = new DOMParser();
return dom_parser.parseFromString(new_template, 'text/html').body.querySelector(tag);
}
getTagFromTemplate(template) {
return template.match(/^\s*<([a-z]+)\b/i)[1].toLocaleLowerCase();
}
// I would love to do this by updating the "name" and "id" fields only, but
// there's a chance that this subform has a lower-level subform. In that
// case, the items in the lower-levels wouldn't have their names and ids
// updated. So, we need to do a string replace on the entire template.
replaceFieldNamesAndIds(txt, method_name, new_id) {
const regexp = new RegExp(`___new_${method_name}___`, 'g');
return txt.replace(regexp, new_id);
}
getNextId(destination, method_name) {
// This will get a list of relevant fields, extract the id from the name,
// convert it to an integer, sort it, and then pop the last one off the
// list. This will give us the last id, we can then add one to it to get
// the next id.
const all_inputs = Array.from(destination.querySelectorAll(`[name*='[${method_name}_attributes]']`))
if (all_inputs.length == 0) {
return 0;
} else {
const re_serial_num = new RegExp(`\\[${method_name}_attributes\\]\\[(\\d+)\\]`)
const max_id = all_inputs
.map((ctrl) => ctrl.name.match(re_serial_num)[1])
.map((x) => parseInt(x))
.toSorted((a,b) => a-b)
.pop()
return max_id + 1;
}
}
markNewItem(item) {
item.dataset.dynamicFormsNewItem = true;
}
isNewItem(item) {
return item.dataset.dynamicFormsNewItem
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment