enhanced questionnaire handling with stable key validation, improved note management, and added option key support
This commit is contained in:
@ -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'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
264
common.php
264
common.php
@ -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);
|
||||||
|
$optLabel = $optKey !== ''
|
||||||
|
? ($qLocal . ' · ' . $optKey)
|
||||||
|
: (preg_match('/^[a-f0-9]{32}$/i', $qLocal)
|
||||||
? ('#' . ($qOrder + 1) . ' · option ' . ((int)$ao['orderIndex'] + 1))
|
? ('#' . ($qOrder + 1) . ' · option ' . ((int)$ao['orderIndex'] + 1))
|
||||||
: ($qLocal . ' · 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 : []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
190
data/countries_de_sorted.txt
Normal file
190
data/countries_de_sorted.txt
Normal 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
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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 (min–max)' },
|
||||||
{ 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 “Other” 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 “Other” 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 “Other” 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 “Other” 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">→ ${esc(opt.nextQuestionId)}</span>` : ''}
|
${opt.nextQuestionId ? `<span class="opt-branch">→ ${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">☰</span>' : ''}
|
${editable ? '<span class="drag-handle" title="Drag to reorder">☰</span>' : ''}
|
||||||
<span class="chevron">▶</span>
|
<span class="chevron">▶</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">☰</span>' : ''}
|
${editable ? '<span class="drag-handle" style="font-size:.9rem">☰</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">→ ${esc(o.nextQuestionId)}</span>` : ''}
|
${o.nextQuestionId ? `<span class="opt-branch">→ ${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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user