1import { Controller } from "@hotwired/stimulus" 2 3// Builds a quiz form: questions, answer options, and (for a personality quiz) 4// result cards plus the answer -> result mapping. 5// 6// Answers reference a result by its KEY rather than its database id, because a 7// result card typed into this form has no id until the whole form is saved. 8// Quiz#link_option_outcomes matches them up server-side afterwards. 9export default class extends Controller { 10 static targets = [ 11 "questionsContainer", "questionTemplate", 12 "outcomesContainer", "outcomeTemplate", 13 "outcomeTitle", "outcomeKey", "outcomeSelect" 14 ] 15 16 connect() { 17 this.refreshOutcomeSelects() 18 } 19 20 // --- Questions --------------------------------------------------------- 21 22 addQuestion(event) { 23 event.preventDefault() 24 const content = this.questionTemplateTarget.innerHTML 25 .replace(/NEW_QUESTION/g, new Date().getTime()) 26 this.questionsContainerTarget.insertAdjacentHTML("beforeend", content) 27 this.renumberQuestions() 28 this.refreshOutcomeSelects() 29 } 30 31 removeQuestion(event) { 32 event.preventDefault() 33 const questionEl = event.target.closest(".quiz-question-fields") 34 if (!questionEl) return 35 36 this._removeOrMarkDestroyed(questionEl) 37 this.renumberQuestions() 38 this.refreshOutcomeSelects() 39 } 40 41 // --- Options ----------------------------------------------------------- 42 43 addOption(event) { 44 event.preventDefault() 45 const questionEl = event.target.closest(".quiz-question-fields") 46 if (!questionEl) return 47 48 const container = questionEl.querySelector(".quiz-options-container") 49 const idx = new Date().getTime() 50 const namePrefix = questionEl.dataset.questionPrefix 51 52 const optionHtml = ` 53 <div class="flex flex-wrap items-center gap-2 quiz-option-fields"> 54 <input type="text" name="${namePrefix}[quiz_options_attributes][${idx}][label]" class="input input-bordered input-sm flex-1 min-w-[12rem]" placeholder="Option text..."> 55 <label class="flex items-center gap-1 text-sm quiz-correct-toggle"> 56 <input type="checkbox" name="${namePrefix}[quiz_options_attributes][${idx}][correct]" value="1" class="checkbox checkbox-sm checkbox-success"> 57 Correct 58 </label> 59 <select name="${namePrefix}[quiz_options_attributes][${idx}][outcome_key]" class="select select-bordered select-sm quiz-outcome-select hidden" data-quiz-builder-target="outcomeSelect"></select> 60 <input type="hidden" name="${namePrefix}[quiz_options_attributes][${idx}][position]" value="0"> 61 <button type="button" class="btn btn-ghost btn-xs text-error" data-action="quiz-builder#removeOption">X</button> 62 </div> 63 ` 64 container.insertAdjacentHTML("beforeend", optionHtml) 65 this.renumberQuestions() 66 this.refreshOutcomeSelects() 67 } 68 69 removeOption(event) { 70 event.preventDefault() 71 const optionEl = event.target.closest(".quiz-option-fields") 72 if (!optionEl) return 73 74 this._removeOrMarkDestroyed(optionEl) 75 this.renumberQuestions() 76 } 77 78 // --- Results ----------------------------------------------------------- 79 80 addOutcome(event) { 81 event.preventDefault() 82 const content = this.outcomeTemplateTarget.innerHTML 83 .replace(/NEW_OUTCOME/g, new Date().getTime()) 84 this.outcomesContainerTarget.insertAdjacentHTML("beforeend", content) 85 this.renumberOutcomes() 86 this.refreshOutcomeSelects() 87 } 88 89 removeOutcome(event) { 90 event.preventDefault() 91 const outcomeEl = event.target.closest(".quiz-outcome-fields") 92 if (!outcomeEl) return 93 94 this._removeOrMarkDestroyed(outcomeEl) 95 this.renumberOutcomes() 96 this.refreshOutcomeSelects() 97 } 98 99 // A saved result keeps the key it was created with, so renaming it does not 100 // orphan the answers already pointing at it. 101 outcomeTitleChanged(event) { 102 const outcomeEl = event.target.closest(".quiz-outcome-fields") 103 if (!outcomeEl) return 104 105 const keyInput = outcomeEl.querySelector("input[name$='[key]']") 106 if (keyInput && keyInput.dataset.locked !== "true") { 107 keyInput.value = this._parameterize(event.target.value) 108 } 109 this.refreshOutcomeSelects() 110 } 111 112 // Rebuilds every answer's result dropdown from the result cards currently on 113 // screen, preserving each answer's existing pick. 114 refreshOutcomeSelects() { 115 const outcomes = this._activeOutcomes() 116 const personalityMode = outcomes.length > 0 117
118 this.outcomeSelectTargets.forEach((select) => { 119 const current = select.value || select.dataset.selected || "" 120 select.innerHTML = "" 121 122 const blank = document.createElement("option") 123 blank.value = "" 124 blank.textContent = "â pick a result â" 125 select.appendChild(blank) 126 127 outcomes.forEach(({ key, title }) => { 128 const option = document.createElement("option") 129 option.value = key 130 option.textContent = title 131 select.appendChild(option) 132 }) 133 134 if (outcomes.some((o) => o.key === current)) select.value = current 135 select.classList.toggle("hidden", !personalityMode) 136 }) 137 138 // Right answers and result cards are mutually exclusive modes. 139 this.element.querySelectorAll(".quiz-correct-toggle").forEach((toggle) => { 140 toggle.classList.toggle("hidden", personalityMode) 141 }) 142 } 143 144 // --- Position renumbering ---------------------------------------------- 145 // 146 // Positions must be unique within their parent, so they are recalculated from 147 // the on-screen order every time something is added or removed. 148 149 renumberQuestions() { 150 let questionIndex = 0 151 this.questionsContainerTarget 152 .querySelectorAll(".quiz-question-fields") 153 .forEach((questionEl) => { 154 if (this._isRemoved(questionEl)) return 155 156 const positionInput = Array.from( 157 questionEl.querySelectorAll("input[name$='[position]']") 158 ).find((input) => !input.name.includes("[quiz_options_attributes]")) 159 if (positionInput) positionInput.value = questionIndex 160 questionIndex += 1 161 162 let optionIndex = 0 163 questionEl.querySelectorAll(".quiz-option-fields").forEach((optionEl) => { 164 if (this._isRemoved(optionEl)) return 165 const input = optionEl.querySelector("input[name$='[position]']") 166 if (input) input.value = optionIndex 167 optionIndex += 1 168 }) 169 }) 170 } 171 172 renumberOutcomes() { 173 let index = 0 174 this.outcomesContainerTarget 175 .querySelectorAll(".quiz-outcome-fields") 176 .forEach((outcomeEl) => { 177 if (this._isRemoved(outcomeEl)) return 178 const input = outcomeEl.querySelector("input[name$='[position]']") 179 if (input) input.value = index 180 index += 1 181 }) 182 } 183 184 // --- Helpers ----------------------------------------------------------- 185 186 _activeOutcomes() { 187 return Array.from( 188 this.outcomesContainerTarget.querySelectorAll(".quiz-outcome-fields") 189 ) 190 .filter((el) => !this._isRemoved(el)) 191 .map((el) => { 192 const title = el.querySelector("input[name$='[title]']")?.value?.trim() 193 const keyInput = el.querySelector("input[name$='[key]']") 194 const key = keyInput?.value?.trim() || this._parameterize(title || "") 195 return { key, title: title || "(untitled result)" } 196 }) 197 .filter((outcome) => outcome.key.length > 0) 198 } 199 200 _removeOrMarkDestroyed(element) { 201 const destroyInput = element.querySelector("input[name$='[_destroy]']") 202 if (destroyInput) { 203 destroyInput.value = "1" 204 destroyInput.checked = true 205 element.style.display = "none" 206 element.dataset.removed = "true" 207 } else { 208 element.remove() 209 } 210 } 211 212 _isRemoved(element) { 213 return element.dataset.removed === "true" || element.style.display === "none" 214 } 215 216 // Mirrors Rails' String#parameterize closely enough for result names. 217 _parameterize(value) { 218 return value 219 .toLowerCase() 220 .replace(/[^a-z0-9]+/g, "-") 221 .replace(/^-+|-+$/g, "") 222 } 223}
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.