enhanced questionnaire handling with stable key validation, improved note management, and added option key support
This commit is contained in:
@ -1158,3 +1158,24 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
|
||||
border-radius: 3px;
|
||||
transition: width .3s ease;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.field-hint code {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.opt-key {
|
||||
font-size: 0.75rem;
|
||||
margin-right: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ const LAYOUT_TYPES = [
|
||||
{ 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' },
|
||||
@ -93,17 +94,174 @@ function layoutLabel(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('__');
|
||||
return { questionID: q.questionID, localId: parts[parts.length - 1], defaultText: q.defaultText };
|
||||
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 stringSpinnerOtherEnabled(config) {
|
||||
return Boolean(config.otherNextQuestionId || config.otherOptionKey);
|
||||
}
|
||||
|
||||
function stringSpinnerNextSectionHTML(config, prefix) {
|
||||
const next = config.nextQuestionId || '';
|
||||
return `
|
||||
<div class="form-group" style="margin-top:12px">
|
||||
<label>Next question (list selection)</label>
|
||||
<select id="${prefix}_nextQuestionId">
|
||||
<option value="">(next in list order)</option>
|
||||
${branchTargetOptionsHTML(next)}
|
||||
</select>
|
||||
<span class="field-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).</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -140,6 +298,7 @@ function configFormHTML(layout, config, prefix) {
|
||||
break;
|
||||
}
|
||||
case 'value_spinner':
|
||||
case 'slider_question':
|
||||
html = `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
@ -150,6 +309,15 @@ function configFormHTML(layout, config, prefix) {
|
||||
<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>`;
|
||||
break;
|
||||
case 'date_spinner': {
|
||||
@ -181,8 +349,11 @@ function configFormHTML(layout, config, prefix) {
|
||||
html = `
|
||||
<div class="form-group">
|
||||
<label>Static options (one per line)</label>
|
||||
<textarea id="${prefix}_stringOptions" rows="4" style="font-family:monospace;font-size:.85rem">${(config.options || []).join('\n')}</textarea>
|
||||
</div>`;
|
||||
<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 = `
|
||||
@ -196,11 +367,6 @@ function configFormHTML(layout, config, prefix) {
|
||||
<input type="number" id="${prefix}_maxLength" value="${config.maxLength ?? 500}" min="1" max="10000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label" style="display:inline-flex">
|
||||
<input type="checkbox" id="${prefix}_multiline" ${config.multiline ? 'checked' : ''}> Multiline input
|
||||
</label>
|
||||
</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">
|
||||
@ -271,10 +437,16 @@ function readConfigFromForm(layout, prefix) {
|
||||
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';
|
||||
@ -292,6 +464,13 @@ function readConfigFromForm(layout, prefix) {
|
||||
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': {
|
||||
@ -299,7 +478,6 @@ function readConfigFromForm(layout, prefix) {
|
||||
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);
|
||||
if (document.getElementById(`${prefix}_multiline`)?.checked) config.multiline = true;
|
||||
break;
|
||||
}
|
||||
case 'client_coach_code_question':
|
||||
@ -490,7 +668,11 @@ function showAddQuestionForm() {
|
||||
<div class="inline-form-card">
|
||||
<h4>New Question</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:3">
|
||||
<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>
|
||||
@ -504,6 +686,7 @@ function showAddQuestionForm() {
|
||||
</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">
|
||||
@ -512,8 +695,11 @@ function showAddQuestionForm() {
|
||||
</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:3;margin-bottom:0">
|
||||
<input type="text" id="aq_opt_text" placeholder="German option text…" required>
|
||||
<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">
|
||||
@ -521,7 +707,7 @@ function showAddQuestionForm() {
|
||||
<div class="form-group" style="width:160px;margin-bottom:0">
|
||||
<select id="aq_opt_next">
|
||||
<option value="">(next in order)</option>
|
||||
${questionLocalIds().map(q => `<option value="${esc(q.localId)}">${esc(q.localId)} - ${esc(q.defaultText)}</option>`).join('')}
|
||||
${branchTargetOptionsHTML()}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-sm" id="aq_opt_add" style="flex-shrink:0;white-space:nowrap">+ Add</button>
|
||||
@ -538,8 +724,12 @@ function showAddQuestionForm() {
|
||||
|
||||
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');
|
||||
const notesEl = document.getElementById('aq_notes_section');
|
||||
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
|
||||
}
|
||||
|
||||
function renderPendingOptions() {
|
||||
@ -551,6 +741,7 @@ function showAddQuestionForm() {
|
||||
}
|
||||
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>` : ''}
|
||||
@ -566,12 +757,15 @@ function showAddQuestionForm() {
|
||||
}
|
||||
|
||||
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 (!text) { showToast('German text is required', 'error'); return; }
|
||||
pendingOptions.push({ text, points: pts, nextQuestionId: next });
|
||||
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 = '';
|
||||
@ -583,17 +777,33 @@ function showAddQuestionForm() {
|
||||
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 notes = readNotesFromForm('aq_cfg');
|
||||
const configJson = readConfigFromForm(type, '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;
|
||||
@ -605,7 +815,13 @@ function showAddQuestionForm() {
|
||||
try {
|
||||
const qData = await apiPost('questions.php', {
|
||||
questionnaireID: questionnaire.questionnaireID,
|
||||
defaultText: text, type, isRequired, configJson
|
||||
questionKey,
|
||||
defaultText: text,
|
||||
type,
|
||||
isRequired,
|
||||
configJson,
|
||||
noteBefore: notes.noteBefore,
|
||||
noteAfter: notes.noteAfter,
|
||||
});
|
||||
if (!qData.success) throw new Error(qData.error || 'Failed');
|
||||
|
||||
@ -615,6 +831,7 @@ function showAddQuestionForm() {
|
||||
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 || '',
|
||||
@ -673,9 +890,18 @@ function renderQuestions() {
|
||||
<div class="question-header">
|
||||
${editable ? '<span class="drag-handle" title="Drag to reorder">☰</span>' : ''}
|
||||
<span class="chevron">▶</span>
|
||||
<span class="q-text">${idx + 1}. ${esc(q.defaultText)}</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>
|
||||
@ -701,13 +927,20 @@ function renderQuestionBody(q) {
|
||||
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:3">
|
||||
<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>
|
||||
@ -719,6 +952,7 @@ function renderQuestionBody(q) {
|
||||
<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}`)}
|
||||
</div>
|
||||
@ -730,8 +964,15 @@ function renderQuestionBody(q) {
|
||||
</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>
|
||||
${layout === 'string_spinner' && (config.nextQuestionId || config.otherNextQuestionId)
|
||||
? `<p><strong>Branches:</strong>
|
||||
${config.nextQuestionId ? `list → <code>${esc(config.nextQuestionId)}</code>` : 'list → (order)'}
|
||||
${config.otherNextQuestionId ? ` · 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>` : ''}
|
||||
</div>
|
||||
`}
|
||||
@ -751,20 +992,47 @@ function renderQuestionBody(q) {
|
||||
|
||||
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 cj = readConfigFromForm(type, `eq_cfg_${q.questionID}`);
|
||||
const notes = readNotesFromForm(cfgPrefix);
|
||||
const cj = readConfigFromForm(type, cfgPrefix);
|
||||
if (!validateStableKey(questionKey, 'Question key')) return;
|
||||
if (!text) { showToast('German text is required', 'error'); return; }
|
||||
updateQuestion(q.questionID, { defaultText: text, type, isRequired: isReq, configJson: cj });
|
||||
if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return;
|
||||
updateQuestion(q.questionID, {
|
||||
questionKey,
|
||||
defaultText: text,
|
||||
type,
|
||||
isRequired: isReq,
|
||||
configJson: cj,
|
||||
noteBefore: notes.noteBefore,
|
||||
noteAfter: notes.noteAfter,
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
document.getElementById(`eq_config_${q.questionID}`).innerHTML = configFormHTML(newLayout, {}, `eq_cfg_${q.questionID}`);
|
||||
document.getElementById(`eq_config_${q.questionID}`).innerHTML = configFormHTML(newLayout, {}, cfgPrefix);
|
||||
wireStringSpinnerOtherToggle(cfgPrefix);
|
||||
});
|
||||
|
||||
wireStringSpinnerOtherToggle(cfgPrefix);
|
||||
|
||||
body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q));
|
||||
|
||||
body.querySelectorAll('.edit-opt-btn').forEach(btn => {
|
||||
@ -784,10 +1052,13 @@ function renderQuestionBody(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>' : ''}
|
||||
<span class="opt-text">${esc(o.defaultText)}</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 ? `
|
||||
@ -812,8 +1083,12 @@ function showAddOptionForm(q) {
|
||||
wrapper.innerHTML = `
|
||||
<div class="inline-form-card" style="margin-top:8px">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:3">
|
||||
<label>German text <span class="required-mark">*</span></label>
|
||||
<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">
|
||||
@ -824,7 +1099,7 @@ function showAddOptionForm(q) {
|
||||
<label>Next question</label>
|
||||
<select id="ao_next_${q.questionID}">
|
||||
<option value="">(next in order)</option>
|
||||
${qIds.map(qi => `<option value="${esc(qi.localId)}">${esc(qi.localId)} - ${esc(qi.defaultText)}</option>`).join('')}
|
||||
${branchTargetOptionsHTML()}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -838,17 +1113,23 @@ function showAddOptionForm(q) {
|
||||
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 (!text) { showToast('German text is required', 'error'); return; }
|
||||
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, defaultText: text, points, nextQuestionId: nextQ
|
||||
questionID: q.questionID,
|
||||
optionKey,
|
||||
defaultText: text,
|
||||
points,
|
||||
nextQuestionId: nextQ,
|
||||
});
|
||||
if (data.success) {
|
||||
if (!q.answerOptions) q.answerOptions = [];
|
||||
@ -887,9 +1168,13 @@ function showEditOptionForm(q, opt) {
|
||||
li.innerHTML = `
|
||||
<div class="inline-form-card" style="width:100%;padding:8px">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:3;margin-bottom:8px">
|
||||
<label style="font-size:.8rem">German text <span class="required-mark">*</span></label>
|
||||
<input type="text" id="eo_text_${opt.answerOptionID}" value="${esc(opt.defaultText)}" required>
|
||||
<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">
|
||||
@ -897,7 +1182,7 @@ function showEditOptionForm(q, opt) {
|
||||
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:8px">
|
||||
<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(qi.localId)} - ${esc(qi.defaultText)}</option>`).join('')}
|
||||
${qIds.map(qi => `<option value="${esc(qi.localId)}" ${opt.nextQuestionId === qi.localId ? 'selected' : ''}>${esc(branchTargetLabel(qi))}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -912,11 +1197,13 @@ function showEditOptionForm(q, opt) {
|
||||
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 (!text) { showToast('German text is required', 'error'); return; }
|
||||
await updateOption(q, opt.answerOptionID, { defaultText: text, points, nextQuestionId: nextQ });
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user