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 $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);
|
||||||
|
|||||||
@ -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. 8–12) oder einzelne Jahre (z. B. 6, 9, 13) eingeben",
|
"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",
|
"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",
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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">✖</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,21 +1068,10 @@ 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');
|
||||||
if (!validateStableKey(questionKey, 'Question key')) return;
|
const postBody = {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const btn = document.getElementById('aq_submit');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Adding...';
|
|
||||||
try {
|
|
||||||
const qData = await apiPost('questions.php', {
|
|
||||||
questionnaireID: questionnaire.questionnaireID,
|
questionnaireID: questionnaire.questionnaireID,
|
||||||
questionKey,
|
questionKey,
|
||||||
defaultText: text,
|
defaultText: text,
|
||||||
@ -956,7 +1080,24 @@ function showAddQuestionForm() {
|
|||||||
configJson,
|
configJson,
|
||||||
noteBefore: notes.noteBefore,
|
noteBefore: notes.noteBefore,
|
||||||
noteAfter: notes.noteAfter,
|
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;
|
||||||
|
if (OPTION_TYPES.has(type) && pendingOptions.length === 0) {
|
||||||
|
showToast('Add at least one answer option', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Adding...';
|
||||||
|
try {
|
||||||
|
const qData = await apiPost('questions.php', postBody);
|
||||||
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));
|
||||||
|
|
||||||
|
|||||||
@ -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>`;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user