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.