expanded questionnaire dependency matrix
Some checks are pending
PHPUnit / test (push) Waiting to run
Some checks are pending
PHPUnit / test (push) Waiting to run
This commit is contained in:
@ -7,7 +7,7 @@ const CONDITION_MODES = [
|
|||||||
{ value: 'requires', label: 'Requires other questionnaires completed' },
|
{ value: 'requires', label: 'Requires other questionnaires completed' },
|
||||||
{ value: 'requires_answer', label: 'Requires completions + answer check' },
|
{ value: 'requires_answer', label: 'Requires completions + answer check' },
|
||||||
{ value: 'requires_coach_band', label: 'Requires coach category (band)' },
|
{ 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)' },
|
{ 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 = '') {
|
function parseRule(obj, selfId = '') {
|
||||||
if (!obj || typeof obj !== 'object') {
|
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) {
|
if (obj.alwaysAvailable) {
|
||||||
return { type: 'always' };
|
return { type: 'always', ...emptyCoachBandFields() };
|
||||||
}
|
}
|
||||||
|
const coachBand = parseCoachBandFields(obj);
|
||||||
const rule = {
|
const rule = {
|
||||||
type: hasQuestionCheck(obj) ? 'requires_answer' : 'requires',
|
type: hasQuestionCheck(obj) ? 'requires_answer' : 'requires',
|
||||||
requires: asRequiresList(obj),
|
requires: asRequiresList(obj),
|
||||||
@ -140,6 +172,7 @@ function parseRule(obj, selfId = '') {
|
|||||||
questionId: obj.questionId || '',
|
questionId: obj.questionId || '',
|
||||||
operator: obj.operator === '!=' ? '!=' : '==',
|
operator: obj.operator === '!=' ? '!=' : '==',
|
||||||
value: obj.value != null ? String(obj.value) : '',
|
value: obj.value != null ? String(obj.value) : '',
|
||||||
|
...coachBand,
|
||||||
};
|
};
|
||||||
return sanitizeAnswerRule(rule, selfId);
|
return sanitizeAnswerRule(rule, selfId);
|
||||||
}
|
}
|
||||||
@ -163,16 +196,13 @@ export function parseConditionForm(conditionJson, questionnaireId) {
|
|||||||
if (obj.alwaysAvailable === true) {
|
if (obj.alwaysAvailable === true) {
|
||||||
return { mode: 'always', messageKey: '', germanLabel: '', customJson: '' };
|
return { mode: 'always', messageKey: '', germanLabel: '', customJson: '' };
|
||||||
}
|
}
|
||||||
if (obj.requiresCoachBand && typeof obj.requiresCoachBand === 'object') {
|
if (obj.requiresCoachBand && typeof obj.requiresCoachBand === 'object' && !Array.isArray(obj.anyOf)) {
|
||||||
const band = obj.requiresCoachBand;
|
const band = parseCoachBandFields(obj);
|
||||||
const allowed = Array.isArray(band.allowed)
|
|
||||||
? band.allowed.filter(Boolean).map(v => String(v).toLowerCase())
|
|
||||||
: [];
|
|
||||||
return {
|
return {
|
||||||
mode: 'requires_coach_band',
|
mode: 'requires_coach_band',
|
||||||
requires: asRequiresList(obj),
|
requires: asRequiresList(obj),
|
||||||
coachBandProfileID: (band.profileID || '').trim(),
|
coachBandProfileID: band.coachBandProfileID,
|
||||||
coachBandAllowed: allowed,
|
coachBandAllowed: band.coachBandAllowed,
|
||||||
messageKey,
|
messageKey,
|
||||||
germanLabel: '',
|
germanLabel: '',
|
||||||
customJson: '',
|
customJson: '',
|
||||||
@ -216,6 +246,17 @@ function buildRule(rule) {
|
|||||||
out.operator = rule.operator === '!=' ? '!=' : '==';
|
out.operator = rule.operator === '!=' ? '!=' : '==';
|
||||||
out.value = String(rule.value ?? '').trim();
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,6 +281,22 @@ export function buildConditionJson(form, selfQuestionnaireId = '') {
|
|||||||
if (!rules.length) {
|
if (!rules.length) {
|
||||||
throw new Error('Add at least one rule for "Any of these rules"');
|
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 };
|
obj = { anyOf: rules };
|
||||||
} else if (form.mode === 'requires_coach_band') {
|
} else if (form.mode === 'requires_coach_band') {
|
||||||
obj = {};
|
obj = {};
|
||||||
@ -287,10 +344,10 @@ export function buildConditionJson(form, selfQuestionnaireId = '') {
|
|||||||
return JSON.stringify(obj);
|
return JSON.stringify(obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bandCheckboxesHTML(selected, editable) {
|
function bandCheckboxesHTML(selected, editable, id = 'condCoachBands') {
|
||||||
const dis = editable ? '' : ' disabled';
|
const dis = editable ? '' : ' disabled';
|
||||||
const sel = new Set((selected || []).map(b => String(b).toLowerCase()));
|
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 => {
|
${SCORING_BANDS.map(b => {
|
||||||
const checked = sel.has(b.value) ? ' checked' : '';
|
const checked = sel.has(b.value) ? ' checked' : '';
|
||||||
return `<label class="scoring-band-picker-item">
|
return `<label class="scoring-band-picker-item">
|
||||||
@ -301,7 +358,7 @@ function bandCheckboxesHTML(selected, editable) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoringProfileSelectHTML(selectedId, scoringProfiles, editable) {
|
function scoringProfileSelectHTML(selectedId, scoringProfiles, editable, id = 'condCoachBandProfile') {
|
||||||
const dis = editable ? '' : ' disabled';
|
const dis = editable ? '' : ' disabled';
|
||||||
const list = (scoringProfiles || []).filter(p => p.profileID);
|
const list = (scoringProfiles || []).filter(p => p.profileID);
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
@ -309,12 +366,37 @@ function scoringProfileSelectHTML(selectedId, scoringProfiles, editable) {
|
|||||||
}
|
}
|
||||||
const options = ['<option value="">— Select scoring profile —</option>']
|
const options = ['<option value="">— Select scoring profile —</option>']
|
||||||
.concat(list.map(p => {
|
.concat(list.map(p => {
|
||||||
const id = p.profileID;
|
const pid = p.profileID;
|
||||||
const sel = selectedId === id ? ' selected' : '';
|
const sel = selectedId === pid ? ' selected' : '';
|
||||||
const label = (p.name || '').trim() || 'Unnamed profile';
|
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) {
|
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">
|
<div class="scoring-coach-band-panel condition-rule-block">
|
||||||
<label class="form-label">Optional: must also be completed first</label>
|
<label class="form-label">Optional: must also be completed first</label>
|
||||||
${requiresCheckboxes('condRequires', form.requires || [], allQuestionnaires, selfId, editable)}
|
${requiresCheckboxes('condRequires', form.requires || [], allQuestionnaires, selfId, editable)}
|
||||||
<div class="scoring-coach-band-fields">
|
${coachBandFieldsHTML('cond', form, scoringProfiles, editable, { optional: false })}
|
||||||
<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>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCoachBandAllowed() {
|
function readCoachBandAllowed(id = 'condCoachBands') {
|
||||||
const root = document.getElementById('condCoachBands');
|
const root = document.getElementById(id);
|
||||||
if (!root) return [];
|
if (!root) return [];
|
||||||
return [...root.querySelectorAll('input[type=checkbox]:checked')].map(cb => cb.value);
|
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) {
|
function requiresCheckboxes(id, selected, allQuestionnaires, selfId, editable) {
|
||||||
const list = (allQuestionnaires || []).filter(q => q.questionnaireID !== selfId);
|
const list = (allQuestionnaires || []).filter(q => q.questionnaireID !== selfId);
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
@ -451,7 +530,10 @@ function answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionna
|
|||||||
</div>`;
|
</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';
|
const showAnswer = rule.type === 'requires_answer';
|
||||||
return `
|
return `
|
||||||
<div class="condition-rule-block">
|
<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>
|
<label class="form-label" style="margin-top:12px">And answer must match</label>
|
||||||
${answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable)}
|
${answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable)}
|
||||||
` : ''}
|
` : ''}
|
||||||
|
${showOptionalCoachBand
|
||||||
|
? coachBandFieldsHTML(prefix, rule, scoringProfiles, editable, { optional: true })
|
||||||
|
: ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -478,7 +563,13 @@ export function renderConditionEditorHTML(form, {
|
|||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const rules = form.rules?.length ? form.rules : [{
|
const rules = form.rules?.length ? form.rules : [{
|
||||||
type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '',
|
type: 'requires',
|
||||||
|
requires: [],
|
||||||
|
questionnaire: '',
|
||||||
|
questionId: '',
|
||||||
|
operator: '==',
|
||||||
|
value: '',
|
||||||
|
...emptyCoachBandFields(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@ -509,7 +600,10 @@ export function renderConditionEditorHTML(form, {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="conditionPanelAnyOf" class="condition-mode-panel" style="display:${form.mode === 'any_of' ? '' : 'none'}">
|
<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">
|
<div id="conditionAnyOfRules">
|
||||||
${rules.map((rule, i) => `
|
${rules.map((rule, i) => `
|
||||||
<div class="condition-anyof-rule card" data-rule-index="${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>
|
<option value="requires_answer" ${rule.type === 'requires_answer' ? 'selected' : ''}>Requires completions + answer</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
${singleRuleHTML(`anyof${i}`, rule, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
|
${singleRuleHTML(`anyof${i}`, rule, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable, {
|
||||||
|
scoringProfiles,
|
||||||
|
showOptionalCoachBand: true,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
@ -600,8 +697,7 @@ export function readConditionFormFromDOM() {
|
|||||||
|
|
||||||
if (mode === 'requires_coach_band') {
|
if (mode === 'requires_coach_band') {
|
||||||
form.requires = readRequires('condRequires');
|
form.requires = readRequires('condRequires');
|
||||||
form.coachBandProfileID = document.getElementById('condCoachBandProfile')?.value?.trim() || '';
|
Object.assign(form, readCoachBandPrefix('cond'));
|
||||||
form.coachBandAllowed = readCoachBandAllowed();
|
|
||||||
return form;
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -610,7 +706,12 @@ export function readConditionFormFromDOM() {
|
|||||||
document.querySelectorAll('#conditionAnyOfRules .condition-anyof-rule').forEach((el, i) => {
|
document.querySelectorAll('#conditionAnyOfRules .condition-anyof-rule').forEach((el, i) => {
|
||||||
const type = el.querySelector('.anyof-rule-type')?.value || 'requires';
|
const type = el.querySelector('.anyof-rule-type')?.value || 'requires';
|
||||||
const requires = readRequires(`anyof${i}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);
|
form.rules.push(rule);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -716,7 +817,13 @@ export function mountConditionEditor(container, {
|
|||||||
form.mode = 'any_of';
|
form.mode = 'any_of';
|
||||||
form.rules = form.rules || [];
|
form.rules = form.rules || [];
|
||||||
form.rules.push({
|
form.rules.push({
|
||||||
type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '',
|
type: 'requires',
|
||||||
|
requires: [],
|
||||||
|
questionnaire: '',
|
||||||
|
questionId: '',
|
||||||
|
operator: '==',
|
||||||
|
value: '',
|
||||||
|
...emptyCoachBandFields(),
|
||||||
});
|
});
|
||||||
label = document.getElementById('conditionGermanLabel')?.value || label;
|
label = document.getElementById('conditionGermanLabel')?.value || label;
|
||||||
render();
|
render();
|
||||||
|
|||||||
Reference in New Issue
Block a user