enhanced questionnaire handling with stable key validation, improved note management, and added option key support

This commit is contained in:
2026-05-26 15:40:35 +02:00
parent 1b3d74d822
commit 7671c9f329
10 changed files with 876 additions and 100 deletions

View File

@ -58,15 +58,21 @@ if ($qnID) {
$shortId = end($parts); $shortId = end($parts);
$layout = $dbQ['type']; $layout = $dbQ['type'];
$config = json_decode($dbQ['configJson'], true) ?: []; $config = qdb_parse_config_json($dbQ['configJson']);
$qKey = qdb_question_key($config, $dbQ['defaultText']);
$q = [ $q = [
'id' => $shortId, 'id' => $shortId,
'layout' => $layout, 'layout' => $layout,
'question' => $dbQ['defaultText'], 'question' => $qKey !== '' ? $qKey : $dbQ['defaultText'],
]; ];
// Add type-specific config fields back to the top level if ($qKey !== '' && !empty($config['noteBefore'])) {
$q['noteBeforeKey'] = qdb_note_before_key($qKey);
}
if ($qKey !== '' && !empty($config['noteAfter'])) {
$q['noteAfterKey'] = qdb_note_after_key($qKey);
}
if (isset($config['textKey'])) $q['textKey'] = $config['textKey']; if (isset($config['textKey'])) $q['textKey'] = $config['textKey'];
if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1']; if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1'];
if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2']; if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2'];
@ -76,21 +82,20 @@ if ($qnID) {
if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms']; if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms'];
if (isset($config['scaleType'])) $q['scaleType'] = $config['scaleType']; if (isset($config['scaleType'])) $q['scaleType'] = $config['scaleType'];
if (isset($config['range'])) $q['range'] = $config['range']; if (isset($config['range'])) $q['range'] = $config['range'];
if (isset($config['step'])) $q['step'] = (int)$config['step'];
if (isset($config['unitLabel'])) $q['unitLabel'] = $config['unitLabel'];
if (isset($config['constraints'])) $q['constraints'] = $config['constraints']; if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
if (isset($config['precision'])) $q['precision'] = $config['precision']; if (isset($config['precision'])) $q['precision'] = $config['precision'];
if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection']; if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection'];
if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength']; if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength'];
if (!empty($config['multiline'])) $q['multiline'] = true; if (isset($config['nextQuestionId'])) $q['nextQuestionId'] = $config['nextQuestionId'];
if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId']; if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId'];
if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey']; if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey'];
// String spinner static options
if ($layout === 'string_spinner' && isset($config['options'])) { if ($layout === 'string_spinner' && isset($config['options'])) {
$q['options'] = $config['options']; $q['options'] = $config['options'];
} }
if (($layout === 'value_spinner' || $layout === 'slider_question') && isset($config['valueOptions'])) {
// Value spinner conditional navigation options
if ($layout === 'value_spinner' && isset($config['valueOptions'])) {
$q['options'] = $config['valueOptions']; $q['options'] = $config['valueOptions'];
} }

View File

@ -305,10 +305,19 @@ function qdb_all_questionnaire_translation_key_set(PDO $pdo): array {
$keys = []; $keys = [];
$rows = $pdo->query("SELECT defaultText, configJson FROM question")->fetchAll(PDO::FETCH_ASSOC); $rows = $pdo->query("SELECT defaultText, configJson FROM question")->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $dbQ) { foreach ($rows as $dbQ) {
if ($dbQ['defaultText'] !== '') { $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
$qk = qdb_question_key($config, $dbQ['defaultText']);
if ($qk !== '') {
$keys[$qk] = true;
if (!empty($config['noteBefore'])) {
$keys[qdb_note_before_key($qk)] = true;
}
if (!empty($config['noteAfter'])) {
$keys[qdb_note_after_key($qk)] = true;
}
} elseif ($dbQ['defaultText'] !== '') {
$keys[$dbQ['defaultText']] = true; $keys[$dbQ['defaultText']] = true;
} }
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) { foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) {
if (!empty($config[$field])) { if (!empty($config[$field])) {
$keys[$config[$field]] = true; $keys[$config[$field]] = true;
@ -373,10 +382,7 @@ function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, s
VALUES (:id, :lang, :t) VALUES (:id, :lang, :t)
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text") ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text")
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
if ($lang === QDB_SOURCE_LANGUAGE) { // defaultText holds optionKey; German label lives only in answer_option_translation.
$pdo->prepare("UPDATE answer_option SET defaultText = :t WHERE answerOptionID = :id")
->execute([':t' => $text, ':id' => $id]);
}
} elseif ($type === 'string' || $type === 'app_string') { } elseif ($type === 'string' || $type === 'app_string') {
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text) $pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text)
VALUES (:id, :lang, :t) VALUES (:id, :lang, :t)
@ -401,6 +407,135 @@ function qdb_require_non_empty_german(string $text, string $label = 'German text
} }
} }
/** Stable identifier: snake_case starting with a letter (case preserved for LM keys). */
function qdb_is_stable_key(string $s): bool {
return $s !== '' && (bool)preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $s);
}
function qdb_validate_stable_key(string $s, string $label = 'Key'): string {
$s = trim($s);
if (!qdb_is_stable_key($s)) {
json_error('INVALID_FIELD', "$label must match [a-zA-Z][a-zA-Z0-9_]*", 400);
}
return $s;
}
/** Resolve app lookup key for a question row. */
function qdb_question_key(array $config, string $defaultText): string {
$key = trim((string)($config['questionKey'] ?? ''));
if ($key !== '' && qdb_is_stable_key($key)) {
return $key;
}
if (qdb_is_stable_key($defaultText)) {
return $defaultText;
}
return '';
}
/** Option key is stored in answer_option.defaultText. */
function qdb_option_key(array $aoRow): string {
$dt = trim((string)($aoRow['defaultText'] ?? ''));
return qdb_is_stable_key($dt) ? $dt : '';
}
function qdb_note_before_key(string $questionKey): string {
return $questionKey . '_note_before';
}
function qdb_note_after_key(string $questionKey): string {
return $questionKey . '_note_after';
}
/** Strip deprecated fields and persist questionKey + notes in config. */
function qdb_normalize_question_config(array $config, string $questionKey, ?string $noteBefore = null, ?string $noteAfter = null): array {
unset($config['multiline']);
$config['questionKey'] = $questionKey;
if ($noteBefore !== null) {
$nb = trim($noteBefore);
if ($nb !== '') {
$config['noteBefore'] = $nb;
} else {
unset($config['noteBefore']);
}
}
if ($noteAfter !== null) {
$na = trim($noteAfter);
if ($na !== '') {
$config['noteAfter'] = $na;
} else {
unset($config['noteAfter']);
}
}
return $config;
}
function qdb_parse_config_json($configJson): array {
if (is_array($configJson)) {
$config = $configJson;
} else {
$config = json_decode($configJson ?: '{}', true) ?: [];
}
unset($config['multiline']);
return $config;
}
/** 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 === '') {
return;
}
foreach ([
'noteBefore' => qdb_note_before_key($questionKey),
'noteAfter' => qdb_note_after_key($questionKey),
] as $field => $stringKey) {
$text = trim((string)($config[$field] ?? ''));
if ($text !== '') {
qdb_put_translation($pdo, 'string', $stringKey, QDB_SOURCE_LANGUAGE, $text);
}
}
}
function qdb_assert_unique_question_key(PDO $pdo, string $qnID, string $questionKey, ?string $excludeQuestionID = null): void {
$stmt = $pdo->prepare('SELECT questionID, configJson, defaultText FROM question WHERE questionnaireID = :qn');
$stmt->execute([':qn' => $qnID]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
if ($excludeQuestionID !== null && $row['questionID'] === $excludeQuestionID) {
continue;
}
$cfg = json_decode($row['configJson'] ?: '{}', true) ?: [];
$existing = qdb_question_key($cfg, $row['defaultText']);
if ($existing !== '' && $existing === $questionKey) {
json_error('CONFLICT', "Question key \"$questionKey\" already exists in this questionnaire", 409);
}
}
}
function qdb_assert_unique_option_key(PDO $pdo, string $questionID, string $optionKey, ?string $excludeOptionID = null): void {
$stmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
$stmt->execute([':qid' => $questionID]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
if ($excludeOptionID !== null && $row['answerOptionID'] === $excludeOptionID) {
continue;
}
if ($row['defaultText'] === $optionKey) {
json_error('CONFLICT', "Option key \"$optionKey\" already exists on this question", 409);
}
}
}
/** German label for an option (translation de, else legacy defaultText when not a stable key). */
function qdb_option_german_label(PDO $pdo, string $answerOptionID, string $storedDefaultText): string {
$tr = qdb_fetch_translation_map($pdo, 'answer_option', $answerOptionID);
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? '');
if ($de !== '') {
return $de;
}
if (!qdb_is_stable_key($storedDefaultText)) {
return $storedDefaultText;
}
return '';
}
/** /**
* Build translation entry lists for one questionnaire. * Build translation entry lists for one questionnaire.
* Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved). * Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved).
@ -420,9 +555,16 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
foreach ($dbQuestions as $dbQ) { foreach ($dbQuestions as $dbQ) {
$qOrder = (int)($dbQ['orderIndex'] ?? 0); $qOrder = (int)($dbQ['orderIndex'] ?? 0);
$qLocal = qdb_question_local_id($dbQ['questionID']); $qLocal = qdb_question_local_id($dbQ['questionID']);
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: [];
$qKey = qdb_question_key($config, $dbQ['defaultText']);
$germanQ = trim($dbQ['defaultText']);
if ($qKey !== '' && qdb_is_stable_key($germanQ) && $germanQ === $qKey) {
$germanQ = '';
}
$contentEntries[] = [ $contentEntries[] = [
'key' => $dbQ['defaultText'], 'key' => $qKey !== '' ? $qKey : $dbQ['defaultText'],
'displayKey' => preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal, 'displayKey' => $qKey !== '' ? $qKey : (preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal),
'germanText' => $germanQ,
'type' => 'question', 'type' => 'question',
'entityId' => $dbQ['questionID'], 'entityId' => $dbQ['questionID'],
'sortOrder' => $qOrder * 1000, 'sortOrder' => $qOrder * 1000,
@ -434,20 +576,23 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
"); ");
$aoStmt->execute([':qid' => $dbQ['questionID']]); $aoStmt->execute([':qid' => $dbQ['questionID']]);
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$optLabel = preg_match('/^[a-f0-9]{32}$/i', $qLocal) $optKey = qdb_option_key($ao);
? ('#' . ($qOrder + 1) . ' · option ' . ((int)$ao['orderIndex'] + 1)) $optLabel = $optKey !== ''
: ($qLocal . ' · option ' . ((int)$ao['orderIndex'] + 1)); ? ($qLocal . ' · ' . $optKey)
: (preg_match('/^[a-f0-9]{32}$/i', $qLocal)
? ('#' . ($qOrder + 1) . ' · option ' . ((int)$ao['orderIndex'] + 1))
: ($qLocal . ' · option ' . ((int)$ao['orderIndex'] + 1)));
$contentEntries[] = [ $contentEntries[] = [
'key' => $ao['defaultText'], 'key' => $optKey !== '' ? $optKey : $ao['defaultText'],
'displayKey' => $optLabel, 'displayKey' => $optLabel,
'germanText' => qdb_option_german_label($pdo, $ao['answerOptionID'], $ao['defaultText']),
'type' => 'answer_option', 'type' => 'answer_option',
'entityId' => $ao['answerOptionID'], 'entityId' => $ao['answerOptionID'],
'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0), 'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0),
]; ];
} }
$config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2', 'otherOptionKey'] as $field) {
foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) {
if (!empty($config[$field])) { if (!empty($config[$field])) {
$stringKeys[$config[$field]] = true; $stringKeys[$config[$field]] = true;
} }
@ -457,6 +602,14 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array {
$stringKeys[$s] = true; $stringKeys[$s] = true;
} }
} }
if ($qKey !== '') {
if (!empty($config['noteBefore'])) {
$stringKeys[qdb_note_before_key($qKey)] = true;
}
if (!empty($config['noteAfter'])) {
$stringKeys[qdb_note_after_key($qKey)] = true;
}
}
} }
$appKeySet = qdb_app_string_key_set(); $appKeySet = qdb_app_string_key_set();
@ -689,18 +842,27 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array {
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
"); ");
$aoStmt->execute([':qid' => $dbQ['questionID']]); $aoStmt->execute([':qid' => $dbQ['questionID']]);
$qKey = qdb_question_key($config, $dbQ['defaultText']);
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$optKey = qdb_option_key($ao);
$optTr = qdb_fetch_translation_map($pdo, 'answer_option', $ao['answerOptionID']);
$optGerman = trim($optTr[QDB_SOURCE_LANGUAGE] ?? '');
if ($optGerman === '' && !qdb_is_stable_key($ao['defaultText'])) {
$optGerman = $ao['defaultText'];
}
$optionsOut[] = [ $optionsOut[] = [
'defaultText' => $ao['defaultText'], 'optionKey' => $optKey !== '' ? $optKey : $ao['defaultText'],
'defaultText' => $optGerman,
'points' => (int)$ao['points'], 'points' => (int)$ao['points'],
'orderIndex' => (int)$ao['orderIndex'], 'orderIndex' => (int)$ao['orderIndex'],
'nextQuestionId' => $ao['nextQuestionId'] ?? '', 'nextQuestionId' => $ao['nextQuestionId'] ?? '',
'translations' => qdb_fetch_translation_map($pdo, 'answer_option', $ao['answerOptionID']), 'translations' => $optTr,
]; ];
} }
$questionsOut[] = [ $questionsOut[] = [
'localId' => $localId, 'localId' => $localId,
'questionKey' => $qKey,
'defaultText' => $dbQ['defaultText'], 'defaultText' => $dbQ['defaultText'],
'type' => $dbQ['type'], 'type' => $dbQ['type'],
'orderIndex' => (int)$dbQ['orderIndex'], 'orderIndex' => (int)$dbQ['orderIndex'],
@ -839,11 +1001,33 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE
$localId = 'q_' . bin2hex(random_bytes(4)); $localId = 'q_' . bin2hex(random_bytes(4));
} }
$questionID = $qnID . '__' . $localId; $questionID = $qnID . '__' . $localId;
$text = trim($q['defaultText'] ?? '');
qdb_require_non_empty_german($text, 'Question German text');
$config = $q['config'] ?? $q['configJson'] ?? []; $config = qdb_parse_config_json($q['config'] ?? $q['configJson'] ?? []);
$configJson = is_string($config) ? $config : json_encode($config ?: [], JSON_UNESCAPED_UNICODE); $questionKey = trim((string)($q['questionKey'] ?? $config['questionKey'] ?? ''));
$text = trim($q['defaultText'] ?? '');
$qTr = $q['translations'] ?? [];
if (is_array($qTr) && isset($qTr[0]['languageCode'])) {
$qTr = qdb_translation_rows_to_map($qTr);
}
if ($questionKey === '' && qdb_is_stable_key($text) && trim($qTr[QDB_SOURCE_LANGUAGE] ?? '') !== '' && $qTr[QDB_SOURCE_LANGUAGE] !== $text) {
$questionKey = $text;
$text = trim($qTr[QDB_SOURCE_LANGUAGE]);
}
if ($questionKey === '') {
json_error(
'MISSING_FIELDS',
"Question key is required for {$qnID} / {$localId} (stable key [a-z][a-z0-9_]*)",
400
);
}
$questionKey = qdb_validate_stable_key($questionKey, 'Question key');
// Duplicate question keys in one questionnaire are allowed (same prompt in different branches).
qdb_require_non_empty_german($text, 'Question German text');
$noteBefore = $config['noteBefore'] ?? null;
$noteAfter = $config['noteAfter'] ?? null;
$config = qdb_normalize_question_config($config, $questionKey, $noteBefore, $noteAfter);
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
$pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) $pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)") VALUES (:id, :qid, :t, :ty, :o, :r, :cj)")
@ -857,32 +1041,50 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE
':cj' => $configJson, ':cj' => $configJson,
]); ]);
$qTr = $q['translations'] ?? [];
if (is_array($qTr) && isset($qTr[0]['languageCode'])) {
$qTr = qdb_translation_rows_to_map($qTr);
}
qdb_upsert_source_translation($pdo, 'question', $questionID, $text); qdb_upsert_source_translation($pdo, 'question', $questionID, $text);
qdb_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []); qdb_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []);
qdb_sync_question_note_strings($pdo, $questionKey, $config);
foreach ($q['answerOptions'] ?? [] as $opt) { foreach ($q['answerOptions'] ?? [] as $opt) {
$optText = trim($opt['defaultText'] ?? ''); $optKey = trim((string)($opt['optionKey'] ?? ''));
qdb_require_non_empty_german($optText, 'Answer option German text'); $optGerman = trim($opt['defaultText'] ?? '');
$oTr = $opt['translations'] ?? [];
if (is_array($oTr) && isset($oTr[0]['languageCode'])) {
$oTr = qdb_translation_rows_to_map($oTr);
}
if ($optKey === '' && qdb_is_stable_key($optGerman) && trim($oTr[QDB_SOURCE_LANGUAGE] ?? '') !== '' && $oTr[QDB_SOURCE_LANGUAGE] !== $optGerman) {
$optKey = $optGerman;
$optGerman = trim($oTr[QDB_SOURCE_LANGUAGE]);
}
if ($optKey === '' && qdb_is_stable_key($optGerman)) {
$optKey = $optGerman;
$optGerman = trim($oTr[QDB_SOURCE_LANGUAGE] ?? $optGerman);
}
if ($optKey === '') {
json_error(
'MISSING_FIELDS',
"Option key is required for {$qnID} / {$localId} option #" . ((int)($opt['orderIndex'] ?? 0) + 1),
400
);
}
$optKey = qdb_validate_stable_key($optKey, 'Option key');
if ($optGerman === '') {
$optGerman = trim($oTr[QDB_SOURCE_LANGUAGE] ?? '');
}
qdb_require_non_empty_german($optGerman, 'Answer option German text');
$aoId = bin2hex(random_bytes(16)); $aoId = bin2hex(random_bytes(16));
$pdo->prepare("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) $pdo->prepare("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :t, :p, :o, :nq)") VALUES (:id, :qid, :t, :p, :o, :nq)")
->execute([ ->execute([
':id' => $aoId, ':id' => $aoId,
':qid' => $questionID, ':qid' => $questionID,
':t' => $optText, ':t' => $optKey,
':p' => (int)($opt['points'] ?? 0), ':p' => (int)($opt['points'] ?? 0),
':o' => (int)($opt['orderIndex'] ?? 0), ':o' => (int)($opt['orderIndex'] ?? 0),
':nq' => trim($opt['nextQuestionId'] ?? ''), ':nq' => trim($opt['nextQuestionId'] ?? ''),
]); ]);
$oTr = $opt['translations'] ?? []; qdb_upsert_source_translation($pdo, 'answer_option', $aoId, $optGerman);
if (is_array($oTr) && isset($oTr[0]['languageCode'])) {
$oTr = qdb_translation_rows_to_map($oTr);
}
qdb_upsert_source_translation($pdo, 'answer_option', $aoId, $optText);
qdb_apply_translation_map($pdo, 'answer_option', $aoId, is_array($oTr) ? $oTr : []); qdb_apply_translation_map($pdo, 'answer_option', $aoId, is_array($oTr) ? $oTr : []);
} }
} }

View File

@ -57,6 +57,8 @@
"november", "november",
"october", "october",
"offline", "offline",
"other_country",
"other_option",
"ok", "ok",
"online", "online",
"password_hint", "password_hint",
@ -138,6 +140,8 @@
"november": "November", "november": "November",
"october": "Oktober", "october": "Oktober",
"offline": "Offline", "offline": "Offline",
"other_country": "anderes Land",
"other_option": "Sonstiges",
"ok": "OK", "ok": "OK",
"online": "Online", "online": "Online",
"password_hint": "Passwort", "password_hint": "Passwort",

View File

@ -0,0 +1,190 @@
Ägypten
Äquatorialguinea
Äthiopien
Afghanistan
Albanien
Algerien
Andorra
Angola
Antigua und Barbuda
Argentinien
Armenien
Aserbaidschan
Australien
Bahamas
Bahrain
Bangladesch
Barbados
Belgien
Belize
Benin
Bhutan
Bolivien
Bosnien und Herzegowina
Botswana
Brasilien
Brunei
Bulgarien
Burkina Faso
Burundi
Cabo Verde
Cambodia
Chile
China
Costa Rica
Dänemark
Deutschland
Djibouti
Dominica
Dominikanische Republik
Ecuador
El Salvador
Eritrea
Estland
Eswatini
Fiji
Finnland
Frankreich
Gabon
Gambia
Georgien
Ghana
Grenada
Griechenland
Guatemala
Guinea
Guinea-Bissau
Guyana
Haiti
Honduras
Indien
Indonesien
Irak
Iran
Irland
Island
Israel
Italien
Jamaika
Japan
Jordanien
Kamerun
Kanada
Kasachstan
Kenia
Kiribati
Kolumbien
Komoren
Kongo (Dem. Rep.)
Kongo (Rep.)
Korea (Nord)
Korea (Süd)
Kroatien
Kuba
Kuwait
Kyrgyzstan
Laos
Latvia
Lebanon
Lesotho
Liberia
Libyen
Liechtenstein
Litauen
Luxemburg
Madagaskar
Malawi
Malaysia
Maldiven
Mali
Malta
Marokko
Marshallinseln
Mauritanien
Mauritius
Mexiko
Mikronesien
Moldawien
Monaco
Mongolei
Montenegro
Mozambique
Namibia
Nauru
Nepal
Nicaragua
Niger
Nigeria
Nordmazedonien
Norwegen
Österreich
Oman
Pakistan
Palau
Panama
Papua-Neuguinea
Paraguay
Peru
Philippinen
Polen
Portugal
Ruanda
Rumänien
Russland
Salomonen
Sambia
Samoa
San Marino
Sao Tome und Principe
Saudi-Arabien
Schweden
Schweiz
Senegal
Serbien
Seychellen
Sierra Leone
Simbabwe
Singapur
Slowakei
Slowenien
Spanien
Sri Lanka
St. Kitts und Nevis
St. Lucia
St. Vincent und die Grenadinen
Sudan
Südafrika
Südkorea
Südsudan
Suriname
Syrien
São Tomé und Príncipe
Tadschikistan
Taiwan
Tansania
Togo
Tonga
Trinidad und Tobago
Tschad
Tschechien
Türkei
Tunisien
Turkmenistan
Tuvalu
Uganda
Ukraine
Ungarn
Uruguay
Usbekistan
Vanuatu
Venezuela
Vereinigte Arabische Emirate
Vereinigte Staaten
Vereinigtes Königreich
Vietnam
Wallis und Futuna
Westjordanland
Westsahara
Yemen
Zentralafrikanische Republik
Zypern

View File

@ -17,6 +17,8 @@ case 'GET':
foreach ($options as &$o) { foreach ($options as &$o) {
$o['points'] = (int)$o['points']; $o['points'] = (int)$o['points'];
$o['orderIndex'] = (int)$o['orderIndex']; $o['orderIndex'] = (int)$o['orderIndex'];
$o['optionKey'] = qdb_option_key($o);
$o['labelGerman'] = qdb_option_german_label($pdo, $o['answerOptionID'], $o['defaultText']);
$tr = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id'); $tr = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id');
$tr->execute([':id' => $o['answerOptionID']]); $tr->execute([':id' => $o['answerOptionID']]);
$o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC); $o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
@ -29,13 +31,14 @@ case 'GET':
case 'POST': case 'POST':
require_role(['admin', 'supervisor'], $tokenRec); require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body(); $body = read_json_body();
if (empty($body['questionID']) || !isset($body['defaultText'])) { if (empty($body['questionID']) || empty($body['optionKey']) || !isset($body['defaultText'])) {
json_error('MISSING_FIELDS', 'questionID and defaultText required', 400); json_error('MISSING_FIELDS', 'questionID, optionKey, and defaultText (German label) required', 400);
} }
$id = bin2hex(random_bytes(16)); $id = bin2hex(random_bytes(16));
$qID = $body['questionID']; $qID = $body['questionID'];
$optKey = qdb_validate_stable_key((string)$body['optionKey'], 'Option key');
$text = trim($body['defaultText']); $text = trim($body['defaultText']);
qdb_require_non_empty_german($text); qdb_require_non_empty_german($text, 'German label');
$points = (int)($body['points'] ?? 0); $points = (int)($body['points'] ?? 0);
$order = (int)($body['orderIndex'] ?? 0); $order = (int)($body['orderIndex'] ?? 0);
$nextQ = trim($body['nextQuestionId'] ?? ''); $nextQ = trim($body['nextQuestionId'] ?? '');
@ -53,14 +56,17 @@ case 'POST':
$max->execute([':id' => $qID]); $max->execute([':id' => $qID]);
$order = (int)$max->fetchColumn(); $order = (int)$max->fetchColumn();
} }
qdb_assert_unique_option_key($pdo, $qID, $optKey);
$pdo->prepare('INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)') $pdo->prepare('INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)')
->execute([':id' => $id, ':qid' => $qID, ':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ]); ->execute([':id' => $id, ':qid' => $qID, ':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
qdb_upsert_source_translation($pdo, 'answer_option', $id, $text); qdb_upsert_source_translation($pdo, 'answer_option', $id, $text);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['answerOption' => [ json_success(['answerOption' => [
'answerOptionID' => $id, 'answerOptionID' => $id,
'questionID' => $qID, 'questionID' => $qID,
'defaultText' => $text, 'optionKey' => $optKey,
'defaultText' => $optKey,
'labelGerman' => $text,
'points' => $points, 'points' => $points,
'orderIndex' => $order, 'orderIndex' => $order,
'nextQuestionId' => $nextQ, 'nextQuestionId' => $nextQ,
@ -90,20 +96,32 @@ case 'PUT':
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Answer option not found', 404); json_error('NOT_FOUND', 'Answer option not found', 404);
} }
$text = trim($body['defaultText'] ?? $row['defaultText']); $optKey = isset($body['optionKey'])
qdb_require_non_empty_german($text); ? qdb_validate_stable_key((string)$body['optionKey'], 'Option key')
: qdb_option_key($row);
if ($optKey === '') {
json_error('MISSING_FIELDS', 'optionKey is required', 400);
}
$labelGerman = trim($body['defaultText'] ?? $body['labelGerman'] ?? '');
if ($labelGerman === '') {
$labelGerman = qdb_option_german_label($pdo, $id, $row['defaultText']);
}
qdb_require_non_empty_german($labelGerman, 'German label');
qdb_assert_unique_option_key($pdo, $row['questionID'], $optKey, $id);
$points = (int)($body['points'] ?? $row['points']); $points = (int)($body['points'] ?? $row['points']);
$order = (int)($body['orderIndex'] ?? $row['orderIndex']); $order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']); $nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']);
$pdo->prepare('UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id') $pdo->prepare('UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id')
->execute([':t' => $text, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]); ->execute([':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
qdb_upsert_source_translation($pdo, 'answer_option', $id, $text); qdb_upsert_source_translation($pdo, 'answer_option', $id, $labelGerman);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['answerOption' => [ json_success(['answerOption' => [
'answerOptionID' => $id, 'answerOptionID' => $id,
'questionID' => $row['questionID'], 'questionID' => $row['questionID'],
'defaultText' => $text, 'optionKey' => $optKey,
'defaultText' => $optKey,
'labelGerman' => $labelGerman,
'points' => $points, 'points' => $points,
'orderIndex' => $order, 'orderIndex' => $order,
'nextQuestionId' => $nextQ, 'nextQuestionId' => $nextQ,

View File

@ -250,14 +250,21 @@ if ($qnID) {
$shortId = end($parts); $shortId = end($parts);
$layout = $dbQ['type']; $layout = $dbQ['type'];
$config = json_decode($dbQ['configJson'], true) ?: []; $config = qdb_parse_config_json($dbQ['configJson']);
$qKey = qdb_question_key($config, $dbQ['defaultText']);
$q = [ $q = [
'id' => $shortId, 'id' => $shortId,
'layout' => $layout, 'layout' => $layout,
'question' => $dbQ['defaultText'], 'question' => $qKey !== '' ? $qKey : $dbQ['defaultText'],
]; ];
if ($qKey !== '' && !empty($config['noteBefore'])) {
$q['noteBeforeKey'] = qdb_note_before_key($qKey);
}
if ($qKey !== '' && !empty($config['noteAfter'])) {
$q['noteAfterKey'] = qdb_note_after_key($qKey);
}
if (isset($config['textKey'])) $q['textKey'] = $config['textKey']; if (isset($config['textKey'])) $q['textKey'] = $config['textKey'];
if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1']; if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1'];
if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2']; if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2'];
@ -267,18 +274,20 @@ if ($qnID) {
if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms']; if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms'];
if (isset($config['scaleType'])) $q['scaleType'] = $config['scaleType']; if (isset($config['scaleType'])) $q['scaleType'] = $config['scaleType'];
if (isset($config['range'])) $q['range'] = $config['range']; if (isset($config['range'])) $q['range'] = $config['range'];
if (isset($config['step'])) $q['step'] = (int)$config['step'];
if (isset($config['unitLabel'])) $q['unitLabel'] = $config['unitLabel'];
if (isset($config['constraints'])) $q['constraints'] = $config['constraints']; if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
if (isset($config['precision'])) $q['precision'] = $config['precision']; if (isset($config['precision'])) $q['precision'] = $config['precision'];
if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection']; if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection'];
if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength']; if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength'];
if (!empty($config['multiline'])) $q['multiline'] = true; if (isset($config['nextQuestionId'])) $q['nextQuestionId'] = $config['nextQuestionId'];
if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId']; if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId'];
if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey']; if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey'];
if ($layout === 'string_spinner' && isset($config['options'])) { if ($layout === 'string_spinner' && isset($config['options'])) {
$q['options'] = $config['options']; $q['options'] = $config['options'];
} }
if ($layout === 'value_spinner' && isset($config['valueOptions'])) { if (($layout === 'value_spinner' || $layout === 'slider_question') && isset($config['valueOptions'])) {
$q['options'] = $config['valueOptions']; $q['options'] = $config['valueOptions'];
} }

View File

@ -19,7 +19,10 @@ case 'GET':
foreach ($questions as &$q) { foreach ($questions as &$q) {
$q['isRequired'] = (int)$q['isRequired']; $q['isRequired'] = (int)$q['isRequired'];
$q['orderIndex'] = (int)$q['orderIndex']; $q['orderIndex'] = (int)$q['orderIndex'];
$q['configJson'] = $q['configJson'] ?: '{}'; $cfg = qdb_parse_config_json($q['configJson']);
$q['configJson'] = json_encode($cfg, JSON_UNESCAPED_UNICODE);
$q['questionKey'] = qdb_question_key($cfg, $q['defaultText']);
$q['localId'] = qdb_question_local_id($q['questionID'], $qnID);
$ao = $pdo->prepare(" $ao = $pdo->prepare("
SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex FROM answer_option WHERE questionID = :qid ORDER BY orderIndex
@ -29,7 +32,10 @@ case 'GET':
foreach ($q['answerOptions'] as &$opt) { foreach ($q['answerOptions'] as &$opt) {
$opt['points'] = (int)$opt['points']; $opt['points'] = (int)$opt['points'];
$opt['orderIndex'] = (int)$opt['orderIndex']; $opt['orderIndex'] = (int)$opt['orderIndex'];
$opt['optionKey'] = qdb_option_key($opt);
$opt['labelGerman'] = qdb_option_german_label($pdo, $opt['answerOptionID'], $opt['defaultText']);
} }
unset($opt);
$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);
@ -42,16 +48,24 @@ case 'GET':
case 'POST': case 'POST':
require_role(['admin', 'supervisor'], $tokenRec); require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body(); $body = read_json_body();
if (empty($body['questionnaireID']) || !isset($body['defaultText'])) { if (empty($body['questionnaireID']) || !isset($body['defaultText']) || empty($body['questionKey'])) {
json_error('BAD_REQUEST', 'questionnaireID and defaultText required', 400); json_error('BAD_REQUEST', 'questionnaireID, questionKey, and defaultText required', 400);
} }
$qnID = $body['questionnaireID']; $qnID = $body['questionnaireID'];
$text = trim($body['defaultText']); $text = trim($body['defaultText']);
qdb_require_non_empty_german($text); qdb_require_non_empty_german($text);
$questionKey = qdb_validate_stable_key((string)$body['questionKey'], 'Question key');
$type = trim($body['type'] ?? ''); $type = trim($body['type'] ?? '');
$order = (int)($body['orderIndex'] ?? 0); $order = (int)($body['orderIndex'] ?? 0);
$req = (int)($body['isRequired'] ?? 0); $req = (int)($body['isRequired'] ?? 0);
$config = $body['configJson'] ?? '{}'; $cfg = qdb_parse_config_json($body['configJson'] ?? '{}');
$config = qdb_normalize_question_config(
$cfg,
$questionKey,
$body['noteBefore'] ?? ($cfg['noteBefore'] ?? null),
$body['noteAfter'] ?? ($cfg['noteAfter'] ?? null)
);
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
[$pdo, $tmpDb, $lockFp] = qdb_open(true); [$pdo, $tmpDb, $lockFp] = qdb_open(true);
try { try {
$chk = $pdo->prepare("SELECT 1 FROM questionnaire WHERE questionnaireID = :id"); $chk = $pdo->prepare("SELECT 1 FROM questionnaire WHERE questionnaireID = :id");
@ -79,13 +93,15 @@ case 'POST':
$pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) $pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)") VALUES (:id, :qid, :t, :ty, :o, :r, :cj)")
->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type, ->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type,
':o' => $order, ':r' => $req, ':cj' => $config]); ':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_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,
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $config, 'answerOptions' => [], 'translations' => [], 'configJson' => $configJson, 'answerOptions' => [], 'translations' => [],
]]); ]]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
@ -115,15 +131,31 @@ case 'PUT':
$type = trim($body['type'] ?? $row['type']); $type = trim($body['type'] ?? $row['type']);
$order = (int)($body['orderIndex'] ?? $row['orderIndex']); $order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$req = (int)($body['isRequired'] ?? $row['isRequired']); $req = (int)($body['isRequired'] ?? $row['isRequired']);
$config = $body['configJson'] ?? $row['configJson']; $oldCfg = qdb_parse_config_json($row['configJson']);
$questionKey = isset($body['questionKey'])
? qdb_validate_stable_key((string)$body['questionKey'], 'Question key')
: qdb_question_key($oldCfg, $row['defaultText']);
if ($questionKey === '') {
json_error('MISSING_FIELDS', 'questionKey is required', 400);
}
$cfgIn = isset($body['configJson']) ? qdb_parse_config_json($body['configJson']) : $oldCfg;
$config = qdb_normalize_question_config(
$cfgIn,
$questionKey,
$body['noteBefore'] ?? ($cfgIn['noteBefore'] ?? null),
$body['noteAfter'] ?? ($cfgIn['noteAfter'] ?? null)
);
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id") $pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $config, ':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_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, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, 'defaultText' => $text, 'questionKey' => $questionKey,
'configJson' => $config, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $configJson,
]]); ]]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);

View File

@ -89,11 +89,11 @@ try {
if (isset($q['precision'])) $config['precision'] = $q['precision']; if (isset($q['precision'])) $config['precision'] = $q['precision'];
if (isset($q['minSelection'])) $config['minSelection'] = $q['minSelection']; if (isset($q['minSelection'])) $config['minSelection'] = $q['minSelection'];
if (isset($q['maxLength'])) $config['maxLength'] = (int)$q['maxLength']; if (isset($q['maxLength'])) $config['maxLength'] = (int)$q['maxLength'];
if (!empty($q['multiline'])) $config['multiline'] = true; if (isset($q['nextQuestionId'])) $config['nextQuestionId'] = $q['nextQuestionId'];
if (isset($q['otherNextQuestionId'])) $config['otherNextQuestionId'] = $q['otherNextQuestionId']; if (isset($q['otherNextQuestionId'])) $config['otherNextQuestionId'] = $q['otherNextQuestionId'];
if (isset($q['otherOptionKey'])) $config['otherOptionKey'] = $q['otherOptionKey']; if (isset($q['otherOptionKey'])) $config['otherOptionKey'] = $q['otherOptionKey'];
if (isset($q['config']) && is_array($q['config'])) { if (isset($q['config']) && is_array($q['config'])) {
foreach (['otherNextQuestionId', 'otherOptionKey', 'hint', 'maxLength', 'multiline', 'textKey', 'precision', 'scaleType'] as $ck) { foreach (['nextQuestionId', 'otherNextQuestionId', 'otherOptionKey', 'hint', 'maxLength', 'textKey', 'precision', 'scaleType', 'noteBefore', 'noteAfter', 'step', 'unitLabel'] as $ck) {
if (isset($q['config'][$ck])) { if (isset($q['config'][$ck])) {
$config[$ck] = $q['config'][$ck]; $config[$ck] = $q['config'][$ck];
} }
@ -117,7 +117,11 @@ try {
} }
} }
if ($qKey !== '') {
$config = qdb_normalize_question_config($config, $qKey);
}
$configJson = !empty($config) ? json_encode($config) : '{}'; $configJson = !empty($config) ? json_encode($config) : '{}';
$germanText = $qKey;
$pdo->prepare("INSERT OR REPLACE INTO question $pdo->prepare("INSERT OR REPLACE INTO question
(questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
@ -125,12 +129,16 @@ try {
->execute([ ->execute([
':id' => $questionID, ':id' => $questionID,
':qnid' => $questionnaireID, ':qnid' => $questionnaireID,
':dt' => $qKey, ':dt' => $germanText,
':ty' => $layout, ':ty' => $layout,
':oi' => $qIdx, ':oi' => $qIdx,
':ir' => 0, ':ir' => 0,
':cj' => $configJson, ':cj' => $configJson,
]); ]);
if ($qKey !== '') {
qdb_upsert_source_translation($pdo, 'question', $questionID, $germanText);
qdb_sync_question_note_strings($pdo, $qKey, $config);
}
if (isset($q['options']) && is_array($q['options'])) { if (isset($q['options']) && is_array($q['options'])) {
$hasObjects = isset($q['options'][0]) && is_array($q['options'][0]); $hasObjects = isset($q['options'][0]) && is_array($q['options'][0]);

View File

@ -1158,3 +1158,24 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
border-radius: 3px; border-radius: 3px;
transition: width .3s ease; transition: width .3s ease;
} }
.field-hint {
display: block;
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 4px;
}
.field-hint code {
font-size: 0.75rem;
}
.badge-warn {
background: #fef3c7;
color: #92400e;
}
.opt-key {
font-size: 0.75rem;
margin-right: 8px;
color: var(--text-secondary);
}

View File

@ -18,6 +18,7 @@ const LAYOUT_TYPES = [
{ value: 'multi_check_box_question', label: 'Checkbox (Multiple Choice)' }, { value: 'multi_check_box_question', label: 'Checkbox (Multiple Choice)' },
{ value: 'glass_scale_question', label: 'Glass Scale' }, { value: 'glass_scale_question', label: 'Glass Scale' },
{ value: 'value_spinner', label: 'Value Spinner' }, { value: 'value_spinner', label: 'Value Spinner' },
{ value: 'slider_question', label: 'Slider (minmax)' },
{ value: 'date_spinner', label: 'Date Spinner' }, { value: 'date_spinner', label: 'Date Spinner' },
{ value: 'string_spinner', label: 'String Spinner' }, { value: 'string_spinner', label: 'String Spinner' },
{ value: 'free_text', label: 'Free Text' }, { value: 'free_text', label: 'Free Text' },
@ -93,17 +94,174 @@ function layoutLabel(value) {
return t ? t.label : value || 'unknown'; return t ? t.label : value || 'unknown';
} }
const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/;
function parseConfig(q) { function parseConfig(q) {
try { return JSON.parse(q.configJson || '{}'); } catch { return {}; } try { return JSON.parse(q.configJson || '{}'); } catch { return {}; }
} }
function questionKeyOf(q) {
const cfg = typeof q === 'object' && q.configJson !== undefined ? parseConfig(q) : (q.config || q);
return (q.questionKey || cfg.questionKey || '').trim();
}
function questionLocalIds() { function questionLocalIds() {
return questions.map(q => { return questions.map(q => {
const parts = q.questionID.split('__'); const parts = q.questionID.split('__');
return { questionID: q.questionID, localId: parts[parts.length - 1], defaultText: q.defaultText }; const localId = parts[parts.length - 1];
return {
questionID: q.questionID,
localId,
defaultText: q.defaultText,
questionKey: questionKeyOf(q),
};
}); });
} }
function branchTargetLabel(q) {
const key = questionKeyOf(q) || q.localId;
return `${q.localId} · ${key}`;
}
function branchTargetOptionsHTML(selected = '') {
return questionLocalIds().map(q =>
`<option value="${esc(q.localId)}" ${selected === q.localId ? 'selected' : ''}>${esc(branchTargetLabel(q))}</option>`
).join('');
}
function validateStableKey(key, label = 'Key') {
if (!STABLE_KEY_RE.test(key)) {
showToast(`${label} must match [a-zA-Z][a-zA-Z0-9_]*`, 'error');
return false;
}
return true;
}
function optionKeyOf(o) {
if (o.optionKey) return o.optionKey;
const dt = (o.defaultText || '').trim();
return STABLE_KEY_RE.test(dt) ? dt : '';
}
function optionGermanLabel(o) {
return (o.labelGerman || '').trim() || (STABLE_KEY_RE.test(o.defaultText || '') ? '' : o.defaultText) || '';
}
function notesSectionHTML(config, prefix, questionKey) {
const nbKey = questionKey ? `${questionKey}_note_before` : '—';
const naKey = questionKey ? `${questionKey}_note_after` : '—';
return `
<div class="notes-section" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
<h4 style="font-size:.85rem;margin-bottom:8px;color:var(--text-secondary)">Notes (German)</h4>
<div class="form-row">
<div class="form-group" style="flex:1">
<label>Note before question</label>
<input type="text" id="${prefix}_noteBefore" value="${esc(config.noteBefore || '')}" placeholder="Optional text shown above the question">
<span class="field-hint">Key: <code>${esc(nbKey)}</code></span>
</div>
<div class="form-group" style="flex:1">
<label>Note after question</label>
<input type="text" id="${prefix}_noteAfter" value="${esc(config.noteAfter || '')}" placeholder="Optional text shown below the question">
<span class="field-hint">Key: <code>${esc(naKey)}</code></span>
</div>
</div>
</div>`;
}
function readNotesFromForm(prefix) {
const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || '';
return { noteBefore: val('noteBefore'), noteAfter: val('noteAfter') };
}
function stringSpinnerOtherEnabled(config) {
return Boolean(config.otherNextQuestionId || config.otherOptionKey);
}
function stringSpinnerNextSectionHTML(config, prefix) {
const next = config.nextQuestionId || '';
return `
<div class="form-group" style="margin-top:12px">
<label>Next question (list selection)</label>
<select id="${prefix}_nextQuestionId">
<option value="">(next in list order)</option>
${branchTargetOptionsHTML(next)}
</select>
<span class="field-hint">Where to go after a normal spinner choice. <strong>Required</strong> when &ldquo;Other&rdquo; targets a question that sits next in order (so list picks can skip it).</span>
</div>`;
}
function stringSpinnerOtherSectionHTML(config, prefix) {
const enabled = stringSpinnerOtherEnabled(config);
const otherKey = config.otherOptionKey || 'other_option';
const otherNext = config.otherNextQuestionId || '';
return `
<div class="form-group" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
<label class="checkbox-label" style="display:inline-flex;margin-bottom:8px">
<input type="checkbox" id="${prefix}_otherEnabled" ${enabled ? 'checked' : ''}>
Show &ldquo;Other&rdquo; option (branch to free-text question)
</label>
<div id="${prefix}_otherFields" style="${enabled ? '' : 'display:none'}">
<div class="form-row">
<div class="form-group" style="flex:1">
<label>Label translation key</label>
<input type="text" id="${prefix}_otherOptionKey" value="${esc(otherKey)}"
placeholder="e.g. other_country" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
<span class="field-hint">Appends this label to the spinner. Translate under <strong>Questionnaire UI strings</strong> (or App strings if listed in catalog).</span>
</div>
<div class="form-group" style="flex:1">
<label>Free-text question when &ldquo;Other&rdquo; is chosen</label>
<select id="${prefix}_otherNextQuestionId">
<option value="">— select question —</option>
${branchTargetOptionsHTML(otherNext)}
</select>
<span class="field-hint">Use layout <strong>Free text</strong> for the follow-up (e.g. country name).</span>
</div>
</div>
</div>
</div>`;
}
function wireStringSpinnerOtherToggle(prefix) {
const cb = document.getElementById(`${prefix}_otherEnabled`);
const panel = document.getElementById(`${prefix}_otherFields`);
if (!cb || !panel) return;
const sync = () => { panel.style.display = cb.checked ? '' : 'none'; };
cb.addEventListener('change', sync);
sync();
}
function validateStringSpinnerConfig(prefix) {
const defaultNext = document.getElementById(`${prefix}_nextQuestionId`)?.value.trim() || '';
const enabled = document.getElementById(`${prefix}_otherEnabled`)?.checked;
if (enabled) {
const key = document.getElementById(`${prefix}_otherOptionKey`)?.value.trim() || '';
const otherNext = document.getElementById(`${prefix}_otherNextQuestionId`)?.value.trim() || '';
if (!key) {
showToast('Other label translation key is required', 'error');
return false;
}
if (!validateStableKey(key, 'Other label key')) return false;
if (!otherNext) {
showToast('Select the free-text question for “Other”', 'error');
return false;
}
const target = questions.find(q => (q.localId || q.questionID.split('__').pop()) === otherNext);
if (target && target.type !== 'free_text') {
showToast('“Other” should branch to a Free text question', 'error');
return false;
}
if (!defaultNext) {
showToast('Set “Next question (list selection)” so normal choices do not fall through to the free-text step', 'error');
return false;
}
if (defaultNext === otherNext) {
showToast('“Next question” and “Other” branch cannot target the same question', 'error');
return false;
}
}
return true;
}
// ── Config form HTML for each layout type ──────────────────────────────── // ── Config form HTML for each layout type ────────────────────────────────
function configFormHTML(layout, config, prefix) { function configFormHTML(layout, config, prefix) {
@ -140,6 +298,7 @@ function configFormHTML(layout, config, prefix) {
break; break;
} }
case 'value_spinner': case 'value_spinner':
case 'slider_question':
html = ` html = `
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
@ -150,6 +309,15 @@ function configFormHTML(layout, config, prefix) {
<label>Max</label> <label>Max</label>
<input type="number" id="${prefix}_rangeMax" value="${config.range?.max ?? 100}"> <input type="number" id="${prefix}_rangeMax" value="${config.range?.max ?? 100}">
</div> </div>
${layout === 'slider_question' ? `
<div class="form-group">
<label>Step</label>
<input type="number" id="${prefix}_step" value="${config.step ?? 1}" min="1">
</div>
<div class="form-group">
<label>Unit label (optional)</label>
<input type="text" id="${prefix}_unitLabel" value="${esc(config.unitLabel || '')}" placeholder="e.g. years">
</div>` : ''}
</div>`; </div>`;
break; break;
case 'date_spinner': { case 'date_spinner': {
@ -181,8 +349,11 @@ function configFormHTML(layout, config, prefix) {
html = ` html = `
<div class="form-group"> <div class="form-group">
<label>Static options (one per line)</label> <label>Static options (one per line)</label>
<textarea id="${prefix}_stringOptions" rows="4" style="font-family:monospace;font-size:.85rem">${(config.options || []).join('\n')}</textarea> <textarea id="${prefix}_stringOptions" rows="8" style="font-family:monospace;font-size:.85rem">${(config.options || []).join('\n')}</textarea>
</div>`; <span class="field-hint">Do not include the &ldquo;Other&rdquo; row here; enable it below.</span>
</div>
${stringSpinnerNextSectionHTML(config, prefix)}
${stringSpinnerOtherSectionHTML(config, prefix)}`;
break; break;
case 'free_text': case 'free_text':
html = ` html = `
@ -196,11 +367,6 @@ function configFormHTML(layout, config, prefix) {
<input type="number" id="${prefix}_maxLength" value="${config.maxLength ?? 500}" min="1" max="10000"> <input type="number" id="${prefix}_maxLength" value="${config.maxLength ?? 500}" min="1" max="10000">
</div> </div>
</div> </div>
<div class="form-group">
<label class="checkbox-label" style="display:inline-flex">
<input type="checkbox" id="${prefix}_multiline" ${config.multiline ? 'checked' : ''}> Multiline input
</label>
</div>
<div class="form-group"> <div class="form-group">
<label>Extra text key (optional)</label> <label>Extra text key (optional)</label>
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. additional_notes_label"> <input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. additional_notes_label">
@ -271,10 +437,16 @@ function readConfigFromForm(layout, prefix) {
break; break;
} }
case 'value_spinner': case 'value_spinner':
case 'slider_question':
config.range = { config.range = {
min: parseInt(val('rangeMin') || '0', 10), min: parseInt(val('rangeMin') || '0', 10),
max: parseInt(val('rangeMax') || '100', 10), max: parseInt(val('rangeMax') || '100', 10),
}; };
if (layout === 'slider_question') {
const step = parseInt(val('step') || '1', 10);
if (step > 0) config.step = step;
if (val('unitLabel')) config.unitLabel = val('unitLabel');
}
break; break;
case 'date_spinner': { case 'date_spinner': {
const prec = val('precision') || 'full'; const prec = val('precision') || 'full';
@ -292,6 +464,13 @@ function readConfigFromForm(layout, prefix) {
const raw = val('stringOptions'); const raw = val('stringOptions');
const opts = raw.split('\n').map(s => s.trim()).filter(Boolean); const opts = raw.split('\n').map(s => s.trim()).filter(Boolean);
if (opts.length) config.options = opts; if (opts.length) config.options = opts;
const otherEnabled = document.getElementById(`${prefix}_otherEnabled`)?.checked;
if (otherEnabled) {
const ok = val('otherOptionKey') || 'other_option';
const on = val('otherNextQuestionId');
if (ok) config.otherOptionKey = ok;
if (on) config.otherNextQuestionId = on;
}
break; break;
} }
case 'free_text': { case 'free_text': {
@ -299,7 +478,6 @@ function readConfigFromForm(layout, prefix) {
if (val('hint')) config.hint = val('hint'); if (val('hint')) config.hint = val('hint');
const ml = parseInt(val('maxLength') || '500', 10); const ml = parseInt(val('maxLength') || '500', 10);
if (!Number.isNaN(ml) && ml > 0) config.maxLength = Math.min(ml, 10000); if (!Number.isNaN(ml) && ml > 0) config.maxLength = Math.min(ml, 10000);
if (document.getElementById(`${prefix}_multiline`)?.checked) config.multiline = true;
break; break;
} }
case 'client_coach_code_question': case 'client_coach_code_question':
@ -490,7 +668,11 @@ function showAddQuestionForm() {
<div class="inline-form-card"> <div class="inline-form-card">
<h4>New Question</h4> <h4>New Question</h4>
<div class="form-row"> <div class="form-row">
<div class="form-group" style="flex:3"> <div class="form-group" style="flex:1;min-width:180px">
<label>Question key <span class="required-mark">*</span></label>
<input type="text" id="aq_questionKey" placeholder="e.g. consent_instruction" required pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
</div>
<div class="form-group" style="flex:2">
<label>German text <span class="required-mark">*</span></label> <label>German text <span class="required-mark">*</span></label>
<input type="text" id="aq_text" placeholder="e.g. Haben Sie zugestimmt?" required> <input type="text" id="aq_text" placeholder="e.g. Haben Sie zugestimmt?" required>
</div> </div>
@ -504,6 +686,7 @@ function showAddQuestionForm() {
</label> </label>
<div id="aq_config_section"></div> <div id="aq_config_section"></div>
<div id="aq_notes_section">${notesSectionHTML({}, 'aq_cfg', '')}</div>
<div id="aq_options_section"> <div id="aq_options_section">
<div class="options-builder"> <div class="options-builder">
@ -512,8 +695,11 @@ function showAddQuestionForm() {
</label> </label>
<ul class="pending-options-list" id="aq_pending_options"></ul> <ul class="pending-options-list" id="aq_pending_options"></ul>
<div style="display:flex;gap:8px;margin-top:6px;align-items:flex-end"> <div style="display:flex;gap:8px;margin-top:6px;align-items:flex-end">
<div class="form-group" style="flex:3;margin-bottom:0"> <div class="form-group" style="flex:1;min-width:140px;margin-bottom:0">
<input type="text" id="aq_opt_text" placeholder="German option text…" required> <input type="text" id="aq_opt_key" placeholder="option_key" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
</div>
<div class="form-group" style="flex:2;margin-bottom:0">
<input type="text" id="aq_opt_text" placeholder="German label…" required>
</div> </div>
<div class="form-group" style="width:90px;margin-bottom:0"> <div class="form-group" style="width:90px;margin-bottom:0">
<input type="number" id="aq_opt_pts" value="0" min="0" placeholder="Pts"> <input type="number" id="aq_opt_pts" value="0" min="0" placeholder="Pts">
@ -521,7 +707,7 @@ function showAddQuestionForm() {
<div class="form-group" style="width:160px;margin-bottom:0"> <div class="form-group" style="width:160px;margin-bottom:0">
<select id="aq_opt_next"> <select id="aq_opt_next">
<option value="">(next in order)</option> <option value="">(next in order)</option>
${questionLocalIds().map(q => `<option value="${esc(q.localId)}">${esc(q.localId)} - ${esc(q.defaultText)}</option>`).join('')} ${branchTargetOptionsHTML()}
</select> </select>
</div> </div>
<button class="btn btn-sm" id="aq_opt_add" style="flex-shrink:0;white-space:nowrap">+ Add</button> <button class="btn btn-sm" id="aq_opt_add" style="flex-shrink:0;white-space:nowrap">+ Add</button>
@ -538,8 +724,12 @@ function showAddQuestionForm() {
function updateTypeUI() { function updateTypeUI() {
const type = document.getElementById('aq_type').value; const type = document.getElementById('aq_type').value;
const qKey = document.getElementById('aq_questionKey')?.value.trim() || '';
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');
const notesEl = document.getElementById('aq_notes_section');
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
} }
function renderPendingOptions() { function renderPendingOptions() {
@ -551,6 +741,7 @@ function showAddQuestionForm() {
} }
list.innerHTML = pendingOptions.map((opt, i) => ` list.innerHTML = pendingOptions.map((opt, i) => `
<li class="pending-option-item"> <li class="pending-option-item">
<code class="opt-key">${esc(opt.optionKey)}</code>
<span class="opt-text">${esc(opt.text)}</span> <span class="opt-text">${esc(opt.text)}</span>
<span class="opt-points">${opt.points} pts</span> <span class="opt-points">${opt.points} pts</span>
${opt.nextQuestionId ? `<span class="opt-branch">&rarr; ${esc(opt.nextQuestionId)}</span>` : ''} ${opt.nextQuestionId ? `<span class="opt-branch">&rarr; ${esc(opt.nextQuestionId)}</span>` : ''}
@ -566,12 +757,15 @@ function showAddQuestionForm() {
} }
function addPendingOption() { function addPendingOption() {
const optionKey = document.getElementById('aq_opt_key').value.trim();
const text = document.getElementById('aq_opt_text').value.trim(); const text = document.getElementById('aq_opt_text').value.trim();
const pts = parseInt(document.getElementById('aq_opt_pts').value || '0', 10); const pts = parseInt(document.getElementById('aq_opt_pts').value || '0', 10);
const next = document.getElementById('aq_opt_next').value; const next = document.getElementById('aq_opt_next').value;
if (!text) { showToast('German text is required', 'error'); return; } if (!validateStableKey(optionKey, 'Option key')) return;
pendingOptions.push({ text, points: pts, nextQuestionId: next }); if (!text) { showToast('German label is required', 'error'); return; }
pendingOptions.push({ optionKey, text, points: pts, nextQuestionId: next });
renderPendingOptions(); renderPendingOptions();
document.getElementById('aq_opt_key').value = '';
document.getElementById('aq_opt_text').value = ''; document.getElementById('aq_opt_text').value = '';
document.getElementById('aq_opt_pts').value = '0'; document.getElementById('aq_opt_pts').value = '0';
document.getElementById('aq_opt_next').value = ''; document.getElementById('aq_opt_next').value = '';
@ -583,17 +777,33 @@ function showAddQuestionForm() {
document.getElementById('aq_text').focus(); document.getElementById('aq_text').focus();
document.getElementById('aq_type').addEventListener('change', updateTypeUI); document.getElementById('aq_type').addEventListener('change', updateTypeUI);
document.getElementById('aq_questionKey').addEventListener('input', () => {
const notesEl = document.getElementById('aq_notes_section');
if (notesEl) {
const nb = document.getElementById('aq_cfg_noteBefore')?.value ?? '';
const na = document.getElementById('aq_cfg_noteAfter')?.value ?? '';
notesEl.innerHTML = notesSectionHTML(
{ noteBefore: nb, noteAfter: na },
'aq_cfg',
document.getElementById('aq_questionKey').value.trim()
);
}
});
document.getElementById('aq_opt_add').addEventListener('click', addPendingOption); document.getElementById('aq_opt_add').addEventListener('click', addPendingOption);
document.getElementById('aq_opt_text').addEventListener('keydown', (e) => { document.getElementById('aq_opt_text').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); addPendingOption(); } if (e.key === 'Enter') { e.preventDefault(); addPendingOption(); }
}); });
document.getElementById('aq_submit').addEventListener('click', async () => { document.getElementById('aq_submit').addEventListener('click', async () => {
const questionKey = document.getElementById('aq_questionKey').value.trim();
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 notes = readNotesFromForm('aq_cfg');
const configJson = readConfigFromForm(type, 'aq_cfg'); const configJson = readConfigFromForm(type, 'aq_cfg');
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 (OPTION_TYPES.has(type) && pendingOptions.length === 0) { if (OPTION_TYPES.has(type) && pendingOptions.length === 0) {
showToast('Add at least one answer option', 'error'); showToast('Add at least one answer option', 'error');
return; return;
@ -605,7 +815,13 @@ function showAddQuestionForm() {
try { try {
const qData = await apiPost('questions.php', { const qData = await apiPost('questions.php', {
questionnaireID: questionnaire.questionnaireID, questionnaireID: questionnaire.questionnaireID,
defaultText: text, type, isRequired, configJson 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');
@ -615,6 +831,7 @@ function showAddQuestionForm() {
for (const opt of pendingOptions) { for (const opt of pendingOptions) {
const oData = await apiPost('answer_options.php', { const oData = await apiPost('answer_options.php', {
questionID: newQ.questionID, questionID: newQ.questionID,
optionKey: opt.optionKey,
defaultText: opt.text, defaultText: opt.text,
points: opt.points, points: opt.points,
nextQuestionId: opt.nextQuestionId || '', nextQuestionId: opt.nextQuestionId || '',
@ -673,9 +890,18 @@ function renderQuestions() {
<div class="question-header"> <div class="question-header">
${editable ? '<span class="drag-handle" title="Drag to reorder">&#x2630;</span>' : ''} ${editable ? '<span class="drag-handle" title="Drag to reorder">&#x2630;</span>' : ''}
<span class="chevron">&#x25B6;</span> <span class="chevron">&#x25B6;</span>
<span class="q-text">${idx + 1}. ${esc(q.defaultText)}</span> <span class="q-text">${idx + 1}. <code>${esc(questionKeyOf(q) || '?')}</code> · ${esc((q.defaultText || '').slice(0, 48))}${(q.defaultText || '').length > 48 ? '…' : ''}</span>
<span class="q-type">${esc(layoutLabel(q.type))}</span> <span class="q-type">${esc(layoutLabel(q.type))}</span>
${!questionKeyOf(q) ? '<span class="badge badge-warn">No key</span>' : ''}
${q.isRequired ? '<span class="badge badge-active">Required</span>' : ''} ${q.isRequired ? '<span class="badge badge-active">Required</span>' : ''}
${(() => {
const c = parseConfig(q);
const parts = [];
if (c.nextQuestionId) parts.push(`${esc(c.nextQuestionId)}`);
if (c.otherNextQuestionId) parts.push(`Other→${esc(c.otherNextQuestionId)}`);
return parts.length
? `<span class="badge" title="Branches">${esc(parts.join(' · '))}</span>` : '';
})()}
</div> </div>
<div class="question-body" id="qbody-${q.questionID}"></div> <div class="question-body" id="qbody-${q.questionID}"></div>
</li> </li>
@ -701,13 +927,20 @@ function renderQuestionBody(q) {
const options = q.answerOptions || []; const options = q.answerOptions || [];
const config = parseConfig(q); const config = parseConfig(q);
const layout = q.type || 'radio_question'; const layout = q.type || 'radio_question';
const qKey = questionKeyOf(q);
const localId = q.localId || q.questionID.split('__').pop();
body.innerHTML = ` body.innerHTML = `
${editable ? ` ${editable ? `
<div class="q-edit-section"> <div class="q-edit-section">
<div class="inline-form-card" style="margin-bottom:12px"> <div class="inline-form-card" style="margin-bottom:12px">
<p class="field-hint" style="margin-bottom:10px"><strong>Local ID:</strong> <code>${esc(localId)}</code></p>
<div class="form-row"> <div class="form-row">
<div class="form-group" style="flex:3"> <div class="form-group" style="flex:1;min-width:180px">
<label>Question key <span class="required-mark">*</span></label>
<input type="text" id="eq_key_${q.questionID}" value="${esc(qKey)}" required pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off">
</div>
<div class="form-group" style="flex:2">
<label>German text <span class="required-mark">*</span></label> <label>German text <span class="required-mark">*</span></label>
<input type="text" id="eq_text_${q.questionID}" value="${esc(q.defaultText)}" required> <input type="text" id="eq_text_${q.questionID}" value="${esc(q.defaultText)}" required>
</div> </div>
@ -719,6 +952,7 @@ function renderQuestionBody(q) {
<label class="checkbox-label" style="margin-bottom:12px;display:inline-flex"> <label class="checkbox-label" style="margin-bottom:12px;display:inline-flex">
<input type="checkbox" id="eq_req_${q.questionID}" ${q.isRequired ? 'checked' : ''}> Required <input type="checkbox" id="eq_req_${q.questionID}" ${q.isRequired ? 'checked' : ''}> Required
</label> </label>
${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}`)}
</div> </div>
@ -730,8 +964,15 @@ function renderQuestionBody(q) {
</div> </div>
` : ` ` : `
<div class="inline-form-card" style="margin-bottom:12px"> <div class="inline-form-card" style="margin-bottom:12px">
<p><strong>Key:</strong> <code>${esc(qKey || '—')}</code> · <strong>ID:</strong> <code>${esc(localId)}</code></p>
<p><strong>German:</strong> ${esc(q.defaultText)}</p> <p><strong>German:</strong> ${esc(q.defaultText)}</p>
<p><strong>Layout:</strong> ${esc(layoutLabel(layout))}</p> <p><strong>Layout:</strong> ${esc(layoutLabel(layout))}</p>
${layout === 'string_spinner' && (config.nextQuestionId || config.otherNextQuestionId)
? `<p><strong>Branches:</strong>
${config.nextQuestionId ? `list → <code>${esc(config.nextQuestionId)}</code>` : 'list → (order)'}
${config.otherNextQuestionId ? ` · Other (<code>${esc(config.otherOptionKey || 'other_option')}</code>) → <code>${esc(config.otherNextQuestionId)}</code>` : ''}
</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>` : ''}
</div> </div>
`} `}
@ -751,20 +992,47 @@ function renderQuestionBody(q) {
if (!editable) return; if (!editable) return;
const cfgPrefix = `eq_cfg_${q.questionID}`;
body.querySelector('.save-q-btn').addEventListener('click', () => { body.querySelector('.save-q-btn').addEventListener('click', () => {
const questionKey = document.getElementById(`eq_key_${q.questionID}`).value.trim();
const text = document.getElementById(`eq_text_${q.questionID}`).value.trim(); const text = document.getElementById(`eq_text_${q.questionID}`).value.trim();
const type = document.getElementById(`eq_type_${q.questionID}`).value; const type = document.getElementById(`eq_type_${q.questionID}`).value;
const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0; const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0;
const cj = readConfigFromForm(type, `eq_cfg_${q.questionID}`); 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 (!text) { showToast('German text is required', 'error'); return; }
updateQuestion(q.questionID, { defaultText: text, type, isRequired: isReq, configJson: cj }); if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return;
updateQuestion(q.questionID, {
questionKey,
defaultText: text,
type,
isRequired: isReq,
configJson: cj,
noteBefore: notes.noteBefore,
noteAfter: notes.noteAfter,
});
});
document.getElementById(`eq_key_${q.questionID}`).addEventListener('input', () => {
const nb = document.getElementById(`${cfgPrefix}_noteBefore`)?.value ?? '';
const na = document.getElementById(`${cfgPrefix}_noteAfter`)?.value ?? '';
const key = document.getElementById(`eq_key_${q.questionID}`).value.trim();
const notesBlock = body.querySelector('.notes-section');
if (notesBlock) {
notesBlock.outerHTML = notesSectionHTML({ noteBefore: nb, noteAfter: na }, cfgPrefix, key);
}
}); });
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, {}, `eq_cfg_${q.questionID}`); document.getElementById(`eq_config_${q.questionID}`).innerHTML = configFormHTML(newLayout, {}, cfgPrefix);
wireStringSpinnerOtherToggle(cfgPrefix);
}); });
wireStringSpinnerOtherToggle(cfgPrefix);
body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q)); body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q));
body.querySelectorAll('.edit-opt-btn').forEach(btn => { body.querySelectorAll('.edit-opt-btn').forEach(btn => {
@ -784,10 +1052,13 @@ function renderQuestionBody(q) {
} }
function optionItemHTML(q, o, editable) { function optionItemHTML(q, o, editable) {
const ok = optionKeyOf(o);
const label = optionGermanLabel(o) || o.defaultText;
return ` return `
<li class="option-item" data-id="${o.answerOptionID}" draggable="${editable}"> <li class="option-item" data-id="${o.answerOptionID}" draggable="${editable}">
${editable ? '<span class="drag-handle" style="font-size:.9rem">&#x2630;</span>' : ''} ${editable ? '<span class="drag-handle" style="font-size:.9rem">&#x2630;</span>' : ''}
<span class="opt-text">${esc(o.defaultText)}</span> <code class="opt-key">${esc(ok || '?')}</code>
<span class="opt-text">${esc(label)}</span>
<span class="opt-points">${o.points} pts</span> <span class="opt-points">${o.points} pts</span>
${o.nextQuestionId ? `<span class="opt-branch">&rarr; ${esc(o.nextQuestionId)}</span>` : ''} ${o.nextQuestionId ? `<span class="opt-branch">&rarr; ${esc(o.nextQuestionId)}</span>` : ''}
${editable ? ` ${editable ? `
@ -812,8 +1083,12 @@ function showAddOptionForm(q) {
wrapper.innerHTML = ` wrapper.innerHTML = `
<div class="inline-form-card" style="margin-top:8px"> <div class="inline-form-card" style="margin-top:8px">
<div class="form-row"> <div class="form-row">
<div class="form-group" style="flex:3"> <div class="form-group" style="flex:1;min-width:140px">
<label>German text <span class="required-mark">*</span></label> <label>Option key <span class="required-mark">*</span></label>
<input type="text" id="ao_key_${q.questionID}" placeholder="e.g. consent_signed" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off" required>
</div>
<div class="form-group" style="flex:2">
<label>German label <span class="required-mark">*</span></label>
<input type="text" id="ao_text_${q.questionID}" placeholder="e.g. Ja, ich stimme zu" required> <input type="text" id="ao_text_${q.questionID}" placeholder="e.g. Ja, ich stimme zu" required>
</div> </div>
<div class="form-group" style="flex:1;min-width:100px"> <div class="form-group" style="flex:1;min-width:100px">
@ -824,7 +1099,7 @@ function showAddOptionForm(q) {
<label>Next question</label> <label>Next question</label>
<select id="ao_next_${q.questionID}"> <select id="ao_next_${q.questionID}">
<option value="">(next in order)</option> <option value="">(next in order)</option>
${qIds.map(qi => `<option value="${esc(qi.localId)}">${esc(qi.localId)} - ${esc(qi.defaultText)}</option>`).join('')} ${branchTargetOptionsHTML()}
</select> </select>
</div> </div>
</div> </div>
@ -838,17 +1113,23 @@ function showAddOptionForm(q) {
document.getElementById(`ao_text_${q.questionID}`).focus(); document.getElementById(`ao_text_${q.questionID}`).focus();
document.getElementById(`ao_submit_${q.questionID}`).addEventListener('click', async () => { document.getElementById(`ao_submit_${q.questionID}`).addEventListener('click', async () => {
const optionKey = document.getElementById(`ao_key_${q.questionID}`).value.trim();
const text = document.getElementById(`ao_text_${q.questionID}`).value.trim(); const text = document.getElementById(`ao_text_${q.questionID}`).value.trim();
const points = parseInt(document.getElementById(`ao_pts_${q.questionID}`).value || '0', 10); const points = parseInt(document.getElementById(`ao_pts_${q.questionID}`).value || '0', 10);
const nextQ = document.getElementById(`ao_next_${q.questionID}`).value; const nextQ = document.getElementById(`ao_next_${q.questionID}`).value;
if (!text) { showToast('German text is required', 'error'); return; } if (!validateStableKey(optionKey, 'Option key')) return;
if (!text) { showToast('German label is required', 'error'); return; }
const btn = document.getElementById(`ao_submit_${q.questionID}`); const btn = document.getElementById(`ao_submit_${q.questionID}`);
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Adding...'; btn.textContent = 'Adding...';
try { try {
const data = await apiPost('answer_options.php', { const data = await apiPost('answer_options.php', {
questionID: q.questionID, defaultText: text, points, nextQuestionId: nextQ questionID: q.questionID,
optionKey,
defaultText: text,
points,
nextQuestionId: nextQ,
}); });
if (data.success) { if (data.success) {
if (!q.answerOptions) q.answerOptions = []; if (!q.answerOptions) q.answerOptions = [];
@ -887,9 +1168,13 @@ function showEditOptionForm(q, opt) {
li.innerHTML = ` li.innerHTML = `
<div class="inline-form-card" style="width:100%;padding:8px"> <div class="inline-form-card" style="width:100%;padding:8px">
<div class="form-row"> <div class="form-row">
<div class="form-group" style="flex:3;margin-bottom:8px"> <div class="form-group" style="flex:1;min-width:120px;margin-bottom:8px">
<label style="font-size:.8rem">German text <span class="required-mark">*</span></label> <label style="font-size:.8rem">Option key <span class="required-mark">*</span></label>
<input type="text" id="eo_text_${opt.answerOptionID}" value="${esc(opt.defaultText)}" required> <input type="text" id="eo_key_${opt.answerOptionID}" value="${esc(optionKeyOf(opt))}" pattern="[a-zA-Z][a-zA-Z0-9_]*" autocomplete="off" required>
</div>
<div class="form-group" style="flex:2;margin-bottom:8px">
<label style="font-size:.8rem">German label <span class="required-mark">*</span></label>
<input type="text" id="eo_text_${opt.answerOptionID}" value="${esc(optionGermanLabel(opt))}" required>
</div> </div>
<div class="form-group" style="flex:1;min-width:80px;margin-bottom:8px"> <div class="form-group" style="flex:1;min-width:80px;margin-bottom:8px">
<input type="number" id="eo_pts_${opt.answerOptionID}" value="${opt.points}" min="0"> <input type="number" id="eo_pts_${opt.answerOptionID}" value="${opt.points}" min="0">
@ -897,7 +1182,7 @@ function showEditOptionForm(q, opt) {
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:8px"> <div class="form-group" style="flex:1;min-width:160px;margin-bottom:8px">
<select id="eo_next_${opt.answerOptionID}"> <select id="eo_next_${opt.answerOptionID}">
<option value="">(next in order)</option> <option value="">(next in order)</option>
${qIds.map(qi => `<option value="${esc(qi.localId)}" ${opt.nextQuestionId === qi.localId ? 'selected' : ''}>${esc(qi.localId)} - ${esc(qi.defaultText)}</option>`).join('')} ${qIds.map(qi => `<option value="${esc(qi.localId)}" ${opt.nextQuestionId === qi.localId ? 'selected' : ''}>${esc(branchTargetLabel(qi))}</option>`).join('')}
</select> </select>
</div> </div>
</div> </div>
@ -912,11 +1197,13 @@ function showEditOptionForm(q, opt) {
document.getElementById(`eo_text_${opt.answerOptionID}`).focus(); document.getElementById(`eo_text_${opt.answerOptionID}`).focus();
document.getElementById(`eo_save_${opt.answerOptionID}`).addEventListener('click', async () => { document.getElementById(`eo_save_${opt.answerOptionID}`).addEventListener('click', async () => {
const optionKey = document.getElementById(`eo_key_${opt.answerOptionID}`).value.trim();
const text = document.getElementById(`eo_text_${opt.answerOptionID}`).value.trim(); const text = document.getElementById(`eo_text_${opt.answerOptionID}`).value.trim();
const points = parseInt(document.getElementById(`eo_pts_${opt.answerOptionID}`).value || '0', 10); const points = parseInt(document.getElementById(`eo_pts_${opt.answerOptionID}`).value || '0', 10);
const nextQ = document.getElementById(`eo_next_${opt.answerOptionID}`).value; const nextQ = document.getElementById(`eo_next_${opt.answerOptionID}`).value;
if (!text) { showToast('German text is required', 'error'); return; } if (!validateStableKey(optionKey, 'Option key')) return;
await updateOption(q, opt.answerOptionID, { defaultText: text, points, nextQuestionId: nextQ }); if (!text) { showToast('German label is required', 'error'); return; }
await updateOption(q, opt.answerOptionID, { optionKey, defaultText: text, points, nextQuestionId: nextQ });
}); });
document.getElementById(`eo_text_${opt.answerOptionID}`).addEventListener('keydown', (e) => { document.getElementById(`eo_text_${opt.answerOptionID}`).addEventListener('keydown', (e) => {