diff --git a/website/js/condition-editor.js b/website/js/condition-editor.js index 4a5be35..17c7921 100644 --- a/website/js/condition-editor.js +++ b/website/js/condition-editor.js @@ -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 `
+ return `
${SCORING_BANDS.map(b => { const checked = sel.has(b.value) ? ' checked' : ''; return `
`; } -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 = [''] .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 ``; + return ``; })); - return ``; + return ``; +} + +function coachBandFieldsHTML(prefix, rule, scoringProfiles, editable, { + optional = false, +} = {}) { + const profileId = `${prefix}CoachBandProfile`; + const bandsId = `${prefix}CoachBands`; + return ` +
+ + ${optional + ? '

Use this when only some paths depend on green/yellow/red. Other OR rules can omit it so the questionnaire unlocks regardless of band.

' + : ''} +
+ + ${scoringProfileSelectHTML(rule.coachBandProfileID || '', scoringProfiles, editable, profileId)} +
+
+ + ${bandCheckboxesHTML(rule.coachBandAllowed || [], editable, bandsId)} + ${optional ? '' : '

Unlocks only after the coach sets one of these categories during score review.

'} +
+
`; } function coachBandRuleHTML(form, allQuestionnaires, scoringProfiles, selfId, editable) { @@ -322,26 +404,23 @@ function coachBandRuleHTML(form, allQuestionnaires, scoringProfiles, selfId, edi
${requiresCheckboxes('condRequires', form.requires || [], allQuestionnaires, selfId, editable)} -
-
- - ${scoringProfileSelectHTML(form.coachBandProfileID || '', scoringProfiles, editable)} -
-
- - ${bandCheckboxesHTML(form.coachBandAllowed || [], editable)} -

Unlocks only after the coach sets one of these categories during score review.

-
-
+ ${coachBandFieldsHTML('cond', form, scoringProfiles, editable, { optional: false })}
`; } -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
`; } -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 `
@@ -461,6 +543,9 @@ function singleRuleHTML(prefix, rule, allQuestionnaires, questionsByQuestionnair ${answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable)} ` : ''} + ${showOptionalCoachBand + ? coachBandFieldsHTML(prefix, rule, scoringProfiles, editable, { optional: true }) + : ''}
`; } @@ -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, {
-

Unlock if any one rule matches.

+

+ Unlock if any one 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). +

${rules.map((rule, i) => `
@@ -524,7 +618,10 @@ export function renderConditionEditorHTML(form, {
- ${singleRuleHTML(`anyof${i}`, rule, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)} + ${singleRuleHTML(`anyof${i}`, rule, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable, { + scoringProfiles, + showOptionalCoachBand: true, + })}
`).join('')}
@@ -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();