import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js'; import { canEdit, showToast } from '../app.js'; import { navigate } from '../router.js'; import { OPTION_TYPES, layoutSelectHTML, layoutLabel, parseConfig, questionLocalIds, configFormHTML, readConfigFromForm, esc, } from '../editor-utils.js'; let questionnaire = null; let questions = []; let isNew = false; let activeTab = 'questions'; // ── Entry point ────────────────────────────────────────────────────────── export async function editorPage(params) { const app = document.getElementById('app'); isNew = !params.id || params.id === 'new'; activeTab = 'questions'; app.innerHTML = `
`; if (isNew) { questionnaire = { questionnaireID: null, name: '', version: '1.0', state: 'draft', orderIndex: 0, showPoints: 0, conditionJson: '{}' }; questions = []; renderEditor(); } else { try { const [qnData, quData] = await Promise.all([ apiGet('questionnaires.php'), apiGet(`questions.php?questionnaireID=${encodeURIComponent(params.id)}`) ]); questionnaire = (qnData.questionnaires || []).find(q => q.questionnaireID === params.id); if (!questionnaire) { showToast('Questionnaire not found', 'error'); navigate('#/'); return; } questions = quData.questions || []; document.getElementById('editorTitle').textContent = questionnaire.name; renderEditor(); } catch (e) { showToast(e.message, 'error'); document.getElementById('editorContent').innerHTML = `

${esc(e.message)}

`; } } } // ── Main editor render (helpers in editor-utils.js) ───────────────────── function renderEditor() { const container = document.getElementById('editorContent'); const editable = canEdit(); const condJson = questionnaire.conditionJson || '{}'; container.innerHTML = `

Questionnaire Details

${editable ? `
Availability Condition (JSON)
` : ''} ${editable ? `` : ''}
${!isNew ? `

Questions

${editable ? `` : ''}
` : '

Save the questionnaire first, then add questions.

'} `; if (editable) { document.getElementById('saveMetaBtn').addEventListener('click', saveMeta); } if (!isNew) { initTabs(); renderQuestions(); if (editable) { document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionForm); initDragReorder(); } } } function initTabs() { const tabBar = document.getElementById('editorTabs'); if (!tabBar) return; tabBar.querySelectorAll('button').forEach(btn => { btn.addEventListener('click', () => { const tab = btn.dataset.tab; if (tab === activeTab) return; activeTab = tab; tabBar.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); document.getElementById('tabContent-questions').style.display = tab === 'questions' ? '' : 'none'; document.getElementById('tabContent-translations').style.display = tab === 'translations' ? '' : 'none'; if (tab === 'translations') loadTranslationsTab(); }); }); } function formatJson(str) { try { return JSON.stringify(JSON.parse(str), null, 2); } catch { return str; } } // ── Save questionnaire meta ────────────────────────────────────────────── async function saveMeta() { const name = document.getElementById('metaName').value.trim(); const version = document.getElementById('metaVersion').value.trim(); const state = document.getElementById('metaState').value; const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10); const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0; let conditionJson = '{}'; const condEl = document.getElementById('metaCondition'); if (condEl) { try { JSON.parse(condEl.value); conditionJson = condEl.value.trim(); } catch { showToast('Condition JSON is invalid', 'error'); return; } } if (!name) { showToast('Name is required', 'error'); return; } try { if (isNew) { const data = await apiPost('questionnaires.php', { name, version, state, orderIndex, showPoints, conditionJson }); if (data.success) { questionnaire = data.questionnaire; isNew = false; showToast('Questionnaire created', 'success'); navigate(`#/questionnaire/${questionnaire.questionnaireID}`); } } else { const data = await apiPut('questionnaires.php', { questionnaireID: questionnaire.questionnaireID, name, version, state, orderIndex, showPoints, conditionJson }); if (data.success) { questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson }; document.getElementById('editorTitle').textContent = name; showToast('Saved', 'success'); } } } catch (e) { showToast(e.message, 'error'); } } // ── Add question form ──────────────────────────────────────────────────── function showAddQuestionForm() { const wrapper = document.getElementById('addQuestionFormWrapper'); if (!wrapper) return; const pendingOptions = []; wrapper.style.display = ''; wrapper.innerHTML = `

New Question

${layoutSelectHTML('radio_question', 'aq_type')}
    `; function updateTypeUI() { const type = document.getElementById('aq_type').value; document.getElementById('aq_options_section').style.display = OPTION_TYPES.has(type) ? '' : 'none'; document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg'); } function renderPendingOptions() { const list = document.getElementById('aq_pending_options'); if (!list) return; if (!pendingOptions.length) { list.innerHTML = `
  • No options yet.
  • `; return; } list.innerHTML = pendingOptions.map((opt, i) => `
  • ${esc(opt.text)} ${opt.points} pts ${opt.nextQuestionId ? `→ ${esc(opt.nextQuestionId)}` : ''}
  • `).join(''); list.querySelectorAll('.remove-pending-opt').forEach(btn => { btn.addEventListener('click', () => { pendingOptions.splice(parseInt(btn.dataset.idx, 10), 1); renderPendingOptions(); }); }); } function addPendingOption() { const text = document.getElementById('aq_opt_text').value.trim(); const pts = parseInt(document.getElementById('aq_opt_pts').value || '0', 10); const next = document.getElementById('aq_opt_next').value; if (!text) { showToast('Option key is required', 'error'); return; } pendingOptions.push({ text, points: pts, nextQuestionId: next }); renderPendingOptions(); document.getElementById('aq_opt_text').value = ''; document.getElementById('aq_opt_pts').value = '0'; document.getElementById('aq_opt_next').value = ''; document.getElementById('aq_opt_text').focus(); } updateTypeUI(); renderPendingOptions(); document.getElementById('aq_text').focus(); document.getElementById('aq_type').addEventListener('change', updateTypeUI); document.getElementById('aq_opt_add').addEventListener('click', addPendingOption); document.getElementById('aq_opt_text').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addPendingOption(); } }); document.getElementById('aq_submit').addEventListener('click', async () => { const text = document.getElementById('aq_text').value.trim(); const type = document.getElementById('aq_type').value; const isRequired = document.getElementById('aq_required').checked ? 1 : 0; const configJson = readConfigFromForm(type, 'aq_cfg'); if (!text) { showToast('Question key is required', 'error'); return; } if (OPTION_TYPES.has(type) && pendingOptions.length === 0) { showToast('Add at least one answer option', 'error'); return; } const btn = document.getElementById('aq_submit'); btn.disabled = true; btn.textContent = 'Adding...'; try { const qData = await apiPost('questions.php', { questionnaireID: questionnaire.questionnaireID, defaultText: text, type, isRequired, configJson }); if (!qData.success) throw new Error(qData.error || 'Failed'); const newQ = qData.question; newQ.answerOptions = []; for (const opt of pendingOptions) { const oData = await apiPost('answer_options.php', { questionID: newQ.questionID, defaultText: opt.text, points: opt.points, nextQuestionId: opt.nextQuestionId || '', }); if (oData.success) newQ.answerOptions.push(oData.answerOption); } questions.push(newQ); renderQuestions(); showToast('Question added', 'success'); document.getElementById('aq_text').value = ''; pendingOptions.length = 0; renderPendingOptions(); updateTypeUI(); document.getElementById('aq_text').focus(); } catch (e) { showToast(e.message, 'error'); } finally { if (document.getElementById('aq_submit')) { btn.disabled = false; btn.textContent = 'Add Question'; } } }); document.getElementById('aq_text').addEventListener('keydown', (e) => { if (e.key === 'Escape') hideAddQuestionForm(); }); document.getElementById('aq_cancel').addEventListener('click', hideAddQuestionForm); } function hideAddQuestionForm() { const wrapper = document.getElementById('addQuestionFormWrapper'); if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; } } // ── Questions list ─────────────────────────────────────────────────────── function renderQuestions() { const list = document.getElementById('questionList'); if (!list) return; const editable = canEdit(); // update tab label const tabBtn = document.querySelector('#editorTabs button[data-tab="questions"]'); if (tabBtn) tabBtn.textContent = `Questions (${questions.length})`; if (!questions.length) { list.innerHTML = `
  • No questions yet

    Add your first question above.

  • `; return; } list.innerHTML = questions.map((q, idx) => `
  • ${editable ? '' : ''} ${idx + 1}. ${esc(q.defaultText)} ${esc(layoutLabel(q.type))} ${q.isRequired ? 'Required' : ''}
  • `).join(''); list.querySelectorAll('.question-header').forEach((hdr, idx) => { hdr.addEventListener('click', (e) => { if (e.target.closest('.drag-handle')) return; const item = hdr.closest('.question-item'); const wasOpen = item.classList.contains('open'); item.classList.toggle('open'); if (!wasOpen) renderQuestionBody(questions[idx]); }); }); } // ── Question body (expanded) ───────────────────────────────────────────── function renderQuestionBody(q) { const body = document.getElementById(`qbody-${q.questionID}`); if (!body) return; const editable = canEdit(); const options = q.answerOptions || []; const config = parseConfig(q); const layout = q.type || 'radio_question'; body.innerHTML = ` ${editable ? `
    ${layoutSelectHTML(layout, `eq_type_${q.questionID}`)}
    ${configFormHTML(layout, config, `eq_cfg_${q.questionID}`)}
    ` : `

    Key: ${esc(q.defaultText)}

    Layout: ${esc(layoutLabel(layout))}

    ${Object.keys(config).length ? `

    Config: ${esc(JSON.stringify(config))}

    ` : ''}
    `} ${OPTION_TYPES.has(layout) ? `

    Answer Options (${options.length})

    ${editable ? ` ` : ''} ` : ''} `; if (!editable) return; body.querySelector('.save-q-btn').addEventListener('click', () => { const text = document.getElementById(`eq_text_${q.questionID}`).value.trim(); const type = document.getElementById(`eq_type_${q.questionID}`).value; const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0; const cj = readConfigFromForm(type, `eq_cfg_${q.questionID}`); if (!text) { showToast('Question key is required', 'error'); return; } updateQuestion(q.questionID, { defaultText: text, type, isRequired: isReq, configJson: cj }); }); document.getElementById(`eq_type_${q.questionID}`).addEventListener('change', () => { const newLayout = document.getElementById(`eq_type_${q.questionID}`).value; document.getElementById(`eq_config_${q.questionID}`).innerHTML = configFormHTML(newLayout, {}, `eq_cfg_${q.questionID}`); }); body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q)); body.querySelectorAll('.edit-opt-btn').forEach(btn => { btn.addEventListener('click', () => { const opt = options.find(o => o.answerOptionID === btn.dataset.id); if (opt) showEditOptionForm(q, opt); }); }); body.querySelectorAll('.del-opt-btn').forEach(btn => { btn.addEventListener('click', () => deleteOption(q, btn.dataset.id)); }); const addOptBtn = document.getElementById(`addOpt-${q.questionID}`); if (addOptBtn) addOptBtn.addEventListener('click', () => showAddOptionForm(q)); if (OPTION_TYPES.has(layout)) initOptionDrag(q); } function optionItemHTML(q, o, editable) { return `
  • ${editable ? '' : ''} ${esc(o.defaultText)} ${o.points} pts ${o.nextQuestionId ? `→ ${esc(o.nextQuestionId)}` : ''} ${editable ? ` ` : ''}
  • `; } // ── Add option form ────────────────────────────────────────────────────── function showAddOptionForm(q) { const wrapper = document.getElementById(`addOptForm-${q.questionID}`); const toggleBtn = document.getElementById(`addOpt-${q.questionID}`); if (!wrapper) return; toggleBtn.style.display = 'none'; wrapper.style.display = ''; const qIds = questionLocalIds(questions); wrapper.innerHTML = `
    `; document.getElementById(`ao_text_${q.questionID}`).focus(); document.getElementById(`ao_submit_${q.questionID}`).addEventListener('click', async () => { const text = document.getElementById(`ao_text_${q.questionID}`).value.trim(); const points = parseInt(document.getElementById(`ao_pts_${q.questionID}`).value || '0', 10); const nextQ = document.getElementById(`ao_next_${q.questionID}`).value; if (!text) { showToast('Option key is required', 'error'); return; } const btn = document.getElementById(`ao_submit_${q.questionID}`); btn.disabled = true; btn.textContent = 'Adding...'; try { const data = await apiPost('answer_options.php', { questionID: q.questionID, defaultText: text, points, nextQuestionId: nextQ }); if (data.success) { if (!q.answerOptions) q.answerOptions = []; q.answerOptions.push(data.answerOption); renderQuestionBody(q); showToast('Option added', 'success'); } } catch (e) { showToast(e.message, 'error'); btn.disabled = false; btn.textContent = 'Add Option'; } }); document.getElementById(`ao_text_${q.questionID}`).addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById(`ao_submit_${q.questionID}`).click(); if (e.key === 'Escape') hideAddOptionForm(q); }); document.getElementById(`ao_cancel_${q.questionID}`).addEventListener('click', () => hideAddOptionForm(q)); } function hideAddOptionForm(q) { const wrapper = document.getElementById(`addOptForm-${q.questionID}`); const toggleBtn = document.getElementById(`addOpt-${q.questionID}`); if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; } if (toggleBtn) toggleBtn.style.display = ''; } // ── Edit option form ───────────────────────────────────────────────────── function showEditOptionForm(q, opt) { const li = document.querySelector(`#opts-${q.questionID} .option-item[data-id="${opt.answerOptionID}"]`); if (!li) return; const qIds = questionLocalIds(questions); li.innerHTML = `
    `; li.draggable = false; document.getElementById(`eo_text_${opt.answerOptionID}`).focus(); document.getElementById(`eo_save_${opt.answerOptionID}`).addEventListener('click', async () => { const text = document.getElementById(`eo_text_${opt.answerOptionID}`).value.trim(); const points = parseInt(document.getElementById(`eo_pts_${opt.answerOptionID}`).value || '0', 10); const nextQ = document.getElementById(`eo_next_${opt.answerOptionID}`).value; if (!text) { showToast('Option key is required', 'error'); return; } await updateOption(q, opt.answerOptionID, { defaultText: text, points, nextQuestionId: nextQ }); }); document.getElementById(`eo_text_${opt.answerOptionID}`).addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById(`eo_save_${opt.answerOptionID}`).click(); if (e.key === 'Escape') renderQuestionBody(q); }); document.getElementById(`eo_cancel_${opt.answerOptionID}`).addEventListener('click', () => renderQuestionBody(q)); } // ── Question CRUD ──────────────────────────────────────────────────────── async function updateQuestion(questionID, fields) { try { const data = await apiPut('questions.php', { questionID, ...fields }); if (data.success) { const idx = questions.findIndex(q => q.questionID === questionID); if (idx >= 0) { questions[idx] = { ...questions[idx], ...data.question, answerOptions: questions[idx].answerOptions }; } renderQuestions(); showToast('Question updated', 'success'); } } catch (e) { showToast(e.message, 'error'); } } async function deleteQuestion(q) { if (!confirm(`Delete "${q.defaultText}" and all its answers?`)) return; try { await apiDelete('questions.php', { questionID: q.questionID }); questions = questions.filter(x => x.questionID !== q.questionID); renderQuestions(); showToast('Question deleted', 'success'); } catch (e) { showToast(e.message, 'error'); } } // ── Answer option CRUD ─────────────────────────────────────────────────── async function updateOption(q, answerOptionID, fields) { try { const data = await apiPut('answer_options.php', { answerOptionID, ...fields }); if (data.success) { const idx = q.answerOptions.findIndex(o => o.answerOptionID === answerOptionID); if (idx >= 0) q.answerOptions[idx] = { ...q.answerOptions[idx], ...data.answerOption }; renderQuestionBody(q); showToast('Option updated', 'success'); } } catch (e) { showToast(e.message, 'error'); } } async function deleteOption(q, answerOptionID) { if (!confirm('Delete this answer option?')) return; try { await apiDelete('answer_options.php', { answerOptionID }); q.answerOptions = (q.answerOptions || []).filter(o => o.answerOptionID !== answerOptionID); renderQuestionBody(q); showToast('Option deleted', 'success'); } catch (e) { showToast(e.message, 'error'); } } // ── Translations tab ───────────────────────────────────────────────────── let transData = null; async function loadTranslationsTab() { const container = document.getElementById('translationsContent'); if (!container) return; container.innerHTML = '
    '; try { const data = await apiGet(`translations.php?questionnaireID=${encodeURIComponent(questionnaire.questionnaireID)}`); transData = data; renderTranslationsTab(); } catch (e) { container.innerHTML = `

    ${esc(e.message)}

    `; } } function renderTranslationsTab() { const container = document.getElementById('translationsContent'); if (!container || !transData) return; const editable = canEdit(); const languages = transData.languages || []; const entries = transData.entries || []; const langCodes = languages.map(l => l.languageCode); if (!entries.length) { container.innerHTML = ` ${editable ? languageManagerHTML(languages) : ''}

    No translatable keys found. Add questions first.

    `; if (editable) bindLanguageManager(languages); return; } const filterHtml = `
    `; const typeBadge = (type) => { const map = { question: 'Q', answer_option: 'Opt', string: 'Str' }; const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str' }; return `${map[type] || type}`; }; container.innerHTML = ` ${editable ? languageManagerHTML(languages) : ''} ${filterHtml}
    ${langCodes.map(lc => ``).join('')} ${entries.map((entry, i) => ` ${langCodes.map(lc => { const val = entry.translations[lc] || ''; const missing = !val; return ``; }).join('')} `).join('')}
    Key Type${esc(lc.toUpperCase())}
    ${esc(entry.key)} ${typeBadge(entry.type)}
    `; updateTransStats(entries, langCodes); if (editable) { bindLanguageManager(languages); bindTransCellEditing(entries); } bindTransFiltering(); } function languageManagerHTML(languages) { return `
    ${languages.map(l => ` ${esc(l.languageCode.toUpperCase())} ${l.name ? `${esc(l.name)}` : ''} `).join('')} ${!languages.length ? 'No languages defined yet.' : ''}
    `; } function bindLanguageManager(languages) { document.getElementById('addLangBtn')?.addEventListener('click', async () => { const code = document.getElementById('newLangCode').value.trim().toLowerCase(); const name = document.getElementById('newLangName').value.trim(); if (!code) { showToast('Language code is required', 'error'); return; } if (languages.some(l => l.languageCode === code)) { showToast('Language already exists', 'error'); return; } try { await apiPut('translations.php', { type: 'language', languageCode: code, name }); showToast(`Language "${code}" added`, 'success'); loadTranslationsTab(); } catch (e) { showToast(e.message, 'error'); } }); document.querySelectorAll('.lang-chip-remove').forEach(btn => { btn.addEventListener('click', async () => { const lc = btn.dataset.lc; if (!confirm(`Remove language "${lc}" and ALL its translations?`)) return; try { await apiDelete('translations.php', { type: 'language', languageCode: lc }); showToast(`Language "${lc}" removed`, 'success'); loadTranslationsTab(); } catch (e) { showToast(e.message, 'error'); } }); }); } function bindTransCellEditing(entries) { const debounceTimers = {}; document.querySelectorAll('.trans-cell-input').forEach(inp => { inp.addEventListener('input', () => { const idx = parseInt(inp.dataset.idx, 10); const lang = inp.dataset.lang; const entry = entries[idx]; if (!entry) return; entry.translations[lang] = inp.value; const cell = inp.closest('td'); cell.classList.toggle('cell-missing', !inp.value); const timerKey = `${idx}_${lang}`; clearTimeout(debounceTimers[timerKey]); debounceTimers[timerKey] = setTimeout(async () => { try { await apiPut('translations.php', { type: entry.type, id: entry.entityId, languageCode: lang, text: inp.value, }); inp.classList.add('save-flash'); setTimeout(() => inp.classList.remove('save-flash'), 400); } catch (e) { showToast(e.message, 'error'); } }, 600); }); inp.addEventListener('keydown', (e) => { if (e.key === 'Tab' || e.key === 'Enter') return; if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const row = inp.closest('tr'); const cellIdx = [...row.children].indexOf(inp.closest('td')); const targetRow = e.key === 'ArrowDown' ? row.nextElementSibling : row.previousElementSibling; if (targetRow && !targetRow.classList.contains('trans-row-hidden')) { const targetInput = targetRow.children[cellIdx]?.querySelector('input'); if (targetInput) targetInput.focus(); } } }); }); } function bindTransFiltering() { const filterInput = document.getElementById('transFilter'); const typeChecks = document.querySelectorAll('.trans-type-filter'); const tbody = document.querySelector('#transTable tbody'); if (!filterInput || !tbody) return; function applyFilter() { const text = filterInput.value.toLowerCase(); const activeTypes = new Set([...typeChecks].filter(c => c.checked).map(c => c.value)); let visible = 0; tbody.querySelectorAll('tr').forEach(row => { const key = row.dataset.key?.toLowerCase() || ''; const type = row.dataset.type || ''; const show = activeTypes.has(type) && (!text || key.includes(text)); row.classList.toggle('trans-row-hidden', !show); if (show) visible++; }); const stats = document.getElementById('transStats'); if (stats) stats.textContent = `Showing ${visible} of ${tbody.children.length} keys`; } filterInput.addEventListener('input', applyFilter); typeChecks.forEach(c => c.addEventListener('change', applyFilter)); } function updateTransStats(entries, langCodes) { const stats = document.getElementById('transStats'); if (!stats) return; const total = entries.length * langCodes.length; let filled = 0; for (const e of entries) { for (const lc of langCodes) { if (e.translations[lc]) filled++; } } const pct = total ? Math.round((filled / total) * 100) : 0; stats.innerHTML = ` ${entries.length} keys × ${langCodes.length} languages = ${total} cells ${filled}/${total} (${pct}%) translated `; } // ── Drag & drop (questions) ────────────────────────────────────────────── function initDragReorder() { const list = document.getElementById('questionList'); if (!list) return; let dragItem = null; list.addEventListener('dragstart', (e) => { const item = e.target.closest('.question-item'); if (!item || !e.target.closest('.drag-handle')) { e.preventDefault(); return; } dragItem = item; item.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); list.addEventListener('dragover', (e) => { e.preventDefault(); const afterEl = getDragAfterElement(list, e.clientY, '.question-item'); if (afterEl) list.insertBefore(dragItem, afterEl); else list.appendChild(dragItem); }); list.addEventListener('dragend', async () => { if (!dragItem) return; dragItem.classList.remove('dragging'); const newOrder = [...list.querySelectorAll('.question-item')].map(el => el.dataset.id); dragItem = null; const map = {}; questions.forEach(q => map[q.questionID] = q); questions = newOrder.map(id => map[id]).filter(Boolean); try { await apiPatch('questions.php', { questionnaireID: questionnaire.questionnaireID, order: newOrder }); showToast('Order saved', 'success'); } catch (e) { showToast(e.message, 'error'); } }); } function initOptionDrag(q) { const list = document.getElementById(`opts-${q.questionID}`); if (!list) return; let dragItem = null; list.addEventListener('dragstart', (e) => { const item = e.target.closest('.option-item'); if (!item || !e.target.closest('.drag-handle')) { e.preventDefault(); return; } dragItem = item; item.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); list.addEventListener('dragover', (e) => { e.preventDefault(); const afterEl = getDragAfterElement(list, e.clientY, '.option-item'); if (afterEl) list.insertBefore(dragItem, afterEl); else list.appendChild(dragItem); }); list.addEventListener('dragend', async () => { if (!dragItem) return; dragItem.classList.remove('dragging'); const newOrder = [...list.querySelectorAll('.option-item')].map(el => el.dataset.id); dragItem = null; const map = {}; (q.answerOptions || []).forEach(o => map[o.answerOptionID] = o); q.answerOptions = newOrder.map(id => map[id]).filter(Boolean); try { await apiPatch('answer_options.php', { questionID: q.questionID, order: newOrder }); showToast('Option order saved', 'success'); } catch (e) { showToast(e.message, 'error'); } }); } function getDragAfterElement(container, y, selector) { const elements = [...container.querySelectorAll(`${selector}:not(.dragging)`)]; return elements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) return { offset, element: child }; return closest; }, { offset: Number.NEGATIVE_INFINITY }).element; }