PageSourceSearch

https://cooksclub.worldspice.com/assets/controllers/quiz_builder_controller-bbe207b8.js

js worldspice.com collected 2026-10-03 21:51:04 UTC 7,948 bytes, 223 lines download raw bytes

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.