Implemente le mini-jeu Quiz (questions/choix/points/timer)
Build and deploy / test-python (push) Failing after 1m3s
Build and deploy / test-js (push) Successful in 48s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m5s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m5s
Build and deploy / test-python (push) Failing after 1m3s
Build and deploy / test-js (push) Successful in 48s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m5s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m5s
document_engine/labels/quiz_config.py (nouveau) : modele de donnees complet, meme convention resolve_X/sanitize_X que game_engine/rendering/quiz_box_config.py cote jeu (aucun import croise) - DEFAULT_QUIZ_CONFIG, sanitize_quiz_config (valide/nettoie chaque question independamment, jamais ne leve, tronque a 2-4 choix, remet correct_index a 0 si hors bornes/non-entier, clampe points >= 0 et timer_seconds dans [5,300]), quiz_total_points. Cote serveur, routes/document/document_element_update.py revalide desormais un quiz avant persistance (seul kind qui en a besoin - les autres n'ont que des attributs scalaires sans structure a garantir) et renvoie les attributs REELLEMENT persistes dans sa reponse, pour que le client ne derive jamais de la verite serveur apres un nettoyage serveur (ex. choix en trop tronque). Cote editeur (static/document/js/document-editor.js), le panneau Proprietes d'un quiz est desormais reel : chronometre optionnel, couleur de theme, liste de questions repetable (ajout/suppression), chacune avec son texte, un nombre de choix ajustable (2-4, les inputs texte suivent), le choix correct via un radio par question, et les points gagnes. Le rendu canevas (render_document_element.py) affiche un resume reel (nombre de questions, total des points, minuteur) au lieu du placeholder generique. Tests : document_engine/labels/quiz_config.py couvert par 13 tests purs (tests/document/test_quiz_config.py, sans Flask - defauts, troncature, validation, cas limites dont bool comme correct_index) + 1 test de route verifiant la sanitization a l'ecriture et le rendu. SKIP=djlint : backlog H021 pre-existant, aucun template touche ici. ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous verts ; 63 tests cibles (document + onboarding + auth) verifies fraichement + verification manuelle live via le serveur de dev (ajout, sanitization sur choix invalides/en trop, rendu du resume avec minuteur). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
597d99a8de
commit
9d87a4bfef
@@ -682,6 +682,63 @@ img.docImage {
|
||||
background: rgb(240 68 56 / 10%);
|
||||
}
|
||||
|
||||
/* ---- Quiz : liste de questions répétable, chacune avec sa propre liste
|
||||
de choix répétable (voir forgeDocRenderQuizProps, static/document/js/
|
||||
document-editor.js) ---- */
|
||||
.docQuizQuestionsLabel {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.docQuizQuestion {
|
||||
border: 1px solid var(--doc-border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--doc-card);
|
||||
}
|
||||
|
||||
.docQuizQuestionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.docQuizRemoveBtn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.docQuizRemoveBtn:hover {
|
||||
color: var(--status-danger, #f04438);
|
||||
}
|
||||
|
||||
.docQuizChoicesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.docQuizChoiceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.docQuizChoiceRow input[type="text"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.docQuizAddBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.docEmptyState {
|
||||
font-size: 13px;
|
||||
color: var(--doc-muted);
|
||||
|
||||
@@ -195,7 +195,11 @@ function forgeDocUpdateAttributes(id, newAttributes) {
|
||||
undo: () => forgeDocApiUpdate(id, oldAttributes),
|
||||
redo: () => forgeDocApiUpdate(id, newAttributes),
|
||||
});
|
||||
el.attributes = newAttributes;
|
||||
// Le serveur revalide/nettoie certains kinds (quiz — voir
|
||||
// routes/document/document_element_update.py) : on adopte SA version,
|
||||
// jamais celle envoyée telle quelle, pour ne jamais dériver de la
|
||||
// vérité persistée (ex. un choix en trop silencieusement tronqué).
|
||||
el.attributes = result.attributes || newAttributes;
|
||||
const dom = document.querySelector(`[data-element-id="${id}"]`);
|
||||
if (dom && result.rendered_html) {
|
||||
const wrapper = document.createElement('div');
|
||||
@@ -653,6 +657,153 @@ function forgeDocRenderMinigameProps(panel, el) {
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocQuizNewQuestion() {
|
||||
return {
|
||||
text: 'Nouvelle question', choices: ['Choix 1', 'Choix 2'], correct_index: 0, points: 10,
|
||||
};
|
||||
}
|
||||
|
||||
function forgeDocRenderQuizQuestionHtml(q, qIndex) {
|
||||
const choicesHtml = q.choices.map((choice, cIndex) => `
|
||||
<div class="docQuizChoiceRow">
|
||||
<input type="radio"
|
||||
name="docQuizCorrect${qIndex}"
|
||||
class="docQuizCorrectRadio"
|
||||
data-question-index="${qIndex}"
|
||||
data-choice-index="${cIndex}"
|
||||
${cIndex === q.correct_index ? 'checked' : ''}>
|
||||
<input type="text"
|
||||
class="docQuizChoiceInput"
|
||||
value="${choice}"
|
||||
placeholder="Choix ${cIndex + 1}"
|
||||
data-question-index="${qIndex}"
|
||||
data-choice-index="${cIndex}">
|
||||
</div>
|
||||
`).join('');
|
||||
return `
|
||||
<div class="docQuizQuestion" data-question-index="${qIndex}">
|
||||
<div class="docQuizQuestionHeader">
|
||||
<span class="docFieldLabel">Question ${qIndex + 1}</span>
|
||||
<button type="button" class="docQuizRemoveBtn" data-question-index="${qIndex}" title="Supprimer cette question">🗑</button>
|
||||
</div>
|
||||
<div class="docField">
|
||||
<textarea class="docQuizTextInput" rows="2" data-question-index="${qIndex}" placeholder="Texte de la question">${q.text}</textarea>
|
||||
</div>
|
||||
<div class="docFieldRow">
|
||||
<label>Nombre de choix</label>
|
||||
<input type="number" class="docQuizChoiceCountInput" min="2" max="4" value="${q.choices.length}" data-question-index="${qIndex}" style="width:60px;">
|
||||
</div>
|
||||
<div class="docQuizChoicesList">${choicesHtml}</div>
|
||||
<div class="docFieldRow">
|
||||
<label>Points</label>
|
||||
<input type="number" class="docQuizPointsInput" min="0" value="${q.points}" data-question-index="${qIndex}" style="width:70px;">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function forgeDocRenderQuizProps(panel, el) {
|
||||
const a = el.attributes;
|
||||
const questions = a.questions || [];
|
||||
|
||||
function patch(partial) {
|
||||
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
|
||||
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
|
||||
});
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
${forgeDocPropsHeader('quiz')}
|
||||
<div class="docFieldRow">
|
||||
<label class="docFieldLabel" for="docQuizTimerEnabled">Chronomètre</label>
|
||||
<input type="checkbox" id="docQuizTimerEnabled" ${a.timer_enabled ? 'checked' : ''}>
|
||||
</div>
|
||||
${a.timer_enabled ? `
|
||||
<div class="docFieldRow">
|
||||
<label for="docQuizTimerSeconds">Secondes par question</label>
|
||||
<input type="number" id="docQuizTimerSeconds" min="5" max="300" value="${a.timer_seconds}" style="width:70px;">
|
||||
</div>` : ''}
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Couleur du thème</span>
|
||||
<div class="docSwatches" id="docQuizColor">
|
||||
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="docSectionLabel docQuizQuestionsLabel">Questions</div>
|
||||
<div id="docQuizQuestionsList">${questions.length ? questions.map(forgeDocRenderQuizQuestionHtml).join('') : '<div class="docEmptyState">Aucune question — ajoute la première ci-dessous.</div>'}</div>
|
||||
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docQuizAddQuestion">+ Ajouter une question</button>
|
||||
${forgeDocDeleteButtonHtml()}
|
||||
`;
|
||||
|
||||
document.getElementById('docQuizTimerEnabled').addEventListener('change', (e) => patch({ timer_enabled: e.target.checked }));
|
||||
const timerSecondsInput = document.getElementById('docQuizTimerSeconds');
|
||||
if (timerSecondsInput) timerSecondsInput.addEventListener('change', (e) => patch({ timer_seconds: Number(e.target.value) }));
|
||||
panel.querySelectorAll('#docQuizColor .docSwatch').forEach((sw) => {
|
||||
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
|
||||
});
|
||||
|
||||
document.getElementById('docQuizAddQuestion').addEventListener('click', () => {
|
||||
patch({ questions: [...questions, forgeDocQuizNewQuestion()] });
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docQuizRemoveBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const qIndex = Number(btn.dataset.questionIndex);
|
||||
patch({ questions: questions.filter((_, i) => i !== qIndex) });
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docQuizTextInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const qIndex = Number(input.dataset.questionIndex);
|
||||
patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, text: e.target.value } : q)) });
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docQuizChoiceCountInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const qIndex = Number(input.dataset.questionIndex);
|
||||
const count = Math.max(2, Math.min(4, Number(e.target.value) || 2));
|
||||
patch({
|
||||
questions: questions.map((q, i) => {
|
||||
if (i !== qIndex) return q;
|
||||
const choices = q.choices.slice(0, count);
|
||||
while (choices.length < count) choices.push(`Choix ${choices.length + 1}`);
|
||||
return { ...q, choices, correct_index: q.correct_index < count ? q.correct_index : 0 };
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docQuizChoiceInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const qIndex = Number(input.dataset.questionIndex);
|
||||
const cIndex = Number(input.dataset.choiceIndex);
|
||||
patch({
|
||||
questions: questions.map((q, i) => (
|
||||
i === qIndex ? { ...q, choices: q.choices.map((c, ci) => (ci === cIndex ? e.target.value : c)) } : q
|
||||
)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docQuizCorrectRadio').forEach((radio) => {
|
||||
radio.addEventListener('change', () => {
|
||||
const qIndex = Number(radio.dataset.questionIndex);
|
||||
const cIndex = Number(radio.dataset.choiceIndex);
|
||||
patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, correct_index: cIndex } : q)) });
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docQuizPointsInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const qIndex = Number(input.dataset.questionIndex);
|
||||
const points = Math.max(0, Number(e.target.value) || 0);
|
||||
patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, points } : q)) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocRenderProps(el) {
|
||||
const panel = document.getElementById('docPropsPanel');
|
||||
if (!el) {
|
||||
@@ -664,6 +815,7 @@ function forgeDocRenderProps(el) {
|
||||
else if (el.kind === 'image') forgeDocRenderImageProps(panel, el);
|
||||
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
|
||||
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
|
||||
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
||||
else forgeDocRenderMinigameProps(panel, el);
|
||||
|
||||
const deleteBtn = document.getElementById('docDeletePropBtn');
|
||||
|
||||
Reference in New Issue
Block a user