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:
101
common.php
101
common.php
@ -1145,6 +1145,85 @@ function qdb_parse_config_json($configJson): array {
|
||||
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. */
|
||||
function qdb_sync_question_note_strings(PDO $pdo, string $questionKey, array $config): void {
|
||||
if ($questionKey === '') {
|
||||
@ -1360,6 +1439,8 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
|
||||
$stringKeys = [];
|
||||
$stringEntries = [];
|
||||
$contentEntries = [];
|
||||
$addedSymptomKeys = [];
|
||||
$appKeySet = qdb_app_string_key_set();
|
||||
|
||||
foreach ($dbQuestions as $dbQ) {
|
||||
$qOrder = (int)($dbQ['orderIndex'] ?? 0);
|
||||
@ -1406,9 +1487,24 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
|
||||
$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) {
|
||||
$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 !== '') {
|
||||
@ -1421,7 +1517,6 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
|
||||
}
|
||||
}
|
||||
|
||||
$appKeySet = qdb_app_string_key_set();
|
||||
$stringOrder = 0;
|
||||
$skList = array_keys($stringKeys);
|
||||
sort($skList, SORT_STRING);
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
"error_not_found",
|
||||
"questionnaire_title",
|
||||
"exit_btn",
|
||||
"extreme_glass",
|
||||
"february",
|
||||
"fill_all_fields",
|
||||
"fill_both_fields",
|
||||
@ -39,6 +40,7 @@
|
||||
"july",
|
||||
"june",
|
||||
"lay",
|
||||
"little_glass",
|
||||
"consultation_unlock_requires_rhs",
|
||||
"enter_ages_ranges_hint",
|
||||
"questionnaire_unlock_2_rhs",
|
||||
@ -52,8 +54,11 @@
|
||||
"login_required",
|
||||
"march",
|
||||
"may",
|
||||
"never_glass",
|
||||
"minutes_short",
|
||||
"moderate_glass",
|
||||
"month",
|
||||
"much_glass",
|
||||
"new_password_hint",
|
||||
"no_clients_assigned",
|
||||
"no_profile",
|
||||
@ -126,6 +131,7 @@
|
||||
"error_not_found": "Nicht gefunden",
|
||||
"questionnaire_title": "Fragebögen",
|
||||
"exit_btn": "Beenden",
|
||||
"extreme_glass": "Extrem",
|
||||
"february": "Februar",
|
||||
"fill_all_fields": "Bitte alle Felder ausfüllen!",
|
||||
"fill_both_fields": "Bitte beide Felder ausfüllen!",
|
||||
@ -134,6 +140,7 @@
|
||||
"july": "Juli",
|
||||
"june": "Juni",
|
||||
"lay": "liegen.",
|
||||
"little_glass": "Wenig",
|
||||
"consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS",
|
||||
"enter_ages_ranges_hint": "Bitte Alter als Bereich (z. B. 8–12) oder einzelne Jahre (z. B. 6, 9, 13) eingeben",
|
||||
"questionnaire_unlock_2_rhs": "Verfügbar nach Abschluss von Demografie",
|
||||
@ -147,8 +154,11 @@
|
||||
"login_required": "Bitte zuerst einloggen",
|
||||
"march": "März",
|
||||
"may": "Mai",
|
||||
"never_glass": "Nie",
|
||||
"minutes_short": "m",
|
||||
"moderate_glass": "Mittel",
|
||||
"month": "Monat",
|
||||
"much_glass": "Viel",
|
||||
"new_password_hint": "Neues Passwort",
|
||||
"no_clients_assigned": "Ihr Supervisor hat Ihnen noch keine Klienten zugewiesen.",
|
||||
"no_profile": "Dieser Klient ist noch nicht Teil der Datenbank",
|
||||
|
||||
@ -38,6 +38,9 @@ case 'GET':
|
||||
$opt['labelGerman'] = qdb_option_german_label($pdo, $opt['answerOptionID'], $opt['defaultText']);
|
||||
}
|
||||
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->execute([':qid' => $q['questionID']]);
|
||||
$q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
|
||||
@ -64,6 +67,10 @@ case 'POST':
|
||||
$order = (int)($body['orderIndex'] ?? 0);
|
||||
$req = (int)($body['isRequired'] ?? 0);
|
||||
$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(
|
||||
$cfg,
|
||||
$questionKey,
|
||||
@ -101,12 +108,17 @@ case 'POST':
|
||||
':o' => $order, ':r' => $req, ':cj' => $configJson]);
|
||||
qdb_upsert_source_translation($pdo, 'question', $id, $text);
|
||||
qdb_sync_question_note_strings($pdo, $questionKey, $config);
|
||||
if ($type === 'glass_scale_question') {
|
||||
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['question' => [
|
||||
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
|
||||
'questionKey' => $questionKey, 'localId' => $localId,
|
||||
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
||||
'configJson' => $configJson, 'answerOptions' => [], 'translations' => [],
|
||||
'glassSymptoms' => $type === 'glass_scale_question'
|
||||
? qdb_glass_symptoms_with_labels($pdo, $config) : [],
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
@ -142,6 +154,10 @@ case 'PUT':
|
||||
json_error('MISSING_FIELDS', 'questionKey is required', 400);
|
||||
}
|
||||
$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(
|
||||
$cfgIn,
|
||||
$questionKey,
|
||||
@ -153,12 +169,17 @@ case 'PUT':
|
||||
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $configJson, ':id' => $id]);
|
||||
qdb_upsert_source_translation($pdo, 'question', $id, $text);
|
||||
qdb_sync_question_note_strings($pdo, $questionKey, $config);
|
||||
if ($type === 'glass_scale_question') {
|
||||
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
|
||||
}
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['question' => [
|
||||
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
|
||||
'defaultText' => $text, 'questionKey' => $questionKey,
|
||||
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
||||
'configJson' => $configJson,
|
||||
'glassSymptoms' => $type === 'glass_scale_question'
|
||||
? qdb_glass_symptoms_with_labels($pdo, $config) : [],
|
||||
]]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
|
||||
@ -53,4 +53,48 @@ final class CommonHelpersTest extends TestCase
|
||||
$this->assertSame('moderate', qdb_glass_symptom_answer_value($json, 'pain'));
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2603,3 +2603,25 @@ select:disabled {
|
||||
text-align: center;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -270,6 +270,12 @@ function visibleQuestionIndices(questions, sectionByIdx) {
|
||||
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) {
|
||||
const layout = q.type || '';
|
||||
const config = parseConfig(q);
|
||||
@ -382,7 +388,7 @@ function renderQuestionBody(q, sectionIdx) {
|
||||
for (const so of scaleOpts) inner += `<th>${esc(so.label)}</th>`;
|
||||
inner += `</tr></thead><tbody>`;
|
||||
symptoms.forEach((sym, si) => {
|
||||
inner += `<tr><td>${esc(sym)}</td>`;
|
||||
inner += `<tr><td>${esc(symptomRowLabel(q, sym))}</td>`;
|
||||
scaleOpts.forEach(so => {
|
||||
inner += `<td><input type="radio" name="${prefix}_g_${si}" data-symptom="${esc(sym)}" value="${esc(so.key)}"></td>`;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user