improved questionnaire editor glass scales
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
@ -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">✖</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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user