PageSourceSearch

https://www.lombardchamber.com/assets/controllers/ticket_questions_builder_controller-176805fc.js

js lombardchamber.com collected 2026-09-24 12:47:41 UTC 30,328 bytes, 893 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3export default class extends Controller {
4  static targets = [
5    "dialog",
6    "templateInput",
7    "promptInput",
8    "schemaInput",
9    "aiModelInput",
10    "ticketNameInput",
11    "modalTemplate",
12    "modalPrompt",
13    "previewButton",
14    "applyButton",
15    "status",
16    "warning",
17    "fields",
18    "empty",
19    "summary",
20    "title",
21    "description",
22    "backButton",
23    "editorView",
24    "copyView",
25    "copyTrigger",
26    "copyPicker",
27    "copyButton",
28    "copyWarning",
29    "replacementNotice",
30  ]
31
32  static values = {
33    previewUrl: String,
34    questionSourceUrl: String,
35    submitOnApply: Boolean,
36  }
37
38  connect() {
39    this.lastPreview = null
40    this.previewDirty = false
41    this.updateSummaryFromHidden()
42  }
43
44  disconnect() {
45    this.previewRequest?.abort()
46    this.copyRequest?.abort()
47  }
48
49  open() {
50    if (!this.hasDialogTarget) return
51
52    this.syncModalFromHidden()
53    this.renderFromHiddenSchema()
54    this.previewDirty = false
55    this.setStatus("")
56    this.switchCopyView(false)
57    this.dialogTarget.showModal()
58  }
59
60  close() {
61    if (!this.hasDialogTarget) return
62    this.dialogTarget.close()
63  }
64
65  backdropClose(event) {
66    if (event.target === this.dialogTarget) this.close()
67  }
68
69  dialogClosed() {
70    this.previewRequest?.abort()
71    this.previewRequest = null
72    this.setBusy(false)
73    this.cancelCopyRequest()
74    this.resetCopyPicker()
75    this.updateSummaryFromHidden()
76  }
77
78  showCopy(event) {
79    event.preventDefault()
80    this.syncQuestionsFromFields()
81    const count = this.currentDraftQuestions().length
82    this.replacementNoticeTarget.textContent = `Copying will replace the ${count} ${count === 1 ? "question" : "questions"} in this draft.`
83    this.replacementNoticeTarget.classList.toggle("hidden", count === 0)
84    this.setCopyWarning("")
85    this.switchCopyView(true)
86    this.copyPickerTarget.querySelector("[role='combobox']")?.focus()
87  }
88
89  showEditor(event) {
90    event?.preventDefault()
91    this.cancelCopyRequest()
92    this.resetCopyPicker()
93    this.switchCopyView(false)
94    this.copyTriggerTarget.focus()
95  }
96
97  switchCopyView(copying) {
98    this.editorViewTarget.classList.toggle("hidden", copying)
99    this.copyViewTarget.classList.toggle("hidden", !copying)
100    this.descriptionTarget.classList.toggle("hidden", copying)
101    this.backButtonTarget.hidden = !copying
102    this.titleTarget.textContent = copying ? "Copy questions" : "Registration questions"
103    this.dialogTarget.classList.toggle("max-w-2xl", copying)
104    this.dialogTarget.classList.toggle("max-w-6xl", !copying)
105  }
106
107  copySourceChanged() {
108    this.cancelCopyRequest()
109    this.setCopyWarning("")
110  }
111
112  copyOnEnter(event) {
113    if (event.defaultPrevented || event.target.getAttribute("role") !== "combobox") return
114
115    event.preventDefault()
116    if (!this.copyRequest) this.copyQuestions(event)
117  }
118
119  resetCopyPicker() {
120    const picker = this.copyPickerTarget.querySelector("[data-controller='search-select']")
121    this.application.getControllerForElementAndIdentifier(picker, "search-select")?.selectValue("")
122  }
123
124  async copyQuestions(event) {
125    event.preventDefault()
126    const sourceId = this.copyPickerTarget.querySelector("[data-search-select-target='source']").value
127    if (!sourceId) {
128      this.setCopyWarning("Choose a ticket to copy questions from.")
129      this.copyPickerTarget.querySelector("[role='combobox']")?.focus()
130      return
131    }
132
133    this.cancelCopyRequest()
134    const request = new AbortController()
135    this.copyRequest = request
136    this.copyButtonTarget.disabled = true
137    this.copyButtonTarget.textContent = "Copying..."
138    this.setCopyWarning("")
139
140    const url = new URL(this.questionSourceUrlValue, window.location.origin)
141    url.searchParams.set("source_ticket_id", sourceId)
142
143    try {
144      const response = await fetch(url, {
145        headers: { Accept: "application/json" },
146        credentials: "same-origin",
147        signal: request.signal,
148      })
149      const payload = await response.json().catch(() => ({}))
150      if (request.signal.aborted) return
151      if (!response.ok || !Array.isArray(payload.questions) || payload.questions.length === 0) {
152        throw new Error(payload.error || "Could not copy questions. Choose another ticket or try again.")
153      }
154
155      this.lastPreview = { questions: payload.questions, warning: "", ai_model: "" }
156      this.modalTemplateTarget.value = ""
157      this.modalPromptTarget.value = ""
158      this.previewDirty = false
159      this.renderPreview(payload.questions)
160      this.setWarning("")
161      this.setStatus("")
162      this.showEditor()
163    } catch (error) {
164      if (error.name !== "AbortError") {
165        this.setCopyWarning(error.message || "Could not copy questions. Try again.")
166      }
167    } finally {
168      if (this.copyRequest === request) this.cancelCopyRequest()
169    }
170  }
171
172  cancelCopyRequest() {
173    this.copyRequest?.abort()
174    this.copyRequest = null
175    this.copyButtonTarget.disabled = false
176    this.copyButtonTarget.textContent = "Copy questions"
177  }
178
179  setCopyWarning(message) {
180    this.copyWarningTarget.textContent = message
181    this.copyWarningTarget.classList.toggle("hidden", message === "")
182  }
183
184  templateChanged() {
185    this.previewDirty = true
186    this.setWarning("")
187    const templateValue = this.modalTemplateTarget.value.toString().trim()
188
189    if (templateValue === "") {
190      this.lastPreview = { questions: [], warning: "", ai_model: "" }
191      this.renderPreview([])
192      this.previewDirty = false
193      this.setStatus("")
194      return
195    }
196
197    this.lastPreview = { questions: [], warning: "", ai_model: "" }
198    this.renderPreview([])
199    this.generatePreview()
200  }
201
202  promptChanged() {
203    this.previewDirty = this.modalPromptTarget.value.toString().trim() !== ""
204    this.setWarning("")
205    this.setStatus("")
206  }
207
208  async generatePreview(event) {
209    event?.preventDefault()
210    if (!this.hasPreviewUrlValue) return
211
212    this.syncQuestionsFromFields()
213    const validationMessage = this.validationMessageForQuestions(this.currentDraftQuestions())
214    if (validationMessage) {
215      this.setWarning(validationMessage)
216      this.setStatus("")
217      return
218    }
219
220    this.setBusy(true)
221    this.setStatus("Generating preview...")
222    this.previewRequest?.abort()
223    const request = new AbortController()
224    this.previewRequest = request
225
226    try {
227      const response = await fetch(this.previewUrlValue, {
228        method: "POST",
229        headers: {
230          Accept: "application/json",
231          "X-CSRF-Token": this.csrfToken(),
232        },
233        credentials: "same-origin",
234        signal: request.signal,
235        body: this.previewFormData(),
236      })
237
238      const payload = await response.json()
239      if (request.signal.aborted) return
240      if (!response.ok) {
241        throw new Error(payload.error || "Could not generate question preview.")
242      }
243
244      const questions = Array.isArray(payload.questions) ? payload.questions : []
245      this.lastPreview = {
246        questions,
247        warning: payload.warning || "",
248        ai_model: payload.ai_model || "",
249      }
250      this.previewDirty = false
251      this.modalPromptTarget.value = ""
252
253      this.renderPreview(questions)
254      this.setWarning(payload.warning || "")
255      this.setStatus("")
256    } catch (error) {
257      if (error.name === "AbortError") return
258      this.setWarning(error.message || "Could not generate question preview.")
259      this.setStatus("")
260    } finally {
261      if (this.previewRequest === request) {
262        this.previewRequest = null
263        this.setBusy(false)
264      }
265    }
266  }
267
268  apply(event) {
269    event?.preventDefault()
270
271    if (this.previewDirty) {
272      this.setWarning("Generate preview or clear the revision request before applying.")
273      return
274    }
275
276    if (!this.lastPreview) {
277      this.lastPreview = { questions: [], warning: "", ai_model: "" }
278    }
279
280    this.syncQuestionsFromFields()
281    const validationMessage = this.validationMessageForQuestions(this.lastPreview.questions || [])
282    if (validationMessage) {
283      this.setWarning(validationMessage)
284      return
285    }
286
287    this.templateInputTarget.value = this.modalTemplateTarget.value
288    this.promptInputTarget.value = ""
289    this.schemaInputTarget.value = JSON.stringify(this.lastPreview.questions || [])
290    this.aiModelInputTarget.value = this.lastPreview.ai_model || ""
291
292    this.updateSummary((this.lastPreview.questions || []).length)
293    if (this.submitOnApplyValue && this.element instanceof HTMLFormElement) {
294      this.close()
295      this.element.requestSubmit()
296      return
297    }
298
299    this.close()
300  }
301
302  reset(event) {
303    event?.preventDefault()
304    this.modalTemplateTarget.value = ""
305    this.modalPromptTarget.value = ""
306    this.lastPreview = { questions: [], warning: "", ai_model: "" }
307    this.previewDirty = false
308    this.renderPreview([])
309    this.setWarning("")
310    this.setStatus("")
311  }
312
313  addQuestion(event) {
314    event?.preventDefault()
315
316    this.syncQuestionsFromFields()
317    const questions = this.currentDraftQuestions()
318    questions.push({
319      key: "",
320      label: `Question ${questions.length + 1}`,
321      field_type: "text",
322      scope: "registration",
323      required: false,
324      options: [],
325      option_capacities: {},
326      placeholder: "",
327      help_text: "",
328    })
329    this.lastPreview = { ...(this.lastPreview || {}), questions }
330    this.previewDirty = this.modalPromptTarget.value.toString().trim() !== ""
331    this.renderPreview(questions)
332    this.setWarning("")
333    this.setStatus("")
334  }
335
336  removeQuestion(event) {
337    event?.preventDefault()
338
339    const index = this.questionIndexFromEvent(event)
340    if (index === null) return
341
342    this.syncQuestionsFromFields()
343    const questions = this.currentDraftQuestions()
344    questions.splice(index, 1)
345    this.lastPreview = { ...(this.lastPreview || {}), questions }
346    this.previewDirty = this.modalPromptTarget.value.toString().trim() !== ""
347    this.renderPreview(questions)
348    this.setWarning("")
349    this.setStatus("")
350  }
351
352  moveQuestion(event) {
353    event?.preventDefault()
354
355    const index = this.questionIndexFromEvent(event)
356    if (index === null) return
357
358    const direction = event.currentTarget.dataset.direction === "down" ? 1 : -1
359    const nextIndex = index + direction
360
361    this.syncQuestionsFromFields()
362    const questions = this.currentDraftQuestions()
363    if (nextIndex < 0 || nextIndex >= questions.length) return
364
365    const [question] = questions.splice(index, 1)
366    questions.splice(nextIndex, 0, question)
367    this.lastPreview = { ...(this.lastPreview || {}), questions }
368    this.previewDirty = this.modalPromptTarget.value.toString().trim() !== ""
369    this.renderPreview(questions)
370    this.setWarning("")
371    this.setStatus("")
372  }
373
374  questionChanged(event) {
375    const wrapper = event.target.closest("[data-question-index]")
376    if (!wrapper) return
377
378    const index = Number.parseInt(wrapper.dataset.questionIndex, 10)
379    const attr = event.target.dataset.questionAttr
380    if (!Number.isInteger(index) || !attr) return
381
382    const questions = this.currentDraftQuestions()
383    const question = questions[index]
384    if (!question) return
385
386    if (attr === "required") {
387      question.required = event.target.checked
388    } else if (attr === "options") {
389      question.options = this.optionsFromText(event.target.value)
390      question.option_capacities = this.filteredOptionCapacities(question)
391    } else if (attr === "option_capacity") {
392      this.setOptionCapacity(question, event.target.dataset.questionOption, event.target.value)
393    } else {
394      question[attr] = event.target.value
395    }
396
397    if (attr === "field_type" || attr === "scope") {
398      if (attr === "field_type" && question.field_type !== "select") {
399        question.options = []
400        question.option_capacities = {}
401      }
402      this.lastPreview = { ...(this.lastPreview || {}), questions }
403      this.previewDirty = this.modalPromptTarget.value.toString().trim() !== ""
404      this.setWarning("")
405      this.renderPreview(questions)
406      return
407    }
408
409    this.lastPreview = { ...(this.lastPreview || {}), questions }
410    this.previewDirty = this.modalPromptTarget.value.toString().trim() !== ""
411    this.setWarning("")
412    this.updateSummary(questions.length)
413  }
414
415  choicesChanged(event) {
416    const wrapper = event.target.closest("[data-question-index]")
417    if (!wrapper) return
418
419    const index = Number.parseInt(wrapper.dataset.questionIndex, 10)
420    const question = this.currentDraftQuestions()[index]
421    const currentControl = wrapper.querySelector("[data-choice-limits]")
422    if (!question || !currentControl) return
423
424    currentControl.replaceWith(this.choiceLimitsControl(question))
425  }
426
427  syncModalFromHidden() {
428    this.modalTemplateTarget.value = this.templateInputTarget.value || ""
429    this.modalPromptTarget.value = this.promptInputTarget.value || ""
430  }
431
432  renderFromHiddenSchema() {
433    const raw = this.schemaInputTarget.value || "[]"
434    let questions = []
435
436    try {
437      const parsed = JSON.parse(raw)
438      questions = Array.isArray(parsed) ? parsed : []
439    } catch (_error) {
440      questions = []
441    }
442
443    this.lastPreview = {
444      questions,
445      warning: "",
446      ai_model: this.aiModelInputTarget.value || "",
447    }
448    this.renderPreview(questions)
449    this.setWarning("")
450  }
451
452  renderPreview(questions) {
453    this.fieldsTarget.innerHTML = ""
454    const safeQuestions = questions.map((question) => this.normalizeQuestion(question))
455    if (this.lastPreview) this.lastPreview.questions = safeQuestions
456
457    if (!safeQuestions.length) {
458      this.emptyTarget.classList.remove("hidden")
459      return
460    }
461
462    this.emptyTarget.classList.add("hidden")
463    safeQuestions.forEach((question, index) => {
464      this.fieldsTarget.appendChild(this.buildField(question, index))
465    })
466  }
467
468  buildField(question, index) {
469    const wrapper = document.createElement("div")
470    wrapper.dataset.questionIndex = index.toString()
471    wrapper.className = "rounded-lg border border-zinc-200 bg-white p-3"
472
473    const header = document.createElement("div")
474    header.className = "flex items-start justify-between gap-3"
475
476    const title = document.createElement("div")
477    title.className = "min-w-0"
478
479    const titleText = document.createElement("div")
480    titleText.className = "text-sm font-semibold text-zinc-900"
481    titleText.textContent = `Question ${index + 1}`
482    title.appendChild(titleText)
483
484    const summary = document.createElement("div")
485    summary.className = "mt-0.5 text-xs text-zinc-500"
486    summary.textContent = `${this.fieldTypeLabel(question.field_type)} / ${this.scopeLabel(question.scope)}`
487    title.appendChild(summary)
488    header.appendChild(title)
489
490    const actions = document.createElement("div")
491    actions.className = "flex shrink-0 items-center gap-1"
492    actions.appendChild(this.iconButton("Move up", "up", "^", index === 0))
493    actions.appendChild(this.iconButton("Move down", "down", "v", index === this.currentDraftQuestions().length - 1))
494
495    const remove = document.createElement("button")
496    remove.type = "button"
497    remove.className = "rounded-md px-2 py-1 text-xs font-medium text-rose-700 hover:bg-rose-50 focus:outline-none focus:ring-2 focus:ring-brand-200"
498    remove.textContent = "Remove"
499    remove.dataset.action = "click->ticket-questions-builder#removeQuestion"
500    actions.appendChild(remove)
501    header.appendChild(actions)
502    wrapper.appendChild(header)
503
504    const grid = document.createElement("div")
505    grid.className = "mt-3 grid gap-3 sm:grid-cols-2"
506
507    grid.appendChild(this.textControl({ label: "Label", attr: "label", value: question.label }))
508    grid.appendChild(this.typeControl(question.field_type))
509    grid.appendChild(this.scopeControl(question.scope))
510    grid.appendChild(this.checkboxControl(question.required))
511    grid.appendChild(this.textControl({ label: "Placeholder", attr: "placeholder", value: question.placeholder || "" }))
512    grid.appendChild(this.textControl({ label: "Help text", attr: "help_text", value: question.help_text || "", textarea: true, wide: true }))
513
514    if (question.field_type === "select") {
515      grid.appendChild(this.textControl({
516        label: "Choices",
517        attr: "options",
518        value: (question.options || []).join("\n"),
519        textarea: true,
520        wide: true,
521        help: "One choice per line.",
522        action: "input->ticket-questions-builder#questionChanged input->ticket-questions-builder#choicesChanged",
523      }))
524      grid.appendChild(this.choiceLimitsControl(question))
525    }
526
527    wrapper.appendChild(grid)
528
529    return wrapper
530  }
531
532  iconButton(label, direction, text, disabled) {
533    const button = document.createElement("button")
534    button.type = "button"
535    button.className = "inline-flex h-7 w-7 items-center justify-center rounded-md text-sm font-semibold text-zinc-500 hover:bg-zinc-50 hover:text-zinc-800 focus:outline-none focus:ring-2 focus:ring-brand-200 disabled:cursor-not-allowed disabled:opacity-40"
536    button.textContent = text
537    button.setAttribute("aria-label", label)
538    button.dataset.direction = direction
539    button.dataset.action = "click->ticket-questions-builder#moveQuestion"
540    button.disabled = disabled
541    return button
542  }
543
544  textControl({ label, attr, value, textarea = false, wide = false, help = "", action = "input->ticket-questions-builder#questionChanged" }) {
545    const wrapper = document.createElement("label")
546    wrapper.className = `${wide ? "sm:col-span-2" : ""} block text-xs font-medium text-zinc-500`
547    wrapper.textContent = label
548
549    const input = textarea ? document.createElement("textarea") : document.createElement("input")
550    if (textarea) input.rows = attr === "options" ? 3 : 2
551    if (!textarea) input.type = "text"
552    input.value = value || ""
553    input.dataset.questionAttr = attr
554    input.dataset.action = action
555    input.className = "mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-200"
556    wrapper.appendChild(input)
557
558    if (help) {
559      const helpText = document.createElement("div")
560      helpText.className = "mt-1 text-xs font-normal text-zinc-500"
561      helpText.textContent = help
562      wrapper.appendChild(helpText)
563    }
564
565    return wrapper
566  }
567
568  typeControl(value) {
569    const wrapper = document.createElement("label")
570    wrapper.className = "block text-xs font-medium text-zinc-500"
571    wrapper.textContent = "Field type"
572
573    const select = document.createElement("select")
574    select.dataset.questionAttr = "field_type"
575    select.dataset.action = "change->ticket-questions-builder#questionChanged"
576    select.className = "mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-200"
577
578    ;[
579      ["text", "Short text"],
580      ["textarea", "Long text"],
581      ["number", "Number"],
582      ["datetime", "Date and time"],
583      ["select", "Dropdown"],
584      ["checkbox", "Checkbox"],
585    ].forEach(([optionValue, label]) => {
586      const option = document.createElement("option")
587      option.value = optionValue
588      option.textContent = label
589      option.selected = optionValue === value
590      select.appendChild(option)
591    })
592
593    wrapper.appendChild(select)
594    return wrapper
595  }
596
597  scopeControl(value) {
598    const wrapper = document.createElement("label")
599    wrapper.className = "block text-xs font-medium text-zinc-500"
600    wrapper.textContent = "Who answers?"
601
602    const select = document.createElement("select")
603    select.dataset.questionAttr = "scope"
604    select.dataset.action = "change->ticket-questions-builder#questionChanged"
605    select.className = "mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-200"
606
607    ;[
608      ["registration", "Once per registration"],
609      ["attendee", "Each attendee"],
610    ].forEach(([optionValue, label]) => {
611      const option = document.createElement("option")
612      option.value = optionValue
613      option.textContent = label
614      option.selected = optionValue === value
615      select.appendChild(option)
616    })
617
618    wrapper.appendChild(select)
619    return wrapper
620  }
621
622  checkboxControl(value) {
623    const label = document.createElement("label")
624    label.className = "mt-5 inline-flex items-center gap-2 text-sm font-medium text-zinc-700"
625
626    const input = document.createElement("input")
627    input.type = "checkbox"
628    input.checked = value === true
629    input.dataset.questionAttr = "required"
630    input.dataset.action = "change->ticket-questions-builder#questionChanged"
631    input.className = "h-4 w-4 rounded border-zinc-300 text-zinc-900 focus:ring-2 focus:ring-brand-200 focus:ring-offset-0"
632
633    const text = document.createElement("span")
634    text.textContent = "Required"
635
636    label.appendChild(input)
637    label.appendChild(text)
638    return label
639  }
640
641  choiceLimitsControl(question) {
642    const details = document.createElement("details")
643    details.dataset.choiceLimits = "true"
644    details.className = "sm:col-span-2 rounded-md border border-zinc-200 px-3 py-2"
645
646    const capacities = question.option_capacities || {}
647    const limitCount = Object.keys(capacities).length
648    const summary = document.createElement("summary")
649    summary.className = "cursor-pointer text-xs font-medium text-zinc-600"
650    summary.textContent = limitCount > 0 ? `Choice limits (${limitCount} set)` : "Choice limits"
651    details.appendChild(summary)
652
653    const fields = document.createElement("div")
654    fields.className = "mt-3 grid gap-2 sm:grid-cols-2"
655    question.options.forEach((option) => {
656      const label = document.createElement("label")
657      label.className = "block text-xs font-medium text-zinc-500"
658      label.textContent = option
659
660      const input = document.createElement("input")
661      input.type = "number"
662      input.min = "0"
663      input.step = "1"
664      input.placeholder = "Unlimited"
665      input.value = Object.prototype.hasOwnProperty.call(capacities, option) ? capacities[option] : ""
666      input.dataset.questionAttr = "option_capacity"
667      input.dataset.questionOption = option
668      input.dataset.action = "input->ticket-questions-builder#questionChanged"
669      input.className = "mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-200"
670
671      label.appendChild(input)
672      fields.appendChild(label)
673    })
674    details.appendChild(fields)
675    return details
676  }
677
678  updateSummaryFromHidden() {
679    const raw = this.schemaInputTarget.value || "[]"
680    try {
681      const parsed = JSON.parse(raw)
682      const count = Array.isArray(parsed) ? parsed.length : 0
683      this.updateSummary(count)
684    } catch (_error) {
685      this.updateSummary(0)
686    }
687  }
688
689  updateSummary(count) {
690    if (!this.hasSummaryTarget) return
691    if (count > 0) {
692      this.summaryTarget.textContent = `${count} additional ${count === 1 ? "registration question" : "registration questions"} selected.`
693      this.summaryTarget.classList.remove("hidden")
694    } else {
695      this.summaryTarget.textContent = ""
696      this.summaryTarget.classList.add("hidden")
697    }
698  }
699
700  setWarning(message) {
701    if (!this.hasWarningTarget) return
702    this.warningTarget.textContent = message
703    this.warningTarget.classList.toggle("hidden", message.toString().trim() === "")
704  }
705
706  setStatus(message) {
707    if (!this.hasStatusTarget) return
708    this.statusTarget.textContent = message
709  }
710
711  setBusy(isBusy) {
712    if (this.hasCopyTriggerTarget) this.copyTriggerTarget.disabled = isBusy
713    if (this.hasPreviewButtonTarget) {
714      this.previewButtonTarget.disabled = isBusy
715      this.previewButtonTarget.classList.toggle("opacity-60", isBusy)
716    }
717    if (this.hasApplyButtonTarget) {
718      this.applyButtonTarget.disabled = isBusy
719      this.applyButtonTarget.classList.toggle("opacity-60", isBusy)
720    }
721  }
722
723  previewFormData() {
724    this.syncQuestionsFromFields()
725    const draftQuestions = this.currentDraftQuestions()
726    const templateValue = this.modalTemplateTarget.value.toString().trim()
727    const applyTemplate = templateValue !== "" && draftQuestions.length === 0
728
729    const formData = new FormData()
730    formData.append("event_ticket[question_template_key]", applyTemplate ? templateValue : "")
731    formData.append("event_ticket[registration_questions_prompt]", this.modalPromptTarget.value || "")
732    formData.append("event_ticket[registration_questions_base_schema_json]", JSON.stringify(draftQuestions))
733    formData.append("event_ticket[ticket_name]", this.ticketNameInputTarget.value || "")
734    return formData
735  }
736
737  currentDraftQuestions() {
738    if (!this.lastPreview || !Array.isArray(this.lastPreview.questions)) return []
739    return this.lastPreview.questions
740  }
741
742  syncQuestionsFromFields() {
743    if (!this.hasFieldsTarget || !this.lastPreview) return
744
745    const wrappers = Array.from(this.fieldsTarget.querySelectorAll("[data-question-index]"))
746    if (wrappers.length === 0) return
747
748    const current = this.currentDraftQuestions()
749    this.lastPreview.questions = wrappers.map((wrapper) => {
750      const index = Number.parseInt(wrapper.dataset.questionIndex, 10)
751      const existing = current[index] || {}
752      const next = this.normalizeQuestion(existing)
753
754      wrapper.querySelectorAll("[data-question-attr]").forEach((input) => {
755        const attr = input.dataset.questionAttr
756        if (attr === "required") {
757          next.required = input.checked
758        } else if (attr === "options") {
759          next.options = this.optionsFromText(input.value)
760        } else if (attr === "option_capacity") {
761          this.setOptionCapacity(next, input.dataset.questionOption, input.value)
762        } else {
763          next[attr] = input.value
764        }
765      })
766
767      if (next.field_type !== "select") {
768        next.options = []
769        next.option_capacities = {}
770      } else {
771        next.option_capacities = this.filteredOptionCapacities(next)
772      }
773      return next
774    })
775  }
776
777  validationMessageForQuestions(questions) {
778    const invalidLabel = questions.find((question) => question.label.toString().trim() === "")
779    if (invalidLabel) return "Add a label for each question before applying."
780
781    const labels = questions.map((question) =>
781 question.label.toString().trim().toLowerCase()).filter(Boolean)
782    const duplicateLabel = labels.find((label, index) => labels.indexOf(label) !== index)
783    if (duplicateLabel) return "Use a different label for each question."
784
785    const keys = questions.map((question) => this.questionKeyForValidation(question)).filter(Boolean)
786    const duplicateKey = keys.find((key, index) => keys.indexOf(key) !== index)
787    if (duplicateKey) return "Use a different label for each question."
788
789    const invalidSelect = questions.find((question) => question.field_type === "select" && question.options.length === 0)
790    if (invalidSelect) return "Add at least one choice for each dropdown question."
791
792    return ""
793  }
794
795  questionKeyForValidation(question) {
796    return (question.key || question.label)
797      .toString()
798      .trim()
799      .toLowerCase()
800      .replace(/[^a-z0-9]+/g, "_")
801      .replace(/^_+|_+$/g, "")
802      .slice(0, 50)
803  }
804
805  normalizeQuestion(question) {
806    const allowedTypes = ["text", "textarea", "number", "datetime", "select", "checkbox"]
807    const fieldType = allowedTypes.includes(question.field_type) ? question.field_type : "text"
808    const options = fieldType === "select" ? (Array.isArray(question.options) ? question.options : []) : []
809    const rawCapacities = question.option_capacities && typeof question.option_capacities === "object"
810      ? question.option_capacities
811      : {}
812    const optionCapacities = {}
813    options.forEach((option) => {
814      const rawCapacity = rawCapacities[option]
815      if (rawCapacity === null || rawCapacity === undefined || rawCapacity.toString().trim() === "") return
816
817      const capacity = Number(rawCapacity)
818      if (Number.isInteger(capacity) && capacity >= 0) optionCapacities[option] = capacity
819    })
820
821    return {
822      key: question.key || "",
823      label: question.label || "",
824      field_type: fieldType,
825      scope: question.scope === "attendee" ? "attendee" : "registration",
826      required: question.required === true,
827      options,
828      option_capacities: optionCapacities,
829      placeholder: question.placeholder || "",
830      help_text: question.help_text || "",
831    }
832  }
833
834  optionsFromText(value) {
835    return value
836      .split(/\r?\n/)
837      .map((option) => option.trim())
838      .filter((option, index, options) => option && options.indexOf(option) === index)
839  }
840
841  setOptionCapacity(question, option, value) {
842    if (!option) return
843
844    question.option_capacities ||= {}
845    const normalized = value.toString().trim()
846    if (normalized === "") {
847      delete question.option_capacities[option]
848      return
849    }
850
851    const capacity = Number(normalized)
852    if (Number.isInteger(capacity) && capacity >= 0) {
853      question.option_capacities[option] = capacity
854    } else {
855      delete question.option_capacities[option]
856    }
857  }
858
859  filteredOptionCapacities(question) {
860    const capacities = question.option_capacities || {}
861    return (question.options || []).reduce((filtered, option) => {
862      if (Object.prototype.hasOwnProperty.call(capacities, option)) filtered[option] = capacities[option]
863      return filtered
864    }, {})
865  }
866
867  questionIndexFromEvent(event) {
868    const wrapper = event.currentTarget.closest("[data-question-index]")
869    if (!wrapper) return null
870
871    const index = Number.parseInt(wrapper.dataset.questionIndex, 10)
872    return Number.isInteger(index) ? index : null
873  }
874
875  fieldTypeLabel(value) {
876    return {
877      text: "Short text",
878      textarea: "Long text",
879      number: "Number",
880      datetime: "Date and time",
881      select: "Dropdown",
882      checkbox: "Checkbox",
883    }[value] || "Short text"
884  }
885
886  scopeLabel(value) {
887    return value === "attendee" ? "Each attendee" : "Once per registration"
888  }
889
890  csrfToken() {
891    return document.querySelector('meta[name="csrf-token"]')?.content || ""
892  }
893}

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.