expanded questionnaire dependency matrix
Some checks are pending
PHPUnit / test (push) Waiting to run

This commit is contained in:
2026-07-17 17:57:36 +02:00
parent 764b4c5545
commit 90bfd0bbd3

View File

@ -7,7 +7,7 @@ const CONDITION_MODES = [
{ value: 'requires', label: 'Requires other questionnaires completed' },
{ value: 'requires_answer', label: 'Requires completions + answer check' },
{ value: 'requires_coach_band', label: 'Requires coach category (band)' },
{ value: 'any_of', label: 'Any of these rules (OR)' },
{ value: 'any_of', label: 'Any of these rules (OR) — mix prerequisites & bands' },
{ value: 'custom', label: 'Custom JSON (advanced)' },
];
@ -126,13 +126,45 @@ function sanitizeAnswerRule(rule, selfId) {
};
}
function emptyCoachBandFields() {
return { coachBandProfileID: '', coachBandAllowed: [] };
}
function parseCoachBandFields(obj) {
const band = obj?.requiresCoachBand;
if (!band || typeof band !== 'object') return emptyCoachBandFields();
const allowed = Array.isArray(band.allowed)
? band.allowed.filter(Boolean).map(v => String(v).toLowerCase())
: [];
return {
coachBandProfileID: (band.profileID || '').trim(),
coachBandAllowed: allowed,
};
}
function hasCoachBandFields(rule) {
return Boolean(
(rule?.coachBandProfileID || '').trim() &&
(rule?.coachBandAllowed || []).filter(Boolean).length
);
}
function parseRule(obj, selfId = '') {
if (!obj || typeof obj !== 'object') {
return { type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '' };
return {
type: 'requires',
requires: [],
questionnaire: '',
questionId: '',
operator: '==',
value: '',
...emptyCoachBandFields(),
};
}
if (obj.alwaysAvailable) {
return { type: 'always' };
return { type: 'always', ...emptyCoachBandFields() };
}
const coachBand = parseCoachBandFields(obj);
const rule = {
type: hasQuestionCheck(obj) ? 'requires_answer' : 'requires',
requires: asRequiresList(obj),
@ -140,6 +172,7 @@ function parseRule(obj, selfId = '') {
questionId: obj.questionId || '',
operator: obj.operator === '!=' ? '!=' : '==',
value: obj.value != null ? String(obj.value) : '',
...coachBand,
};
return sanitizeAnswerRule(rule, selfId);
}
@ -163,16 +196,13 @@ export function parseConditionForm(conditionJson, questionnaireId) {
if (obj.alwaysAvailable === true) {
return { mode: 'always', messageKey: '', germanLabel: '', customJson: '' };
}
if (obj.requiresCoachBand && typeof obj.requiresCoachBand === 'object') {
const band = obj.requiresCoachBand;
const allowed = Array.isArray(band.allowed)
? band.allowed.filter(Boolean).map(v => String(v).toLowerCase())
: [];
if (obj.requiresCoachBand && typeof obj.requiresCoachBand === 'object' && !Array.isArray(obj.anyOf)) {
const band = parseCoachBandFields(obj);
return {
mode: 'requires_coach_band',
requires: asRequiresList(obj),
coachBandProfileID: (band.profileID || '').trim(),
coachBandAllowed: allowed,
coachBandProfileID: band.coachBandProfileID,
coachBandAllowed: band.coachBandAllowed,
messageKey,
germanLabel: '',
customJson: '',
@ -216,6 +246,17 @@ function buildRule(rule) {
out.operator = rule.operator === '!=' ? '!=' : '==';
out.value = String(rule.value ?? '').trim();
}
const profileID = (rule.coachBandProfileID || '').trim();
const allowed = (rule.coachBandAllowed || []).filter(Boolean);
if (profileID || allowed.length) {
if (!profileID) {
throw new Error('Select a scoring profile for the coach category rule (or clear the band checkboxes)');
}
if (!allowed.length) {
throw new Error('Select at least one allowed coach category (band), or clear the scoring profile');
}
out.requiresCoachBand = { profileID, allowed: [...allowed] };
}
return out;
}
@ -240,6 +281,22 @@ export function buildConditionJson(form, selfQuestionnaireId = '') {
if (!rules.length) {
throw new Error('Add at least one rule for "Any of these rules"');
}
for (const rule of form.rules || []) {
if (rule.type === 'requires' && !rule.requires?.length && !hasCoachBandFields(rule)) {
throw new Error('Each OR rule needs prerequisites and/or a coach category');
}
if (rule.type === 'requires_answer') {
if (!(rule.questionnaire || '').trim()) {
throw new Error('Select the questionnaire for each answer-check rule');
}
if (!(rule.questionId || '').trim()) {
throw new Error('Select the question for each answer-check rule');
}
if (!(rule.value ?? '').toString().trim()) {
throw new Error('Enter the expected answer for each answer-check rule');
}
}
}
obj = { anyOf: rules };
} else if (form.mode === 'requires_coach_band') {
obj = {};
@ -287,10 +344,10 @@ export function buildConditionJson(form, selfQuestionnaireId = '') {
return JSON.stringify(obj);
}
function bandCheckboxesHTML(selected, editable) {
function bandCheckboxesHTML(selected, editable, id = 'condCoachBands') {
const dis = editable ? '' : ' disabled';
const sel = new Set((selected || []).map(b => String(b).toLowerCase()));
return `<div class="scoring-band-picker" id="condCoachBands">
return `<div class="scoring-band-picker" id="${esc(id)}">
${SCORING_BANDS.map(b => {
const checked = sel.has(b.value) ? ' checked' : '';
return `<label class="scoring-band-picker-item">
@ -301,7 +358,7 @@ function bandCheckboxesHTML(selected, editable) {
</div>`;
}
function scoringProfileSelectHTML(selectedId, scoringProfiles, editable) {
function scoringProfileSelectHTML(selectedId, scoringProfiles, editable, id = 'condCoachBandProfile') {
const dis = editable ? '' : ' disabled';
const list = (scoringProfiles || []).filter(p => p.profileID);
if (!list.length) {
@ -309,12 +366,37 @@ function scoringProfileSelectHTML(selectedId, scoringProfiles, editable) {
}
const options = ['<option value="">— Select scoring profile —</option>']
.concat(list.map(p => {
const id = p.profileID;
const sel = selectedId === id ? ' selected' : '';
const pid = p.profileID;
const sel = selectedId === pid ? ' selected' : '';
const label = (p.name || '').trim() || 'Unnamed profile';
return `<option value="${esc(id)}"${sel}>${esc(label)}</option>`;
return `<option value="${esc(pid)}"${sel}>${esc(label)}</option>`;
}));
return `<select id="condCoachBandProfile" class="condition-qn-select scoring-profile-select"${dis}>${options.join('')}</select>`;
return `<select id="${esc(id)}" class="scoring-profile-select"${dis}>${options.join('')}</select>`;
}
function coachBandFieldsHTML(prefix, rule, scoringProfiles, editable, {
optional = false,
} = {}) {
const profileId = `${prefix}CoachBandProfile`;
const bandsId = `${prefix}CoachBands`;
return `
<div class="scoring-coach-band-fields" style="margin-top:12px">
<label class="form-label">${optional
? 'Optional: coach category (leave empty = any band / no category check)'
: 'Coach category'}</label>
${optional
? '<p class="field-hint" style="margin:0 0 8px">Use this when only some paths depend on green/yellow/red. Other OR rules can omit it so the questionnaire unlocks regardless of band.</p>'
: ''}
<div class="form-group">
<label class="form-label">Scoring profile</label>
${scoringProfileSelectHTML(rule.coachBandProfileID || '', scoringProfiles, editable, profileId)}
</div>
<div class="form-group">
<label class="form-label">Allowed coach categories</label>
${bandCheckboxesHTML(rule.coachBandAllowed || [], editable, bandsId)}
${optional ? '' : '<p class="field-hint">Unlocks only after the coach sets one of these categories during score review.</p>'}
</div>
</div>`;
}
function coachBandRuleHTML(form, allQuestionnaires, scoringProfiles, selfId, editable) {
@ -322,26 +404,23 @@ function coachBandRuleHTML(form, allQuestionnaires, scoringProfiles, selfId, edi
<div class="scoring-coach-band-panel condition-rule-block">
<label class="form-label">Optional: must also be completed first</label>
${requiresCheckboxes('condRequires', form.requires || [], allQuestionnaires, selfId, editable)}
<div class="scoring-coach-band-fields">
<div class="form-group">
<label class="form-label">Scoring profile</label>
${scoringProfileSelectHTML(form.coachBandProfileID || '', scoringProfiles, editable)}
</div>
<div class="form-group">
<label class="form-label">Allowed coach categories</label>
${bandCheckboxesHTML(form.coachBandAllowed || [], editable)}
<p class="field-hint">Unlocks only after the coach sets one of these categories during score review.</p>
</div>
</div>
${coachBandFieldsHTML('cond', form, scoringProfiles, editable, { optional: false })}
</div>`;
}
function readCoachBandAllowed() {
const root = document.getElementById('condCoachBands');
function readCoachBandAllowed(id = 'condCoachBands') {
const root = document.getElementById(id);
if (!root) return [];
return [...root.querySelectorAll('input[type=checkbox]:checked')].map(cb => cb.value);
}
function readCoachBandPrefix(prefix) {
return {
coachBandProfileID: document.getElementById(`${prefix}CoachBandProfile`)?.value?.trim() || '',
coachBandAllowed: readCoachBandAllowed(`${prefix}CoachBands`),
};
}
function requiresCheckboxes(id, selected, allQuestionnaires, selfId, editable) {
const list = (allQuestionnaires || []).filter(q => q.questionnaireID !== selfId);
if (!list.length) {
@ -451,7 +530,10 @@ function answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionna
</div>`;
}
function singleRuleHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable) {
function singleRuleHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable, {
scoringProfiles = [],
showOptionalCoachBand = false,
} = {}) {
const showAnswer = rule.type === 'requires_answer';
return `
<div class="condition-rule-block">
@ -461,6 +543,9 @@ function singleRuleHTML(prefix, rule, allQuestionnaires, questionsByQuestionnair
<label class="form-label" style="margin-top:12px">And answer must match</label>
${answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable)}
` : ''}
${showOptionalCoachBand
? coachBandFieldsHTML(prefix, rule, scoringProfiles, editable, { optional: true })
: ''}
</div>`;
}
@ -478,7 +563,13 @@ export function renderConditionEditorHTML(form, {
).join('');
const rules = form.rules?.length ? form.rules : [{
type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '',
type: 'requires',
requires: [],
questionnaire: '',
questionId: '',
operator: '==',
value: '',
...emptyCoachBandFields(),
}];
return `
@ -509,7 +600,10 @@ export function renderConditionEditorHTML(form, {
</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>
<p class="data-toolbar-hint" style="margin:0 0 10px">
Unlock if <strong>any one</strong> rule matches. Mix prerequisites and coach categories per rule:
e.g. Rule 1 = questionnaires A+B + green band; Rule 2 = questionnaire C only (any band).
</p>
<div id="conditionAnyOfRules">
${rules.map((rule, i) => `
<div class="condition-anyof-rule card" data-rule-index="${i}">
@ -524,7 +618,10 @@ export function renderConditionEditorHTML(form, {
<option value="requires_answer" ${rule.type === 'requires_answer' ? 'selected' : ''}>Requires completions + answer</option>
</select>
</div>
${singleRuleHTML(`anyof${i}`, rule, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
${singleRuleHTML(`anyof${i}`, rule, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable, {
scoringProfiles,
showOptionalCoachBand: true,
})}
</div>
`).join('')}
</div>
@ -600,8 +697,7 @@ export function readConditionFormFromDOM() {
if (mode === 'requires_coach_band') {
form.requires = readRequires('condRequires');
form.coachBandProfileID = document.getElementById('condCoachBandProfile')?.value?.trim() || '';
form.coachBandAllowed = readCoachBandAllowed();
Object.assign(form, readCoachBandPrefix('cond'));
return form;
}
@ -610,7 +706,12 @@ export function readConditionFormFromDOM() {
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}`) };
const rule = {
type,
requires,
...readAnswerPrefix(`anyof${i}`),
...readCoachBandPrefix(`anyof${i}`),
};
form.rules.push(rule);
});
}
@ -716,7 +817,13 @@ export function mountConditionEditor(container, {
form.mode = 'any_of';
form.rules = form.rules || [];
form.rules.push({
type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '',
type: 'requires',
requires: [],
questionnaire: '',
questionId: '',
operator: '==',
value: '',
...emptyCoachBandFields(),
});
label = document.getElementById('conditionGermanLabel')?.value || label;
render();