diff --git a/website/css/style.css b/website/css/style.css index a5bc5e9..899ccdb 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -2399,3 +2399,207 @@ select:disabled { 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); +} diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index cd74632..9b16c80 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -16,6 +16,7 @@ import { mountConditionEditor, parseConditionForm, } from '../condition-editor.js'; +import { mountQuestionnairePreview } from '../questionnaire-preview.js'; const LAYOUT_TYPES = [ { value: 'radio_question', label: 'Radio (Single Choice)' }, @@ -602,6 +603,7 @@ function renderEditor() { ${!isNew ? `
+
@@ -619,6 +621,9 @@ function renderEditor() {
+ ` : '

Save the questionnaire first, then add questions.

'} `; @@ -648,11 +653,22 @@ function initTabs() { 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-translations').style.display = tab === 'translations' ? '' : 'none'; + document.getElementById('tabContent-preview').style.display = tab === 'preview' ? '' : 'none'; 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) { if (!messageKey) return ''; try { diff --git a/website/js/questionnaire-preview.js b/website/js/questionnaire-preview.js new file mode 100644 index 0000000..1a9186c --- /dev/null +++ b/website/js/questionnaire-preview.js @@ -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 += `

${esc(config.noteBefore)}

`; + inner += `

${esc(qText)}

`; + + switch (layout) { + case 'radio_question': + inner += `
`; + for (const o of (q.answerOptions || [])) { + const key = optionKeyOf(o); + inner += ``; + } + inner += `
`; + break; + + case 'multi_check_box_question': + inner += `
`; + for (const o of (q.answerOptions || [])) { + const key = optionKeyOf(o); + inner += ``; + } + if (config.otherOptionKey && config.otherNextQuestionId) { + const ok = config.otherOptionKey; + inner += ``; + } + inner += `
`; + if (config.minSelection) { + inner += `

Select at least ${config.minSelection} option(s).

`; + } + break; + + case 'value_spinner': { + const values = valueSpinnerValues(config); + inner += ``; + 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 += ` +
+ + ${min}${config.unitLabel ? ' ' + esc(config.unitLabel) : ''} +
`; + 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 += `
`; + if (precision === 'full') { + inner += ``; + } + if (precision !== 'year') { + inner += ``; + } + inner += `
`; + break; + } + + case 'string_spinner': { + inner += ``; + break; + } + + case 'free_text': + inner += ``; + break; + + case 'glass_scale_question': { + const symptoms = config.symptoms || []; + const scaleOpts = glassScaleOptions(q); + if (!symptoms.length) { + inner += `

Informational screen (no symptom rows).

`; + } else { + inner += `
`; + for (const so of scaleOpts) inner += ``; + inner += ``; + symptoms.forEach((sym, si) => { + inner += ``; + scaleOpts.forEach(so => { + inner += ``; + }); + inner += ``; + }); + inner += `
Symptom${esc(so.label)}
${esc(sym)}
`; + } + break; + } + + case 'client_coach_code_question': + inner += ` +
+ + +
`; + break; + + case 'client_not_signed': + inner += ``; + break; + + case 'last_page': + inner += `

${esc(config.textKey || 'End of questionnaire.')}

`; + break; + + default: + inner += `

Preview not available for layout ${esc(layout)}.

`; + } + + if (config.noteAfter) inner += `

${esc(config.noteAfter)}

`; + 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 = ` +
+

No questions to preview. Add questions on the Questions tab first.

+
`; + return; + } + + const sections = questions.map((q, idx) => { + const lid = localId(q); + const num = idx + 1; + return ` + `; + }).join(''); + + container.innerHTML = ` +
+
Preview mode — all questions on one page. Answers are not saved.
+
+ + +
+
+
+ ${sections} +
+
+
`; + + 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() }; +}