added preview window in questionnaire editor
This commit is contained in:
@ -2399,3 +2399,207 @@ select:disabled {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Questionnaire preview tab (all-in-one) */
|
||||||
|
.qp-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.qp-banner {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--primary-subtle);
|
||||||
|
border: 1px solid var(--primary-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--primary-text);
|
||||||
|
}
|
||||||
|
.qp-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.qp-visible-count {
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.qp-scroll {
|
||||||
|
padding: 0;
|
||||||
|
max-height: min(75vh, 900px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.qp-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.qp-section {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.qp-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.qp-section[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.qp-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.qp-section-num {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-subtle);
|
||||||
|
color: var(--primary-text);
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.qp-section-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.qp-id {
|
||||||
|
font-size: .8rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.qp-type {
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
.qp-question-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.qp-note {
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.qp-note-after { margin-top: 12px; margin-bottom: 0; }
|
||||||
|
.qp-hint {
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.qp-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.qp-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.qp-option:hover {
|
||||||
|
border-color: var(--primary-border);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
.qp-option input { flex-shrink: 0; }
|
||||||
|
.qp-select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: .9rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.qp-select-sm { max-width: 140px; }
|
||||||
|
.qp-date-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.qp-text-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: .9rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.qp-slider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.qp-slider input[type=range] { flex: 1; max-width: 400px; }
|
||||||
|
.qp-slider-value {
|
||||||
|
min-width: 3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.qp-form-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.qp-form-stack label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.qp-form-stack input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: .9rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.qp-glass-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.qp-glass-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
.qp-glass-table th,
|
||||||
|
.qp-glass-table td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.qp-glass-table th:first-child,
|
||||||
|
.qp-glass-table td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.qp-empty {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
mountConditionEditor,
|
mountConditionEditor,
|
||||||
parseConditionForm,
|
parseConditionForm,
|
||||||
} from '../condition-editor.js';
|
} from '../condition-editor.js';
|
||||||
|
import { mountQuestionnairePreview } from '../questionnaire-preview.js';
|
||||||
|
|
||||||
const LAYOUT_TYPES = [
|
const LAYOUT_TYPES = [
|
||||||
{ value: 'radio_question', label: 'Radio (Single Choice)' },
|
{ value: 'radio_question', label: 'Radio (Single Choice)' },
|
||||||
@ -602,6 +603,7 @@ function renderEditor() {
|
|||||||
${!isNew ? `
|
${!isNew ? `
|
||||||
<div class="tab-bar" id="editorTabs">
|
<div class="tab-bar" id="editorTabs">
|
||||||
<button class="active" data-tab="questions">Questions (${questions.length})</button>
|
<button class="active" data-tab="questions">Questions (${questions.length})</button>
|
||||||
|
<button data-tab="preview">Preview</button>
|
||||||
<button data-tab="translations">Translations</button>
|
<button data-tab="translations">Translations</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="tabContent-questions">
|
<div id="tabContent-questions">
|
||||||
@ -619,6 +621,9 @@ function renderEditor() {
|
|||||||
<div id="translationsContent"><div class="spinner"></div></div>
|
<div id="translationsContent"><div class="spinner"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tabContent-preview" style="display:none">
|
||||||
|
<div id="previewContent"></div>
|
||||||
|
</div>
|
||||||
` : '<p style="color:var(--text-secondary);margin-top:12px">Save the questionnaire first, then add questions.</p>'}
|
` : '<p style="color:var(--text-secondary);margin-top:12px">Save the questionnaire first, then add questions.</p>'}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -648,11 +653,22 @@ function initTabs() {
|
|||||||
tabBar.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
tabBar.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||||||
document.getElementById('tabContent-questions').style.display = tab === 'questions' ? '' : 'none';
|
document.getElementById('tabContent-questions').style.display = tab === 'questions' ? '' : 'none';
|
||||||
document.getElementById('tabContent-translations').style.display = tab === 'translations' ? '' : 'none';
|
document.getElementById('tabContent-translations').style.display = tab === 'translations' ? '' : 'none';
|
||||||
|
document.getElementById('tabContent-preview').style.display = tab === 'preview' ? '' : 'none';
|
||||||
if (tab === 'translations') loadTranslationsTab();
|
if (tab === 'translations') loadTranslationsTab();
|
||||||
|
if (tab === 'preview') loadPreviewTab();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadPreviewTab() {
|
||||||
|
const mount = document.getElementById('previewContent');
|
||||||
|
if (!mount) return;
|
||||||
|
mountQuestionnairePreview(mount, {
|
||||||
|
questionnaireName: questionnaire?.name || '',
|
||||||
|
questions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAppStringGerman(messageKey) {
|
async function fetchAppStringGerman(messageKey) {
|
||||||
if (!messageKey) return '';
|
if (!messageKey) return '';
|
||||||
try {
|
try {
|
||||||
|
|||||||
549
website/js/questionnaire-preview.js
Normal file
549
website/js/questionnaire-preview.js
Normal file
@ -0,0 +1,549 @@
|
|||||||
|
/**
|
||||||
|
* All-in-one interactive preview for questionnaire editing.
|
||||||
|
* All visible questions on one scrollable page; branching updates visibility live.
|
||||||
|
* Answers stay in memory only — nothing is saved to the server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GLASS_LABELS = [
|
||||||
|
{ key: 'never_glass', label: 'Never' },
|
||||||
|
{ key: 'little_glass', label: 'A little' },
|
||||||
|
{ key: 'moderate_glass', label: 'Moderate' },
|
||||||
|
{ key: 'much_glass', label: 'Much' },
|
||||||
|
{ key: 'extreme_glass', label: 'Extreme' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONTHS = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s ?? '';
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(q) {
|
||||||
|
try { return JSON.parse(q.configJson || '{}'); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function localId(q) {
|
||||||
|
return q.localId || (q.questionID || '').split('__').pop() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionKey(q) {
|
||||||
|
const cfg = parseConfig(q);
|
||||||
|
return (q.questionKey || cfg.questionKey || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionKeyOf(o) {
|
||||||
|
if (o.optionKey) return o.optionKey;
|
||||||
|
const dt = (o.defaultText || '').trim();
|
||||||
|
return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(dt) ? dt : dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionLabel(o) {
|
||||||
|
return (o.labelGerman || '').trim() || o.defaultText || o.optionKey || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function glassScaleOptions(q) {
|
||||||
|
const opts = q.answerOptions || [];
|
||||||
|
if (opts.length) {
|
||||||
|
return opts.map(o => ({ key: optionKeyOf(o), label: optionLabel(o) }));
|
||||||
|
}
|
||||||
|
return GLASS_LABELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueSpinnerValues(config) {
|
||||||
|
const range = config.range || { min: 0, max: 10 };
|
||||||
|
const min = Number(range.min ?? 0);
|
||||||
|
const max = Number(range.max ?? 10);
|
||||||
|
const lo = Math.min(min, max);
|
||||||
|
const hi = Math.max(min, max);
|
||||||
|
const values = [];
|
||||||
|
for (let v = lo; v <= hi; v++) values.push(v);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBranchingOptions(q) {
|
||||||
|
const layout = q.type || '';
|
||||||
|
const config = parseConfig(q);
|
||||||
|
if (layout === 'radio_question') {
|
||||||
|
return (q.answerOptions || []).some(o => o.nextQuestionId?.trim());
|
||||||
|
}
|
||||||
|
if (layout === 'value_spinner') {
|
||||||
|
const opts = config.valueOptions || config.options || [];
|
||||||
|
return opts.some(o => o.nextQuestionId?.trim()) || Boolean(config.nextQuestionId?.trim());
|
||||||
|
}
|
||||||
|
if (layout === 'string_spinner') {
|
||||||
|
return Boolean(config.nextQuestionId?.trim() || config.otherNextQuestionId?.trim());
|
||||||
|
}
|
||||||
|
if (layout === 'multi_check_box_question') {
|
||||||
|
return Boolean(config.nextQuestionId?.trim() || config.otherNextQuestionId?.trim());
|
||||||
|
}
|
||||||
|
const withQuestionNext = new Set([
|
||||||
|
'free_text', 'date_spinner', 'slider_question', 'glass_scale_question',
|
||||||
|
'client_coach_code_question', 'client_not_signed',
|
||||||
|
]);
|
||||||
|
return withQuestionNext.has(layout) && Boolean(config.nextQuestionId?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNextLocalId(q, answer) {
|
||||||
|
const layout = q.type || '';
|
||||||
|
const config = parseConfig(q);
|
||||||
|
|
||||||
|
if (layout === 'radio_question') {
|
||||||
|
const key = typeof answer === 'string' ? answer : '';
|
||||||
|
const opt = (q.answerOptions || []).find(o => optionKeyOf(o) === key);
|
||||||
|
return opt?.nextQuestionId?.trim() || null;
|
||||||
|
}
|
||||||
|
if (layout === 'value_spinner') {
|
||||||
|
const num = parseInt(String(answer), 10);
|
||||||
|
const branch = (config.valueOptions || config.options || [])
|
||||||
|
.find(o => Number(o.value) === num);
|
||||||
|
if (branch?.nextQuestionId?.trim()) return branch.nextQuestionId.trim();
|
||||||
|
return config.nextQuestionId?.trim() || null;
|
||||||
|
}
|
||||||
|
if (layout === 'string_spinner') {
|
||||||
|
const val = String(answer ?? '');
|
||||||
|
const otherKey = config.otherOptionKey?.trim();
|
||||||
|
const otherNext = config.otherNextQuestionId?.trim();
|
||||||
|
if (otherKey && otherNext && val === otherKey) return otherNext;
|
||||||
|
return config.nextQuestionId?.trim() || null;
|
||||||
|
}
|
||||||
|
if (layout === 'multi_check_box_question') {
|
||||||
|
const keys = Array.isArray(answer) ? answer : [];
|
||||||
|
const otherKey = config.otherOptionKey?.trim();
|
||||||
|
const otherNext = config.otherNextQuestionId?.trim();
|
||||||
|
if (otherKey && otherNext && keys.includes(otherKey)) return otherNext;
|
||||||
|
return config.nextQuestionId?.trim() || null;
|
||||||
|
}
|
||||||
|
return config.nextQuestionId?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAnswerFromBody(body, q) {
|
||||||
|
if (!body) return null;
|
||||||
|
const layout = q.type || '';
|
||||||
|
const config = parseConfig(q);
|
||||||
|
|
||||||
|
switch (layout) {
|
||||||
|
case 'radio_question': {
|
||||||
|
const checked = body.querySelector('input[type=radio]:checked');
|
||||||
|
return checked ? checked.value : null;
|
||||||
|
}
|
||||||
|
case 'multi_check_box_question': {
|
||||||
|
const boxes = body.querySelectorAll('input[type=checkbox]:checked');
|
||||||
|
return [...boxes].map(el => el.value);
|
||||||
|
}
|
||||||
|
case 'value_spinner':
|
||||||
|
case 'string_spinner': {
|
||||||
|
const sel = body.querySelector('select.qp-select');
|
||||||
|
const val = sel?.value ?? '';
|
||||||
|
return val === '' ? null : val;
|
||||||
|
}
|
||||||
|
case 'slider_question': {
|
||||||
|
const range = body.querySelector('input[type=range]');
|
||||||
|
return range ? range.value : null;
|
||||||
|
}
|
||||||
|
case 'date_spinner': {
|
||||||
|
const precision = config.precision || 'full';
|
||||||
|
const year = body.querySelector('[data-part=year]')?.value;
|
||||||
|
if (!year) return null;
|
||||||
|
if (precision === 'year') return year;
|
||||||
|
const month = body.querySelector('[data-part=month]')?.value || '01';
|
||||||
|
if (precision === 'year_month') return `${year}-${month}`;
|
||||||
|
const day = body.querySelector('[data-part=day]')?.value || '01';
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
case 'free_text': {
|
||||||
|
const text = body.querySelector('input[type=text], textarea')?.value?.trim();
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
case 'glass_scale_question': {
|
||||||
|
const symptoms = config.symptoms || [];
|
||||||
|
if (!symptoms.length) return {};
|
||||||
|
const result = {};
|
||||||
|
for (const sym of symptoms) {
|
||||||
|
const picked = body.querySelector(`input[data-symptom="${CSS.escape(sym)}"]:checked`);
|
||||||
|
if (!picked) return null;
|
||||||
|
result[sym] = picked.value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'client_coach_code_question': {
|
||||||
|
const client = body.querySelector('[data-field=client]')?.value?.trim();
|
||||||
|
const coach = body.querySelector('[data-field=coach]')?.value?.trim();
|
||||||
|
if (!client && !coach) return null;
|
||||||
|
return { client_code: client || '', coach_code: coach || '' };
|
||||||
|
}
|
||||||
|
case 'client_not_signed': {
|
||||||
|
const coach = body.querySelector('[data-field=coach]')?.value?.trim();
|
||||||
|
return coach || null;
|
||||||
|
}
|
||||||
|
case 'last_page':
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnswerInUi(q, sectionEl) {
|
||||||
|
const body = sectionEl?.querySelector('.qp-question-body');
|
||||||
|
const layout = q.type || '';
|
||||||
|
const config = parseConfig(q);
|
||||||
|
const answer = readAnswerFromBody(body, q);
|
||||||
|
|
||||||
|
switch (layout) {
|
||||||
|
case 'radio_question':
|
||||||
|
return answer !== null;
|
||||||
|
case 'multi_check_box_question': {
|
||||||
|
const min = config.minSelection || 0;
|
||||||
|
const count = Array.isArray(answer) ? answer.length : 0;
|
||||||
|
return count >= Math.max(min, 0) && (count > 0 || !q.isRequired);
|
||||||
|
}
|
||||||
|
case 'value_spinner':
|
||||||
|
case 'string_spinner':
|
||||||
|
return answer !== null && answer !== '';
|
||||||
|
case 'slider_question':
|
||||||
|
return answer !== null;
|
||||||
|
case 'date_spinner': {
|
||||||
|
const year = body?.querySelector('[data-part=year]')?.value;
|
||||||
|
if (!year) return false;
|
||||||
|
if (config.precision === 'year') return true;
|
||||||
|
const month = body?.querySelector('[data-part=month]')?.value;
|
||||||
|
if (!month) return false;
|
||||||
|
if (config.precision === 'year_month') return true;
|
||||||
|
return Boolean(body?.querySelector('[data-part=day]')?.value);
|
||||||
|
}
|
||||||
|
case 'free_text':
|
||||||
|
return Boolean(answer);
|
||||||
|
case 'glass_scale_question': {
|
||||||
|
const symptoms = config.symptoms || [];
|
||||||
|
if (!symptoms.length) return true;
|
||||||
|
return answer !== null && typeof answer === 'object'
|
||||||
|
&& symptoms.every(s => answer[s]);
|
||||||
|
}
|
||||||
|
case 'client_coach_code_question':
|
||||||
|
return Boolean(answer?.client_code && answer?.coach_code);
|
||||||
|
case 'client_not_signed':
|
||||||
|
return Boolean(answer) || !q.isRequired;
|
||||||
|
case 'last_page':
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchAnswerFromSection(q, sectionEl) {
|
||||||
|
if (hasBranchingOptions(q) && !hasAnswerInUi(q, sectionEl)) return null;
|
||||||
|
const body = sectionEl?.querySelector('.qp-question-body');
|
||||||
|
return readAnswerFromBody(body, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleQuestionIndices(questions, sectionByIdx) {
|
||||||
|
const visible = new Set();
|
||||||
|
let i = 0;
|
||||||
|
while (i < questions.length) {
|
||||||
|
const q = questions[i];
|
||||||
|
if (q.type === 'last_page') {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visible.add(i);
|
||||||
|
const sectionEl = sectionByIdx.get(i);
|
||||||
|
const answer = sectionEl ? branchAnswerFromSection(q, sectionEl) : null;
|
||||||
|
const nextId = answer !== null ? resolveNextLocalId(q, answer) : null;
|
||||||
|
|
||||||
|
if (nextId) {
|
||||||
|
const isLastPageTarget = questions.some(
|
||||||
|
qq => qq.type === 'last_page' && localId(qq) === nextId,
|
||||||
|
);
|
||||||
|
if (isLastPageTarget) break;
|
||||||
|
const target = questions.findIndex(qq => localId(qq) === nextId);
|
||||||
|
i = target >= 0 ? target : i + 1;
|
||||||
|
} else if (hasBranchingOptions(q) && answer === null) {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuestionBody(q, sectionIdx) {
|
||||||
|
const layout = q.type || '';
|
||||||
|
const config = parseConfig(q);
|
||||||
|
const qText = q.defaultText || questionKey(q) || 'Question';
|
||||||
|
const prefix = `qp_${sectionIdx}`;
|
||||||
|
|
||||||
|
let inner = '';
|
||||||
|
if (config.noteBefore) inner += `<p class="qp-note">${esc(config.noteBefore)}</p>`;
|
||||||
|
inner += `<p class="qp-question-text">${esc(qText)}</p>`;
|
||||||
|
|
||||||
|
switch (layout) {
|
||||||
|
case 'radio_question':
|
||||||
|
inner += `<div class="qp-options">`;
|
||||||
|
for (const o of (q.answerOptions || [])) {
|
||||||
|
const key = optionKeyOf(o);
|
||||||
|
inner += `<label class="qp-option"><input type="radio" name="${prefix}_radio" value="${esc(key)}"> ${esc(optionLabel(o))}</label>`;
|
||||||
|
}
|
||||||
|
inner += `</div>`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'multi_check_box_question':
|
||||||
|
inner += `<div class="qp-options">`;
|
||||||
|
for (const o of (q.answerOptions || [])) {
|
||||||
|
const key = optionKeyOf(o);
|
||||||
|
inner += `<label class="qp-option"><input type="checkbox" value="${esc(key)}"> ${esc(optionLabel(o))}</label>`;
|
||||||
|
}
|
||||||
|
if (config.otherOptionKey && config.otherNextQuestionId) {
|
||||||
|
const ok = config.otherOptionKey;
|
||||||
|
inner += `<label class="qp-option"><input type="checkbox" value="${esc(ok)}"> ${esc(ok)} (Other)</label>`;
|
||||||
|
}
|
||||||
|
inner += `</div>`;
|
||||||
|
if (config.minSelection) {
|
||||||
|
inner += `<p class="qp-hint">Select at least ${config.minSelection} option(s).</p>`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'value_spinner': {
|
||||||
|
const values = valueSpinnerValues(config);
|
||||||
|
inner += `<select class="qp-select"><option value="">— choose —</option>`;
|
||||||
|
for (const v of values) inner += `<option value="${v}">${v}</option>`;
|
||||||
|
inner += `</select>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'slider_question': {
|
||||||
|
const range = config.range || { min: 0, max: 100 };
|
||||||
|
const min = Number(range.min ?? 0);
|
||||||
|
const max = Number(range.max ?? 100);
|
||||||
|
const step = Number(config.step ?? 1) || 1;
|
||||||
|
inner += `
|
||||||
|
<div class="qp-slider">
|
||||||
|
<input type="range" min="${min}" max="${max}" step="${step}" value="${min}">
|
||||||
|
<output class="qp-slider-value">${min}${config.unitLabel ? ' ' + esc(config.unitLabel) : ''}</output>
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'date_spinner': {
|
||||||
|
const precision = config.precision || 'full';
|
||||||
|
const years = [];
|
||||||
|
const now = new Date().getFullYear();
|
||||||
|
for (let yr = now + 1; yr >= 1900; yr--) years.push(yr);
|
||||||
|
inner += `<div class="qp-date-row">`;
|
||||||
|
if (precision === 'full') {
|
||||||
|
inner += `<select data-part="day" class="qp-select qp-select-sm"><option value="">Day</option>`;
|
||||||
|
for (let day = 1; day <= 31; day++) {
|
||||||
|
const ds = String(day).padStart(2, '0');
|
||||||
|
inner += `<option value="${ds}">${day}</option>`;
|
||||||
|
}
|
||||||
|
inner += `</select>`;
|
||||||
|
}
|
||||||
|
if (precision !== 'year') {
|
||||||
|
inner += `<select data-part="month" class="qp-select qp-select-sm"><option value="">Month</option>`;
|
||||||
|
for (let mi = 0; mi < 12; mi++) {
|
||||||
|
const ms = String(mi + 1).padStart(2, '0');
|
||||||
|
inner += `<option value="${ms}">${MONTHS[mi]}</option>`;
|
||||||
|
}
|
||||||
|
inner += `</select>`;
|
||||||
|
}
|
||||||
|
inner += `<select data-part="year" class="qp-select qp-select-sm"><option value="">Year</option>`;
|
||||||
|
for (const yr of years) inner += `<option value="${yr}">${yr}</option>`;
|
||||||
|
inner += `</select></div>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'string_spinner': {
|
||||||
|
inner += `<select class="qp-select"><option value="">— choose —</option>`;
|
||||||
|
for (const opt of (config.options || [])) {
|
||||||
|
inner += `<option value="${esc(opt)}">${esc(opt)}</option>`;
|
||||||
|
}
|
||||||
|
if (config.otherOptionKey && config.otherNextQuestionId) {
|
||||||
|
const ok = config.otherOptionKey;
|
||||||
|
inner += `<option value="${esc(ok)}">${esc(ok)} (Other)</option>`;
|
||||||
|
}
|
||||||
|
inner += `</select>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'free_text':
|
||||||
|
inner += `<input type="text" class="qp-text-input" maxlength="${config.maxLength || 500}">`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'glass_scale_question': {
|
||||||
|
const symptoms = config.symptoms || [];
|
||||||
|
const scaleOpts = glassScaleOptions(q);
|
||||||
|
if (!symptoms.length) {
|
||||||
|
inner += `<p class="qp-hint">Informational screen (no symptom rows).</p>`;
|
||||||
|
} else {
|
||||||
|
inner += `<div class="qp-glass-table"><table><thead><tr><th>Symptom</th>`;
|
||||||
|
for (const so of scaleOpts) inner += `<th>${esc(so.label)}</th>`;
|
||||||
|
inner += `</tr></thead><tbody>`;
|
||||||
|
symptoms.forEach((sym, si) => {
|
||||||
|
inner += `<tr><td>${esc(sym)}</td>`;
|
||||||
|
scaleOpts.forEach(so => {
|
||||||
|
inner += `<td><input type="radio" name="${prefix}_g_${si}" data-symptom="${esc(sym)}" value="${esc(so.key)}"></td>`;
|
||||||
|
});
|
||||||
|
inner += `</tr>`;
|
||||||
|
});
|
||||||
|
inner += `</tbody></table></div>`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'client_coach_code_question':
|
||||||
|
inner += `
|
||||||
|
<div class="qp-form-stack">
|
||||||
|
<label>Client code<input type="text" data-field="client"></label>
|
||||||
|
<label>Coach code<input type="text" data-field="coach"></label>
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'client_not_signed':
|
||||||
|
inner += `<label>Coach code<input type="text" data-field="coach"></label>`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'last_page':
|
||||||
|
inner += `<p class="qp-hint">${esc(config.textKey || 'End of questionnaire.')}</p>`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
inner += `<p class="qp-hint">Preview not available for layout <code>${esc(layout)}</code>.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.noteAfter) inner += `<p class="qp-note qp-note-after">${esc(config.noteAfter)}</p>`;
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutLabel(type) {
|
||||||
|
const labels = {
|
||||||
|
radio_question: 'Radio',
|
||||||
|
multi_check_box_question: 'Checkbox',
|
||||||
|
glass_scale_question: 'Glass scale',
|
||||||
|
value_spinner: 'Value spinner',
|
||||||
|
slider_question: 'Slider',
|
||||||
|
date_spinner: 'Date',
|
||||||
|
string_spinner: 'String spinner',
|
||||||
|
free_text: 'Free text',
|
||||||
|
client_coach_code_question: 'Client / coach code',
|
||||||
|
client_not_signed: 'Client not signed',
|
||||||
|
last_page: 'Last page',
|
||||||
|
};
|
||||||
|
return labels[type] || type || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {{ questionnaireName?: string, questions: object[] }} opts
|
||||||
|
*/
|
||||||
|
export function mountQuestionnairePreview(container, opts) {
|
||||||
|
const questions = opts.questions || [];
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!questions.length) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="qp-empty">
|
||||||
|
<p>No questions to preview. Add questions on the Questions tab first.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = questions.map((q, idx) => {
|
||||||
|
const lid = localId(q);
|
||||||
|
const num = idx + 1;
|
||||||
|
return `
|
||||||
|
<section class="qp-section" data-idx="${idx}" data-qid="${esc(lid)}" hidden>
|
||||||
|
<header class="qp-section-header">
|
||||||
|
<span class="qp-section-num">${num}</span>
|
||||||
|
<div class="qp-section-meta">
|
||||||
|
<code class="qp-id">${esc(lid)}</code>
|
||||||
|
<span class="qp-type">${esc(layoutLabel(q.type))}</span>
|
||||||
|
${q.isRequired ? '<span class="badge badge-active">Required</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="qp-question-body">
|
||||||
|
${renderQuestionBody(q, idx)}
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="qp-shell">
|
||||||
|
<div class="qp-banner">Preview mode — all questions on one page. Answers are not saved.</div>
|
||||||
|
<div class="qp-toolbar">
|
||||||
|
<span class="qp-visible-count" id="qpVisibleCount"></span>
|
||||||
|
<button type="button" class="btn btn-sm" data-action="restart">Reset all</button>
|
||||||
|
</div>
|
||||||
|
<div class="qp-scroll card">
|
||||||
|
<form class="qp-form" id="qpForm" novalidate>
|
||||||
|
${sections}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
wireEvents();
|
||||||
|
updateVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionElements() {
|
||||||
|
return [...container.querySelectorAll('.qp-section')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionByIdxMap() {
|
||||||
|
const map = new Map();
|
||||||
|
for (const el of sectionElements()) {
|
||||||
|
map.set(parseInt(el.dataset.idx, 10), el);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisibility() {
|
||||||
|
const map = sectionByIdxMap();
|
||||||
|
const visible = visibleQuestionIndices(questions, map);
|
||||||
|
let shown = 0;
|
||||||
|
for (const el of sectionElements()) {
|
||||||
|
const idx = parseInt(el.dataset.idx, 10);
|
||||||
|
const isVisible = visible.has(idx);
|
||||||
|
el.hidden = !isVisible;
|
||||||
|
el.classList.toggle('qp-section-hidden', !isVisible);
|
||||||
|
if (isVisible) shown++;
|
||||||
|
}
|
||||||
|
const counter = container.querySelector('#qpVisibleCount');
|
||||||
|
if (counter) {
|
||||||
|
counter.textContent = `Showing ${shown} of ${questions.length} questions`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireEvents() {
|
||||||
|
const form = container.querySelector('#qpForm');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener('input', () => updateVisibility());
|
||||||
|
form.addEventListener('change', () => updateVisibility());
|
||||||
|
|
||||||
|
form.querySelectorAll('input[type=range]').forEach(slider => {
|
||||||
|
const output = slider.closest('.qp-slider')?.querySelector('.qp-slider-value');
|
||||||
|
if (!output) return;
|
||||||
|
slider.addEventListener('input', () => {
|
||||||
|
output.textContent = slider.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelector('[data-action=restart]')?.addEventListener('click', () => {
|
||||||
|
form.reset();
|
||||||
|
form.querySelectorAll('input[type=radio], input[type=checkbox]').forEach(el => {
|
||||||
|
el.checked = false;
|
||||||
|
});
|
||||||
|
form.querySelectorAll('.qp-slider-value').forEach((output, i) => {
|
||||||
|
const slider = form.querySelectorAll('input[type=range]')[i];
|
||||||
|
if (slider) output.textContent = slider.value;
|
||||||
|
});
|
||||||
|
updateVisibility();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
return { restart: () => container.querySelector('[data-action=restart]')?.click() };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user