improved condition json to have UI integration

This commit is contained in:
2026-05-28 12:26:45 +02:00
parent 9cf67e765f
commit 9a6fa22d84
5 changed files with 932 additions and 27 deletions

View 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),
};
}