1import { Controller } from "@hotwired/stimulus"; 2 3// Connects to data-controller="nested-form" 4export default class extends Controller { 5 static targets = ["container", "template", "item", "button", "maxHint"]; 6 static values = { 7 index: Number, 8 maxFields: { 9 type: Number, 10 default: 5, 11 }, 12 minItems: { 13 type: Number, 14 default: 1, 15 }, 16 maxHintText: String, 17 }; 18 19 connect() { 20 if (!this.hasIndexValue) { 21 this.indexValue = this.itemTargets.length; 22 } 23 24 this.updateButtonStates(); 25 } 26 27 add(event) { 28 event.preventDefault(); 29 30 if (this.maxReached()) { 31 return; 32 } 33 34 const content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, this.indexValue); 35 this.containerTarget.insertAdjacentHTML("beforeend", content); 36 this.indexValue += 1; 37 38 const newItem = this.itemTargets[this.itemTargets.length - 1]; 39 const focusableField = newItem?.querySelector( 40 "input:not([type='hidden']):not([disabled]), textarea:not([disabled]), select:not([disabled])", 41 ); 42 43 if (focusableField) { 44 requestAnimationFrame(() => focusableField.focus()); 45 } 46 47 this.dispatchLifecycleEvent("add"); 48 this.updateButtonStates(); 49 } 50 51 remove(event) { 52 event.preventDefault(); 53 54 const item = event.currentTarget.closest('[data-nested-form-target="item"]'); 55 56 if (!item) { 57 return; 58 } 59 60 if (!this.canRemoveItem()) { 61 return; 62 } 63 64 const destroyInput = item.querySelector('input[name*="[_destroy]"]'); 65 66 if (item.dataset.newRecord === "true" || !destroyInput) { 67 item.remove(); 68 this.dispatchLifecycleEvent("remove"); 69 this.updateButtonStates(); 70 return; 71 } 72 73 destroyInput.value = "1"; 74 item.classList.add("hidden"); 75 76 this.dispatchLifecycleEvent("remove"); 77 this.updateButtonStates(); 78 } 79 80 activeItemCount() { 81 return this.itemTargets.filter((item) => !item.classList.contains("hidden")).length; 82 } 83 84 canRemoveItem() { 85 return this.activeItemCount() > this.minItemsValue; 86 } 87 88 updateButtonStates() { 89 this.updateAddButtonState(); 90 this.updateRemoveButtonsState(); 91 this.updateMaxHintState(); 92 } 93 94 updateAddButtonState() { 95 const addButton = this.addButtonElement; 96 if (!addButton) { 97 return; 98 } 99 100 addButton.disabled = this.maxReached(); 101 } 102 103 updateRemoveButtonsState() { 104 const disableRemove = !this.canRemoveItem(); 105 this.removeButtons.forEach((button) => { 106 button.disabled = disableRemove; 107 }); 108 } 109 110 get addButtonElement() { 111 if (this.hasButtonTarget) { 112 return this.buttonTarget; 113 } 114 115 return this.element.querySelector('[data-action~="nested-form#add"]'); 116 } 117 118 get removeButtons() { 119 return Array.from(this.element.querySelectorAll('[data-action~="nested-form#remove"]')); 120 } 121 122 updateMaxHintState() { 123 if (!this.hasMaxHintTarget) { 124 return; 125 } 126 127 const showHint = this.maxReached(); 128 this.maxHintTarget.classList.toggle("hidden", !showHint); 129 130 if (this.hasMaxHintTextValue && this.maxHintTextValue.length > 0) { 131 this.maxHintTarget.textContent = this.maxHintTextValue; 132 return; 133 } 134 135 this.maxHintTarget.textContent = `Maximum of ${this.maxFieldsValue} items reached.`; 136 } 137 138 maxReached() { 139 return this.maxFieldsValue > 0 && this.activeItemCount() >= this.maxFieldsValue; 140 } 141 142 dispatchLifecycleEvent(actionName) { 143 this.element.dispatchEvent(new CustomEvent(`rails-nested-form:${actionName}`, { bubbles: true })); 144 } 145};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.