improved condition json to have UI integration
This commit is contained in:
101
common.php
101
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);
|
json_error('INVALID_FIELD', 'German label is required', 400);
|
||||||
}
|
}
|
||||||
$stringKey = qdb_questionnaire_group_string_key($categoryKey);
|
$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);
|
qdb_update_questionnaire_group_german_default($stringKey, $text);
|
||||||
return $stringKey;
|
return $stringKey;
|
||||||
}
|
}
|
||||||
@ -569,12 +569,111 @@ function qdb_app_ui_string_entries(): array {
|
|||||||
* Static catalog plus questionnaire_group_* keys for categoryKey values in use.
|
* Static catalog plus questionnaire_group_* keys for categoryKey values in use.
|
||||||
* @return list<array{key: string, type: string, entityId: string, sortOrder: int}>
|
* @return list<array{key: string, type: string, entityId: string, sortOrder: int}>
|
||||||
*/
|
*/
|
||||||
|
/** 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<string, true> */
|
||||||
|
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<array{key: string, displayKey: string, type: string, entityId: string, sortOrder: int, germanDefault: string}>
|
||||||
|
*/
|
||||||
|
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 {
|
function qdb_app_ui_string_entries_merged(PDO $pdo): array {
|
||||||
$merged = qdb_app_ui_string_entries();
|
$merged = qdb_app_ui_string_entries();
|
||||||
|
$existing = [];
|
||||||
|
foreach ($merged as $e) {
|
||||||
|
$existing[$e['key']] = true;
|
||||||
|
}
|
||||||
$order = count($merged);
|
$order = count($merged);
|
||||||
foreach (qdb_category_group_string_entries($pdo) as $e) {
|
foreach (qdb_category_group_string_entries($pdo) as $e) {
|
||||||
|
if (!isset($existing[$e['key']])) {
|
||||||
$e['sortOrder'] = $order++;
|
$e['sortOrder'] = $order++;
|
||||||
$merged[] = $e;
|
$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;
|
return $merged;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,6 +49,25 @@ case 'POST':
|
|||||||
require_role(['admin', 'supervisor'], $tokenRec);
|
require_role(['admin', 'supervisor'], $tokenRec);
|
||||||
$body = read_json_body();
|
$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') {
|
if (($body['action'] ?? '') === 'updateCategoryLabel') {
|
||||||
$categoryKey = trim((string)($body['categoryKey'] ?? ''));
|
$categoryKey = trim((string)($body['categoryKey'] ?? ''));
|
||||||
$germanLabel = (string)($body['germanLabel'] ?? '');
|
$germanLabel = (string)($body['germanLabel'] ?? '');
|
||||||
|
|||||||
@ -1726,3 +1726,61 @@ select:disabled {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: not-allowed;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
662
website/js/condition-editor.js
Normal file
662
website/js/condition-editor.js
Normal file
@ -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 '<p class="data-toolbar-hint">No other questionnaires to require.</p>';
|
||||||
|
}
|
||||||
|
return `<div class="condition-requires-list" id="${id}">
|
||||||
|
${list.map(q => {
|
||||||
|
const checked = selected.includes(q.questionnaireID) ? ' checked' : '';
|
||||||
|
return `<label class="checkbox-label condition-requires-item">
|
||||||
|
<input type="checkbox" value="${esc(q.questionnaireID)}"${checked}${editable ? '' : ' disabled'}>
|
||||||
|
${esc(q.name || q.questionnaireID)}
|
||||||
|
</label>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionnaireSelectHTML(prefix, selectedId, allQuestionnaires, selfId, editable) {
|
||||||
|
const dis = editable ? '' : ' disabled';
|
||||||
|
const list = (allQuestionnaires || []).filter(
|
||||||
|
q => q.questionnaireID && q.questionnaireID !== selfId
|
||||||
|
);
|
||||||
|
const options = ['<option value="">— Select questionnaire —</option>']
|
||||||
|
.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 `<option value="${esc(id)}"${sel}>${esc(label)}${esc(suffix)}</option>`;
|
||||||
|
}));
|
||||||
|
return `<select id="${prefix}Questionnaire" class="condition-qn-select"${dis}>${options.join('')}</select>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionSelectHTML(prefix, questionnaireId, selectedQuestionId, questionsByQuestionnaire, editable) {
|
||||||
|
const dis = editable ? '' : ' disabled';
|
||||||
|
const questions = questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId);
|
||||||
|
if (!questionnaireId) {
|
||||||
|
return `<select id="${prefix}QuestionId" class="condition-question-select" disabled${dis}>
|
||||||
|
<option value="">Select a questionnaire first</option>
|
||||||
|
</select>`;
|
||||||
|
}
|
||||||
|
const options = ['<option value="">— Select question —</option>'];
|
||||||
|
for (const q of questions) {
|
||||||
|
const val = conditionQuestionId(q);
|
||||||
|
const sel = questionMatchesSelection(q, selectedQuestionId) ? ' selected' : '';
|
||||||
|
options.push(`<option value="${esc(val)}"${sel}>${esc(conditionQuestionLabel(q))}</option>`);
|
||||||
|
}
|
||||||
|
const known = questions.some(q => questionMatchesSelection(q, selectedQuestionId));
|
||||||
|
if (selectedQuestionId && !known) {
|
||||||
|
options.push(
|
||||||
|
`<option value="${esc(selectedQuestionId)}" selected>${esc(selectedQuestionId)} (saved)</option>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!questions.length) {
|
||||||
|
options.push('<option value="" disabled>No questions in this questionnaire</option>');
|
||||||
|
}
|
||||||
|
return `<select id="${prefix}QuestionId" class="condition-question-select"${dis}>${options.join('')}</select>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ['<option value="">— Select answer —</option>'];
|
||||||
|
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(`<option value="${esc(val)}"${sel}>${esc(val)} — ${esc(label)}</option>`);
|
||||||
|
}
|
||||||
|
if (rule.value && !options.some(o => (o.optionKey || o.defaultText || '').trim() === rule.value)) {
|
||||||
|
opts.push(`<option value="${esc(rule.value)}" selected>${esc(rule.value)} (saved)</option>`);
|
||||||
|
}
|
||||||
|
return `<select id="${prefix}Value" class="condition-value-select"${dis}>${opts.join('')}</select>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<input type="text" id="${prefix}Value" class="condition-value-input" value="${esc(rule.value)}" placeholder="e.g. consent_signed"${dis}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable) {
|
||||||
|
const dis = editable ? '' : ' disabled';
|
||||||
|
return `
|
||||||
|
<div class="condition-answer-grid meta-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Questionnaire (answer check)</label>
|
||||||
|
${questionnaireSelectHTML(prefix, rule.questionnaire, allQuestionnaires, selfId, editable)}
|
||||||
|
<p class="field-hint">Must be a different questionnaire than this one.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Question</label>
|
||||||
|
${questionSelectHTML(prefix, rule.questionnaire, rule.questionId, questionsByQuestionnaire, editable)}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Operator</label>
|
||||||
|
<select id="${prefix}Operator"${dis}>
|
||||||
|
<option value="==" ${rule.operator !== '!=' ? 'selected' : ''}>equals (==)</option>
|
||||||
|
<option value="!=" ${rule.operator === '!=' ? 'selected' : ''}>not equals (!=)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Expected answer</label>
|
||||||
|
${answerValueFieldHTML(prefix, rule, questionsByQuestionnaire, editable)}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleRuleHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable) {
|
||||||
|
const showAnswer = rule.type === 'requires_answer';
|
||||||
|
return `
|
||||||
|
<div class="condition-rule-block">
|
||||||
|
<label class="form-label">Must be completed</label>
|
||||||
|
${requiresCheckboxes(`${prefix}Requires`, rule.requires || [], allQuestionnaires, selfId, editable)}
|
||||||
|
${showAnswer ? `
|
||||||
|
<label class="form-label" style="margin-top:12px">And answer must match</label>
|
||||||
|
${answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable)}
|
||||||
|
` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderConditionEditorHTML(form, {
|
||||||
|
questionnaireId,
|
||||||
|
allQuestionnaires,
|
||||||
|
questionsByQuestionnaire,
|
||||||
|
germanLabel,
|
||||||
|
editable,
|
||||||
|
}) {
|
||||||
|
const dis = editable ? '' : ' disabled';
|
||||||
|
const modeOptions = CONDITION_MODES.map(m =>
|
||||||
|
`<option value="${m.value}" ${form.mode === m.value ? 'selected' : ''}>${m.label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const rules = form.rules?.length ? form.rules : [{
|
||||||
|
type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '',
|
||||||
|
}];
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="condition-editor" id="conditionEditor">
|
||||||
|
<h4 style="margin:0 0 12px;font-size:.95rem">Availability</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>When can coaches open this questionnaire?</label>
|
||||||
|
<select id="conditionMode"${dis}>${modeOptions}</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="conditionPanelRequires" class="condition-mode-panel" style="display:${form.mode === 'requires' ? '' : 'none'}">
|
||||||
|
${singleRuleHTML('cond', { type: 'requires', requires: form.requires || [] }, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="conditionPanelRequiresAnswer" class="condition-mode-panel" style="display:${form.mode === 'requires_answer' ? '' : 'none'}">
|
||||||
|
${singleRuleHTML('cond', {
|
||||||
|
type: 'requires_answer',
|
||||||
|
requires: form.requires || [],
|
||||||
|
questionnaire: form.questionnaire || '',
|
||||||
|
questionId: form.questionId || '',
|
||||||
|
operator: form.operator || '==',
|
||||||
|
value: form.value || '',
|
||||||
|
}, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="conditionPanelAnyOf" class="condition-mode-panel" style="display:${form.mode === 'any_of' ? '' : 'none'}">
|
||||||
|
<p class="data-toolbar-hint" style="margin:0 0 10px">Unlock if any one rule matches.</p>
|
||||||
|
<div id="conditionAnyOfRules">
|
||||||
|
${rules.map((rule, i) => `
|
||||||
|
<div class="condition-anyof-rule card" data-rule-index="${i}">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||||
|
<strong>Rule ${i + 1}</strong>
|
||||||
|
${editable && rules.length > 1 ? `<button type="button" class="btn btn-sm remove-anyof-rule">Remove</button>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Rule type</label>
|
||||||
|
<select class="anyof-rule-type" data-rule-index="${i}"${dis}>
|
||||||
|
<option value="requires" ${rule.type === 'requires' ? 'selected' : ''}>Requires completions</option>
|
||||||
|
<option value="requires_answer" ${rule.type === 'requires_answer' ? 'selected' : ''}>Requires completions + answer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
${singleRuleHTML(`anyof${i}`, rule, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
${editable ? '<button type="button" class="btn btn-sm" id="addAnyOfRule" style="margin-top:8px">+ Add rule</button>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="conditionPanelCustom" class="condition-mode-panel" style="display:${form.mode === 'custom' ? '' : 'none'}">
|
||||||
|
<textarea id="conditionCustomJson" rows="8" class="condition-json-fallback"${dis}>${esc(form.customJson || '{}')}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="conditionLockedMessage" class="condition-locked-message" style="display:${form.mode === 'always' ? 'none' : ''};margin-top:16px;padding-top:16px;border-top:1px solid var(--border)">
|
||||||
|
<p class="data-toolbar-hint" style="margin:0 0 10px">Shown on the app when locked. Also listed under Translations → App strings.</p>
|
||||||
|
<div class="meta-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Message key</label>
|
||||||
|
<input type="text" id="conditionMessageKey" value="${esc(form.messageKey || '')}" placeholder="${esc(defaultConditionMessageKey(questionnaireId))}"${dis}>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>German label</label>
|
||||||
|
<input type="text" id="conditionGermanLabel" value="${esc(germanLabel || '')}" placeholder="e.g. Complete Demografie and RHS first"${dis}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -12,6 +12,10 @@ import {
|
|||||||
renderGroupedTranslationsHTML,
|
renderGroupedTranslationsHTML,
|
||||||
escHtml,
|
escHtml,
|
||||||
} from '../translations-helpers.js';
|
} from '../translations-helpers.js';
|
||||||
|
import {
|
||||||
|
mountConditionEditor,
|
||||||
|
parseConditionForm,
|
||||||
|
} from '../condition-editor.js';
|
||||||
|
|
||||||
const LAYOUT_TYPES = [
|
const LAYOUT_TYPES = [
|
||||||
{ value: 'radio_question', label: 'Radio (Single Choice)' },
|
{ value: 'radio_question', label: 'Radio (Single Choice)' },
|
||||||
@ -33,6 +37,9 @@ let questionnaire = null;
|
|||||||
let questions = [];
|
let questions = [];
|
||||||
let isNew = false;
|
let isNew = false;
|
||||||
let activeTab = 'questions';
|
let activeTab = 'questions';
|
||||||
|
let allQuestionnaires = [];
|
||||||
|
let questionsByQuestionnaire = {};
|
||||||
|
let conditionEditorApi = null;
|
||||||
|
|
||||||
// ── Entry point ──────────────────────────────────────────────────────────
|
// ── Entry point ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -52,6 +59,13 @@ export async function editorPage(params) {
|
|||||||
<div id="editorContent"><div class="spinner"></div></div>
|
<div id="editorContent"><div class="spinner"></div></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const qnList = await apiGet('questionnaires.php');
|
||||||
|
allQuestionnaires = qnList.questionnaires || [];
|
||||||
|
} catch {
|
||||||
|
allQuestionnaires = [];
|
||||||
|
}
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
questionnaire = { questionnaireID: null, name: '', version: '1.0', state: 'draft',
|
questionnaire = { questionnaireID: null, name: '', version: '1.0', state: 'draft',
|
||||||
orderIndex: 0, showPoints: 0, conditionJson: '{}' };
|
orderIndex: 0, showPoints: 0, conditionJson: '{}' };
|
||||||
@ -59,11 +73,8 @@ export async function editorPage(params) {
|
|||||||
renderEditor();
|
renderEditor();
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const [qnData, quData] = await Promise.all([
|
const quData = await apiGet(`questions.php?questionnaireID=${encodeURIComponent(params.id)}`);
|
||||||
apiGet('questionnaires.php'),
|
questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id);
|
||||||
apiGet(`questions.php?questionnaireID=${encodeURIComponent(params.id)}`)
|
|
||||||
]);
|
|
||||||
questionnaire = (qnData.questionnaires || []).find(q => q.questionnaireID === params.id);
|
|
||||||
if (!questionnaire) {
|
if (!questionnaire) {
|
||||||
showToast('Questionnaire not found', 'error');
|
showToast('Questionnaire not found', 'error');
|
||||||
navigate('#/');
|
navigate('#/');
|
||||||
@ -547,16 +558,7 @@ function renderEditor() {
|
|||||||
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
|
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${editable ? `
|
<div id="conditionEditorMount" style="margin:16px 0"></div>
|
||||||
<details class="condition-details" style="margin-bottom:16px">
|
|
||||||
<summary style="cursor:pointer;font-size:.85rem;font-weight:600;color:var(--text-secondary)">
|
|
||||||
Availability Condition (JSON)
|
|
||||||
</summary>
|
|
||||||
<textarea id="metaCondition" rows="6"
|
|
||||||
style="width:100%;margin-top:8px;font-family:monospace;font-size:.85rem;padding:8px 12px;border:1px solid var(--border);border-radius:6px"
|
|
||||||
>${formatJson(condJson)}</textarea>
|
|
||||||
</details>
|
|
||||||
` : ''}
|
|
||||||
${editable ? `<button class="btn btn-primary" id="saveMetaBtn">${isNew ? 'Create Questionnaire' : 'Save Changes'}</button>` : ''}
|
${editable ? `<button class="btn btn-primary" id="saveMetaBtn">${isNew ? 'Create Questionnaire' : 'Save Changes'}</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -586,6 +588,7 @@ function renderEditor() {
|
|||||||
if (editable) {
|
if (editable) {
|
||||||
document.getElementById('saveMetaBtn').addEventListener('click', saveMeta);
|
document.getElementById('saveMetaBtn').addEventListener('click', saveMeta);
|
||||||
}
|
}
|
||||||
|
initConditionEditor(condJson);
|
||||||
|
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
initTabs();
|
initTabs();
|
||||||
@ -613,14 +616,60 @@ function initTabs() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatJson(str) {
|
async function fetchAppStringGerman(messageKey) {
|
||||||
|
if (!messageKey) return '';
|
||||||
try {
|
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 {
|
} 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 = '<div class="spinner"></div>';
|
||||||
|
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 ──────────────────────────────────────────────
|
// ── Save questionnaire meta ──────────────────────────────────────────────
|
||||||
|
|
||||||
async function saveMeta() {
|
async function saveMeta() {
|
||||||
@ -631,13 +680,13 @@ async function saveMeta() {
|
|||||||
const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0;
|
const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0;
|
||||||
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
|
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
|
||||||
let conditionJson = '{}';
|
let conditionJson = '{}';
|
||||||
const condEl = document.getElementById('metaCondition');
|
let conditionForm = null;
|
||||||
if (condEl) {
|
if (conditionEditorApi) {
|
||||||
try {
|
try {
|
||||||
JSON.parse(condEl.value);
|
conditionJson = conditionEditorApi.buildJson();
|
||||||
conditionJson = condEl.value.trim();
|
conditionForm = conditionEditorApi.readForm();
|
||||||
} catch {
|
} catch (e) {
|
||||||
showToast('Condition JSON is invalid', 'error');
|
showToast(e.message || 'Invalid availability settings', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -649,6 +698,7 @@ async function saveMeta() {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
questionnaire = data.questionnaire;
|
questionnaire = data.questionnaire;
|
||||||
isNew = false;
|
isNew = false;
|
||||||
|
await saveConditionMessageLabel(conditionForm);
|
||||||
showToast('Questionnaire created', 'success');
|
showToast('Questionnaire created', 'success');
|
||||||
navigate(`#/questionnaire/${questionnaire.questionnaireID}`);
|
navigate(`#/questionnaire/${questionnaire.questionnaireID}`);
|
||||||
}
|
}
|
||||||
@ -660,6 +710,7 @@ async function saveMeta() {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson, categoryKey };
|
questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson, categoryKey };
|
||||||
document.getElementById('editorTitle').textContent = name;
|
document.getElementById('editorTitle').textContent = name;
|
||||||
|
await saveConditionMessageLabel(conditionForm);
|
||||||
showToast('Saved', 'success');
|
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 ────────────────────────────────────────────────────
|
// ── Add question form ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function showAddQuestionForm() {
|
function showAddQuestionForm() {
|
||||||
|
|||||||
Reference in New Issue
Block a user