enhanced questionnaire version handling and handling uploads from outdated questionnaires through automatic revisioning and checks
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-12 21:38:39 +02:00
parent 5dff24a232
commit b4d4de72f4
17 changed files with 1289 additions and 209 deletions

View File

@ -318,6 +318,18 @@ code {
.badge-draft { background: var(--badge-draft-bg); color: var(--badge-draft-fg); }
.badge-active { background: var(--badge-active-bg); color: var(--badge-active-fg); }
.badge-archived { background: var(--badge-archived-bg); color: var(--badge-archived-fg); }
.retired-questions-panel summary {
cursor: pointer;
font-weight: 600;
margin-bottom: 8px;
}
.retired-question-item {
opacity: 0.85;
}
.retired-question-item .question-header {
cursor: default;
}
.badge-completed { background: var(--badge-active-bg); color: var(--badge-active-fg); }
.badge-in_progress { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); }
.badge-pending { background: var(--badge-pending-bg); color: var(--badge-pending-fg); }

View File

@ -467,6 +467,7 @@ function clientQnBlockHTML(clientCode, q) {
data-qn="${esc(q.questionnaireID)}"
data-submission="${esc(s.submissionID)}">${vExpanded ? '▼' : '▶'}</button>
<strong>Version ${s.version}</strong>
${s.structureRevision ? `<span class="badge badge-q">struct rev ${s.structureRevision}</span>` : ''}
${s.changedCount > 0
? `<span class="badge badge-warn">${s.changedCount} diff vs live</span>`
: '<span class="client-qn-summary">Same as live</span>'}

View File

@ -43,6 +43,7 @@ const GLASS_SCORE_LEVELS = [
let questionnaire = null;
let questions = [];
let retiredQuestions = [];
let isNew = false;
let activeTab = 'questions';
let allQuestionnaires = [];
@ -86,14 +87,19 @@ export async function editorPage(params) {
renderEditor();
} else {
try {
const quData = await apiGet(`questions.php?questionnaireID=${encodeURIComponent(params.id)}`);
const quData = await apiGet(
`questions.php?questionnaireID=${encodeURIComponent(params.id)}&includeRetired=1`
);
questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id);
if (!questionnaire) {
showToast('Questionnaire not found', 'error');
navigate('#/questionnaires');
return;
}
questions = quData.questions || [];
questionnaire.structureRevision = quData.structureRevision ?? questionnaire.structureRevision ?? 1;
const allQs = quData.questions || [];
questions = allQs.filter(q => !q.retired);
retiredQuestions = allQs.filter(q => q.retired);
document.getElementById('editorTitle').textContent = questionnaire.name;
renderEditor();
} catch (e) {
@ -772,6 +778,11 @@ function renderEditor() {
<label>Version</label>
<input type="text" id="metaVersion" value="${esc(questionnaire.version)}" ${editable ? '' : 'disabled'}>
</div>
<div class="form-group">
<label>Structure revision</label>
<input type="text" value="${esc(String(questionnaire.structureRevision ?? 1))}" disabled
title="Auto-incremented when questions or options change structurally">
</div>
<div class="form-group">
<label>State</label>
<select id="metaState" ${editable ? '' : 'disabled'}>
@ -809,6 +820,13 @@ function renderEditor() {
</div>
<div id="addQuestionFormWrapper" style="display:none"></div>
<ul class="question-list" id="questionList"></ul>
<div id="retiredQuestionsSection" style="display:none;margin-top:20px">
<details class="retired-questions-panel">
<summary>Retired questions (<span id="retiredCount">0</span>)</summary>
<p class="field-hint">Retired questions stay in upload history; counselors on older forms can still submit.</p>
<ul class="question-list retired-question-list" id="retiredQuestionList"></ul>
</details>
</div>
</div>
</div>
<div id="tabContent-translations" style="display:none">
@ -830,6 +848,7 @@ function renderEditor() {
if (!isNew) {
initTabs();
renderQuestions();
renderRetiredQuestions();
if (editable) {
document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionForm);
initDragReorder();
@ -837,6 +856,21 @@ function renderEditor() {
}
}
function applyStructureRevision(rev) {
if (rev == null) return;
questionnaire.structureRevision = rev;
const input = document.querySelector('.meta-form input[disabled][title*="Auto-incremented"]');
if (input) input.value = String(rev);
}
function notifyStructureBump(data) {
const rev = data?.structureRevision;
if (rev != null) {
applyStructureRevision(rev);
showToast(`Structure revision bumped to ${rev}.`, 'info');
}
}
function initTabs() {
const tabBar = document.getElementById('editorTabs');
if (!tabBar) return;
@ -1181,6 +1215,7 @@ function showAddQuestionForm() {
questions.push(newQ);
renderQuestions();
notifyStructureBump(qData);
showToast('Question added', 'success');
document.getElementById('aq_text').value = '';
@ -1211,6 +1246,30 @@ function hideAddQuestionForm() {
// ── Questions list ───────────────────────────────────────────────────────
function renderRetiredQuestions() {
const section = document.getElementById('retiredQuestionsSection');
const list = document.getElementById('retiredQuestionList');
const countEl = document.getElementById('retiredCount');
if (!section || !list) return;
if (!retiredQuestions.length) {
section.style.display = 'none';
list.innerHTML = '';
if (countEl) countEl.textContent = '0';
return;
}
section.style.display = '';
if (countEl) countEl.textContent = String(retiredQuestions.length);
list.innerHTML = retiredQuestions.map((q, idx) => `
<li class="question-item retired-question-item">
<div class="question-header">
<span class="q-text">${idx + 1}. <code>${esc(questionKeyOf(q) || '?')}</code> · ${esc((q.defaultText || '').slice(0, 48))}</span>
<span class="badge badge-archived">Retired</span>
${q.retiredInRevision ? `<span class="badge">rev ${q.retiredInRevision}</span>` : ''}
</div>
</li>
`).join('');
}
function renderQuestions() {
const list = document.getElementById('questionList');
if (!list) return;
@ -1219,6 +1278,7 @@ function renderQuestions() {
// update tab label
const tabBtn = document.querySelector('#editorTabs button[data-tab="questions"]');
if (tabBtn) tabBtn.textContent = `Questions (${questions.length})`;
renderRetiredQuestions();
if (!questions.length) {
list.innerHTML = `<li class="empty-state" style="padding:30px"><h3>No questions yet</h3><p>Add your first question above.</p></li>`;
@ -1603,12 +1663,23 @@ async function updateQuestion(questionID, fields) {
}
async function deleteQuestion(q) {
if (!confirm(`Delete "${q.defaultText}" and all its answers?`)) return;
if (q.retired) {
showToast('This question is already retired', 'error');
return;
}
const msg = 'Remove this question? If client data exists it will be retired (not deleted) so upload history stays intact.';
if (!confirm(msg)) return;
try {
await apiDelete('questions.php', { questionID: q.questionID });
const data = await apiDelete('questions.php', { questionID: q.questionID });
questions = questions.filter(x => x.questionID !== q.questionID);
if (data.retired) {
retiredQuestions.push({ ...q, retired: true, retiredAt: Date.now() / 1000 | 0 });
showToast('Question retired', 'success');
} else {
showToast('Question deleted', 'success');
}
notifyStructureBump(data);
renderQuestions();
showToast('Question deleted', 'success');
} catch (e) {
showToast(e.message, 'error');
}
@ -1631,12 +1702,15 @@ async function updateOption(q, answerOptionID, fields) {
}
async function deleteOption(q, answerOptionID) {
if (!confirm('Delete this answer option?')) return;
if (!confirm('Remove this answer option?')) return;
try {
await apiDelete('answer_options.php', { answerOptionID });
q.answerOptions = (q.answerOptions || []).filter(o => o.answerOptionID !== answerOptionID);
const data = await apiDelete('answer_options.php', { answerOptionID });
if (!data.retired) {
q.answerOptions = (q.answerOptions || []).filter(o => o.answerOptionID !== answerOptionID);
}
renderQuestionBody(q);
showToast('Option deleted', 'success');
notifyStructureBump(data);
showToast(data.retired ? 'Option retired' : 'Option deleted', 'success');
} catch (e) {
showToast(e.message, 'error');
}