Created
September 28, 2023 04:02
-
-
Save mdchaney/f25b2afd8f1253d07bd234a1b3f00a93 to your computer and use it in GitHub Desktop.
Stimulus-based subform
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- | |
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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- | |
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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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