2108 lines
94 KiB
JavaScript
2108 lines
94 KiB
JavaScript
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
|
||
import { canEdit, homeNavButton, showToast } from '../app.js';
|
||
import { confirmAction } from '../confirm-modal.js';
|
||
import { navigate } from '../router.js';
|
||
import {
|
||
SOURCE_LANG,
|
||
normalizeEntry,
|
||
translationFor,
|
||
targetLanguages,
|
||
buildTranslationGroups,
|
||
sourceLanguageLabel,
|
||
translationListHeaderHTML,
|
||
renderCollapsibleGroupedTranslationsHTML,
|
||
escHtml,
|
||
} from '../translations-helpers.js';
|
||
import {
|
||
mountConditionEditor,
|
||
parseConditionForm,
|
||
} from '../condition-editor.js';
|
||
import { mountQuestionnairePreview } from '../questionnaire-preview.js';
|
||
|
||
const LAYOUT_TYPES = [
|
||
{ value: 'radio_question', label: 'Radio (Single Choice)' },
|
||
{ value: 'multi_check_box_question', label: 'Checkbox (Multiple Choice)' },
|
||
{ value: 'glass_scale_question', label: 'Glass Scale' },
|
||
{ value: 'value_spinner', label: 'Value Spinner' },
|
||
{ value: 'slider_question', label: 'Slider (min–max)' },
|
||
{ value: 'date_spinner', label: 'Date Spinner' },
|
||
{ value: 'string_spinner', label: 'String Spinner' },
|
||
{ value: 'free_text', label: 'Free Text' },
|
||
{ value: 'client_coach_code_question', label: 'Client / Counselor code' },
|
||
{ value: 'last_page', label: 'Last Page' },
|
||
];
|
||
|
||
const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
|
||
|
||
const GLASS_SCORE_LEVELS = [
|
||
{ key: 'never_glass', label: 'Never' },
|
||
{ key: 'little_glass', label: 'Little' },
|
||
{ key: 'moderate_glass', label: 'Moderate' },
|
||
{ key: 'much_glass', label: 'Much' },
|
||
{ key: 'extreme_glass', label: 'Extreme' },
|
||
];
|
||
|
||
let questionnaire = null;
|
||
let questions = [];
|
||
let retiredQuestions = [];
|
||
let isNew = false;
|
||
let activeTab = 'questions';
|
||
let allQuestionnaires = [];
|
||
let questionsByQuestionnaire = {};
|
||
let conditionEditorApi = null;
|
||
|
||
// ── Entry point ──────────────────────────────────────────────────────────
|
||
|
||
export async function editorPage(params) {
|
||
const app = document.getElementById('app');
|
||
isNew = !params.id || params.id === 'new';
|
||
activeTab = 'questions';
|
||
|
||
app.innerHTML = `
|
||
<div class="page-header">
|
||
<div class="page-header-start">
|
||
${homeNavButton()}
|
||
<div class="page-header-titles">
|
||
<h1 id="editorTitle">${isNew ? 'New Questionnaire' : 'Loading...'}</h1>
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<a href="#/questionnaires" class="btn">Questionnaires</a>
|
||
${!isNew ? `<a href="#/questionnaire/${params.id}/results" class="btn">View Results</a>` : ''}
|
||
</div>
|
||
</div>
|
||
<div id="editorContent"><div class="spinner"></div></div>
|
||
`;
|
||
|
||
try {
|
||
const qnList = await apiGet('questionnaires.php');
|
||
allQuestionnaires = qnList.questionnaires || [];
|
||
} catch {
|
||
allQuestionnaires = [];
|
||
}
|
||
|
||
if (isNew) {
|
||
questionnaire = { questionnaireID: null, name: '', version: '1.0', state: 'draft',
|
||
orderIndex: 0, showPoints: 0, conditionJson: '{}' };
|
||
questions = [];
|
||
renderEditor();
|
||
} else {
|
||
try {
|
||
const quData = await apiGet(
|
||
`questions.php?questionnaireID=${encodeURIComponent(params.id)}&includeRetired=1`
|
||
);
|
||
questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id);
|
||
if (!questionnaire) {
|
||
showToast('Questionnaire not found', 'error');
|
||
navigate('#/questionnaires');
|
||
return;
|
||
}
|
||
questionnaire.structureRevision = quData.structureRevision ?? questionnaire.structureRevision ?? 1;
|
||
const allQs = quData.questions || [];
|
||
questions = allQs.filter(q => !q.retired);
|
||
retiredQuestions = allQs.filter(q => q.retired);
|
||
document.getElementById('editorTitle').textContent = questionnaire.name;
|
||
renderEditor();
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
document.getElementById('editorContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Layout helpers ───────────────────────────────────────────────────────
|
||
|
||
function layoutSelectHTML(selected = 'radio_question', id = '') {
|
||
return `<select ${id ? `id="${id}"` : ''} class="type-select">
|
||
${LAYOUT_TYPES.map(t =>
|
||
`<option value="${t.value}" ${selected === t.value ? 'selected' : ''}>${t.label}</option>`
|
||
).join('')}
|
||
</select>`;
|
||
}
|
||
|
||
function layoutLabel(value) {
|
||
const t = LAYOUT_TYPES.find(t => t.value === value);
|
||
return t ? t.label : value || 'unknown';
|
||
}
|
||
|
||
const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
||
|
||
function parseConfig(q) {
|
||
try { return JSON.parse(q.configJson || '{}'); } catch { return {}; }
|
||
}
|
||
|
||
function questionKeyOf(q) {
|
||
const cfg = typeof q === 'object' && q.configJson !== undefined ? parseConfig(q) : (q.config || q);
|
||
return (q.questionKey || cfg.questionKey || '').trim();
|
||
}
|
||
|
||
function questionLocalIds() {
|
||
return questions.map(q => {
|
||
const parts = q.questionID.split('__');
|
||
const localId = parts[parts.length - 1];
|
||
return {
|
||
questionID: q.questionID,
|
||
localId,
|
||
defaultText: q.defaultText,
|
||
questionKey: questionKeyOf(q),
|
||
};
|
||
});
|
||
}
|
||
|
||
function branchTargetLabel(q) {
|
||
const key = questionKeyOf(q) || q.localId;
|
||
return `${q.localId} · ${key}`;
|
||
}
|
||
|
||
function branchTargetOptionsHTML(selected = '') {
|
||
return questionLocalIds().map(q =>
|
||
`<option value="${esc(q.localId)}" ${selected === q.localId ? 'selected' : ''}>${esc(branchTargetLabel(q))}</option>`
|
||
).join('');
|
||
}
|
||
|
||
function validateStableKey(key, label = 'Key') {
|
||
if (!STABLE_KEY_RE.test(key)) {
|
||
showToast(`${label} must match [a-zA-Z][a-zA-Z0-9_]*`, 'error');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function optionKeyOf(o) {
|
||
if (o.optionKey) return o.optionKey;
|
||
const dt = (o.defaultText || '').trim();
|
||
return STABLE_KEY_RE.test(dt) ? dt : '';
|
||
}
|
||
|
||
function optionGermanLabel(o) {
|
||
return (o.labelGerman || '').trim() || (STABLE_KEY_RE.test(o.defaultText || '') ? '' : o.defaultText) || '';
|
||
}
|
||
|
||
function notesSectionHTML(config, prefix, questionKey) {
|
||
const nbKey = questionKey ? `${questionKey}_note_before` : '—';
|
||
const naKey = questionKey ? `${questionKey}_note_after` : '—';
|
||
return `
|
||
<div class="notes-section" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
|
||
<h4 style="font-size:.85rem;margin-bottom:8px;color:var(--text-secondary)">Notes (German)</h4>
|
||
<div class="form-row">
|
||
<div class="form-group" style="flex:1">
|
||
<label>Note before question</label>
|
||
<input type="text" id="${prefix}_noteBefore" value="${esc(config.noteBefore || '')}" placeholder="Optional text shown above the question">
|
||
<span class="field-hint">Key: <code>${esc(nbKey)}</code></span>
|
||
</div>
|
||
<div class="form-group" style="flex:1">
|
||
<label>Note after question</label>
|
||
<input type="text" id="${prefix}_noteAfter" value="${esc(config.noteAfter || '')}" placeholder="Optional text shown below the question">
|
||
<span class="field-hint">Key: <code>${esc(naKey)}</code></span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function readNotesFromForm(prefix) {
|
||
const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || '';
|
||
return { noteBefore: val('noteBefore'), noteAfter: val('noteAfter') };
|
||
}
|
||
|
||
function glassSymptomsRowsFromQuestion(q) {
|
||
if (Array.isArray(q?.glassSymptoms) && q.glassSymptoms.length) {
|
||
return q.glassSymptoms.map(r => ({
|
||
key: (r.key || '').trim(),
|
||
labelDe: (r.labelDe || '').trim(),
|
||
scoreLevels: r.scoreLevels || defaultGlassScoreLevels(),
|
||
}));
|
||
}
|
||
const config = parseConfig(q);
|
||
return (config.symptoms || []).map(key => ({
|
||
key: String(key).trim(),
|
||
labelDe: '',
|
||
scoreLevels: defaultGlassScoreLevels(),
|
||
}));
|
||
}
|
||
|
||
function defaultGlassScoreLevels() {
|
||
return { never_glass: 0, little_glass: 1, moderate_glass: 2, much_glass: 3, extreme_glass: 4 };
|
||
}
|
||
|
||
function valueScoreRulesSectionHTML(prefix, config, scoreRules = []) {
|
||
const min = Number(config.range?.min ?? 0);
|
||
const max = Number(config.range?.max ?? 100);
|
||
const step = Math.max(1, Number(config.step ?? 1));
|
||
const lo = Math.min(min, max);
|
||
const hi = Math.max(min, max);
|
||
const ruleMap = {};
|
||
for (const r of scoreRules || []) {
|
||
ruleMap[r.levelKey] = r.points;
|
||
}
|
||
const values = [];
|
||
for (let v = lo; v <= hi; v += step) values.push(v);
|
||
if (!values.length) values.push(lo);
|
||
const rows = values.map(v => `
|
||
<div class="value-score-row">
|
||
<span class="value-score-label">${v}</span>
|
||
<input type="number" class="value-score-pts" data-value="${v}" value="${ruleMap[String(v)] ?? 0}" min="0" step="1">
|
||
</div>`).join('');
|
||
return `
|
||
<div class="value-score-rules" id="${prefix}_value_scores">
|
||
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary)">Points per value</label>
|
||
<p class="field-hint">Set points for each selectable value (used when the questionnaire is scored).</p>
|
||
<div class="value-score-grid">${rows}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function readValueScoreRulesFromForm(prefix) {
|
||
const container = document.getElementById(`${prefix}_value_scores`);
|
||
if (!container) return [];
|
||
const rules = [];
|
||
container.querySelectorAll('.value-score-pts').forEach(inp => {
|
||
rules.push({
|
||
scopeKey: '',
|
||
levelKey: String(inp.dataset.value),
|
||
points: parseInt(inp.value || '0', 10) || 0,
|
||
});
|
||
});
|
||
return rules;
|
||
}
|
||
|
||
function wireValueScoreRules(prefix) {
|
||
const minInp = document.getElementById(`${prefix}_rangeMin`);
|
||
const maxInp = document.getElementById(`${prefix}_rangeMax`);
|
||
const stepInp = document.getElementById(`${prefix}_step`);
|
||
const rerender = () => {
|
||
const section = document.getElementById(`${prefix}_value_scores`);
|
||
if (!section) return;
|
||
const config = {
|
||
range: {
|
||
min: parseInt(minInp?.value || '0', 10),
|
||
max: parseInt(maxInp?.value || '100', 10),
|
||
},
|
||
step: parseInt(stepInp?.value || '1', 10) || 1,
|
||
};
|
||
const existing = readValueScoreRulesFromForm(prefix);
|
||
section.outerHTML = valueScoreRulesSectionHTML(prefix, config, existing);
|
||
};
|
||
[minInp, maxInp, stepInp].forEach(el => el?.addEventListener('change', rerender));
|
||
}
|
||
|
||
function suggestSymptomKey(label) {
|
||
let slug = (label || '').trim().toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '_')
|
||
.replace(/^_+|_+$/g, '');
|
||
if (!slug) return '';
|
||
if (!/^[a-z]/.test(slug)) slug = `sym_${slug}`;
|
||
if (!STABLE_KEY_RE.test(slug)) slug = `sym_${slug.replace(/^[^a-z]+/, '')}`;
|
||
return STABLE_KEY_RE.test(slug) ? slug : '';
|
||
}
|
||
|
||
function glassSymptomsSectionHTML(rows, prefix) {
|
||
const list = rows.length ? rows : [];
|
||
return `
|
||
<div class="glass-symptoms-section" id="${prefix}_glass_symptoms">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary)">Symptoms</label>
|
||
<button type="button" class="btn btn-sm" data-action="add-glass-symptom">+ Add symptom</button>
|
||
</div>
|
||
<p class="field-hint" style="margin-bottom:10px">Each row is one table line in the app. German text is saved with the question; translate other languages on the Translations tab.</p>
|
||
<ul class="glass-symptoms-list">
|
||
${list.length ? list.map((row, i) => glassSymptomRowHTML(prefix, row, i)).join('')
|
||
: '<li class="glass-symptoms-empty">No symptoms yet.</li>'}
|
||
</ul>
|
||
</div>`;
|
||
}
|
||
|
||
function glassSymptomRowHTML(prefix, row, idx) {
|
||
const levels = row.scoreLevels || defaultGlassScoreLevels();
|
||
const ptsRow = GLASS_SCORE_LEVELS.map(l => `
|
||
<div class="glass-score-cell">
|
||
<label>${esc(l.label)}</label>
|
||
<input type="number" class="glass-score-pts" data-level="${esc(l.key)}"
|
||
value="${Number(levels[l.key] ?? 0)}" min="0" step="1">
|
||
</div>`).join('');
|
||
return `
|
||
<li class="glass-symptom-row" data-idx="${idx}">
|
||
<div class="form-row" style="align-items:flex-end;margin-bottom:0">
|
||
<div class="form-group" style="flex:1;min-width:140px;margin-bottom:0">
|
||
<label style="font-size:.75rem">Symptom key <span class="required-mark">*</span></label>
|
||
<input type="text" class="glass-symptom-key" value="${esc(row.key || '')}"
|
||
placeholder="e.g. q10_reexperience_trauma" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
|
||
</div>
|
||
<div class="form-group" style="flex:2;margin-bottom:0">
|
||
<label style="font-size:.75rem">German label <span class="required-mark">*</span></label>
|
||
<input type="text" class="glass-symptom-label" value="${esc(row.labelDe || '')}"
|
||
placeholder="Text shown in the app for this row">
|
||
</div>
|
||
<button type="button" class="btn-icon btn-icon-danger glass-symptom-remove" title="Remove">✖</button>
|
||
</div>
|
||
<div class="glass-score-grid">${ptsRow}</div>
|
||
</li>`;
|
||
}
|
||
|
||
function readGlassSymptomsFromForm(prefix) {
|
||
const container = document.getElementById(`${prefix}_glass_symptoms`);
|
||
if (!container) return [];
|
||
const rows = [];
|
||
container.querySelectorAll('.glass-symptom-row').forEach(rowEl => {
|
||
const key = rowEl.querySelector('.glass-symptom-key')?.value?.trim() || '';
|
||
const labelDe = rowEl.querySelector('.glass-symptom-label')?.value?.trim() || '';
|
||
const scoreLevels = {};
|
||
rowEl.querySelectorAll('.glass-score-pts').forEach(inp => {
|
||
scoreLevels[inp.dataset.level] = parseInt(inp.value || '0', 10) || 0;
|
||
});
|
||
if (key || labelDe) rows.push({ key, labelDe, scoreLevels });
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
function validateGlassSymptoms(rows) {
|
||
const seen = new Set();
|
||
for (const row of rows) {
|
||
if (!row.key) {
|
||
showToast('Each symptom needs a key', 'error');
|
||
return false;
|
||
}
|
||
if (!validateStableKey(row.key, 'Symptom key')) return false;
|
||
if (!row.labelDe) {
|
||
showToast(`German label is required for symptom "${row.key}"`, 'error');
|
||
return false;
|
||
}
|
||
if (seen.has(row.key)) {
|
||
showToast(`Duplicate symptom key "${row.key}"`, 'error');
|
||
return false;
|
||
}
|
||
seen.add(row.key);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function wireGlassSymptomsSection(prefix) {
|
||
const container = document.getElementById(`${prefix}_glass_symptoms`);
|
||
if (!container) return;
|
||
|
||
const renderList = (rows) => {
|
||
const list = container.querySelector('.glass-symptoms-list');
|
||
if (!list) return;
|
||
if (!rows.length) {
|
||
list.innerHTML = '<li class="glass-symptoms-empty">No symptoms yet.</li>';
|
||
return;
|
||
}
|
||
list.innerHTML = rows.map((row, i) => glassSymptomRowHTML(prefix, row, i)).join('');
|
||
bindRowEvents();
|
||
};
|
||
|
||
const collectRows = () => readGlassSymptomsFromForm(prefix);
|
||
|
||
const bindRowEvents = () => {
|
||
container.querySelectorAll('.glass-symptom-remove').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const rowEl = btn.closest('.glass-symptom-row');
|
||
rowEl?.remove();
|
||
const list = container.querySelector('.glass-symptoms-list');
|
||
if (list && !list.querySelector('.glass-symptom-row')) {
|
||
list.innerHTML = '<li class="glass-symptoms-empty">No symptoms yet.</li>';
|
||
}
|
||
});
|
||
});
|
||
container.querySelectorAll('.glass-symptom-label').forEach(inp => {
|
||
inp.addEventListener('blur', () => {
|
||
const rowEl = inp.closest('.glass-symptom-row');
|
||
const keyInp = rowEl?.querySelector('.glass-symptom-key');
|
||
if (!keyInp || keyInp.value.trim()) return;
|
||
const suggested = suggestSymptomKey(inp.value);
|
||
if (suggested) keyInp.value = suggested;
|
||
});
|
||
});
|
||
};
|
||
|
||
container.querySelector('[data-action=add-glass-symptom]')?.addEventListener('click', () => {
|
||
const rows = collectRows();
|
||
rows.push({ key: '', labelDe: '', scoreLevels: defaultGlassScoreLevels() });
|
||
renderList(rows);
|
||
const lastKey = container.querySelector('.glass-symptom-row:last-child .glass-symptom-label');
|
||
lastKey?.focus();
|
||
});
|
||
|
||
bindRowEvents();
|
||
}
|
||
|
||
function stringSpinnerOtherEnabled(config) {
|
||
return Boolean(config.otherNextQuestionId || config.otherOptionKey);
|
||
}
|
||
|
||
/** Question types that use config.nextQuestionId (not per-option branching). */
|
||
const QUESTION_LEVEL_NEXT_LAYOUTS = new Set([
|
||
'value_spinner',
|
||
'slider_question',
|
||
'free_text',
|
||
'date_spinner',
|
||
'multi_check_box_question',
|
||
'glass_scale_question',
|
||
'client_coach_code_question',
|
||
'last_page',
|
||
]);
|
||
|
||
function questionNextSectionHTML(config, prefix, { label, hint } = {}) {
|
||
const next = config.nextQuestionId || '';
|
||
return `
|
||
<div class="form-group question-next-field" style="margin-top:12px">
|
||
<label>${label || 'Next question'}</label>
|
||
<select id="${prefix}_nextQuestionId">
|
||
<option value="">(next in list order)</option>
|
||
${branchTargetOptionsHTML(next)}
|
||
</select>
|
||
<span class="field-hint">${hint || 'Where to go after this question. Leave empty to continue in list order.'}</span>
|
||
</div>`;
|
||
}
|
||
|
||
function stringSpinnerNextSectionHTML(config, prefix) {
|
||
return questionNextSectionHTML(config, prefix, {
|
||
label: 'Next question (list selection)',
|
||
hint: 'Where to go after a normal spinner choice. <strong>Required</strong> when “Other” targets a question that sits next in order (so list picks can skip it).',
|
||
});
|
||
}
|
||
|
||
function readQuestionNextFromForm(prefix, config) {
|
||
const next = document.getElementById(`${prefix}_nextQuestionId`)?.value?.trim() || '';
|
||
if (next) config.nextQuestionId = next;
|
||
}
|
||
|
||
function stringSpinnerOtherSectionHTML(config, prefix) {
|
||
const enabled = stringSpinnerOtherEnabled(config);
|
||
const otherKey = config.otherOptionKey || 'other_option';
|
||
const otherNext = config.otherNextQuestionId || '';
|
||
return `
|
||
<div class="form-group" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
|
||
<label class="checkbox-label" style="display:inline-flex;margin-bottom:8px">
|
||
<input type="checkbox" id="${prefix}_otherEnabled" ${enabled ? 'checked' : ''}>
|
||
Show “Other” option (branch to free-text question)
|
||
</label>
|
||
<div id="${prefix}_otherFields" style="${enabled ? '' : 'display:none'}">
|
||
<div class="form-row">
|
||
<div class="form-group" style="flex:1">
|
||
<label>Label translation key</label>
|
||
<input type="text" id="${prefix}_otherOptionKey" value="${esc(otherKey)}"
|
||
placeholder="e.g. other_country" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
|
||
<span class="field-hint">Appends this label to the spinner. Translate under <strong>Questionnaire UI strings</strong> (or App strings if listed in catalog).</span>
|
||
</div>
|
||
<div class="form-group" style="flex:1">
|
||
<label>Free-text question when “Other” is chosen</label>
|
||
<select id="${prefix}_otherNextQuestionId">
|
||
<option value="">— select question —</option>
|
||
${branchTargetOptionsHTML(otherNext)}
|
||
</select>
|
||
<span class="field-hint">Use layout <strong>Free text</strong> for the follow-up (e.g. country name).</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function wireStringSpinnerOtherToggle(prefix) {
|
||
const cb = document.getElementById(`${prefix}_otherEnabled`);
|
||
const panel = document.getElementById(`${prefix}_otherFields`);
|
||
if (!cb || !panel) return;
|
||
const sync = () => { panel.style.display = cb.checked ? '' : 'none'; };
|
||
cb.addEventListener('change', sync);
|
||
sync();
|
||
}
|
||
|
||
function validateStringSpinnerConfig(prefix) {
|
||
const defaultNext = document.getElementById(`${prefix}_nextQuestionId`)?.value.trim() || '';
|
||
const enabled = document.getElementById(`${prefix}_otherEnabled`)?.checked;
|
||
if (enabled) {
|
||
const key = document.getElementById(`${prefix}_otherOptionKey`)?.value.trim() || '';
|
||
const otherNext = document.getElementById(`${prefix}_otherNextQuestionId`)?.value.trim() || '';
|
||
if (!key) {
|
||
showToast('Other label translation key is required', 'error');
|
||
return false;
|
||
}
|
||
if (!validateStableKey(key, 'Other label key')) return false;
|
||
if (!otherNext) {
|
||
showToast('Select the free-text question for “Other”', 'error');
|
||
return false;
|
||
}
|
||
const target = questions.find(q => (q.localId || q.questionID.split('__').pop()) === otherNext);
|
||
if (target && target.type !== 'free_text') {
|
||
showToast('“Other” should branch to a Free text question', 'error');
|
||
return false;
|
||
}
|
||
if (!defaultNext) {
|
||
showToast('Set “Next question (list selection)” so normal choices do not fall through to the free-text step', 'error');
|
||
return false;
|
||
}
|
||
if (defaultNext === otherNext) {
|
||
showToast('“Next question” and “Other” branch cannot target the same question', 'error');
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ── Config form HTML for each layout type ────────────────────────────────
|
||
|
||
function configFormHTML(layout, config, prefix, opts = {}) {
|
||
let html = '';
|
||
switch (layout) {
|
||
case 'radio_question':
|
||
html = `
|
||
<div class="form-group">
|
||
<label>Extra text key (optional)</label>
|
||
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. resilience_reflection_prompt">
|
||
</div>
|
||
<p class="field-hint" style="margin-top:8px">Branching: set <strong>Next question</strong> on each answer option below.</p>`;
|
||
break;
|
||
case 'multi_check_box_question':
|
||
html = `
|
||
<div class="form-group">
|
||
<label>Min selections</label>
|
||
<input type="number" id="${prefix}_minSelection" value="${config.minSelection || 0}" min="0">
|
||
</div>
|
||
${stringSpinnerOtherSectionHTML(config, prefix)}`;
|
||
break;
|
||
case 'glass_scale_question': {
|
||
const scaleType = config.scaleType || 'glass';
|
||
const rows = opts.glassSymptoms || (config.symptoms || []).map(k => ({ key: k, labelDe: '' }));
|
||
html = `
|
||
<div class="form-group">
|
||
<label>Scale display</label>
|
||
<select id="${prefix}_scaleType">
|
||
<option value="glass" ${scaleType === 'glass' ? 'selected' : ''}>Glass (pain scale)</option>
|
||
<option value="thermometer" ${scaleType === 'thermometer' ? 'selected' : ''}>Thermometer</option>
|
||
</select>
|
||
</div>
|
||
${glassSymptomsSectionHTML(rows, prefix)}`;
|
||
break;
|
||
}
|
||
case 'value_spinner':
|
||
case 'slider_question': {
|
||
const scoreRules = opts.scoreRules || [];
|
||
html = `
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Min</label>
|
||
<input type="number" id="${prefix}_rangeMin" value="${config.range?.min ?? 0}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Max</label>
|
||
<input type="number" id="${prefix}_rangeMax" value="${config.range?.max ?? 100}">
|
||
</div>
|
||
${layout === 'slider_question' ? `
|
||
<div class="form-group">
|
||
<label>Step</label>
|
||
<input type="number" id="${prefix}_step" value="${config.step ?? 1}" min="1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Unit label (optional)</label>
|
||
<input type="text" id="${prefix}_unitLabel" value="${esc(config.unitLabel || '')}" placeholder="e.g. years">
|
||
</div>` : ''}
|
||
</div>
|
||
${valueScoreRulesSectionHTML(prefix, config, scoreRules)}`;
|
||
break;
|
||
}
|
||
case 'date_spinner': {
|
||
const notBefore = config.constraints?.notBefore || '';
|
||
const notAfter = config.constraints?.notAfter || '';
|
||
const precision = config.precision || 'full';
|
||
html = `
|
||
<div class="form-group">
|
||
<label>Date precision</label>
|
||
<select id="${prefix}_precision">
|
||
<option value="full" ${precision === 'full' ? 'selected' : ''}>Year, month, and day</option>
|
||
<option value="year_month" ${precision === 'year_month' ? 'selected' : ''}>Year and month</option>
|
||
<option value="year" ${precision === 'year' ? 'selected' : ''}>Year only</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Not before (question key)</label>
|
||
<input type="text" id="${prefix}_notBefore" value="${esc(notBefore)}" placeholder="e.g. departure_country">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Not after (question key)</label>
|
||
<input type="text" id="${prefix}_notAfter" value="${esc(notAfter)}" placeholder="e.g. since_in_germany">
|
||
</div>
|
||
</div>`;
|
||
break;
|
||
}
|
||
case 'string_spinner':
|
||
html = `
|
||
<div class="form-group">
|
||
<label>Static options (one per line)</label>
|
||
<textarea id="${prefix}_stringOptions" rows="8" style="font-family:monospace;font-size:.85rem">${(config.options || []).join('\n')}</textarea>
|
||
<span class="field-hint">Do not include the “Other” row here; enable it below.</span>
|
||
</div>
|
||
${stringSpinnerNextSectionHTML(config, prefix)}
|
||
${stringSpinnerOtherSectionHTML(config, prefix)}`;
|
||
break;
|
||
case 'free_text':
|
||
html = `
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Hint key (optional)</label>
|
||
<input type="text" id="${prefix}_hint" value="${esc(config.hint || '')}" placeholder="e.g. enter_text_to_continue">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Max length</label>
|
||
<input type="number" id="${prefix}_maxLength" value="${config.maxLength ?? 500}" min="1" max="10000">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Extra text key (optional)</label>
|
||
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. additional_notes_label">
|
||
</div>`;
|
||
break;
|
||
case 'client_coach_code_question':
|
||
html = `
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Hint 1 key</label>
|
||
<input type="text" id="${prefix}_hint1" value="${esc(config.hint1 || '')}" placeholder="client_code">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Hint 2 key</label>
|
||
<input type="text" id="${prefix}_hint2" value="${esc(config.hint2 || '')}" placeholder="berater_code">
|
||
</div>
|
||
</div>`;
|
||
break;
|
||
case 'last_page':
|
||
html = `
|
||
<div class="form-group">
|
||
<label>Text key</label>
|
||
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="finish_data_entry">
|
||
</div>`;
|
||
break;
|
||
}
|
||
if (QUESTION_LEVEL_NEXT_LAYOUTS.has(layout)) {
|
||
html += questionNextSectionHTML(config, prefix);
|
||
}
|
||
return html ? `<div class="config-section">${html}</div>` : '';
|
||
}
|
||
|
||
function readConfigFromForm(layout, prefix) {
|
||
const config = {};
|
||
const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || '';
|
||
|
||
switch (layout) {
|
||
case 'radio_question':
|
||
if (val('textKey')) config.textKey = val('textKey');
|
||
break;
|
||
case 'multi_check_box_question': {
|
||
const ms = parseInt(val('minSelection') || '0', 10);
|
||
if (ms > 0) config.minSelection = ms;
|
||
const otherEnabled = document.getElementById(`${prefix}_otherEnabled`)?.checked;
|
||
if (otherEnabled) {
|
||
const ok = val('otherOptionKey') || 'other_option';
|
||
const on = val('otherNextQuestionId');
|
||
if (ok) config.otherOptionKey = ok;
|
||
if (on) config.otherNextQuestionId = on;
|
||
}
|
||
break;
|
||
}
|
||
case 'glass_scale_question': {
|
||
const st = val('scaleType') || 'glass';
|
||
if (st && st !== 'glass') config.scaleType = st;
|
||
break;
|
||
}
|
||
case 'value_spinner':
|
||
case 'slider_question':
|
||
config.range = {
|
||
min: parseInt(val('rangeMin') || '0', 10),
|
||
max: parseInt(val('rangeMax') || '100', 10),
|
||
};
|
||
if (layout === 'slider_question') {
|
||
const step = parseInt(val('step') || '1', 10);
|
||
if (step > 0) config.step = step;
|
||
if (val('unitLabel')) config.unitLabel = val('unitLabel');
|
||
}
|
||
break;
|
||
case 'date_spinner': {
|
||
const prec = val('precision') || 'full';
|
||
if (prec && prec !== 'full') config.precision = prec;
|
||
const nb = val('notBefore');
|
||
const na = val('notAfter');
|
||
if (nb || na) {
|
||
config.constraints = {};
|
||
if (nb) config.constraints.notBefore = nb;
|
||
if (na) config.constraints.notAfter = na;
|
||
}
|
||
break;
|
||
}
|
||
case 'string_spinner': {
|
||
const raw = val('stringOptions');
|
||
const opts = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
||
if (opts.length) config.options = opts;
|
||
const otherEnabled = document.getElementById(`${prefix}_otherEnabled`)?.checked;
|
||
if (otherEnabled) {
|
||
const ok = val('otherOptionKey') || 'other_option';
|
||
const on = val('otherNextQuestionId');
|
||
if (ok) config.otherOptionKey = ok;
|
||
if (on) config.otherNextQuestionId = on;
|
||
}
|
||
break;
|
||
}
|
||
case 'free_text': {
|
||
if (val('textKey')) config.textKey = val('textKey');
|
||
if (val('hint')) config.hint = val('hint');
|
||
const ml = parseInt(val('maxLength') || '500', 10);
|
||
if (!Number.isNaN(ml) && ml > 0) config.maxLength = Math.min(ml, 10000);
|
||
break;
|
||
}
|
||
case 'client_coach_code_question':
|
||
if (val('hint1')) config.hint1 = val('hint1');
|
||
if (val('hint2')) config.hint2 = val('hint2');
|
||
break;
|
||
case 'last_page':
|
||
if (val('textKey')) config.textKey = val('textKey');
|
||
break;
|
||
}
|
||
if (QUESTION_LEVEL_NEXT_LAYOUTS.has(layout) || layout === 'string_spinner') {
|
||
readQuestionNextFromForm(prefix, config);
|
||
}
|
||
return JSON.stringify(config);
|
||
}
|
||
|
||
// ── Main editor render ───────────────────────────────────────────────────
|
||
|
||
function renderEditor() {
|
||
const container = document.getElementById('editorContent');
|
||
const editable = canEdit();
|
||
const condJson = questionnaire.conditionJson || '{}';
|
||
|
||
container.innerHTML = `
|
||
<div class="card" style="margin-bottom:20px">
|
||
<h3 style="margin-bottom:12px">Questionnaire Details</h3>
|
||
<div class="meta-form">
|
||
<div class="form-group">
|
||
<label>Name</label>
|
||
<input type="text" id="metaName" value="${esc(questionnaire.name)}" ${editable ? '' : 'disabled'}>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Version</label>
|
||
<input type="text" id="metaVersion" value="${esc(questionnaire.version)}" ${editable ? '' : 'disabled'}>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Structure revision</label>
|
||
<input type="text" value="${esc(String(questionnaire.structureRevision ?? 1))}" disabled
|
||
title="Auto-incremented when questions or options change structurally">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>State</label>
|
||
<select id="metaState" ${editable ? '' : 'disabled'}>
|
||
${['draft','active','archived'].map(s =>
|
||
`<option value="${s}" ${questionnaire.state === s ? 'selected' : ''}>${s}</option>`
|
||
).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="meta-form" style="margin-top:0">
|
||
<div class="form-group">
|
||
<label>Order index</label>
|
||
<input type="number" id="metaOrder" value="${questionnaire.orderIndex ?? 0}" min="0" ${editable ? '' : 'disabled'}>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Category key (app grouping)</label>
|
||
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
|
||
</div>
|
||
</div>
|
||
<div id="conditionEditorMount" style="margin:16px 0"></div>
|
||
${editable ? `<button class="btn btn-primary" id="saveMetaBtn">${isNew ? 'Create Questionnaire' : 'Save Changes'}</button>` : ''}
|
||
</div>
|
||
|
||
${!isNew ? `
|
||
<div class="tab-bar" id="editorTabs">
|
||
<button class="active" data-tab="questions">Questions (${questions.length})</button>
|
||
<button data-tab="preview">Preview</button>
|
||
<button data-tab="translations">Translations</button>
|
||
</div>
|
||
<div id="tabContent-questions">
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||
<h3>Questions</h3>
|
||
${editable ? `<button class="btn btn-primary btn-sm" id="addQuestionBtn">+ Add Question</button>` : ''}
|
||
</div>
|
||
<div id="addQuestionFormWrapper" style="display:none"></div>
|
||
<ul class="question-list" id="questionList"></ul>
|
||
<div id="retiredQuestionsSection" style="display:none;margin-top:20px">
|
||
<details class="retired-questions-panel">
|
||
<summary>Retired questions (<span id="retiredCount">0</span>)</summary>
|
||
<p class="field-hint">Retired questions stay in upload history; Counselors on older forms can still submit.</p>
|
||
<ul class="question-list retired-question-list" id="retiredQuestionList"></ul>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="tabContent-translations" style="display:none">
|
||
<div class="card" id="translationsCard">
|
||
<div id="translationsContent"><div class="spinner"></div></div>
|
||
</div>
|
||
</div>
|
||
<div id="tabContent-preview" style="display:none">
|
||
<div id="previewContent"></div>
|
||
</div>
|
||
` : '<p style="color:var(--text-secondary);margin-top:12px">Save the questionnaire first, then add questions.</p>'}
|
||
`;
|
||
|
||
if (editable) {
|
||
document.getElementById('saveMetaBtn').addEventListener('click', saveMeta);
|
||
}
|
||
initConditionEditor(condJson);
|
||
|
||
if (!isNew) {
|
||
initTabs();
|
||
renderQuestions();
|
||
renderRetiredQuestions();
|
||
if (editable) {
|
||
document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionForm);
|
||
initDragReorder();
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyStructureRevision(rev) {
|
||
if (rev == null) return;
|
||
questionnaire.structureRevision = rev;
|
||
const input = document.querySelector('.meta-form input[disabled][title*="Auto-incremented"]');
|
||
if (input) input.value = String(rev);
|
||
}
|
||
|
||
function notifyStructureBump(data) {
|
||
const rev = data?.structureRevision;
|
||
if (rev != null) {
|
||
applyStructureRevision(rev);
|
||
showToast(`Structure revision bumped to ${rev}.`, 'info');
|
||
}
|
||
}
|
||
|
||
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';
|
||
document.getElementById('tabContent-preview').style.display = tab === 'preview' ? '' : 'none';
|
||
if (tab === 'translations') loadTranslationsTab();
|
||
if (tab === 'preview') loadPreviewTab();
|
||
});
|
||
});
|
||
}
|
||
|
||
function loadPreviewTab() {
|
||
const mount = document.getElementById('previewContent');
|
||
if (!mount) return;
|
||
mountQuestionnairePreview(mount, {
|
||
questionnaireName: questionnaire?.name || '',
|
||
questions,
|
||
});
|
||
}
|
||
|
||
async function fetchAppStringGerman(messageKey) {
|
||
if (!messageKey) return '';
|
||
try {
|
||
const data = await apiGet(
|
||
`translations.php?type=app_string&id=${encodeURIComponent(messageKey)}`
|
||
);
|
||
const row = (data.translations || []).find(
|
||
tr => tr.languageCode === SOURCE_LANG
|
||
);
|
||
return row?.text?.trim() || '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
async function loadQuestionsByQuestionnaire() {
|
||
const ids = allQuestionnaires.map(q => q.questionnaireID).filter(Boolean);
|
||
if (!ids.length) {
|
||
questionsByQuestionnaire = {};
|
||
return;
|
||
}
|
||
const pairs = await Promise.all(ids.map(async id => {
|
||
try {
|
||
const data = await apiGet(
|
||
`questions.php?questionnaireID=${encodeURIComponent(id)}`
|
||
);
|
||
return [id, data.questions || []];
|
||
} catch {
|
||
return [id, []];
|
||
}
|
||
}));
|
||
questionsByQuestionnaire = Object.fromEntries(pairs);
|
||
}
|
||
|
||
async function initConditionEditor(condJson) {
|
||
const mount = document.getElementById('conditionEditorMount');
|
||
if (!mount) return;
|
||
mount.innerHTML = '<div class="spinner"></div>';
|
||
await loadQuestionsByQuestionnaire();
|
||
const qid = questionnaire?.questionnaireID || 'new';
|
||
const parsed = parseConditionForm(condJson, qid);
|
||
const germanLabel = parsed.messageKey
|
||
? await fetchAppStringGerman(parsed.messageKey)
|
||
: '';
|
||
conditionEditorApi = mountConditionEditor(mount, {
|
||
questionnaireId: qid,
|
||
allQuestionnaires,
|
||
questionsByQuestionnaire,
|
||
conditionJson: condJson,
|
||
germanLabel,
|
||
editable: canEdit(),
|
||
});
|
||
}
|
||
|
||
// ── 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 = 0;
|
||
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
|
||
let conditionJson = '{}';
|
||
let conditionForm = null;
|
||
if (conditionEditorApi) {
|
||
try {
|
||
conditionJson = conditionEditorApi.buildJson();
|
||
conditionForm = conditionEditorApi.readForm();
|
||
} catch (e) {
|
||
showToast(e.message || 'Invalid availability settings', '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, categoryKey });
|
||
if (data.success) {
|
||
questionnaire = data.questionnaire;
|
||
isNew = false;
|
||
await saveConditionMessageLabel(conditionForm);
|
||
showToast('Questionnaire created', 'success');
|
||
navigate(`#/questionnaire/${questionnaire.questionnaireID}`);
|
||
}
|
||
} else {
|
||
const data = await apiPut('questionnaires.php', {
|
||
questionnaireID: questionnaire.questionnaireID,
|
||
name, version, state, orderIndex, showPoints, conditionJson, categoryKey
|
||
});
|
||
if (data.success) {
|
||
questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson, categoryKey };
|
||
document.getElementById('editorTitle').textContent = name;
|
||
await saveConditionMessageLabel(conditionForm);
|
||
showToast('Saved', 'success');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function saveConditionMessageLabel(form) {
|
||
if (!form || form.mode === 'always') return;
|
||
const messageKey = (form.messageKey || '').trim();
|
||
const germanLabel = (form.germanLabel || '').trim();
|
||
if (!messageKey || !germanLabel) return;
|
||
try {
|
||
await apiPost('questionnaires.php', {
|
||
action: 'updateConditionMessageLabel',
|
||
messageKey,
|
||
germanLabel,
|
||
});
|
||
} catch (e) {
|
||
showToast(`Saved questionnaire; message label failed: ${e.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// ── Add question form ────────────────────────────────────────────────────
|
||
|
||
function showAddQuestionForm() {
|
||
const wrapper = document.getElementById('addQuestionFormWrapper');
|
||
if (!wrapper) return;
|
||
|
||
const pendingOptions = [];
|
||
|
||
wrapper.style.display = '';
|
||
wrapper.innerHTML = `
|
||
<div class="inline-form-card">
|
||
<h4>New Question</h4>
|
||
<div class="form-row">
|
||
<div class="form-group" style="flex:1;min-width:180px">
|
||
<label>Question key <span class="required-mark">*</span></label>
|
||
<input type="text" id="aq_questionKey" placeholder="e.g. consent_instruction" required pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
|
||
</div>
|
||
<div class="form-group" style="flex:2">
|
||
<label>German text <span class="required-mark">*</span></label>
|
||
<input type="text" id="aq_text" placeholder="e.g. Haben Sie zugestimmt?" required>
|
||
</div>
|
||
<div class="form-group" style="flex:1;min-width:200px">
|
||
<label>Layout type</label>
|
||
${layoutSelectHTML('radio_question', 'aq_type')}
|
||
</div>
|
||
</div>
|
||
<label class="checkbox-label" style="margin-bottom:12px;display:inline-flex">
|
||
<input type="checkbox" id="aq_required"> Required
|
||
</label>
|
||
|
||
<div id="aq_config_section"></div>
|
||
<div id="aq_notes_section">${notesSectionHTML({}, 'aq_cfg', '')}</div>
|
||
|
||
<div id="aq_options_section">
|
||
<div class="options-builder">
|
||
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:8px">
|
||
Answer Options
|
||
</label>
|
||
<ul class="pending-options-list" id="aq_pending_options"></ul>
|
||
<div style="display:flex;gap:8px;margin-top:6px;align-items:flex-end">
|
||
<div class="form-group" style="flex:1;min-width:140px;margin-bottom:0">
|
||
<input type="text" id="aq_opt_key" placeholder="option_key" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
|
||
</div>
|
||
<div class="form-group" style="flex:2;margin-bottom:0">
|
||
<input type="text" id="aq_opt_text" placeholder="German label…" required>
|
||
</div>
|
||
<div class="form-group" style="width:90px;margin-bottom:0">
|
||
<input type="number" id="aq_opt_pts" value="0" min="0" placeholder="Pts">
|
||
</div>
|
||
<div class="form-group" style="width:160px;margin-bottom:0">
|
||
<label style="font-size:.75rem;color:var(--text-secondary)">Next question</label>
|
||
<select id="aq_opt_next">
|
||
<option value="">(next in order)</option>
|
||
${branchTargetOptionsHTML()}
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-sm" id="aq_opt_add" style="flex-shrink:0;white-space:nowrap">+ Add</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:8px;margin-top:14px">
|
||
<button class="btn btn-primary btn-sm" id="aq_submit">Add Question</button>
|
||
<button class="btn btn-sm" id="aq_cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
function updateTypeUI() {
|
||
const type = document.getElementById('aq_type').value;
|
||
const qKey = document.getElementById('aq_questionKey')?.value.trim() || '';
|
||
document.getElementById('aq_options_section').style.display = OPTION_TYPES.has(type) ? '' : 'none';
|
||
document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg');
|
||
wireStringSpinnerOtherToggle('aq_cfg');
|
||
if (type === 'glass_scale_question') wireGlassSymptomsSection('aq_cfg');
|
||
if (type === 'slider_question' || type === 'value_spinner') wireValueScoreRules('aq_cfg');
|
||
const notesEl = document.getElementById('aq_notes_section');
|
||
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
|
||
}
|
||
|
||
function renderPendingOptions() {
|
||
const list = document.getElementById('aq_pending_options');
|
||
if (!list) return;
|
||
if (!pendingOptions.length) {
|
||
list.innerHTML = `<li class="pending-options-empty">No options yet.</li>`;
|
||
return;
|
||
}
|
||
list.innerHTML = pendingOptions.map((opt, i) => `
|
||
<li class="pending-option-item">
|
||
<code class="opt-key">${esc(opt.optionKey)}</code>
|
||
<span class="opt-text">${esc(opt.text)}</span>
|
||
<span class="opt-points">${opt.points} pts</span>
|
||
${opt.nextQuestionId ? `<span class="opt-branch">→ ${esc(opt.nextQuestionId)}</span>` : ''}
|
||
<button class="btn-icon btn-icon-danger remove-pending-opt" data-idx="${i}" title="Remove">✖</button>
|
||
</li>
|
||
`).join('');
|
||
list.querySelectorAll('.remove-pending-opt').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
pendingOptions.splice(parseInt(btn.dataset.idx, 10), 1);
|
||
renderPendingOptions();
|
||
});
|
||
});
|
||
}
|
||
|
||
function addPendingOption() {
|
||
const optionKey = document.getElementById('aq_opt_key').value.trim();
|
||
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 (!validateStableKey(optionKey, 'Option key')) return;
|
||
if (!text) { showToast('German label is required', 'error'); return; }
|
||
pendingOptions.push({ optionKey, text, points: pts, nextQuestionId: next });
|
||
renderPendingOptions();
|
||
document.getElementById('aq_opt_key').value = '';
|
||
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_questionKey').addEventListener('input', () => {
|
||
const notesEl = document.getElementById('aq_notes_section');
|
||
if (notesEl) {
|
||
const nb = document.getElementById('aq_cfg_noteBefore')?.value ?? '';
|
||
const na = document.getElementById('aq_cfg_noteAfter')?.value ?? '';
|
||
notesEl.innerHTML = notesSectionHTML(
|
||
{ noteBefore: nb, noteAfter: na },
|
||
'aq_cfg',
|
||
document.getElementById('aq_questionKey').value.trim()
|
||
);
|
||
}
|
||
});
|
||
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 questionKey = document.getElementById('aq_questionKey').value.trim();
|
||
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 btn = document.getElementById('aq_submit');
|
||
const notes = readNotesFromForm('aq_cfg');
|
||
const configJson = readConfigFromForm(type, 'aq_cfg');
|
||
const postBody = {
|
||
questionnaireID: questionnaire.questionnaireID,
|
||
questionKey,
|
||
defaultText: text,
|
||
type,
|
||
isRequired,
|
||
configJson,
|
||
noteBefore: notes.noteBefore,
|
||
noteAfter: notes.noteAfter,
|
||
};
|
||
if (type === 'glass_scale_question') {
|
||
const glassSymptoms = readGlassSymptomsFromForm('aq_cfg');
|
||
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
|
||
postBody.glassSymptoms = glassSymptoms;
|
||
}
|
||
if (type === 'slider_question' || type === 'value_spinner') {
|
||
postBody.scoreRules = readValueScoreRulesFromForm('aq_cfg');
|
||
}
|
||
if (!validateStableKey(questionKey, 'Question key')) return;
|
||
if (!text) { showToast('German text is required', 'error'); return; }
|
||
if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return;
|
||
if (OPTION_TYPES.has(type) && pendingOptions.length === 0) {
|
||
showToast('Add at least one answer option', 'error');
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = 'Adding...';
|
||
try {
|
||
const qData = await apiPost('questions.php', postBody);
|
||
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,
|
||
optionKey: opt.optionKey,
|
||
defaultText: opt.text,
|
||
points: opt.points,
|
||
nextQuestionId: opt.nextQuestionId || '',
|
||
});
|
||
if (oData.success) newQ.answerOptions.push(oData.answerOption);
|
||
}
|
||
|
||
questions.push(newQ);
|
||
renderQuestions();
|
||
notifyStructureBump(qData);
|
||
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 renderRetiredQuestions() {
|
||
const section = document.getElementById('retiredQuestionsSection');
|
||
const list = document.getElementById('retiredQuestionList');
|
||
const countEl = document.getElementById('retiredCount');
|
||
if (!section || !list) return;
|
||
if (!retiredQuestions.length) {
|
||
section.style.display = 'none';
|
||
list.innerHTML = '';
|
||
if (countEl) countEl.textContent = '0';
|
||
return;
|
||
}
|
||
section.style.display = '';
|
||
if (countEl) countEl.textContent = String(retiredQuestions.length);
|
||
list.innerHTML = retiredQuestions.map((q, idx) => `
|
||
<li class="question-item retired-question-item">
|
||
<div class="question-header">
|
||
<span class="q-text">${idx + 1}. <code>${esc(questionKeyOf(q) || '?')}</code> · ${esc((q.defaultText || '').slice(0, 48))}</span>
|
||
<span class="badge badge-archived">Retired</span>
|
||
${q.retiredInRevision ? `<span class="badge">rev ${q.retiredInRevision}</span>` : ''}
|
||
</div>
|
||
</li>
|
||
`).join('');
|
||
}
|
||
|
||
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})`;
|
||
renderRetiredQuestions();
|
||
|
||
if (!questions.length) {
|
||
list.innerHTML = `<li class="empty-state" style="padding:30px"><h3>No questions yet</h3><p>Add your first question above.</p></li>`;
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = questions.map((q, idx) => `
|
||
<li class="question-item" data-id="${q.questionID}" draggable="${editable}">
|
||
<div class="question-header">
|
||
${editable ? '<span class="drag-handle" title="Drag to reorder">☰</span>' : ''}
|
||
<span class="chevron">▶</span>
|
||
<span class="q-text">${idx + 1}. <code>${esc(questionKeyOf(q) || '?')}</code> · ${esc((q.defaultText || '').slice(0, 48))}${(q.defaultText || '').length > 48 ? '…' : ''}</span>
|
||
<span class="q-type">${esc(layoutLabel(q.type))}</span>
|
||
${!questionKeyOf(q) ? '<span class="badge badge-warn">No key</span>' : ''}
|
||
${q.isRequired ? '<span class="badge badge-active">Required</span>' : ''}
|
||
${(() => {
|
||
const c = parseConfig(q);
|
||
const parts = [];
|
||
if (c.nextQuestionId) parts.push(`→ ${esc(c.nextQuestionId)}`);
|
||
if (c.otherNextQuestionId) parts.push(`Other→${esc(c.otherNextQuestionId)}`);
|
||
return parts.length
|
||
? `<span class="badge" title="Branches">${esc(parts.join(' · '))}</span>` : '';
|
||
})()}
|
||
</div>
|
||
<div class="question-body" id="qbody-${q.questionID}"></div>
|
||
</li>
|
||
`).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';
|
||
const qKey = questionKeyOf(q);
|
||
const localId = q.localId || q.questionID.split('__').pop();
|
||
|
||
body.innerHTML = `
|
||
${editable ? `
|
||
<div class="q-edit-section">
|
||
<div class="inline-form-card" style="margin-bottom:12px">
|
||
<p class="field-hint" style="margin-bottom:10px"><strong>Local ID:</strong> <code>${esc(localId)}</code></p>
|
||
<div class="form-row">
|
||
<div class="form-group" style="flex:1;min-width:180px">
|
||
<label>Question key <span class="required-mark">*</span></label>
|
||
<input type="text" id="eq_key_${q.questionID}" value="${esc(qKey)}" required pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
|
||
</div>
|
||
<div class="form-group" style="flex:2">
|
||
<label>German text <span class="required-mark">*</span></label>
|
||
<input type="text" id="eq_text_${q.questionID}" value="${esc(q.defaultText)}" required>
|
||
</div>
|
||
<div class="form-group" style="flex:1;min-width:200px">
|
||
<label>Layout type</label>
|
||
${layoutSelectHTML(layout, `eq_type_${q.questionID}`)}
|
||
</div>
|
||
</div>
|
||
<label class="checkbox-label" style="margin-bottom:12px;display:inline-flex">
|
||
<input type="checkbox" id="eq_req_${q.questionID}" ${q.isRequired ? 'checked' : ''}> Required
|
||
</label>
|
||
${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
|
||
<div id="eq_config_${q.questionID}">
|
||
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, {
|
||
glassSymptoms: glassSymptomsRowsFromQuestion(q),
|
||
scoreRules: q.scoreRules || [],
|
||
})}
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-top:8px">
|
||
<button class="btn btn-primary btn-sm save-q-btn">Save Question</button>
|
||
<button class="btn btn-sm btn-danger del-q-btn">Delete Question</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : `
|
||
<div class="inline-form-card" style="margin-bottom:12px">
|
||
<p><strong>Key:</strong> <code>${esc(qKey || '—')}</code> · <strong>ID:</strong> <code>${esc(localId)}</code></p>
|
||
<p><strong>German:</strong> ${esc(q.defaultText)}</p>
|
||
<p><strong>Layout:</strong> ${esc(layoutLabel(layout))}</p>
|
||
${config.nextQuestionId || config.otherNextQuestionId
|
||
? `<p><strong>Branches:</strong>
|
||
${config.nextQuestionId ? `→ <code>${esc(config.nextQuestionId)}</code>` : ''}
|
||
${config.otherNextQuestionId ? `${config.nextQuestionId ? ' · ' : ''}Other (<code>${esc(config.otherOptionKey || 'other_option')}</code>) → <code>${esc(config.otherNextQuestionId)}</code>` : ''}
|
||
</p>`
|
||
: ''}
|
||
${Object.keys(config).length ? `<p><strong>Config:</strong> <code>${esc(JSON.stringify(config))}</code></p>` : ''}
|
||
${layout === 'glass_scale_question' && glassSymptomsRowsFromQuestion(q).length ? `
|
||
<p><strong>Symptoms:</strong></p>
|
||
<ul class="option-list">
|
||
${glassSymptomsRowsFromQuestion(q).map(r =>
|
||
`<li><code>${esc(r.key)}</code> · ${esc(r.labelDe || '—')}</li>`
|
||
).join('')}
|
||
</ul>` : ''}
|
||
</div>
|
||
`}
|
||
|
||
${OPTION_TYPES.has(layout) ? `
|
||
<h4 style="font-size:.85rem;color:var(--text-secondary);margin-bottom:6px">Answer Options (${options.length})</h4>
|
||
<ul class="option-list" id="opts-${q.questionID}">
|
||
${options.map(o => optionItemHTML(q, o, editable)).join('')
|
||
|| '<li style="color:var(--text-secondary);padding:8px;font-size:.85rem">No answer options</li>'}
|
||
</ul>
|
||
${editable ? `
|
||
<div id="addOptForm-${q.questionID}" style="display:none"></div>
|
||
<button class="btn btn-sm" id="addOpt-${q.questionID}" style="margin-top:6px">+ Add Option</button>
|
||
` : ''}
|
||
` : ''}
|
||
`;
|
||
|
||
if (!editable) return;
|
||
|
||
const cfgPrefix = `eq_cfg_${q.questionID}`;
|
||
|
||
body.querySelector('.save-q-btn').addEventListener('click', () => {
|
||
const questionKey = document.getElementById(`eq_key_${q.questionID}`).value.trim();
|
||
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 notes = readNotesFromForm(cfgPrefix);
|
||
const cj = readConfigFromForm(type, cfgPrefix);
|
||
const payload = {
|
||
questionKey,
|
||
defaultText: text,
|
||
type,
|
||
isRequired: isReq,
|
||
configJson: cj,
|
||
noteBefore: notes.noteBefore,
|
||
noteAfter: notes.noteAfter,
|
||
};
|
||
if (type === 'glass_scale_question') {
|
||
const glassSymptoms = readGlassSymptomsFromForm(cfgPrefix);
|
||
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
|
||
payload.glassSymptoms = glassSymptoms;
|
||
}
|
||
if (type === 'slider_question' || type === 'value_spinner') {
|
||
payload.scoreRules = readValueScoreRulesFromForm(cfgPrefix);
|
||
}
|
||
if (!validateStableKey(questionKey, 'Question key')) return;
|
||
if (!text) { showToast('German text is required', 'error'); return; }
|
||
if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return;
|
||
updateQuestion(q.questionID, payload);
|
||
});
|
||
|
||
document.getElementById(`eq_key_${q.questionID}`).addEventListener('input', () => {
|
||
const nb = document.getElementById(`${cfgPrefix}_noteBefore`)?.value ?? '';
|
||
const na = document.getElementById(`${cfgPrefix}_noteAfter`)?.value ?? '';
|
||
const key = document.getElementById(`eq_key_${q.questionID}`).value.trim();
|
||
const notesBlock = body.querySelector('.notes-section');
|
||
if (notesBlock) {
|
||
notesBlock.outerHTML = notesSectionHTML({ noteBefore: nb, noteAfter: na }, cfgPrefix, key);
|
||
}
|
||
});
|
||
|
||
document.getElementById(`eq_type_${q.questionID}`).addEventListener('change', () => {
|
||
const newLayout = document.getElementById(`eq_type_${q.questionID}`).value;
|
||
const opts = newLayout === 'glass_scale_question'
|
||
? { glassSymptoms: glassSymptomsRowsFromQuestion(q) }
|
||
: (newLayout === 'slider_question' || newLayout === 'value_spinner')
|
||
? { scoreRules: q.scoreRules || [] }
|
||
: {};
|
||
document.getElementById(`eq_config_${q.questionID}`).innerHTML =
|
||
configFormHTML(newLayout, {}, cfgPrefix, opts);
|
||
wireStringSpinnerOtherToggle(cfgPrefix);
|
||
if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
|
||
if (newLayout === 'slider_question' || newLayout === 'value_spinner') wireValueScoreRules(cfgPrefix);
|
||
});
|
||
|
||
wireStringSpinnerOtherToggle(cfgPrefix);
|
||
if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
|
||
if (layout === 'slider_question' || layout === 'value_spinner') wireValueScoreRules(cfgPrefix);
|
||
|
||
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) {
|
||
const ok = optionKeyOf(o);
|
||
const label = optionGermanLabel(o) || o.defaultText;
|
||
return `
|
||
<li class="option-item" data-id="${o.answerOptionID}" draggable="${editable}">
|
||
${editable ? '<span class="drag-handle" style="font-size:.9rem">☰</span>' : ''}
|
||
<code class="opt-key">${esc(ok || '?')}</code>
|
||
<span class="opt-text">${esc(label)}</span>
|
||
<span class="opt-points">${o.points} pts</span>
|
||
${o.nextQuestionId ? `<span class="opt-branch">→ ${esc(o.nextQuestionId)}</span>` : ''}
|
||
${editable ? `
|
||
<button class="btn-icon edit-opt-btn" data-id="${o.answerOptionID}" title="Edit">✎</button>
|
||
<button class="btn-icon btn-icon-danger del-opt-btn" data-id="${o.answerOptionID}" title="Delete">✖</button>
|
||
` : ''}
|
||
</li>
|
||
`;
|
||
}
|
||
|
||
// ── 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();
|
||
|
||
wrapper.innerHTML = `
|
||
<div class="inline-form-card" style="margin-top:8px">
|
||
<div class="form-row">
|
||
<div class="form-group" style="flex:1;min-width:140px">
|
||
<label>Option key <span class="required-mark">*</span></label>
|
||
<input type="text" id="ao_key_${q.questionID}" placeholder="e.g. consent_signed" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off" required>
|
||
</div>
|
||
<div class="form-group" style="flex:2">
|
||
<label>German label <span class="required-mark">*</span></label>
|
||
<input type="text" id="ao_text_${q.questionID}" placeholder="e.g. Ja, ich stimme zu" required>
|
||
</div>
|
||
<div class="form-group" style="flex:1;min-width:100px">
|
||
<label>Points</label>
|
||
<input type="number" id="ao_pts_${q.questionID}" value="0" min="0">
|
||
</div>
|
||
<div class="form-group" style="flex:1;min-width:160px">
|
||
<label>Next question</label>
|
||
<select id="ao_next_${q.questionID}">
|
||
<option value="">(next in order)</option>
|
||
${branchTargetOptionsHTML()}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn btn-primary btn-sm" id="ao_submit_${q.questionID}">Add Option</button>
|
||
<button class="btn btn-sm" id="ao_cancel_${q.questionID}">Cancel</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById(`ao_text_${q.questionID}`).focus();
|
||
|
||
document.getElementById(`ao_submit_${q.questionID}`).addEventListener('click', async () => {
|
||
const optionKey = document.getElementById(`ao_key_${q.questionID}`).value.trim();
|
||
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 (!validateStableKey(optionKey, 'Option key')) return;
|
||
if (!text) { showToast('German label 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,
|
||
optionKey,
|
||
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();
|
||
|
||
li.innerHTML = `
|
||
<div class="inline-form-card" style="width:100%;padding:8px">
|
||
<div class="form-row">
|
||
<div class="form-group" style="flex:1;min-width:120px;margin-bottom:8px">
|
||
<label style="font-size:.8rem">Option key <span class="required-mark">*</span></label>
|
||
<input type="text" id="eo_key_${opt.answerOptionID}" value="${esc(optionKeyOf(opt))}" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off" required>
|
||
</div>
|
||
<div class="form-group" style="flex:2;margin-bottom:8px">
|
||
<label style="font-size:.8rem">German label <span class="required-mark">*</span></label>
|
||
<input type="text" id="eo_text_${opt.answerOptionID}" value="${esc(optionGermanLabel(opt))}" required>
|
||
</div>
|
||
<div class="form-group" style="flex:1;min-width:80px;margin-bottom:8px">
|
||
<input type="number" id="eo_pts_${opt.answerOptionID}" value="${opt.points}" min="0">
|
||
</div>
|
||
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:8px">
|
||
<label style="font-size:.8rem">Next question</label>
|
||
<select id="eo_next_${opt.answerOptionID}">
|
||
<option value="">(next in order)</option>
|
||
${qIds.map(qi => `<option value="${esc(qi.localId)}" ${opt.nextQuestionId === qi.localId ? 'selected' : ''}>${esc(branchTargetLabel(qi))}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:6px">
|
||
<button class="btn btn-primary btn-sm" id="eo_save_${opt.answerOptionID}">Save</button>
|
||
<button class="btn btn-sm" id="eo_cancel_${opt.answerOptionID}">Cancel</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
li.draggable = false;
|
||
|
||
document.getElementById(`eo_text_${opt.answerOptionID}`).focus();
|
||
|
||
document.getElementById(`eo_save_${opt.answerOptionID}`).addEventListener('click', async () => {
|
||
const optionKey = document.getElementById(`eo_key_${opt.answerOptionID}`).value.trim();
|
||
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 (!validateStableKey(optionKey, 'Option key')) return;
|
||
if (!text) { showToast('German label is required', 'error'); return; }
|
||
await updateOption(q, opt.answerOptionID, { optionKey, 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 (q.retired) {
|
||
showToast('This question is already retired', 'error');
|
||
return;
|
||
}
|
||
const msg = 'Remove this question? If client data exists it will be retired (not deleted) so upload history stays intact.';
|
||
if (!(await confirmAction({
|
||
title: 'Remove question',
|
||
message: msg,
|
||
confirmLabel: 'Remove',
|
||
variant: 'danger',
|
||
}))) return;
|
||
try {
|
||
const data = await apiDelete('questions.php', { questionID: q.questionID });
|
||
questions = questions.filter(x => x.questionID !== q.questionID);
|
||
if (data.retired) {
|
||
retiredQuestions.push({ ...q, retired: true, retiredAt: Date.now() / 1000 | 0 });
|
||
showToast('Question retired', 'success');
|
||
} else {
|
||
showToast('Question deleted', 'success');
|
||
}
|
||
notifyStructureBump(data);
|
||
renderQuestions();
|
||
} 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 (!(await confirmAction({
|
||
title: 'Remove answer option',
|
||
message: 'Remove this answer option?',
|
||
confirmLabel: 'Remove',
|
||
variant: 'danger',
|
||
}))) return;
|
||
try {
|
||
const data = await apiDelete('answer_options.php', { answerOptionID });
|
||
if (!data.retired) {
|
||
q.answerOptions = (q.answerOptions || []).filter(o => o.answerOptionID !== answerOptionID);
|
||
}
|
||
renderQuestionBody(q);
|
||
notifyStructureBump(data);
|
||
showToast(data.retired ? 'Option retired' : 'Option deleted', 'success');
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── Translations tab ─────────────────────────────────────────────────────
|
||
|
||
let transData = null;
|
||
let editorTransLang = '';
|
||
let editorTransSaveTimers = {};
|
||
|
||
async function loadTranslationsTab() {
|
||
const container = document.getElementById('translationsContent');
|
||
if (!container) return;
|
||
container.innerHTML = '<div class="spinner"></div>';
|
||
|
||
try {
|
||
try {
|
||
await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' });
|
||
} catch (_) { /* already exists */ }
|
||
const data = await apiGet(`translations.php?questionnaireID=${encodeURIComponent(questionnaire.questionnaireID)}`);
|
||
transData = data;
|
||
transData.entries = (transData.entries || []).map(normalizeEntry);
|
||
renderTranslationsTab();
|
||
} catch (e) {
|
||
container.innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
function renderTranslationsTab() {
|
||
const container = document.getElementById('translationsContent');
|
||
if (!container || !transData) return;
|
||
const editable = canEdit();
|
||
const languages = transData.languages || [];
|
||
const qnEntries = transData.entries || [];
|
||
const targets = targetLanguages(languages);
|
||
|
||
if (!qnEntries.length) {
|
||
container.innerHTML = `
|
||
${editable ? languageManagerHTML(languages) : ''}
|
||
<p style="color:var(--text-secondary);margin-top:12px">No translatable content yet. Add questions with German text first.</p>
|
||
`;
|
||
if (editable) bindLanguageManager(languages);
|
||
return;
|
||
}
|
||
|
||
if (!editorTransLang || !targets.some(l => l.languageCode === editorTransLang)) {
|
||
editorTransLang = targets[0]?.languageCode || '';
|
||
}
|
||
|
||
const langSelectHtml = targets.length
|
||
? `<label class="trans-control" style="max-width:280px;margin-bottom:14px">
|
||
<span>Translate into</span>
|
||
<select id="editorTransLangSelect" class="trans-select">
|
||
${targets.map(l => {
|
||
const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode;
|
||
return `<option value="${escHtml(l.languageCode)}" ${l.languageCode === editorTransLang ? 'selected' : ''}>${escHtml(label)}</option>`;
|
||
}).join('')}
|
||
</select>
|
||
</label>`
|
||
: `<p class="text-muted" style="margin-bottom:14px">Add a language below (e.g. English) to translate into.</p>`;
|
||
|
||
container.innerHTML = `
|
||
${editable ? languageManagerHTML(languages) : ''}
|
||
${langSelectHtml}
|
||
${targets.length && editorTransLang ? renderEditorTransList(qnEntries, editable) : ''}
|
||
`;
|
||
|
||
if (editable) bindLanguageManager(languages);
|
||
|
||
document.getElementById('editorTransLangSelect')?.addEventListener('change', (e) => {
|
||
editorTransLang = e.target.value;
|
||
renderTranslationsTab();
|
||
});
|
||
|
||
if (targets.length && editorTransLang) {
|
||
bindEditorTransEditing(qnEntries, editable);
|
||
bindEditorTransFiltering(qnEntries);
|
||
}
|
||
}
|
||
|
||
function renderEditorTransList(qnEntries, editable) {
|
||
const targets = targetLanguages(transData.languages || []);
|
||
const langLabel = targets.find(l => l.languageCode === editorTransLang)?.name || editorTransLang.toUpperCase();
|
||
const sourceLabel = sourceLanguageLabel(transData.languages || []);
|
||
const missingCount = qnEntries.filter(e => !translationFor(e, editorTransLang).trim()).length;
|
||
const pct = qnEntries.length ? Math.round(((qnEntries.length - missingCount) / qnEntries.length) * 100) : 0;
|
||
|
||
const qnGroups = buildTranslationGroups(qnEntries, editorTransLang, 0);
|
||
const bodyHtml = renderCollapsibleGroupedTranslationsHTML(qnGroups, editorTransLang, sourceLabel, editable, {
|
||
openGroupsWithMissing: true,
|
||
});
|
||
|
||
return `
|
||
<div class="trans-toolbar">
|
||
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search…">
|
||
<select id="transTypeFilter" class="trans-select trans-type-select">
|
||
<option value="">All types</option>
|
||
<option value="string">UI strings</option>
|
||
<option value="question">Questions</option>
|
||
<option value="answer_option">Answer options</option>
|
||
</select>
|
||
</div>
|
||
<div class="trans-sheet">
|
||
${translationListHeaderHTML(langLabel, sourceLabel)}
|
||
<div id="transGroupedRoot" class="trans-sheet-body">
|
||
<div class="trans-scope-block">${bodyHtml}</div>
|
||
</div>
|
||
</div>
|
||
<div class="trans-stats">
|
||
<span id="transStats">${missingCount ? `${missingCount} missing · ` : ''}${qnEntries.length} keys</span>
|
||
<span class="trans-stats-progress">
|
||
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
|
||
<span class="trans-progress-label">${pct}% in ${escHtml(editorTransLang.toUpperCase())}</span>
|
||
</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function bindEditorTransEditing(entries, editable) {
|
||
if (!editable) return;
|
||
editorTransSaveTimers = {};
|
||
document.querySelectorAll('#transGroupedRoot .trans-cell-input').forEach(inp => {
|
||
inp.addEventListener('input', () => {
|
||
const idx = parseInt(inp.dataset.idx, 10);
|
||
const entry = entries[idx];
|
||
if (!entry) return;
|
||
const isGerman = inp.dataset.field === 'german';
|
||
if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {};
|
||
if (isGerman) {
|
||
entry.germanText = inp.value;
|
||
entry.translations[SOURCE_LANG] = inp.value;
|
||
} else {
|
||
entry.translations[editorTransLang] = inp.value;
|
||
const missing = !inp.value.trim();
|
||
inp.classList.toggle('trans-input-missing', missing);
|
||
const row = inp.closest('.trans-list-item');
|
||
if (row) {
|
||
row.classList.toggle('trans-list-item-missing', missing);
|
||
row.dataset.missing = missing ? '1' : '0';
|
||
}
|
||
}
|
||
const timerKey = isGerman ? `${idx}_de` : `${idx}_${editorTransLang}`;
|
||
clearTimeout(editorTransSaveTimers[timerKey]);
|
||
editorTransSaveTimers[timerKey] = setTimeout(() => saveEditorTranslation(entry, inp, isGerman), 500);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function saveEditorTranslation(entry, inp, isGerman) {
|
||
try {
|
||
await apiPut('translations.php', {
|
||
type: entry.type,
|
||
id: entry.entityId,
|
||
languageCode: isGerman ? SOURCE_LANG : editorTransLang,
|
||
text: inp.value,
|
||
});
|
||
inp.classList.add('save-flash');
|
||
setTimeout(() => inp.classList.remove('save-flash'), 400);
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function bindEditorTransFiltering(entries) {
|
||
const filterInput = document.getElementById('transFilter');
|
||
const typeFilter = document.getElementById('transTypeFilter');
|
||
const root = document.getElementById('transGroupedRoot');
|
||
if (!filterInput || !root) return;
|
||
|
||
function applyFilter() {
|
||
const text = filterInput.value.toLowerCase();
|
||
const type = typeFilter?.value || '';
|
||
let visible = 0;
|
||
let visibleMissing = 0;
|
||
root.querySelectorAll('.trans-list-item').forEach(row => {
|
||
const key = row.dataset.key?.toLowerCase() || '';
|
||
const label = row.dataset.label?.toLowerCase() || '';
|
||
const rowType = row.dataset.type || '';
|
||
const german = row.querySelector('.trans-source-text')?.textContent?.toLowerCase()
|
||
|| row.querySelector('[data-field="german"]')?.value?.toLowerCase() || '';
|
||
const val = row.querySelector('[data-field="translation"]')?.value?.toLowerCase() || '';
|
||
const show = (!type || rowType === type) &&
|
||
(!text || key.includes(text) || label.includes(text) || german.includes(text) || val.includes(text));
|
||
row.classList.toggle('trans-row-hidden', !show);
|
||
if (show) {
|
||
visible++;
|
||
if (row.dataset.missing === '1') visibleMissing++;
|
||
}
|
||
});
|
||
root.querySelectorAll('.trans-group-details').forEach(group => {
|
||
const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
|
||
group.classList.toggle('trans-group-hidden', !anyVisible);
|
||
});
|
||
root.querySelectorAll('.trans-scope-block').forEach(block => {
|
||
const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
|
||
block.classList.toggle('trans-qn-hidden', !anyVisible);
|
||
});
|
||
const stats = document.getElementById('transStats');
|
||
if (stats) {
|
||
stats.textContent = text || type
|
||
? `Showing ${visible} (${visibleMissing} missing)`
|
||
: `${[...root.querySelectorAll('.trans-list-item')].filter(r => r.dataset.missing === '1').length} missing · ${entries.length} keys`;
|
||
}
|
||
}
|
||
|
||
filterInput.addEventListener('input', applyFilter);
|
||
typeFilter?.addEventListener('change', applyFilter);
|
||
}
|
||
|
||
function languageManagerHTML(languages) {
|
||
const others = targetLanguages(languages);
|
||
return `
|
||
<div class="lang-manager">
|
||
<p style="font-size:.85rem;color:var(--text-secondary);margin:0 0 10px">
|
||
German source text is edited under Questions. Add other languages here.
|
||
</p>
|
||
<div class="lang-chips">
|
||
<span class="lang-chip lang-chip-fixed">
|
||
<strong>DE</strong>
|
||
<span class="lang-chip-name">German (source)</span>
|
||
</span>
|
||
${others.map(l => `
|
||
<span class="lang-chip">
|
||
<strong>${esc(l.languageCode.toUpperCase())}</strong>
|
||
${l.name ? `<span class="lang-chip-name">${esc(l.name)}</span>` : ''}
|
||
<button class="lang-chip-remove" data-lc="${esc(l.languageCode)}" title="Remove language">×</button>
|
||
</span>
|
||
`).join('')}
|
||
</div>
|
||
<div class="lang-add-row">
|
||
<input type="text" id="newLangCode" placeholder="Code (en)" class="lang-add-code">
|
||
<input type="text" id="newLangName" placeholder="Name (English)" class="lang-add-name">
|
||
<button class="btn btn-sm btn-primary" id="addLangBtn">+ Add Language</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 (code === SOURCE_LANG) {
|
||
showToast('German (de) is always available as the source language', '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');
|
||
editorTransLang = code;
|
||
loadTranslationsTab();
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll('.lang-chip-remove').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const lc = btn.dataset.lc;
|
||
if (!(await confirmAction({
|
||
title: 'Remove language',
|
||
message: `Remove language "${lc}" and ALL its translations?`,
|
||
confirmLabel: 'Remove',
|
||
variant: 'danger',
|
||
}))) return;
|
||
try {
|
||
await apiDelete('translations.php', { type: 'language', languageCode: lc });
|
||
showToast(`Language "${lc}" removed`, 'success');
|
||
loadTranslationsTab();
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
function esc(s) {
|
||
const d = document.createElement('div');
|
||
d.textContent = s ?? '';
|
||
return d.innerHTML;
|
||
}
|