From 7671c9f329bd9e0f8da5e437cc712122d82c908b Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Tue, 26 May 2026 15:40:35 +0200 Subject: [PATCH] enhanced questionnaire handling with stable key validation, improved note management, and added option key support --- api/app_questionnaires.php | 21 +- common.php | 266 +++++++++++++++++++++--- data/app_ui_strings.json | 4 + data/countries_de_sorted.txt | 190 +++++++++++++++++ handlers/answer_options.php | 38 +++- handlers/app_questionnaires.php | 17 +- handlers/questions.php | 52 ++++- import_questionnaires.php | 14 +- website/css/style.css | 21 ++ website/js/pages/editor.js | 353 +++++++++++++++++++++++++++++--- 10 files changed, 876 insertions(+), 100 deletions(-) create mode 100644 data/countries_de_sorted.txt diff --git a/api/app_questionnaires.php b/api/app_questionnaires.php index 0813d4b..db60c8e 100644 --- a/api/app_questionnaires.php +++ b/api/app_questionnaires.php @@ -58,15 +58,21 @@ if ($qnID) { $shortId = end($parts); $layout = $dbQ['type']; - $config = json_decode($dbQ['configJson'], true) ?: []; + $config = qdb_parse_config_json($dbQ['configJson']); + $qKey = qdb_question_key($config, $dbQ['defaultText']); $q = [ 'id' => $shortId, '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['textKey1'])) $q['textKey1'] = $config['textKey1']; 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['scaleType'])) $q['scaleType'] = $config['scaleType']; 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['precision'])) $q['precision'] = $config['precision']; if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection']; 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['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey']; - // String spinner static options if ($layout === 'string_spinner' && isset($config['options'])) { $q['options'] = $config['options']; } - - // Value spinner conditional navigation options - if ($layout === 'value_spinner' && isset($config['valueOptions'])) { + if (($layout === 'value_spinner' || $layout === 'slider_question') && isset($config['valueOptions'])) { $q['options'] = $config['valueOptions']; } diff --git a/common.php b/common.php index 9b05818..b48df17 100644 --- a/common.php +++ b/common.php @@ -305,10 +305,19 @@ function qdb_all_questionnaire_translation_key_set(PDO $pdo): array { $keys = []; $rows = $pdo->query("SELECT defaultText, configJson FROM question")->fetchAll(PDO::FETCH_ASSOC); 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; } - $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) { if (!empty($config[$field])) { $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) ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text") ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); - if ($lang === QDB_SOURCE_LANGUAGE) { - $pdo->prepare("UPDATE answer_option SET defaultText = :t WHERE answerOptionID = :id") - ->execute([':t' => $text, ':id' => $id]); - } + // defaultText holds optionKey; German label lives only in answer_option_translation. } elseif ($type === 'string' || $type === 'app_string') { $pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text) 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. * Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved). @@ -420,9 +555,16 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array { foreach ($dbQuestions as $dbQ) { $qOrder = (int)($dbQ['orderIndex'] ?? 0); $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[] = [ - 'key' => $dbQ['defaultText'], - 'displayKey' => preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal, + 'key' => $qKey !== '' ? $qKey : $dbQ['defaultText'], + 'displayKey' => $qKey !== '' ? $qKey : (preg_match('/^[a-f0-9]{32}$/i', $qLocal) ? ('#' . ($qOrder + 1)) : $qLocal), + 'germanText' => $germanQ, 'type' => 'question', 'entityId' => $dbQ['questionID'], 'sortOrder' => $qOrder * 1000, @@ -434,20 +576,23 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array { "); $aoStmt->execute([':qid' => $dbQ['questionID']]); foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { - $optLabel = preg_match('/^[a-f0-9]{32}$/i', $qLocal) - ? ('#' . ($qOrder + 1) . ' · option ' . ((int)$ao['orderIndex'] + 1)) - : ($qLocal . ' · option ' . ((int)$ao['orderIndex'] + 1)); + $optKey = qdb_option_key($ao); + $optLabel = $optKey !== '' + ? ($qLocal . ' · ' . $optKey) + : (preg_match('/^[a-f0-9]{32}$/i', $qLocal) + ? ('#' . ($qOrder + 1) . ' · option ' . ((int)$ao['orderIndex'] + 1)) + : ($qLocal . ' · option ' . ((int)$ao['orderIndex'] + 1))); $contentEntries[] = [ - 'key' => $ao['defaultText'], + 'key' => $optKey !== '' ? $optKey : $ao['defaultText'], 'displayKey' => $optLabel, + 'germanText' => qdb_option_german_label($pdo, $ao['answerOptionID'], $ao['defaultText']), 'type' => 'answer_option', 'entityId' => $ao['answerOptionID'], 'sortOrder' => $qOrder * 1000 + 10 + (int)($ao['orderIndex'] ?? 0), ]; } - $config = json_decode($dbQ['configJson'] ?: '{}', true) ?: []; - foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2'] as $field) { + foreach (['textKey', 'textKey1', 'textKey2', 'hint', 'hint1', 'hint2', 'otherOptionKey'] as $field) { if (!empty($config[$field])) { $stringKeys[$config[$field]] = true; } @@ -457,6 +602,14 @@ function qdb_translation_entry_lists(PDO $pdo, string $qnID): array { $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(); @@ -689,18 +842,27 @@ function qdb_export_questionnaire_bundle(PDO $pdo, string $qnID): ?array { FROM answer_option WHERE questionID = :qid ORDER BY orderIndex "); $aoStmt->execute([':qid' => $dbQ['questionID']]); + $qKey = qdb_question_key($config, $dbQ['defaultText']); 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[] = [ - 'defaultText' => $ao['defaultText'], + 'optionKey' => $optKey !== '' ? $optKey : $ao['defaultText'], + 'defaultText' => $optGerman, 'points' => (int)$ao['points'], 'orderIndex' => (int)$ao['orderIndex'], 'nextQuestionId' => $ao['nextQuestionId'] ?? '', - 'translations' => qdb_fetch_translation_map($pdo, 'answer_option', $ao['answerOptionID']), + 'translations' => $optTr, ]; } $questionsOut[] = [ 'localId' => $localId, + 'questionKey' => $qKey, 'defaultText' => $dbQ['defaultText'], 'type' => $dbQ['type'], '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)); } $questionID = $qnID . '__' . $localId; - $text = trim($q['defaultText'] ?? ''); - qdb_require_non_empty_german($text, 'Question German text'); - $config = $q['config'] ?? $q['configJson'] ?? []; - $configJson = is_string($config) ? $config : json_encode($config ?: [], JSON_UNESCAPED_UNICODE); + $config = qdb_parse_config_json($q['config'] ?? $q['configJson'] ?? []); + $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) 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, ]); - $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_apply_translation_map($pdo, 'question', $questionID, is_array($qTr) ? $qTr : []); + qdb_sync_question_note_strings($pdo, $questionKey, $config); foreach ($q['answerOptions'] ?? [] as $opt) { - $optText = trim($opt['defaultText'] ?? ''); - qdb_require_non_empty_german($optText, 'Answer option German text'); + $optKey = trim((string)($opt['optionKey'] ?? '')); + $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)); $pdo->prepare("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)") ->execute([ ':id' => $aoId, ':qid' => $questionID, - ':t' => $optText, + ':t' => $optKey, ':p' => (int)($opt['points'] ?? 0), ':o' => (int)($opt['orderIndex'] ?? 0), ':nq' => trim($opt['nextQuestionId'] ?? ''), ]); - $oTr = $opt['translations'] ?? []; - 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_upsert_source_translation($pdo, 'answer_option', $aoId, $optGerman); qdb_apply_translation_map($pdo, 'answer_option', $aoId, is_array($oTr) ? $oTr : []); } } diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json index 2ef79a6..ff11b53 100644 --- a/data/app_ui_strings.json +++ b/data/app_ui_strings.json @@ -57,6 +57,8 @@ "november", "october", "offline", + "other_country", + "other_option", "ok", "online", "password_hint", @@ -138,6 +140,8 @@ "november": "November", "october": "Oktober", "offline": "Offline", + "other_country": "anderes Land", + "other_option": "Sonstiges", "ok": "OK", "online": "Online", "password_hint": "Passwort", diff --git a/data/countries_de_sorted.txt b/data/countries_de_sorted.txt new file mode 100644 index 0000000..9c78521 --- /dev/null +++ b/data/countries_de_sorted.txt @@ -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 diff --git a/handlers/answer_options.php b/handlers/answer_options.php index 3442573..433f327 100644 --- a/handlers/answer_options.php +++ b/handlers/answer_options.php @@ -17,6 +17,8 @@ case 'GET': foreach ($options as &$o) { $o['points'] = (int)$o['points']; $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->execute([':id' => $o['answerOptionID']]); $o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC); @@ -29,13 +31,14 @@ case 'GET': case 'POST': require_role(['admin', 'supervisor'], $tokenRec); $body = read_json_body(); - if (empty($body['questionID']) || !isset($body['defaultText'])) { - json_error('MISSING_FIELDS', 'questionID and defaultText required', 400); + if (empty($body['questionID']) || empty($body['optionKey']) || !isset($body['defaultText'])) { + json_error('MISSING_FIELDS', 'questionID, optionKey, and defaultText (German label) required', 400); } $id = bin2hex(random_bytes(16)); $qID = $body['questionID']; + $optKey = qdb_validate_stable_key((string)$body['optionKey'], 'Option key'); $text = trim($body['defaultText']); - qdb_require_non_empty_german($text); + qdb_require_non_empty_german($text, 'German label'); $points = (int)($body['points'] ?? 0); $order = (int)($body['orderIndex'] ?? 0); $nextQ = trim($body['nextQuestionId'] ?? ''); @@ -53,14 +56,17 @@ case 'POST': $max->execute([':id' => $qID]); $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)') - ->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_save($tmpDb, $lockFp); json_success(['answerOption' => [ 'answerOptionID' => $id, 'questionID' => $qID, - 'defaultText' => $text, + 'optionKey' => $optKey, + 'defaultText' => $optKey, + 'labelGerman' => $text, 'points' => $points, 'orderIndex' => $order, 'nextQuestionId' => $nextQ, @@ -90,20 +96,32 @@ case 'PUT': qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Answer option not found', 404); } - $text = trim($body['defaultText'] ?? $row['defaultText']); - qdb_require_non_empty_german($text); + $optKey = isset($body['optionKey']) + ? 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']); $order = (int)($body['orderIndex'] ?? $row['orderIndex']); $nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']); $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]); - qdb_upsert_source_translation($pdo, 'answer_option', $id, $text); + ->execute([':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]); + qdb_upsert_source_translation($pdo, 'answer_option', $id, $labelGerman); qdb_save($tmpDb, $lockFp); json_success(['answerOption' => [ 'answerOptionID' => $id, 'questionID' => $row['questionID'], - 'defaultText' => $text, + 'optionKey' => $optKey, + 'defaultText' => $optKey, + 'labelGerman' => $labelGerman, 'points' => $points, 'orderIndex' => $order, 'nextQuestionId' => $nextQ, diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index 1be6431..cc653bc 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -250,14 +250,21 @@ if ($qnID) { $shortId = end($parts); $layout = $dbQ['type']; - $config = json_decode($dbQ['configJson'], true) ?: []; + $config = qdb_parse_config_json($dbQ['configJson']); + $qKey = qdb_question_key($config, $dbQ['defaultText']); $q = [ 'id' => $shortId, '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['textKey1'])) $q['textKey1'] = $config['textKey1']; 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['scaleType'])) $q['scaleType'] = $config['scaleType']; 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['precision'])) $q['precision'] = $config['precision']; if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection']; 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['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey']; if ($layout === 'string_spinner' && isset($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']; } diff --git a/handlers/questions.php b/handlers/questions.php index 43d15d3..feb1ded 100644 --- a/handlers/questions.php +++ b/handlers/questions.php @@ -19,7 +19,10 @@ case 'GET': foreach ($questions as &$q) { $q['isRequired'] = (int)$q['isRequired']; $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(" SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId FROM answer_option WHERE questionID = :qid ORDER BY orderIndex @@ -29,7 +32,10 @@ case 'GET': foreach ($q['answerOptions'] as &$opt) { $opt['points'] = (int)$opt['points']; $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->execute([':qid' => $q['questionID']]); $q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC); @@ -42,16 +48,24 @@ case 'GET': case 'POST': require_role(['admin', 'supervisor'], $tokenRec); $body = read_json_body(); - if (empty($body['questionnaireID']) || !isset($body['defaultText'])) { - json_error('BAD_REQUEST', 'questionnaireID and defaultText required', 400); + if (empty($body['questionnaireID']) || !isset($body['defaultText']) || empty($body['questionKey'])) { + json_error('BAD_REQUEST', 'questionnaireID, questionKey, and defaultText required', 400); } $qnID = $body['questionnaireID']; $text = trim($body['defaultText']); qdb_require_non_empty_german($text); + $questionKey = qdb_validate_stable_key((string)$body['questionKey'], 'Question key'); $type = trim($body['type'] ?? ''); $order = (int)($body['orderIndex'] ?? 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); try { $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) VALUES (:id, :qid, :t, :ty, :o, :r, :cj)") ->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_sync_question_note_strings($pdo, $questionKey, $config); qdb_save($tmpDb, $lockFp); json_success(['question' => [ 'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text, + 'questionKey' => $questionKey, 'localId' => $localId, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, - 'configJson' => $config, 'answerOptions' => [], 'translations' => [], + 'configJson' => $configJson, 'answerOptions' => [], 'translations' => [], ]]); } catch (Throwable $e) { qdb_discard($tmpDb, $lockFp); @@ -115,15 +131,31 @@ case 'PUT': $type = trim($body['type'] ?? $row['type']); $order = (int)($body['orderIndex'] ?? $row['orderIndex']); $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") - ->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_sync_question_note_strings($pdo, $questionKey, $config); qdb_save($tmpDb, $lockFp); json_success(['question' => [ 'questionID' => $id, 'questionnaireID' => $row['questionnaireID'], - 'defaultText' => $text, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, - 'configJson' => $config, + 'defaultText' => $text, 'questionKey' => $questionKey, + 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req, + 'configJson' => $configJson, ]]); } catch (Throwable $e) { qdb_discard($tmpDb, $lockFp); diff --git a/import_questionnaires.php b/import_questionnaires.php index 55db515..746c6fa 100644 --- a/import_questionnaires.php +++ b/import_questionnaires.php @@ -89,11 +89,11 @@ try { if (isset($q['precision'])) $config['precision'] = $q['precision']; if (isset($q['minSelection'])) $config['minSelection'] = $q['minSelection']; 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['otherOptionKey'])) $config['otherOptionKey'] = $q['otherOptionKey']; 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])) { $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) : '{}'; + $germanText = $qKey; $pdo->prepare("INSERT OR REPLACE INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson) @@ -125,12 +129,16 @@ try { ->execute([ ':id' => $questionID, ':qnid' => $questionnaireID, - ':dt' => $qKey, + ':dt' => $germanText, ':ty' => $layout, ':oi' => $qIdx, ':ir' => 0, ':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'])) { $hasObjects = isset($q['options'][0]) && is_array($q['options'][0]); diff --git a/website/css/style.css b/website/css/style.css index 6b8d135..b6cd609 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -1158,3 +1158,24 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; } border-radius: 3px; 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); +} diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index 07e3ec1..cdf9288 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -18,6 +18,7 @@ const LAYOUT_TYPES = [ { value: 'multi_check_box_question', label: 'Checkbox (Multiple Choice)' }, { value: 'glass_scale_question', label: 'Glass Scale' }, { value: 'value_spinner', label: 'Value Spinner' }, + { value: 'slider_question', label: 'Slider (min–max)' }, { value: 'date_spinner', label: 'Date Spinner' }, { value: 'string_spinner', label: 'String Spinner' }, { value: 'free_text', label: 'Free Text' }, @@ -93,17 +94,174 @@ function layoutLabel(value) { return t ? t.label : value || 'unknown'; } +const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/; + function parseConfig(q) { 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() { return questions.map(q => { 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 => + `` + ).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 ` +
+

Notes (German)

+
+
+ + + Key: ${esc(nbKey)} +
+
+ + + Key: ${esc(naKey)} +
+
+
`; +} + +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 ` +
+ + + Where to go after a normal spinner choice. Required when “Other” targets a question that sits next in order (so list picks can skip it). +
`; +} + +function stringSpinnerOtherSectionHTML(config, prefix) { + const enabled = stringSpinnerOtherEnabled(config); + const otherKey = config.otherOptionKey || 'other_option'; + const otherNext = config.otherNextQuestionId || ''; + return ` +
+ +
+
+
+ + + Appends this label to the spinner. Translate under Questionnaire UI strings (or App strings if listed in catalog). +
+
+ + + Use layout Free text for the follow-up (e.g. country name). +
+
+
+
`; +} + +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 ──────────────────────────────── function configFormHTML(layout, config, prefix) { @@ -140,6 +298,7 @@ function configFormHTML(layout, config, prefix) { break; } case 'value_spinner': + case 'slider_question': html = `
@@ -150,6 +309,15 @@ function configFormHTML(layout, config, prefix) {
+ ${layout === 'slider_question' ? ` +
+ + +
+
+ + +
` : ''}
`; break; case 'date_spinner': { @@ -181,8 +349,11 @@ function configFormHTML(layout, config, prefix) { html = `
- -
`; + + Do not include the “Other” row here; enable it below. + + ${stringSpinnerNextSectionHTML(config, prefix)} + ${stringSpinnerOtherSectionHTML(config, prefix)}`; break; case 'free_text': html = ` @@ -196,11 +367,6 @@ function configFormHTML(layout, config, prefix) { -
- -
@@ -271,10 +437,16 @@ function readConfigFromForm(layout, prefix) { break; } case 'value_spinner': + case 'slider_question': config.range = { min: parseInt(val('rangeMin') || '0', 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; case 'date_spinner': { const prec = val('precision') || 'full'; @@ -292,6 +464,13 @@ function readConfigFromForm(layout, prefix) { const raw = val('stringOptions'); const opts = raw.split('\n').map(s => s.trim()).filter(Boolean); 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; } case 'free_text': { @@ -299,7 +478,6 @@ function readConfigFromForm(layout, prefix) { if (val('hint')) config.hint = val('hint'); const ml = parseInt(val('maxLength') || '500', 10); if (!Number.isNaN(ml) && ml > 0) config.maxLength = Math.min(ml, 10000); - if (document.getElementById(`${prefix}_multiline`)?.checked) config.multiline = true; break; } case 'client_coach_code_question': @@ -490,7 +668,11 @@ function showAddQuestionForm() {

New Question

-
+
+ + +
+
@@ -504,6 +686,7 @@ function showAddQuestionForm() {
+
${notesSectionHTML({}, 'aq_cfg', '')}
@@ -512,8 +695,11 @@ function showAddQuestionForm() {
    -
    - +
    + +
    +
    +
    @@ -521,7 +707,7 @@ function showAddQuestionForm() {
    @@ -538,8 +724,12 @@ function showAddQuestionForm() { function updateTypeUI() { 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_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() { @@ -551,6 +741,7 @@ function showAddQuestionForm() { } list.innerHTML = pendingOptions.map((opt, i) => `
  • + ${esc(opt.optionKey)} ${esc(opt.text)} ${opt.points} pts ${opt.nextQuestionId ? `→ ${esc(opt.nextQuestionId)}` : ''} @@ -566,12 +757,15 @@ function showAddQuestionForm() { } function addPendingOption() { + const optionKey = document.getElementById('aq_opt_key').value.trim(); const text = document.getElementById('aq_opt_text').value.trim(); const pts = parseInt(document.getElementById('aq_opt_pts').value || '0', 10); const next = document.getElementById('aq_opt_next').value; - if (!text) { showToast('German text is required', 'error'); return; } - pendingOptions.push({ text, points: pts, nextQuestionId: next }); + if (!validateStableKey(optionKey, 'Option key')) return; + if (!text) { showToast('German label is required', 'error'); return; } + pendingOptions.push({ optionKey, text, points: pts, nextQuestionId: next }); renderPendingOptions(); + document.getElementById('aq_opt_key').value = ''; document.getElementById('aq_opt_text').value = ''; document.getElementById('aq_opt_pts').value = '0'; document.getElementById('aq_opt_next').value = ''; @@ -583,17 +777,33 @@ function showAddQuestionForm() { document.getElementById('aq_text').focus(); 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_text').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addPendingOption(); } }); document.getElementById('aq_submit').addEventListener('click', async () => { + const questionKey = document.getElementById('aq_questionKey').value.trim(); const text = document.getElementById('aq_text').value.trim(); const type = document.getElementById('aq_type').value; const isRequired = document.getElementById('aq_required').checked ? 1 : 0; + const notes = readNotesFromForm('aq_cfg'); const configJson = readConfigFromForm(type, 'aq_cfg'); + 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; @@ -605,7 +815,13 @@ function showAddQuestionForm() { try { const qData = await apiPost('questions.php', { 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'); @@ -615,6 +831,7 @@ function showAddQuestionForm() { for (const opt of pendingOptions) { const oData = await apiPost('answer_options.php', { questionID: newQ.questionID, + optionKey: opt.optionKey, defaultText: opt.text, points: opt.points, nextQuestionId: opt.nextQuestionId || '', @@ -673,9 +890,18 @@ function renderQuestions() {
    ${editable ? '' : ''} - ${idx + 1}. ${esc(q.defaultText)} + ${idx + 1}. ${esc(questionKeyOf(q) || '?')} · ${esc((q.defaultText || '').slice(0, 48))}${(q.defaultText || '').length > 48 ? '…' : ''} ${esc(layoutLabel(q.type))} + ${!questionKeyOf(q) ? 'No key' : ''} ${q.isRequired ? 'Required' : ''} + ${(() => { + 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 + ? `${esc(parts.join(' · '))}` : ''; + })()}
  • @@ -701,13 +927,20 @@ function renderQuestionBody(q) { const options = q.answerOptions || []; const config = parseConfig(q); const layout = q.type || 'radio_question'; + const qKey = questionKeyOf(q); + const localId = q.localId || q.questionID.split('__').pop(); body.innerHTML = ` ${editable ? `
    +

    Local ID: ${esc(localId)}

    -
    +
    + + +
    +
    @@ -719,6 +952,7 @@ function renderQuestionBody(q) { + ${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
    ${configFormHTML(layout, config, `eq_cfg_${q.questionID}`)}
    @@ -730,8 +964,15 @@ function renderQuestionBody(q) {
    ` : `
    +

    Key: ${esc(qKey || '—')} · ID: ${esc(localId)}

    German: ${esc(q.defaultText)}

    Layout: ${esc(layoutLabel(layout))}

    + ${layout === 'string_spinner' && (config.nextQuestionId || config.otherNextQuestionId) + ? `

    Branches: + ${config.nextQuestionId ? `list → ${esc(config.nextQuestionId)}` : 'list → (order)'} + ${config.otherNextQuestionId ? ` · Other (${esc(config.otherOptionKey || 'other_option')}) → ${esc(config.otherNextQuestionId)}` : ''} +

    ` + : ''} ${Object.keys(config).length ? `

    Config: ${esc(JSON.stringify(config))}

    ` : ''}
    `} @@ -751,20 +992,47 @@ function renderQuestionBody(q) { if (!editable) return; + const cfgPrefix = `eq_cfg_${q.questionID}`; + 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 type = document.getElementById(`eq_type_${q.questionID}`).value; 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; } - 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', () => { 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.querySelectorAll('.edit-opt-btn').forEach(btn => { @@ -784,10 +1052,13 @@ function renderQuestionBody(q) { } function optionItemHTML(q, o, editable) { + const ok = optionKeyOf(o); + const label = optionGermanLabel(o) || o.defaultText; return `
  • ${editable ? '' : ''} - ${esc(o.defaultText)} + ${esc(ok || '?')} + ${esc(label)} ${o.points} pts ${o.nextQuestionId ? `→ ${esc(o.nextQuestionId)}` : ''} ${editable ? ` @@ -812,8 +1083,12 @@ function showAddOptionForm(q) { wrapper.innerHTML = `
    -
    - +
    + + +
    +
    +
    @@ -824,7 +1099,7 @@ function showAddOptionForm(q) {
    @@ -838,17 +1113,23 @@ function showAddOptionForm(q) { document.getElementById(`ao_text_${q.questionID}`).focus(); 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 points = parseInt(document.getElementById(`ao_pts_${q.questionID}`).value || '0', 10); 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}`); btn.disabled = true; btn.textContent = 'Adding...'; try { 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 (!q.answerOptions) q.answerOptions = []; @@ -887,9 +1168,13 @@ function showEditOptionForm(q, opt) { li.innerHTML = `
    -
    - - +
    + + +
    +
    + +
    @@ -897,7 +1182,7 @@ function showEditOptionForm(q, opt) {
    @@ -912,11 +1197,13 @@ function showEditOptionForm(q, opt) { document.getElementById(`eo_text_${opt.answerOptionID}`).focus(); 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 points = parseInt(document.getElementById(`eo_pts_${opt.answerOptionID}`).value || '0', 10); const nextQ = document.getElementById(`eo_next_${opt.answerOptionID}`).value; - if (!text) { showToast('German text is required', 'error'); return; } - await updateOption(q, opt.answerOptionID, { defaultText: text, points, nextQuestionId: nextQ }); + if (!validateStableKey(optionKey, 'Option key')) return; + 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) => {