Add support for app string translations in questionnaire imports, enhance UI strings, and improve question navigation in editor
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-06 16:07:06 +02:00
parent c37940c9a9
commit 91834d0b16
3 changed files with 70 additions and 8 deletions

View File

@ -2094,6 +2094,26 @@ function qdb_import_questionnaires_bundle(PDO $pdo, array $bundle, bool $replace
if (!is_array($items) || !$items) { if (!is_array($items) || !$items) {
json_error('MISSING_FIELDS', 'bundle.questionnaires array is required', 400); json_error('MISSING_FIELDS', 'bundle.questionnaires array is required', 400);
} }
foreach ($bundle['appStringTranslations'] ?? [] as $st) {
if (!is_array($st)) {
continue;
}
$sk = trim($st['stringKey'] ?? '');
if ($sk === '') {
continue;
}
$map = qdb_normalize_bundle_translations($st['translations'] ?? []);
if (!$map) {
continue;
}
if (isset($map[QDB_SOURCE_LANGUAGE])) {
qdb_set_app_string_german_label($pdo, $sk, $map[QDB_SOURCE_LANGUAGE]);
unset($map[QDB_SOURCE_LANGUAGE]);
}
if ($map) {
qdb_apply_translation_map($pdo, 'app_string', $sk, $map);
}
}
$results = []; $results = [];
foreach ($items as $item) { foreach ($items as $item) {
$results[] = qdb_import_questionnaire_bundle($pdo, $item, $replaceIfExists); $results[] = qdb_import_questionnaire_bundle($pdo, $item, $replaceIfExists);

View File

@ -40,6 +40,10 @@
"june", "june",
"lay", "lay",
"consultation_unlock_requires_rhs", "consultation_unlock_requires_rhs",
"enter_ages_ranges_hint",
"questionnaire_unlock_2_rhs",
"questionnaire_unlock_3_integration_index",
"questionnaire_unlock_4_consultation_results",
"locked", "locked",
"questionnaire_chip_edit", "questionnaire_chip_edit",
"questionnaire_chip_edit_pending", "questionnaire_chip_edit_pending",
@ -131,6 +135,10 @@
"june": "Juni", "june": "Juni",
"lay": "liegen.", "lay": "liegen.",
"consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS", "consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS",
"enter_ages_ranges_hint": "Bitte Alter als Bereich (z. B. 812) oder einzelne Jahre (z. B. 6, 9, 13) eingeben",
"questionnaire_unlock_2_rhs": "Verfügbar nach Abschluss von Demografie",
"questionnaire_unlock_3_integration_index": "Verfügbar nach Abschluss von Demografie und RHS",
"questionnaire_unlock_4_consultation_results": "Verfügbar nach Abschluss von Demografie (Einwilligung unterschrieben), RHS und IPL",
"locked": "Gesperrt", "locked": "Gesperrt",
"questionnaire_chip_edit": "Bearbeiten", "questionnaire_chip_edit": "Bearbeiten",
"questionnaire_chip_edit_pending": "Bearbeiten · Upload ausstehend", "questionnaire_chip_edit_pending": "Bearbeiten · Upload ausstehend",

View File

@ -193,19 +193,44 @@ function stringSpinnerOtherEnabled(config) {
return Boolean(config.otherNextQuestionId || config.otherOptionKey); return Boolean(config.otherNextQuestionId || config.otherOptionKey);
} }
function stringSpinnerNextSectionHTML(config, prefix) { /** Question types that use config.nextQuestionId (not per-option branching). */
const QUESTION_LEVEL_NEXT_LAYOUTS = new Set([
'value_spinner',
'slider_question',
'free_text',
'date_spinner',
'multi_check_box_question',
'glass_scale_question',
'client_coach_code_question',
'client_not_signed',
'last_page',
]);
function questionNextSectionHTML(config, prefix, { label, hint } = {}) {
const next = config.nextQuestionId || ''; const next = config.nextQuestionId || '';
return ` return `
<div class="form-group" style="margin-top:12px"> <div class="form-group question-next-field" style="margin-top:12px">
<label>Next question (list selection)</label> <label>${label || 'Next question'}</label>
<select id="${prefix}_nextQuestionId"> <select id="${prefix}_nextQuestionId">
<option value="">(next in list order)</option> <option value="">(next in list order)</option>
${branchTargetOptionsHTML(next)} ${branchTargetOptionsHTML(next)}
</select> </select>
<span class="field-hint">Where to go after a normal spinner choice. <strong>Required</strong> when &ldquo;Other&rdquo; targets a question that sits next in order (so list picks can skip it).</span> <span class="field-hint">${hint || 'Where to go after this question. Leave empty to continue in list order.'}</span>
</div>`; </div>`;
} }
function stringSpinnerNextSectionHTML(config, prefix) {
return questionNextSectionHTML(config, prefix, {
label: 'Next question (list selection)',
hint: 'Where to go after a normal spinner choice. <strong>Required</strong> when &ldquo;Other&rdquo; targets a question that sits next in order (so list picks can skip it).',
});
}
function readQuestionNextFromForm(prefix, config) {
const next = document.getElementById(`${prefix}_nextQuestionId`)?.value?.trim() || '';
if (next) config.nextQuestionId = next;
}
function stringSpinnerOtherSectionHTML(config, prefix) { function stringSpinnerOtherSectionHTML(config, prefix) {
const enabled = stringSpinnerOtherEnabled(config); const enabled = stringSpinnerOtherEnabled(config);
const otherKey = config.otherOptionKey || 'other_option'; const otherKey = config.otherOptionKey || 'other_option';
@ -288,7 +313,8 @@ function configFormHTML(layout, config, prefix) {
<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. resilience_reflection_prompt"> <input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. resilience_reflection_prompt">
</div>`; </div>
<p class="field-hint" style="margin-top:8px">Branching: set <strong>Next question</strong> on each answer option below.</p>`;
break; break;
case 'multi_check_box_question': case 'multi_check_box_question':
html = ` html = `
@ -429,6 +455,9 @@ function configFormHTML(layout, config, prefix) {
</div>`; </div>`;
break; break;
} }
if (QUESTION_LEVEL_NEXT_LAYOUTS.has(layout)) {
html += questionNextSectionHTML(config, prefix);
}
return html ? `<div class="config-section">${html}</div>` : ''; return html ? `<div class="config-section">${html}</div>` : '';
} }
@ -517,6 +546,9 @@ function readConfigFromForm(layout, prefix) {
if (val('textKey')) config.textKey = val('textKey'); if (val('textKey')) config.textKey = val('textKey');
break; break;
} }
if (QUESTION_LEVEL_NEXT_LAYOUTS.has(layout) || layout === 'string_spinner') {
readQuestionNextFromForm(prefix, config);
}
return JSON.stringify(config); return JSON.stringify(config);
} }
@ -790,6 +822,7 @@ function showAddQuestionForm() {
<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">
</div> </div>
<div class="form-group" style="width:160px;margin-bottom:0"> <div class="form-group" style="width:160px;margin-bottom:0">
<label style="font-size:.75rem;color:var(--text-secondary)">Next question</label>
<select id="aq_opt_next"> <select id="aq_opt_next">
<option value="">(next in order)</option> <option value="">(next in order)</option>
${branchTargetOptionsHTML()} ${branchTargetOptionsHTML()}
@ -1052,10 +1085,10 @@ function renderQuestionBody(q) {
<p><strong>Key:</strong> <code>${esc(qKey || '—')}</code> · <strong>ID:</strong> <code>${esc(localId)}</code></p> <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) ${config.nextQuestionId || config.otherNextQuestionId
? `<p><strong>Branches:</strong> ? `<p><strong>Branches:</strong>
${config.nextQuestionId ? `list → <code>${esc(config.nextQuestionId)}</code>` : 'list → (order)'} ${config.nextQuestionId ? `→ <code>${esc(config.nextQuestionId)}</code>` : ''}
${config.otherNextQuestionId ? ` · Other (<code>${esc(config.otherOptionKey || 'other_option')}</code>) → <code>${esc(config.otherNextQuestionId)}</code>` : ''} ${config.otherNextQuestionId ? `${config.nextQuestionId ? ' · ' : ''}Other (<code>${esc(config.otherOptionKey || 'other_option')}</code>) → <code>${esc(config.otherNextQuestionId)}</code>` : ''}
</p>` </p>`
: ''} : ''}
${Object.keys(config).length ? `<p><strong>Config:</strong> <code>${esc(JSON.stringify(config))}</code></p>` : ''} ${Object.keys(config).length ? `<p><strong>Config:</strong> <code>${esc(JSON.stringify(config))}</code></p>` : ''}
@ -1265,6 +1298,7 @@ function showEditOptionForm(q, opt) {
<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">
</div> </div>
<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">
<label style="font-size:.8rem">Next question</label>
<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(branchTargetLabel(qi))}</option>`).join('')} ${qIds.map(qi => `<option value="${esc(qi.localId)}" ${opt.nextQuestionId === qi.localId ? 'selected' : ''}>${esc(branchTargetLabel(qi))}</option>`).join('')}