1// ============================================================= 2// SESSION ADMIN â PERSISTANCE (save handlers) 3// ============================================================= 4// 5// Module extrait de session-admin.js le 2026-07-22 (2ème extraction). 6// Regroupe les 2 handlers de persistance qui sérialisent l'état de 7// l'éditeur admin et POSTent à l'API. 8// 9// Contenu : 10// 1. saveAdminSkills(sessionId) â collecte les compétences depuis 11// #admin-skills-list .admin-skill-row et POSTe /api/bo/sessions/ 12// :id/skills. Affiche le résultat dans #admin-skills-msg. 13// 2. saveAdminQuestions(sessionId) â collecte les questions depuis 14// #admin-questions-list .admin-question-row et POSTe /api/bo/ 15// sessions/:id/questions. Gère les questions conditionnelles et 16// les valeurs des enums (radio/checkbox). Affiche le résultat 17// dans #admin-questions-msg. 18// 19// Les 2 fonctions sont câblées depuis renderSessionAdmin (boutons 20// "Enregistrer" dans les onglets Compétences et Questions). 21// 22// Dépendances externes (globales JS déjà chargées) : 23// - safeInt (core.js) â parsing d'entier avec fallback 24// - fetch / setTimeout (browser standard) 25 26/** 27 * Collecte les compétences depuis l'éditeur admin et les envoie à l'API. 28 * @param {string} sessionId 29 */ 30async function saveAdminSkills(sessionId) { 31 const rows = document.querySelectorAll("#admin-skills-list .admin-skill-row"); 32 const skills = []; 33 34 rows.forEach((row, i) => { 35 const name = row.querySelector(".ask-name").value.trim(); 36 if (!name) return; 37 skills.push({ 38 name, 39 category: row.querySelector(".ask-category").value.trim() || undefined, 40 priorityWeight: safeInt(row.querySelector(".ask-weight").value, 5), 41 order: i, 42 }); 43 }); 44 45 const msgSpan = document.getElementById("skills-save-msg"); 46 const res = await fetch(`/api/bo/sessions/${sessionId}/skills`, { 47 method: "POST", 48 headers: { "Content-Type": "application/json" }, 49 credentials: "same-origin", 50 body: JSON.stringify(skills), 51 }); 52 53 if (res.ok) { 54 msgSpan.textContent = `${skills.length} compétence(s) enregistrée(s)`; 55 msgSpan.style.color = "var(--color-success)"; 56 } else { 57 const d = await res.json(); 58 msgSpan.textContent = d.error || "Erreur"; 59 msgSpan.style.color = "var(--color-error)"; 60 } 61 setTimeout(() => { msgSpan.textContent = ""; }, 3000); 62} 63 64/** 65 * Collecte les questions depuis l'éditeur admin et les envoie à l'API. 66 * @param {string} sessionId 67 */ 68async function saveAdminQuestions(sessionId) { 69 const rows = document.querySelectorAll("#admin-questions-list .admin-question-row"); 70 const questions = []; 71 72 // Snapshot de toutes les rows présentes (incluant les vides) â utilisé 73 // pour résoudre l'index de la question parente d'une condition. On 74 // s'appuie sur cet ordre, pas sur l'index de la boucle filtrée 75 // (qui exclut les rows sans label). 76 const allRowsSnapshot = Array.from(rows); 77 const msgSpan = document.getElementById("questions-save-msg"); 78 79 // --- Pré-validation des conditions d'affichage --- 80 // 81 // Avant ce contrôle, si le toggle "Cette question est conditionnelle" 82 // était coché mais que la parente ou les valeurs déclenchantes étaient 83 // vides, la condition était silencieusement ignorée à la sauvegarde. 84 // Conséquence : la question apparaissait toujours côté candidat alors 85 // que l'utilisateur pensait l'avoir conditionnée. 86 // 87 // On collecte ici toutes les conditions invalides et on bloque la 88 // sauvegarde avec un message clair + une bordure rouge sur la ligne 89 // fautive (réinitialisée au prochain save). 90 const condErrors = []; 91 rows.forEach((row, i) => { 92 row.style.borderLeft = ""; // reset visuel d'une éventuelle erreur précédente 93 const label = row.querySelector(".aq-label").value.trim() || "(sans titre)"; 94 const condToggle = row.querySelector(".aq-cond-toggle"); 95 if (!condToggle || !condToggle.checked) return; 96 97 const parentSelect = row.querySelector(".aq-cond-parent"); 98 const parentId = parentSelect ? parentSelect.value : ""; 99 const valueChecks = row.querySelectorAll(".aq-cond-value:checked"); 100 101 if (!parentId) { 102 condErrors.push(`"${label}" : aucune question parente choisie`); 103 row.style.borderLeft = "3px solid var(--color-error)"; 104 return; 105 }
106 if (valueChecks.length === 0) { 107 condErrors.push(`"${label}" : aucune valeur déclenchante cochée`); 108 row.style.borderLeft = "3px solid var(--color-error)"; 109 return; 110 } 111 112 // Vérifier que la parente est bien AVANT cette question (sinon la 113 // condition sera silencieusement ignorée par le backend). Cas 114 // typique : l'utilisateur a déplacé la parente APRÃS la fille. 115 const parentRow = document.querySelector(`#admin-questions-list .admin-question-row[data-question-id="${parentId}"]`); 116 const parentIdx = allRowsSnapshot.indexOf(parentRow); 117 if (parentIdx < 0 || parentIdx >= i) { 118 condErrors.push(`"${label}" : la question parente doit être placée AVANT cette question (utilise les flèches â²â¼)`); 119 row.style.borderLeft = "3px solid var(--color-error)"; 120 } 121 }); 122 123 if (condErrors.length > 0) { 124 msgSpan.textContent = "Conditions invalides â " + condErrors.join(" ; "); 125 msgSpan.style.color = "var(--color-error)"; 126 setTimeout(() => { msgSpan.textContent = ""; }, 8000); 127 return; // on ne sauvegarde pas tant que les conditions ne sont pas valides 128 } 129 130 // --- Construction du payload --- 131 rows.forEach((row, i) => { 132 const label = row.querySelector(".aq-label").value.trim(); 133 if (!label) return; 134 135 const type = row.dataset.type; 136 const required = row.querySelector(".aq-required").checked; 137 const question = { label, type, required, order: i }; 138 139 if (type === "radio" || type === "checkbox") { 140 const optionsText = row.querySelector(".aq-options").value.trim(); 141 if (optionsText) { 142 question.options = optionsText.split("\n").map((o) => o.trim()).filter(Boolean); 143 } 144 // Bonus scoring : disponible uniquement pour radio (pas checkbox 145 // dans l'app actuelle, le scoring multi-valeur n'est pas géré) 146 if (type === "radio") { 147 const bonusEl = row.querySelector(".aq-bonus"); 148 const bonusText = bonusEl ? bonusEl.value.trim() : ""; 149 if (bonusText) { 150 try { 151 question.bonusRules = JSON.parse(bonusText); 152 } catch (e) { 153 bonusEl.style.borderColor = "var(--color-error)"; 154 console.warn(`[Questions] Bonus JSON invalide pour "${label}":`, bonusText, e.message); 155 } 156 } 157 } 158 } else { 159 question.maxLength = safeInt(row.querySelector(".aq-maxlength").value, 1500); 160 } 161 162 // Condition d'affichage â la pré-validation ci-dessus garantit que 163 // si on entre ici, parentId / values / parentIdx sont tous valides. 164 const condToggle = row.querySelector(".aq-cond-toggle"); 165 if (condToggle && condToggle.checked) { 166 const parentId = row.querySelector(".aq-cond-parent").value; 167 const values = Array.from(row.querySelectorAll(".aq-cond-value:checked")).map((c) => c.value); 168 const parentRow = document.querySelector(`#admin-questions-list .admin-question-row[data-question-id="${parentId}"]`); 169 const parentIdx = allRowsSnapshot.indexOf(parentRow); 170 question.conditionalOn = { questionIndex: parentIdx, values }; 171 } 172 173 questions.push(question); 174 }); 175 176 // Log pour debug â visible dans la console navigateur (F12) 177 console.log("[Questions] Payload envoyé:", JSON.stringify(questions, null, 2)); 178 179 // msgSpan a déjà été récupéré au début de la fonction pour la 180 // pré-validation des conditions ; on le réutilise ici. 181 const res = await fetch(`/api/bo/sessions/${sessionId}/questions`, { 182 method: "POST", 183 headers: { "Content-Type": "application/json" }, 184 credentials: "same-origin", 185 body: JSON.stringify(questions), 186 }); 187 188 if (res.ok) { 189 msgSpan.textContent = `${questions.length} question(s) enregistrée(s)`; 190 msgSpan.style.color = "var(--color-success)"; 191 } else { 192 const d = await res.json(); 193 // Afficher le détail de l'erreur de validation si disponible 194 const details = d.details ? ` (${d.details.join(", ")})` : ""; 195 msgSpan.textContent = (d.error || "Erreur") + details; 196 msgSpan.style.color = "var(--color-error)"; 197 } 198 setTimeout(() => { msgSpan.textContent = ""; }, 5000); 199}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.