improved questionnaire editor glass scales
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-06 16:28:10 +02:00
parent d68b2b267d
commit e5ae48c02e
7 changed files with 388 additions and 30 deletions

View File

@ -190,6 +190,145 @@ function readNotesFromForm(prefix) {
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(),
}));
}
const config = parseConfig(q);
return (config.symptoms || []).map(key => ({ key: String(key).trim(), labelDe: '' }));
}
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) {
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">&#x2716;</button>
</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() || '';
if (key || labelDe) rows.push({ key, labelDe });
});
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: '' });
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);
}
@ -306,7 +445,7 @@ function validateStringSpinnerConfig(prefix) {
// ── Config form HTML for each layout type ────────────────────────────────
function configFormHTML(layout, config, prefix) {
function configFormHTML(layout, config, prefix, opts = {}) {
let html = '';
switch (layout) {
case 'radio_question':
@ -327,6 +466,7 @@ function configFormHTML(layout, 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>
@ -335,10 +475,7 @@ function configFormHTML(layout, config, prefix) {
<option value="thermometer" ${scaleType === 'thermometer' ? 'selected' : ''}>Thermometer</option>
</select>
</div>
<div class="form-group">
<label>Symptom keys (one per line)</label>
<textarea id="${prefix}_symptoms" rows="5" style="font-family:monospace;font-size:.85rem">${(config.symptoms || []).join('\n')}</textarea>
</div>`;
${glassSymptomsSectionHTML(rows, prefix)}`;
break;
}
case 'value_spinner':
@ -485,9 +622,6 @@ function readConfigFromForm(layout, prefix) {
case 'glass_scale_question': {
const st = val('scaleType') || 'glass';
if (st && st !== 'glass') config.scaleType = st;
const raw = val('symptoms');
const syms = raw.split('\n').map(s => s.trim()).filter(Boolean);
if (syms.length) config.symptoms = syms;
break;
}
case 'value_spinner':
@ -862,6 +996,7 @@ function showAddQuestionForm() {
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');
const notesEl = document.getElementById('aq_notes_section');
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
}
@ -933,8 +1068,24 @@ function showAddQuestionForm() {
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 (!validateStableKey(questionKey, 'Question key')) return;
if (!text) { showToast('German text is required', 'error'); return; }
if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return;
@ -943,20 +1094,10 @@ function showAddQuestionForm() {
return;
}
const btn = document.getElementById('aq_submit');
btn.disabled = true;
btn.textContent = 'Adding...';
try {
const qData = await apiPost('questions.php', {
questionnaireID: questionnaire.questionnaireID,
questionKey,
defaultText: text,
type,
isRequired,
configJson,
noteBefore: notes.noteBefore,
noteAfter: notes.noteAfter,
});
const qData = await apiPost('questions.php', postBody);
if (!qData.success) throw new Error(qData.error || 'Failed');
const newQ = qData.question;
@ -1088,7 +1229,7 @@ function renderQuestionBody(q) {
</label>
${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
<div id="eq_config_${q.questionID}">
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`)}
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, { glassSymptoms: glassSymptomsRowsFromQuestion(q) })}
</div>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-primary btn-sm save-q-btn">Save Question</button>
@ -1108,6 +1249,13 @@ function renderQuestionBody(q) {
</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>
`}
@ -1135,10 +1283,7 @@ function renderQuestionBody(q) {
const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0;
const notes = readNotesFromForm(cfgPrefix);
const cj = readConfigFromForm(type, 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, {
const payload = {
questionKey,
defaultText: text,
type,
@ -1146,7 +1291,16 @@ function renderQuestionBody(q) {
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 (!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', () => {
@ -1161,11 +1315,17 @@ function renderQuestionBody(q) {
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, {}, cfgPrefix);
const opts = newLayout === 'glass_scale_question'
? { glassSymptoms: glassSymptomsRowsFromQuestion(q) }
: {};
document.getElementById(`eq_config_${q.questionID}`).innerHTML =
configFormHTML(newLayout, {}, cfgPrefix, opts);
wireStringSpinnerOtherToggle(cfgPrefix);
if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
});
wireStringSpinnerOtherToggle(cfgPrefix);
if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q));