From 9a6fa22d841fdf2a90b5ba9d4ae95951b846abf6 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Thu, 28 May 2026 12:26:45 +0200 Subject: [PATCH] improved condition json to have UI integration --- common.php | 105 +++++- handlers/questionnaires.php | 19 + website/css/style.css | 58 +++ website/js/condition-editor.js | 662 +++++++++++++++++++++++++++++++++ website/js/pages/editor.js | 115 ++++-- 5 files changed, 932 insertions(+), 27 deletions(-) create mode 100644 website/js/condition-editor.js diff --git a/common.php b/common.php index dc0e79c..9895e11 100644 --- a/common.php +++ b/common.php @@ -512,7 +512,7 @@ function qdb_set_category_german_label(PDO $pdo, string $categoryKey, string $te json_error('INVALID_FIELD', 'German label is required', 400); } $stringKey = qdb_questionnaire_group_string_key($categoryKey); - qdb_put_translation($pdo, 'app_string', $stringKey, QDB_SOURCE_LANGUAGE, $text); + qdb_set_app_string_german_label($pdo, $stringKey, $text); qdb_update_questionnaire_group_german_default($stringKey, $text); return $stringKey; } @@ -569,12 +569,111 @@ function qdb_app_ui_string_entries(): array { * Static catalog plus questionnaire_group_* keys for categoryKey values in use. * @return list */ +/** messageKey field inside questionnaire conditionJson (app locked hint). */ +function qdb_message_key_from_condition_json(?string $json): string { + if ($json === null || trim($json) === '' || trim($json) === '{}') { + return ''; + } + $obj = json_decode($json, true); + if (!is_array($obj)) { + return ''; + } + $key = trim((string)($obj['messageKey'] ?? '')); + return $key !== '' && qdb_is_stable_key($key) ? $key : ''; +} + +/** @return array */ +function qdb_collect_condition_message_keys(PDO $pdo): array { + $keys = []; + foreach ($pdo->query('SELECT conditionJson FROM questionnaire')->fetchAll(PDO::FETCH_COLUMN) as $json) { + $mk = qdb_message_key_from_condition_json($json); + if ($mk !== '') { + $keys[$mk] = true; + } + } + return $keys; +} + +/** + * App string entries for condition messageKey values referenced in questionnaire conditions. + * @return list + */ +function qdb_condition_message_string_entries(PDO $pdo): array { + $staticKeys = qdb_app_string_key_set(); + $defaults = qdb_app_string_catalog()['germanDefaults'] ?? []; + $entries = []; + $order = 0; + foreach (array_keys(qdb_collect_condition_message_keys($pdo)) as $key) { + if (isset($staticKeys[$key])) { + continue; + } + $entries[] = [ + 'key' => $key, + 'displayKey' => $key, + 'type' => 'app_string', + 'entityId' => $key, + 'sortOrder' => $order++, + 'germanDefault' => $defaults[$key] ?? $key, + ]; + } + usort($entries, fn($a, $b) => strcmp($a['key'], $b['key'])); + return $entries; +} + +function qdb_update_app_string_german_default(string $stringKey, string $text): void { + $path = qdb_app_string_catalog_path(); + if (!is_readable($path) || !is_writable($path)) { + return; + } + $data = json_decode(file_get_contents($path), true); + if (!is_array($data)) { + return; + } + if (!isset($data['germanDefaults']) || !is_array($data['germanDefaults'])) { + $data['germanDefaults'] = []; + } + if (($data['germanDefaults'][$stringKey] ?? '') === $text) { + return; + } + $data['germanDefaults'][$stringKey] = $text; + file_put_contents( + $path, + json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", + LOCK_EX + ); + qdb_app_string_catalog(true); +} + +function qdb_set_app_string_german_label(PDO $pdo, string $stringKey, string $text): void { + $stringKey = qdb_validate_stable_key($stringKey, 'messageKey'); + $text = trim($text); + if ($text === '') { + json_error('INVALID_FIELD', 'German label is required', 400); + } + qdb_put_translation($pdo, 'app_string', $stringKey, QDB_SOURCE_LANGUAGE, $text); + qdb_update_app_string_german_default($stringKey, $text); +} + function qdb_app_ui_string_entries_merged(PDO $pdo): array { $merged = qdb_app_ui_string_entries(); + $existing = []; + foreach ($merged as $e) { + $existing[$e['key']] = true; + } $order = count($merged); foreach (qdb_category_group_string_entries($pdo) as $e) { - $e['sortOrder'] = $order++; - $merged[] = $e; + if (!isset($existing[$e['key']])) { + $e['sortOrder'] = $order++; + $merged[] = $e; + $existing[$e['key']] = true; + } + } + foreach (qdb_condition_message_string_entries($pdo) as $e) { + if (!isset($existing[$e['key']])) { + $e['sortOrder'] = $order++; + $merged[] = $e; + $existing[$e['key']] = true; + } } return $merged; } diff --git a/handlers/questionnaires.php b/handlers/questionnaires.php index ad56d35..41c3c68 100644 --- a/handlers/questionnaires.php +++ b/handlers/questionnaires.php @@ -49,6 +49,25 @@ case 'POST': require_role(['admin', 'supervisor'], $tokenRec); $body = read_json_body(); + if (($body['action'] ?? '') === 'updateConditionMessageLabel') { + $messageKey = trim((string)($body['messageKey'] ?? '')); + $germanLabel = (string)($body['germanLabel'] ?? ''); + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + try { + qdb_set_app_string_german_label($pdo, $messageKey, $germanLabel); + qdb_save($tmpDb, $lockFp); + json_success([ + 'messageKey' => qdb_validate_stable_key($messageKey, 'messageKey'), + 'germanLabel' => trim($germanLabel), + ]); + } catch (Throwable $e) { + qdb_discard($tmpDb, $lockFp); + error_log('updateConditionMessageLabel: ' . $e->getMessage()); + json_error('SERVER_ERROR', 'Server error', 500); + } + break; + } + if (($body['action'] ?? '') === 'updateCategoryLabel') { $categoryKey = trim((string)($body['categoryKey'] ?? '')); $germanLabel = (string)($body['germanLabel'] ?? ''); diff --git a/website/css/style.css b/website/css/style.css index d22f201..1ad861e 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -1726,3 +1726,61 @@ select:disabled { color: var(--text-secondary); cursor: not-allowed; } + +/* Questionnaire availability condition editor */ +.condition-editor { + padding: 16px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 8px; +} +.condition-mode-panel { + margin-top: 12px; +} +.condition-requires-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 200px; + overflow-y: auto; + padding: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; +} +.condition-requires-item { + margin: 0; +} +.condition-answer-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; +} +.condition-answer-grid select, +.condition-editor .form-group > select { + width: 100%; +} +.condition-anyof-rule { + margin-bottom: 12px; + padding: 12px; +} +.condition-json-fallback { + width: 100%; + font-family: ui-monospace, monospace; + font-size: 0.85rem; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); +} +.condition-locked-message .meta-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +@media (max-width: 720px) { + .condition-locked-message .meta-form { + grid-template-columns: 1fr; + } +} diff --git a/website/js/condition-editor.js b/website/js/condition-editor.js new file mode 100644 index 0000000..79562a1 --- /dev/null +++ b/website/js/condition-editor.js @@ -0,0 +1,662 @@ +/** + * Questionnaire availability condition UI (same JSON schema as the Android app). + */ + +const CONDITION_MODES = [ + { value: 'always', label: 'Always available' }, + { value: 'requires', label: 'Requires other questionnaires completed' }, + { value: 'requires_answer', label: 'Requires completions + answer check' }, + { value: 'any_of', label: 'Any of these rules (OR)' }, + { value: 'custom', label: 'Custom JSON (advanced)' }, +]; + +const CHOICE_LAYOUT_TYPES = new Set([ + 'radio_question', + 'multi_check_box_question', + 'glass_scale_question', +]); + +export function defaultConditionMessageKey(questionnaireId) { + const id = (questionnaireId || 'new').replace(/^questionnaire_/, ''); + const slug = id.replace(/[^a-zA-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '') || 'new'; + const normalized = /^[a-zA-Z]/.test(slug) ? slug : `k_${slug}`; + return `questionnaire_unlock_${normalized}`; +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} + +function parseQuestionConfig(q) { + try { + return JSON.parse(q?.configJson || '{}'); + } catch { + return {}; + } +} + +/** Stable id stored in conditionJson.questionId (matches app endsWith check). */ +export function conditionQuestionId(q) { + const cfg = parseQuestionConfig(q); + const key = (q?.questionKey || cfg.questionKey || '').trim(); + if (key) return key; + if (q?.localId) return q.localId; + const parts = (q?.questionID || '').split('__'); + return parts[parts.length - 1] || ''; +} + +function conditionQuestionLabel(q) { + const id = conditionQuestionId(q); + const text = (q?.defaultText || '').trim(); + if (!text) return id; + const short = text.length > 48 ? `${text.slice(0, 45)}…` : text; + return `${id} — ${short}`; +} + +function questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId) { + if (!questionnaireId) return []; + return questionsByQuestionnaire?.[questionnaireId] || []; +} + +function findQuestion(questionsByQuestionnaire, questionnaireId, questionId) { + if (!questionnaireId || !questionId) return null; + return questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId) + .find(q => { + const cid = conditionQuestionId(q); + const local = q.localId || (q.questionID || '').split('__').pop(); + return questionId === cid || questionId === local || questionId === q.questionID; + }) || null; +} + +function questionMatchesSelection(q, selectedQuestionId) { + if (!selectedQuestionId) return false; + const cid = conditionQuestionId(q); + const local = q.localId || (q.questionID || '').split('__').pop(); + return selectedQuestionId === cid + || selectedQuestionId === local + || selectedQuestionId === q.questionID; +} + +function asRequiresList(obj) { + if (!obj) return []; + if (Array.isArray(obj.requiresCompleted)) { + return obj.requiresCompleted.filter(Boolean).map(String); + } + if (typeof obj.requiresCompleted === 'string' && obj.requiresCompleted) { + return [obj.requiresCompleted]; + } + return []; +} + +function hasQuestionCheck(obj) { + return Boolean( + obj?.questionnaire?.trim() && + obj?.questionId?.trim() && + obj?.operator?.trim() && + obj?.value != null && String(obj.value).trim() !== '' + ); +} + +function filterRequires(requires, selfId) { + if (!requires?.length) return []; + if (!selfId) return [...requires]; + return requires.filter(id => id !== selfId); +} + +/** Answer checks cannot reference the questionnaire being edited. */ +function sanitizeAnswerRule(rule, selfId) { + if (!selfId || rule.questionnaire !== selfId) { + return { ...rule, requires: filterRequires(rule.requires, selfId) }; + } + return { + ...rule, + requires: filterRequires(rule.requires, selfId), + questionnaire: '', + questionId: '', + value: '', + }; +} + +function parseRule(obj, selfId = '') { + if (!obj || typeof obj !== 'object') { + return { type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '' }; + } + if (obj.alwaysAvailable) { + return { type: 'always' }; + } + const rule = { + type: hasQuestionCheck(obj) ? 'requires_answer' : 'requires', + requires: asRequiresList(obj), + questionnaire: obj.questionnaire || '', + questionId: obj.questionId || '', + operator: obj.operator === '!=' ? '!=' : '==', + value: obj.value != null ? String(obj.value) : '', + }; + return sanitizeAnswerRule(rule, selfId); +} + +export function parseConditionForm(conditionJson, questionnaireId) { + const raw = (conditionJson || '{}').trim() || '{}'; + let obj; + try { + obj = JSON.parse(raw); + } catch { + return { + mode: 'custom', + customJson: raw, + messageKey: defaultConditionMessageKey(questionnaireId), + germanLabel: '', + }; + } + + const messageKey = (obj.messageKey || '').trim() || defaultConditionMessageKey(questionnaireId); + + if (obj.alwaysAvailable === true) { + return { mode: 'always', messageKey: '', germanLabel: '', customJson: '' }; + } + if (Array.isArray(obj.anyOf) && obj.anyOf.length) { + return { + mode: 'any_of', + rules: obj.anyOf.map(r => parseRule(r, questionnaireId)).filter(r => r.type !== 'always'), + messageKey, + germanLabel: '', + customJson: '', + }; + } + + const rule = parseRule(obj, questionnaireId); + return { + mode: rule.type === 'always' ? 'always' : rule.type, + requires: rule.requires, + questionnaire: rule.questionnaire, + questionId: rule.questionId, + operator: rule.operator, + value: rule.value, + messageKey, + germanLabel: '', + customJson: '', + }; +} + +function buildRule(rule) { + if (rule.type === 'always') { + return { alwaysAvailable: true }; + } + const out = {}; + if (rule.requires?.length) { + out.requiresCompleted = [...rule.requires]; + } + if (rule.type === 'requires_answer') { + out.questionnaire = (rule.questionnaire || '').trim(); + out.questionId = (rule.questionId || '').trim(); + out.operator = rule.operator === '!=' ? '!=' : '=='; + out.value = String(rule.value ?? '').trim(); + } + return out; +} + +function assertAnswerNotSelf(questionnaireId, selfId) { + if (selfId && questionnaireId === selfId) { + throw new Error('Answer check must use a different questionnaire (not this one)'); + } +} + +export function buildConditionJson(form, selfQuestionnaireId = '') { + if (form.mode === 'custom') { + const raw = (form.customJson || '{}').trim() || '{}'; + JSON.parse(raw); + return raw; + } + + let obj; + if (form.mode === 'always') { + obj = { alwaysAvailable: true }; + } else if (form.mode === 'any_of') { + const rules = (form.rules || []).map(buildRule).filter(r => Object.keys(r).length); + if (!rules.length) { + throw new Error('Add at least one rule for "Any of these rules"'); + } + obj = { anyOf: rules }; + } else { + obj = buildRule({ + type: form.mode, + requires: form.requires || [], + questionnaire: form.questionnaire, + questionId: form.questionId, + operator: form.operator, + value: form.value, + }); + if (form.mode === 'requires' && !obj.requiresCompleted?.length) { + throw new Error('Select at least one required questionnaire'); + } + if (form.mode === 'requires_answer') { + if (!obj.questionnaire) { + throw new Error('Select the questionnaire for the answer check'); + } + if (!obj.questionId) { + throw new Error('Select the question for the answer check'); + } + if (!obj.value) { + throw new Error('Enter or select the expected answer value'); + } + } + } + + const messageKey = (form.messageKey || '').trim(); + if (form.mode !== 'always' && messageKey) { + obj.messageKey = messageKey; + } + return JSON.stringify(obj); +} + +function requiresCheckboxes(id, selected, allQuestionnaires, selfId, editable) { + const list = (allQuestionnaires || []).filter(q => q.questionnaireID !== selfId); + if (!list.length) { + return '

No other questionnaires to require.

'; + } + return `
+ ${list.map(q => { + const checked = selected.includes(q.questionnaireID) ? ' checked' : ''; + return ``; + }).join('')} +
`; +} + +function questionnaireSelectHTML(prefix, selectedId, allQuestionnaires, selfId, editable) { + const dis = editable ? '' : ' disabled'; + const list = (allQuestionnaires || []).filter( + q => q.questionnaireID && q.questionnaireID !== selfId + ); + const options = [''] + .concat(list.map(q => { + const id = q.questionnaireID; + const sel = selectedId === id ? ' selected' : ''; + const label = q.name ? `${q.name}` : id; + const suffix = q.name && q.name !== id ? ` (${id})` : ''; + return ``; + })); + return ``; +} + +function questionSelectHTML(prefix, questionnaireId, selectedQuestionId, questionsByQuestionnaire, editable) { + const dis = editable ? '' : ' disabled'; + const questions = questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId); + if (!questionnaireId) { + return ``; + } + const options = ['']; + for (const q of questions) { + const val = conditionQuestionId(q); + const sel = questionMatchesSelection(q, selectedQuestionId) ? ' selected' : ''; + options.push(``); + } + const known = questions.some(q => questionMatchesSelection(q, selectedQuestionId)); + if (selectedQuestionId && !known) { + options.push( + `` + ); + } + if (!questions.length) { + options.push(''); + } + return ``; +} + +function answerValueFieldHTML(prefix, rule, questionsByQuestionnaire, editable) { + const dis = editable ? '' : ' disabled'; + const q = findQuestion(questionsByQuestionnaire, rule.questionnaire, rule.questionId); + const options = q?.answerOptions || []; + const hasChoices = q && CHOICE_LAYOUT_TYPES.has(q.type) && options.length > 0; + + if (hasChoices) { + const opts = ['']; + for (const opt of options) { + const val = (opt.optionKey || opt.defaultText || '').trim(); + if (!val) continue; + const label = opt.labelGerman || opt.defaultText || val; + const sel = rule.value === val ? ' selected' : ''; + opts.push(``); + } + if (rule.value && !options.some(o => (o.optionKey || o.defaultText || '').trim() === rule.value)) { + opts.push(``); + } + return ``; + } + + return ``; +} + +function answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable) { + const dis = editable ? '' : ' disabled'; + return ` +
+
+ + ${questionnaireSelectHTML(prefix, rule.questionnaire, allQuestionnaires, selfId, editable)} +

Must be a different questionnaire than this one.

+
+
+ + ${questionSelectHTML(prefix, rule.questionnaire, rule.questionId, questionsByQuestionnaire, editable)} +
+
+ + +
+
+ + ${answerValueFieldHTML(prefix, rule, questionsByQuestionnaire, editable)} +
+
`; +} + +function singleRuleHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable) { + const showAnswer = rule.type === 'requires_answer'; + return ` +
+ + ${requiresCheckboxes(`${prefix}Requires`, rule.requires || [], allQuestionnaires, selfId, editable)} + ${showAnswer ? ` + + ${answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable)} + ` : ''} +
`; +} + +export function renderConditionEditorHTML(form, { + questionnaireId, + allQuestionnaires, + questionsByQuestionnaire, + germanLabel, + editable, +}) { + const dis = editable ? '' : ' disabled'; + const modeOptions = CONDITION_MODES.map(m => + `` + ).join(''); + + const rules = form.rules?.length ? form.rules : [{ + type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '', + }]; + + return ` +
+

Availability

+
+ + +
+ +
+ ${singleRuleHTML('cond', { type: 'requires', requires: form.requires || [] }, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)} +
+ +
+ ${singleRuleHTML('cond', { + type: 'requires_answer', + requires: form.requires || [], + questionnaire: form.questionnaire || '', + questionId: form.questionId || '', + operator: form.operator || '==', + value: form.value || '', + }, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)} +
+ +
+

Unlock if any one rule matches.

+
+ ${rules.map((rule, i) => ` +
+
+ Rule ${i + 1} + ${editable && rules.length > 1 ? `` : ''} +
+
+ + +
+ ${singleRuleHTML(`anyof${i}`, rule, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)} +
+ `).join('')} +
+ ${editable ? '' : ''} +
+ +
+ +
+ +
+

Shown on the app when locked. Also listed under Translations → App strings.

+
+
+ + +
+
+ + +
+
+
+
`; +} + +function readRequires(containerId) { + const root = document.getElementById(containerId); + if (!root) return []; + return [...root.querySelectorAll('input[type=checkbox]:checked')].map(cb => cb.value); +} + +function readAnswerPrefix(prefix) { + return { + questionnaire: document.getElementById(`${prefix}Questionnaire`)?.value?.trim() || '', + questionId: document.getElementById(`${prefix}QuestionId`)?.value?.trim() || '', + operator: document.getElementById(`${prefix}Operator`)?.value || '==', + value: document.getElementById(`${prefix}Value`)?.value?.trim() ?? '', + }; +} + +export function readConditionFormFromDOM() { + const mode = document.getElementById('conditionMode')?.value || 'always'; + const form = { mode, messageKey: '', germanLabel: '', customJson: '' }; + + if (mode === 'custom') { + form.customJson = document.getElementById('conditionCustomJson')?.value || '{}'; + try { + const obj = JSON.parse(form.customJson); + form.messageKey = (obj.messageKey || '').trim(); + } catch { /* validated on save */ } + form.germanLabel = document.getElementById('conditionGermanLabel')?.value?.trim() || ''; + return form; + } + + if (mode === 'always') { + return form; + } + + form.messageKey = document.getElementById('conditionMessageKey')?.value?.trim() || ''; + form.germanLabel = document.getElementById('conditionGermanLabel')?.value?.trim() || ''; + + if (mode === 'requires') { + form.requires = readRequires('condRequires'); + return form; + } + + if (mode === 'requires_answer') { + form.requires = readRequires('condRequires'); + Object.assign(form, readAnswerPrefix('cond')); + return form; + } + + if (mode === 'any_of') { + form.rules = []; + document.querySelectorAll('#conditionAnyOfRules .condition-anyof-rule').forEach((el, i) => { + const type = el.querySelector('.anyof-rule-type')?.value || 'requires'; + const requires = readRequires(`anyof${i}Requires`); + const rule = { type, requires, ...readAnswerPrefix(`anyof${i}`) }; + form.rules.push(rule); + }); + } + + return form; +} + +function wireConditionSelects(container, { questionnaireId, onChange }) { + container.querySelectorAll('.condition-qn-select').forEach(sel => { + sel.addEventListener('change', () => { + const prefix = sel.id.replace(/Questionnaire$/, ''); + const form = readConditionFormFromDOM(); + if (prefix === 'cond' && form.mode === 'requires_answer') { + const answer = readAnswerPrefix('cond'); + answer.questionnaire = sel.value; + if (answer.questionnaire !== form.questionnaire) { + answer.questionId = ''; + answer.value = ''; + } + form.questionnaire = answer.questionnaire; + form.questionId = answer.questionId; + form.value = answer.value; + } else if (form.rules) { + const idx = parseInt(prefix.replace(/^anyof/, ''), 10); + if (!Number.isNaN(idx) && form.rules[idx]) { + const prev = form.rules[idx].questionnaire; + form.rules[idx].questionnaire = sel.value; + if (sel.value !== prev) { + form.rules[idx].questionId = ''; + form.rules[idx].value = ''; + } + } + } + onChange(form); + }); + }); + + container.querySelectorAll('.condition-question-select').forEach(sel => { + sel.addEventListener('change', () => { + const prefix = sel.id.replace(/QuestionId$/, ''); + const form = readConditionFormFromDOM(); + const qId = sel.value; + if (prefix === 'cond' && form.mode === 'requires_answer') { + form.questionId = qId; + form.value = ''; + } else if (form.rules) { + const idx = parseInt(prefix.replace(/^anyof/, ''), 10); + if (!Number.isNaN(idx) && form.rules[idx]) { + form.rules[idx].questionId = qId; + form.rules[idx].value = ''; + } + } + onChange(form); + }); + }); +} + +/** + * Mount editor into container; returns { readForm, buildJson }. + */ +export function mountConditionEditor(container, { + questionnaireId, + allQuestionnaires, + questionsByQuestionnaire = {}, + conditionJson, + germanLabel, + editable, +}) { + let form = parseConditionForm(conditionJson, questionnaireId); + let label = germanLabel || ''; + + const render = () => { + container.innerHTML = renderConditionEditorHTML( + { ...form, germanLabel: label }, + { + questionnaireId, + allQuestionnaires, + questionsByQuestionnaire, + germanLabel: label, + editable, + } + ); + wire(); + }; + + const wire = () => { + const modeEl = document.getElementById('conditionMode'); + modeEl?.addEventListener('change', () => { + form = readConditionFormFromDOM(); + form.mode = modeEl.value; + if (form.mode === 'always') { + form.messageKey = ''; + } else if (!form.messageKey) { + form.messageKey = defaultConditionMessageKey(questionnaireId); + } + render(); + }); + + document.getElementById('addAnyOfRule')?.addEventListener('click', () => { + form = readConditionFormFromDOM(); + form.mode = 'any_of'; + form.rules = form.rules || []; + form.rules.push({ + type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '', + }); + label = document.getElementById('conditionGermanLabel')?.value || label; + render(); + }); + + container.querySelectorAll('.remove-anyof-rule').forEach(btn => { + btn.addEventListener('click', () => { + form = readConditionFormFromDOM(); + label = document.getElementById('conditionGermanLabel')?.value || label; + const idx = parseInt(btn.closest('.condition-anyof-rule')?.dataset.ruleIndex || '-1', 10); + form.rules.splice(idx, 1); + render(); + }); + }); + + container.querySelectorAll('.anyof-rule-type').forEach(sel => { + sel.addEventListener('change', () => { + form = readConditionFormFromDOM(); + label = document.getElementById('conditionGermanLabel')?.value || label; + const idx = parseInt(sel.dataset.ruleIndex || '0', 10); + if (form.rules?.[idx]) { + form.rules[idx].type = sel.value; + } + render(); + }); + }); + + wireConditionSelects(container, { + questionnaireId, + onChange: (updated) => { + form = updated; + label = document.getElementById('conditionGermanLabel')?.value || label; + render(); + }, + }); + }; + + render(); + + return { + readForm: () => { + form = readConditionFormFromDOM(); + label = document.getElementById('conditionGermanLabel')?.value?.trim() || label; + return { ...form, germanLabel: label }; + }, + buildJson: () => buildConditionJson(readConditionFormFromDOM(), questionnaireId), + }; +} diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index da8e2cd..eab5c88 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -12,6 +12,10 @@ import { renderGroupedTranslationsHTML, escHtml, } from '../translations-helpers.js'; +import { + mountConditionEditor, + parseConditionForm, +} from '../condition-editor.js'; const LAYOUT_TYPES = [ { value: 'radio_question', label: 'Radio (Single Choice)' }, @@ -33,6 +37,9 @@ let questionnaire = null; let questions = []; let isNew = false; let activeTab = 'questions'; +let allQuestionnaires = []; +let questionsByQuestionnaire = {}; +let conditionEditorApi = null; // ── Entry point ────────────────────────────────────────────────────────── @@ -52,6 +59,13 @@ export async function editorPage(params) {
`; + try { + const qnList = await apiGet('questionnaires.php'); + allQuestionnaires = qnList.questionnaires || []; + } catch { + allQuestionnaires = []; + } + if (isNew) { questionnaire = { questionnaireID: null, name: '', version: '1.0', state: 'draft', orderIndex: 0, showPoints: 0, conditionJson: '{}' }; @@ -59,11 +73,8 @@ export async function editorPage(params) { renderEditor(); } else { try { - const [qnData, quData] = await Promise.all([ - apiGet('questionnaires.php'), - apiGet(`questions.php?questionnaireID=${encodeURIComponent(params.id)}`) - ]); - questionnaire = (qnData.questionnaires || []).find(q => q.questionnaireID === params.id); + const quData = await apiGet(`questions.php?questionnaireID=${encodeURIComponent(params.id)}`); + questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id); if (!questionnaire) { showToast('Questionnaire not found', 'error'); navigate('#/'); @@ -547,16 +558,7 @@ function renderEditor() { - ${editable ? ` -
- - Availability Condition (JSON) - - -
- ` : ''} +
${editable ? `` : ''} @@ -586,6 +588,7 @@ function renderEditor() { if (editable) { document.getElementById('saveMetaBtn').addEventListener('click', saveMeta); } + initConditionEditor(condJson); if (!isNew) { initTabs(); @@ -613,14 +616,60 @@ function initTabs() { }); } -function formatJson(str) { +async function fetchAppStringGerman(messageKey) { + if (!messageKey) return ''; try { - return JSON.stringify(JSON.parse(str), null, 2); + const data = await apiGet( + `translations.php?type=app_string&id=${encodeURIComponent(messageKey)}` + ); + const row = (data.translations || []).find( + tr => tr.languageCode === SOURCE_LANG + ); + return row?.text?.trim() || ''; } catch { - return str; + return ''; } } +async function loadQuestionsByQuestionnaire() { + const ids = allQuestionnaires.map(q => q.questionnaireID).filter(Boolean); + if (!ids.length) { + questionsByQuestionnaire = {}; + return; + } + const pairs = await Promise.all(ids.map(async id => { + try { + const data = await apiGet( + `questions.php?questionnaireID=${encodeURIComponent(id)}` + ); + return [id, data.questions || []]; + } catch { + return [id, []]; + } + })); + questionsByQuestionnaire = Object.fromEntries(pairs); +} + +async function initConditionEditor(condJson) { + const mount = document.getElementById('conditionEditorMount'); + if (!mount) return; + mount.innerHTML = '
'; + await loadQuestionsByQuestionnaire(); + const qid = questionnaire?.questionnaireID || 'new'; + const parsed = parseConditionForm(condJson, qid); + const germanLabel = parsed.messageKey + ? await fetchAppStringGerman(parsed.messageKey) + : ''; + conditionEditorApi = mountConditionEditor(mount, { + questionnaireId: qid, + allQuestionnaires, + questionsByQuestionnaire, + conditionJson: condJson, + germanLabel, + editable: canEdit(), + }); +} + // ── Save questionnaire meta ────────────────────────────────────────────── async function saveMeta() { @@ -631,13 +680,13 @@ async function saveMeta() { const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0; const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim(); let conditionJson = '{}'; - const condEl = document.getElementById('metaCondition'); - if (condEl) { + let conditionForm = null; + if (conditionEditorApi) { try { - JSON.parse(condEl.value); - conditionJson = condEl.value.trim(); - } catch { - showToast('Condition JSON is invalid', 'error'); + conditionJson = conditionEditorApi.buildJson(); + conditionForm = conditionEditorApi.readForm(); + } catch (e) { + showToast(e.message || 'Invalid availability settings', 'error'); return; } } @@ -649,6 +698,7 @@ async function saveMeta() { if (data.success) { questionnaire = data.questionnaire; isNew = false; + await saveConditionMessageLabel(conditionForm); showToast('Questionnaire created', 'success'); navigate(`#/questionnaire/${questionnaire.questionnaireID}`); } @@ -660,6 +710,7 @@ async function saveMeta() { if (data.success) { questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson, categoryKey }; document.getElementById('editorTitle').textContent = name; + await saveConditionMessageLabel(conditionForm); showToast('Saved', 'success'); } } @@ -668,6 +719,22 @@ async function saveMeta() { } } +async function saveConditionMessageLabel(form) { + if (!form || form.mode === 'always') return; + const messageKey = (form.messageKey || '').trim(); + const germanLabel = (form.germanLabel || '').trim(); + if (!messageKey || !germanLabel) return; + try { + await apiPost('questionnaires.php', { + action: 'updateConditionMessageLabel', + messageKey, + germanLabel, + }); + } catch (e) { + showToast(`Saved questionnaire; message label failed: ${e.message}`, 'error'); + } +} + // ── Add question form ──────────────────────────────────────────────────── function showAddQuestionForm() {