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

@ -1145,6 +1145,85 @@ function qdb_parse_config_json($configJson): array {
return $config; return $config;
} }
/**
* @return list<array{key: string, labelDe: string}>
*/
function qdb_parse_glass_symptoms_body(array $body, array $config): array {
if (isset($body['glassSymptoms']) && is_array($body['glassSymptoms'])) {
$rows = [];
foreach ($body['glassSymptoms'] as $row) {
if (!is_array($row)) {
continue;
}
$key = trim((string)($row['key'] ?? ''));
if ($key === '') {
continue;
}
$key = qdb_validate_stable_key($key, 'Symptom key');
$rows[] = [
'key' => $key,
'labelDe' => trim((string)($row['labelDe'] ?? '')),
];
}
return $rows;
}
$out = [];
foreach ($config['symptoms'] ?? [] as $symptomKey) {
$key = trim((string)$symptomKey);
if ($key !== '') {
$out[] = ['key' => $key, 'labelDe' => ''];
}
}
return $out;
}
/** Apply ordered symptom keys from editor rows into question config. */
function qdb_apply_glass_symptoms_config(array $config, array $glassSymptoms): array {
$keys = [];
foreach ($glassSymptoms as $row) {
$keys[] = $row['key'];
}
if ($keys) {
$config['symptoms'] = $keys;
} else {
unset($config['symptoms']);
}
return $config;
}
/** Upsert German labels for glass-scale symptom string keys. */
function qdb_sync_glass_symptom_strings(PDO $pdo, array $glassSymptoms): void {
foreach ($glassSymptoms as $row) {
$key = $row['key'] ?? '';
$label = trim((string)($row['labelDe'] ?? ''));
if ($key === '' || $label === '') {
continue;
}
qdb_put_translation($pdo, 'string', $key, QDB_SOURCE_LANGUAGE, $label);
}
}
/**
* Symptom keys with German labels for editor / API consumers.
*
* @return list<array{key: string, labelDe: string}>
*/
function qdb_glass_symptoms_with_labels(PDO $pdo, array $config): array {
$rows = [];
foreach ($config['symptoms'] ?? [] as $symptomKey) {
$key = trim((string)$symptomKey);
if ($key === '') {
continue;
}
$label = qdb_string_german_label($pdo, $key);
if ($label === $key) {
$label = '';
}
$rows[] = ['key' => $key, 'labelDe' => $label];
}
return $rows;
}
/** Sync note German text into string_translation rows for the app. */ /** Sync note German text into string_translation rows for the app. */
function qdb_sync_question_note_strings(PDO $pdo, string $questionKey, array $config): void { function qdb_sync_question_note_strings(PDO $pdo, string $questionKey, array $config): void {
if ($questionKey === '') { if ($questionKey === '') {
@ -1360,6 +1439,8 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
$stringKeys = []; $stringKeys = [];
$stringEntries = []; $stringEntries = [];
$contentEntries = []; $contentEntries = [];
$addedSymptomKeys = [];
$appKeySet = qdb_app_string_key_set();
foreach ($dbQuestions as $dbQ) { foreach ($dbQuestions as $dbQ) {
$qOrder = (int)($dbQ['orderIndex'] ?? 0); $qOrder = (int)($dbQ['orderIndex'] ?? 0);
@ -1406,9 +1487,24 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
$stringKeys[$config[$field]] = true; $stringKeys[$config[$field]] = true;
} }
} }
if (!empty($config['symptoms']) && is_array($config['symptoms'])) { if (($dbQ['type'] ?? '') === 'glass_scale_question'
&& !empty($config['symptoms']) && is_array($config['symptoms'])) {
$symIdx = 0;
foreach ($config['symptoms'] as $s) { foreach ($config['symptoms'] as $s) {
$stringKeys[$s] = true; $sk = trim((string)$s);
if ($sk === '' || isset($appKeySet[$sk]) || isset($addedSymptomKeys[$sk])) {
continue;
}
$addedSymptomKeys[$sk] = true;
$parentLabel = $qKey !== '' ? $qKey : $qLocal;
$stringEntries[] = [
'key' => $sk,
'displayKey' => $qLocal . ' · ' . $parentLabel . ' · ' . $sk,
'type' => 'string',
'entityId' => $sk,
'sortOrder' => $qOrder * 1000 + 5 + $symIdx,
];
$symIdx++;
} }
} }
if ($qKey !== '') { if ($qKey !== '') {
@ -1421,7 +1517,6 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
} }
} }
$appKeySet = qdb_app_string_key_set();
$stringOrder = 0; $stringOrder = 0;
$skList = array_keys($stringKeys); $skList = array_keys($stringKeys);
sort($skList, SORT_STRING); sort($skList, SORT_STRING);

View File

@ -31,6 +31,7 @@
"error_not_found", "error_not_found",
"questionnaire_title", "questionnaire_title",
"exit_btn", "exit_btn",
"extreme_glass",
"february", "february",
"fill_all_fields", "fill_all_fields",
"fill_both_fields", "fill_both_fields",
@ -39,6 +40,7 @@
"july", "july",
"june", "june",
"lay", "lay",
"little_glass",
"consultation_unlock_requires_rhs", "consultation_unlock_requires_rhs",
"enter_ages_ranges_hint", "enter_ages_ranges_hint",
"questionnaire_unlock_2_rhs", "questionnaire_unlock_2_rhs",
@ -52,8 +54,11 @@
"login_required", "login_required",
"march", "march",
"may", "may",
"never_glass",
"minutes_short", "minutes_short",
"moderate_glass",
"month", "month",
"much_glass",
"new_password_hint", "new_password_hint",
"no_clients_assigned", "no_clients_assigned",
"no_profile", "no_profile",
@ -126,6 +131,7 @@
"error_not_found": "Nicht gefunden", "error_not_found": "Nicht gefunden",
"questionnaire_title": "Fragebögen", "questionnaire_title": "Fragebögen",
"exit_btn": "Beenden", "exit_btn": "Beenden",
"extreme_glass": "Extrem",
"february": "Februar", "february": "Februar",
"fill_all_fields": "Bitte alle Felder ausfüllen!", "fill_all_fields": "Bitte alle Felder ausfüllen!",
"fill_both_fields": "Bitte beide Felder ausfüllen!", "fill_both_fields": "Bitte beide Felder ausfüllen!",
@ -134,6 +140,7 @@
"july": "Juli", "july": "Juli",
"june": "Juni", "june": "Juni",
"lay": "liegen.", "lay": "liegen.",
"little_glass": "Wenig",
"consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS", "consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS",
"enter_ages_ranges_hint": "Bitte Alter als Bereich (z. B. 812) oder einzelne Jahre (z. B. 6, 9, 13) eingeben", "enter_ages_ranges_hint": "Bitte Alter als Bereich (z. B. 812) oder einzelne Jahre (z. B. 6, 9, 13) eingeben",
"questionnaire_unlock_2_rhs": "Verfügbar nach Abschluss von Demografie", "questionnaire_unlock_2_rhs": "Verfügbar nach Abschluss von Demografie",
@ -147,8 +154,11 @@
"login_required": "Bitte zuerst einloggen", "login_required": "Bitte zuerst einloggen",
"march": "März", "march": "März",
"may": "Mai", "may": "Mai",
"never_glass": "Nie",
"minutes_short": "m", "minutes_short": "m",
"moderate_glass": "Mittel",
"month": "Monat", "month": "Monat",
"much_glass": "Viel",
"new_password_hint": "Neues Passwort", "new_password_hint": "Neues Passwort",
"no_clients_assigned": "Ihr Supervisor hat Ihnen noch keine Klienten zugewiesen.", "no_clients_assigned": "Ihr Supervisor hat Ihnen noch keine Klienten zugewiesen.",
"no_profile": "Dieser Klient ist noch nicht Teil der Datenbank", "no_profile": "Dieser Klient ist noch nicht Teil der Datenbank",

View File

@ -38,6 +38,9 @@ case 'GET':
$opt['labelGerman'] = qdb_option_german_label($pdo, $opt['answerOptionID'], $opt['defaultText']); $opt['labelGerman'] = qdb_option_german_label($pdo, $opt['answerOptionID'], $opt['defaultText']);
} }
unset($opt); unset($opt);
if (($q['type'] ?? '') === 'glass_scale_question') {
$q['glassSymptoms'] = qdb_glass_symptoms_with_labels($pdo, $cfg);
}
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid"); $tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
$tr->execute([':qid' => $q['questionID']]); $tr->execute([':qid' => $q['questionID']]);
$q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC); $q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
@ -64,6 +67,10 @@ case 'POST':
$order = (int)($body['orderIndex'] ?? 0); $order = (int)($body['orderIndex'] ?? 0);
$req = (int)($body['isRequired'] ?? 0); $req = (int)($body['isRequired'] ?? 0);
$cfg = qdb_parse_config_json($body['configJson'] ?? '{}'); $cfg = qdb_parse_config_json($body['configJson'] ?? '{}');
$glassSymptoms = qdb_parse_glass_symptoms_body($body, $cfg);
if ($type === 'glass_scale_question') {
$cfg = qdb_apply_glass_symptoms_config($cfg, $glassSymptoms);
}
$config = qdb_normalize_question_config( $config = qdb_normalize_question_config(
$cfg, $cfg,
$questionKey, $questionKey,
@ -101,12 +108,17 @@ case 'POST':
':o' => $order, ':r' => $req, ':cj' => $configJson]); ':o' => $order, ':r' => $req, ':cj' => $configJson]);
qdb_upsert_source_translation($pdo, 'question', $id, $text); qdb_upsert_source_translation($pdo, 'question', $id, $text);
qdb_sync_question_note_strings($pdo, $questionKey, $config); qdb_sync_question_note_strings($pdo, $questionKey, $config);
if ($type === 'glass_scale_question') {
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
}
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['question' => [ json_success(['question' => [
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text, 'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
'questionKey' => $questionKey, 'localId' => $localId, 'questionKey' => $questionKey, 'localId' => $localId,
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $configJson, 'answerOptions' => [], 'translations' => [], 'configJson' => $configJson, 'answerOptions' => [], 'translations' => [],
'glassSymptoms' => $type === 'glass_scale_question'
? qdb_glass_symptoms_with_labels($pdo, $config) : [],
]]); ]]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
@ -142,6 +154,10 @@ case 'PUT':
json_error('MISSING_FIELDS', 'questionKey is required', 400); json_error('MISSING_FIELDS', 'questionKey is required', 400);
} }
$cfgIn = isset($body['configJson']) ? qdb_parse_config_json($body['configJson']) : $oldCfg; $cfgIn = isset($body['configJson']) ? qdb_parse_config_json($body['configJson']) : $oldCfg;
$glassSymptoms = qdb_parse_glass_symptoms_body($body, $cfgIn);
if ($type === 'glass_scale_question') {
$cfgIn = qdb_apply_glass_symptoms_config($cfgIn, $glassSymptoms);
}
$config = qdb_normalize_question_config( $config = qdb_normalize_question_config(
$cfgIn, $cfgIn,
$questionKey, $questionKey,
@ -153,12 +169,17 @@ case 'PUT':
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $configJson, ':id' => $id]); ->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $configJson, ':id' => $id]);
qdb_upsert_source_translation($pdo, 'question', $id, $text); qdb_upsert_source_translation($pdo, 'question', $id, $text);
qdb_sync_question_note_strings($pdo, $questionKey, $config); qdb_sync_question_note_strings($pdo, $questionKey, $config);
if ($type === 'glass_scale_question') {
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
}
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['question' => [ json_success(['question' => [
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'], 'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
'defaultText' => $text, 'questionKey' => $questionKey, 'defaultText' => $text, 'questionKey' => $questionKey,
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $configJson, 'configJson' => $configJson,
'glassSymptoms' => $type === 'glass_scale_question'
? qdb_glass_symptoms_with_labels($pdo, $config) : [],
]]); ]]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);

View File

@ -53,4 +53,48 @@ final class CommonHelpersTest extends TestCase
$this->assertSame('moderate', qdb_glass_symptom_answer_value($json, 'pain')); $this->assertSame('moderate', qdb_glass_symptom_answer_value($json, 'pain'));
$this->assertSame('', qdb_glass_symptom_answer_value($json, 'missing')); $this->assertSame('', qdb_glass_symptom_answer_value($json, 'missing'));
} }
public function testApplyGlassSymptomsConfig(): void
{
$config = ['scaleType' => 'glass'];
$rows = [
['key' => 'pain', 'labelDe' => 'Schmerz'],
['key' => 'fatigue', 'labelDe' => 'Müdigkeit'],
];
$out = qdb_apply_glass_symptoms_config($config, $rows);
$this->assertSame(['pain', 'fatigue'], $out['symptoms']);
$this->assertSame('glass', $out['scaleType']);
}
public function testApplyGlassSymptomsConfigClearsWhenEmpty(): void
{
$config = ['symptoms' => ['old_key'], 'scaleType' => 'thermometer'];
$out = qdb_apply_glass_symptoms_config($config, []);
$this->assertArrayNotHasKey('symptoms', $out);
}
public function testParseGlassSymptomsBody(): void
{
$body = [
'glassSymptoms' => [
['key' => 'pain', 'labelDe' => 'Schmerz'],
['key' => 'invalid key', 'labelDe' => 'x'],
],
];
$rows = qdb_parse_glass_symptoms_body($body, []);
$this->assertSame([
['key' => 'pain', 'labelDe' => 'Schmerz'],
['key' => 'invalid_key', 'labelDe' => 'x'],
], $rows);
}
public function testParseGlassSymptomsBodyFromConfigWhenMissing(): void
{
$config = ['symptoms' => ['fatigue', 'pain']];
$rows = qdb_parse_glass_symptoms_body([], $config);
$this->assertSame([
['key' => 'fatigue', 'labelDe' => ''],
['key' => 'pain', 'labelDe' => ''],
], $rows);
}
} }

View File

@ -2603,3 +2603,25 @@ select:disabled {
text-align: center; text-align: center;
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Glass-scale symptom rows in editor */
.glass-symptoms-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.glass-symptom-row {
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.glass-symptoms-empty {
padding: 12px;
color: var(--text-secondary);
font-size: .85rem;
font-style: italic;
}

View File

@ -190,6 +190,145 @@ function readNotesFromForm(prefix) {
return { noteBefore: val('noteBefore'), noteAfter: val('noteAfter') }; 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) { function stringSpinnerOtherEnabled(config) {
return Boolean(config.otherNextQuestionId || config.otherOptionKey); return Boolean(config.otherNextQuestionId || config.otherOptionKey);
} }
@ -306,7 +445,7 @@ function validateStringSpinnerConfig(prefix) {
// ── Config form HTML for each layout type ──────────────────────────────── // ── Config form HTML for each layout type ────────────────────────────────
function configFormHTML(layout, config, prefix) { function configFormHTML(layout, config, prefix, opts = {}) {
let html = ''; let html = '';
switch (layout) { switch (layout) {
case 'radio_question': case 'radio_question':
@ -327,6 +466,7 @@ function configFormHTML(layout, config, prefix) {
break; break;
case 'glass_scale_question': { case 'glass_scale_question': {
const scaleType = config.scaleType || 'glass'; const scaleType = config.scaleType || 'glass';
const rows = opts.glassSymptoms || (config.symptoms || []).map(k => ({ key: k, labelDe: '' }));
html = ` html = `
<div class="form-group"> <div class="form-group">
<label>Scale display</label> <label>Scale display</label>
@ -335,10 +475,7 @@ function configFormHTML(layout, config, prefix) {
<option value="thermometer" ${scaleType === 'thermometer' ? 'selected' : ''}>Thermometer</option> <option value="thermometer" ${scaleType === 'thermometer' ? 'selected' : ''}>Thermometer</option>
</select> </select>
</div> </div>
<div class="form-group"> ${glassSymptomsSectionHTML(rows, prefix)}`;
<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>`;
break; break;
} }
case 'value_spinner': case 'value_spinner':
@ -485,9 +622,6 @@ function readConfigFromForm(layout, prefix) {
case 'glass_scale_question': { case 'glass_scale_question': {
const st = val('scaleType') || 'glass'; const st = val('scaleType') || 'glass';
if (st && st !== 'glass') config.scaleType = st; 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; break;
} }
case 'value_spinner': case 'value_spinner':
@ -862,6 +996,7 @@ function showAddQuestionForm() {
document.getElementById('aq_options_section').style.display = OPTION_TYPES.has(type) ? '' : 'none'; document.getElementById('aq_options_section').style.display = OPTION_TYPES.has(type) ? '' : 'none';
document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg'); document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg');
wireStringSpinnerOtherToggle('aq_cfg'); wireStringSpinnerOtherToggle('aq_cfg');
if (type === 'glass_scale_question') wireGlassSymptomsSection('aq_cfg');
const notesEl = document.getElementById('aq_notes_section'); const notesEl = document.getElementById('aq_notes_section');
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey); if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
} }
@ -933,8 +1068,24 @@ function showAddQuestionForm() {
const text = document.getElementById('aq_text').value.trim(); const text = document.getElementById('aq_text').value.trim();
const type = document.getElementById('aq_type').value; const type = document.getElementById('aq_type').value;
const isRequired = document.getElementById('aq_required').checked ? 1 : 0; const isRequired = document.getElementById('aq_required').checked ? 1 : 0;
const btn = document.getElementById('aq_submit');
const notes = readNotesFromForm('aq_cfg'); const notes = readNotesFromForm('aq_cfg');
const configJson = readConfigFromForm(type, '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 (!validateStableKey(questionKey, 'Question key')) return;
if (!text) { showToast('German text is required', 'error'); return; } if (!text) { showToast('German text is required', 'error'); return; }
if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return; if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return;
@ -943,20 +1094,10 @@ function showAddQuestionForm() {
return; return;
} }
const btn = document.getElementById('aq_submit');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Adding...'; btn.textContent = 'Adding...';
try { try {
const qData = await apiPost('questions.php', { const qData = await apiPost('questions.php', postBody);
questionnaireID: questionnaire.questionnaireID,
questionKey,
defaultText: text,
type,
isRequired,
configJson,
noteBefore: notes.noteBefore,
noteAfter: notes.noteAfter,
});
if (!qData.success) throw new Error(qData.error || 'Failed'); if (!qData.success) throw new Error(qData.error || 'Failed');
const newQ = qData.question; const newQ = qData.question;
@ -1088,7 +1229,7 @@ function renderQuestionBody(q) {
</label> </label>
${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)} ${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
<div id="eq_config_${q.questionID}"> <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>
<div style="display:flex;gap:8px;margin-top:8px"> <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-primary btn-sm save-q-btn">Save Question</button>
@ -1108,6 +1249,13 @@ function renderQuestionBody(q) {
</p>` </p>`
: ''} : ''}
${Object.keys(config).length ? `<p><strong>Config:</strong> <code>${esc(JSON.stringify(config))}</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> </div>
`} `}
@ -1135,10 +1283,7 @@ function renderQuestionBody(q) {
const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0; const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0;
const notes = readNotesFromForm(cfgPrefix); const notes = readNotesFromForm(cfgPrefix);
const cj = readConfigFromForm(type, cfgPrefix); const cj = readConfigFromForm(type, cfgPrefix);
if (!validateStableKey(questionKey, 'Question key')) return; const payload = {
if (!text) { showToast('German text is required', 'error'); return; }
if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return;
updateQuestion(q.questionID, {
questionKey, questionKey,
defaultText: text, defaultText: text,
type, type,
@ -1146,7 +1291,16 @@ function renderQuestionBody(q) {
configJson: cj, configJson: cj,
noteBefore: notes.noteBefore, noteBefore: notes.noteBefore,
noteAfter: notes.noteAfter, 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', () => { document.getElementById(`eq_key_${q.questionID}`).addEventListener('input', () => {
@ -1161,11 +1315,17 @@ function renderQuestionBody(q) {
document.getElementById(`eq_type_${q.questionID}`).addEventListener('change', () => { document.getElementById(`eq_type_${q.questionID}`).addEventListener('change', () => {
const newLayout = document.getElementById(`eq_type_${q.questionID}`).value; 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); wireStringSpinnerOtherToggle(cfgPrefix);
if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
}); });
wireStringSpinnerOtherToggle(cfgPrefix); wireStringSpinnerOtherToggle(cfgPrefix);
if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q)); body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q));

View File

@ -270,6 +270,12 @@ function visibleQuestionIndices(questions, sectionByIdx) {
return visible; return visible;
} }
function symptomRowLabel(q, symptomKey) {
const rows = q.glassSymptoms || [];
const row = rows.find(r => r.key === symptomKey);
return (row?.labelDe || '').trim() || symptomKey;
}
function renderQuestionBody(q, sectionIdx) { function renderQuestionBody(q, sectionIdx) {
const layout = q.type || ''; const layout = q.type || '';
const config = parseConfig(q); const config = parseConfig(q);
@ -382,7 +388,7 @@ function renderQuestionBody(q, sectionIdx) {
for (const so of scaleOpts) inner += `<th>${esc(so.label)}</th>`; for (const so of scaleOpts) inner += `<th>${esc(so.label)}</th>`;
inner += `</tr></thead><tbody>`; inner += `</tr></thead><tbody>`;
symptoms.forEach((sym, si) => { symptoms.forEach((sym, si) => {
inner += `<tr><td>${esc(sym)}</td>`; inner += `<tr><td>${esc(symptomRowLabel(q, sym))}</td>`;
scaleOpts.forEach(so => { scaleOpts.forEach(so => {
inner += `<td><input type="radio" name="${prefix}_g_${si}" data-symptom="${esc(sym)}" value="${esc(so.key)}"></td>`; inner += `<td><input type="radio" name="${prefix}_g_${si}" data-symptom="${esc(sym)}" value="${esc(so.key)}"></td>`;
}); });