Ajoute les listes à puces et numérotées dans la bibliothèque de contenu
Retour utilisateur du 24/09/2026 : "dans la section contenu il manque la possibilité d'utiliser une liste à puce ou ordonnée". - Deux nouveaux kinds "liste_puces"/"liste_numerotee" dans CONTENT_KINDS, chacun sélectionnable directement dans la bibliothèque (comme titre/paragraphe/image/bouton) — partagent la même structure d'attributs (`items`, une liste de chaînes), c'est le kind lui-même qui décide <ul> ou <ol> au rendu (_render_list), pas un attribut "ordered" redondant à tenir synchronisé. - Rendu : un <li> par item, échappé (html.escape) comme tout le contenu texte du document — une liste vide rend <ul>/<ol> sans enfant plutôt qu'un placeholder (état normal, pas une image sans fichier). - Panneau Propriétés : même patron répéteur que l'Association/Memory (ajouter/renommer/supprimer une ligne, rechargé après chaque modification). - Icônes de bibliothèque (☰/①) + style .docList (puces/numéros visibles, espacement entre items). 6 nouveaux tests Python (attributs par défaut, rendu <ul>/<ol>, échappement, liste vide, route d'ajout) + vérifié par un test jsdom dédié contre un vrai support (bibliothèque, panneau Propriétés : ajout/modification/suppression d'item réellement fonctionnels). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
eac45f1c9d
commit
e7c6ed7159
@@ -11,7 +11,7 @@ from .mots_config import DEFAULT_MOTS_CONFIG
|
|||||||
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
||||||
from .scenario_config import DEFAULT_SCENARIO_CONFIG
|
from .scenario_config import DEFAULT_SCENARIO_CONFIG
|
||||||
|
|
||||||
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
|
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton", "liste_puces", "liste_numerotee")
|
||||||
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
|
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
|
||||||
|
|
||||||
# "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le
|
# "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le
|
||||||
@@ -28,6 +28,8 @@ ELEMENT_KIND_LABELS: dict[str, str] = {
|
|||||||
"paragraphe": "Paragraphe",
|
"paragraphe": "Paragraphe",
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
"bouton": "Bouton",
|
"bouton": "Bouton",
|
||||||
|
"liste_puces": "Liste à puces",
|
||||||
|
"liste_numerotee": "Liste numérotée",
|
||||||
"quiz": "Quiz",
|
"quiz": "Quiz",
|
||||||
"association": "Association",
|
"association": "Association",
|
||||||
"memory": "Memory",
|
"memory": "Memory",
|
||||||
@@ -57,6 +59,12 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
|
|||||||
return {"src": "", "alt": ""}
|
return {"src": "", "alt": ""}
|
||||||
if kind == "bouton":
|
if kind == "bouton":
|
||||||
return {"label": "Bouton", "target": ""}
|
return {"label": "Bouton", "target": ""}
|
||||||
|
if kind in ("liste_puces", "liste_numerotee"):
|
||||||
|
# Une seule et même structure d'attributs pour les deux kinds —
|
||||||
|
# "ordonnée ou non" se lit directement sur le kind au moment du
|
||||||
|
# rendu (voir render_document_element._render_list), jamais un
|
||||||
|
# attribut "ordered" redondant à tenir synchronisé avec le kind.
|
||||||
|
return {"items": ["Premier élément", "Deuxième élément"]}
|
||||||
if kind == "row":
|
if kind == "row":
|
||||||
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
|
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
|
||||||
if kind == "quiz":
|
if kind == "quiz":
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ groupée par catégorie (panneau gauche de l'éditeur), libellés d'affichage, e
|
|||||||
attributs par défaut posés à la création de chaque type.
|
attributs par défaut posés à la création de chaque type.
|
||||||
|
|
||||||
## `CONTENT_KINDS: tuple[str, ...]`
|
## `CONTENT_KINDS: tuple[str, ...]`
|
||||||
`("titre", "paragraphe", "image", "bouton")` — éléments du flux, peuvent
|
`("titre", "paragraphe", "image", "bouton", "liste_puces",
|
||||||
être top-niveau ou enfants d'une rangée.
|
"liste_numerotee")` — éléments du flux, peuvent être top-niveau ou
|
||||||
|
enfants d'une rangée. `"liste_puces"`/`"liste_numerotee"` partagent la
|
||||||
|
même structure d'attributs (`items`) ; c'est le `kind` lui-même qui
|
||||||
|
décide `<ul>` ou `<ol>` au rendu (voir
|
||||||
|
`document_engine/rendering/render_document_element.py::_render_list`),
|
||||||
|
jamais un attribut `ordered` redondant.
|
||||||
|
|
||||||
## `MINIGAME_KINDS: tuple[str, ...]`
|
## `MINIGAME_KINDS: tuple[str, ...]`
|
||||||
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
|
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
|
||||||
@@ -30,7 +35,8 @@ Attributs posés à la création d'un élément de ce type (voir
|
|||||||
`document_engine/elements/add_document_element.py`).
|
`document_engine/elements/add_document_element.py`).
|
||||||
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
|
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
|
||||||
texte (`content/style` + `bold/italic/underline/align/color`),
|
texte (`content/style` + `bold/italic/underline/align/color`),
|
||||||
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
|
image (`src/alt`), bouton (`label/target`), liste à puces/numérotée
|
||||||
|
(`items`, une liste de chaînes), rangée (`gap/align/justify`),
|
||||||
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
|
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
|
||||||
(`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), memory
|
(`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), memory
|
||||||
(`DEFAULT_MEMORY_CONFIG`, voir `memory_config.py`), autre mini-jeu
|
(`DEFAULT_MEMORY_CONFIG`, voir `memory_config.py`), autre mini-jeu
|
||||||
|
|||||||
@@ -74,6 +74,20 @@ def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list
|
|||||||
return f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" src="{src}" alt="{alt}">'
|
return f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" src="{src}" alt="{alt}">'
|
||||||
|
|
||||||
|
|
||||||
|
def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
|
"""Liste à puces (<ul>) ou numérotée (<ol>) — le kind lui-même décide
|
||||||
|
la balise, pas un attribut "ordered" séparé (voir
|
||||||
|
element_kind_labels.element_default_attributes). Une "items" vide
|
||||||
|
rend une liste vide plutôt qu'un placeholder : contrairement à une
|
||||||
|
image sans fichier, une liste sans élément n'a rien d'anormal à
|
||||||
|
afficher (le créateur vient peut-être de tout supprimer avant d'en
|
||||||
|
retaper un)."""
|
||||||
|
items = el["attributes"].get("items", [])
|
||||||
|
tag = "ol" if el["kind"] == "liste_numerotee" else "ul"
|
||||||
|
items_html = "".join(f"<li>{html_lib.escape(str(item))}</li>" for item in items)
|
||||||
|
return f'<{tag} class="docList" data-element-id="{el["id"]}" data-kind="{el["kind"]}">{items_html}</{tag}>'
|
||||||
|
|
||||||
|
|
||||||
def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
a = el["attributes"]
|
a = el["attributes"]
|
||||||
label = html_lib.escape(str(a.get("label", "Bouton")))
|
label = html_lib.escape(str(a.get("label", "Bouton")))
|
||||||
@@ -498,6 +512,8 @@ _RENDERERS = {
|
|||||||
"paragraphe": _render_text,
|
"paragraphe": _render_text,
|
||||||
"image": _render_image,
|
"image": _render_image,
|
||||||
"bouton": _render_button,
|
"bouton": _render_button,
|
||||||
|
"liste_puces": _render_list,
|
||||||
|
"liste_numerotee": _render_list,
|
||||||
"quiz": _render_quiz,
|
"quiz": _render_quiz,
|
||||||
"association": _render_association,
|
"association": _render_association,
|
||||||
"memory": _render_memory,
|
"memory": _render_memory,
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ regroupement à chaque appel.
|
|||||||
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
|
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
|
||||||
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
|
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
|
||||||
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel.
|
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel.
|
||||||
|
- **Liste à puces/numérotée** (`liste_puces`/`liste_numerotee`) :
|
||||||
|
`<ul>` ou `<ol>` selon le `kind` (fonction privée `_render_list`,
|
||||||
|
partagée par les deux) — un `<li>` par entrée de `attributes["items"]`.
|
||||||
|
Une liste vide rend `<ul>`/`<ol>` sans enfant plutôt qu'un placeholder :
|
||||||
|
contrairement à une image sans fichier, ce n'est pas un état anormal.
|
||||||
- **Quiz** : toujours une carte résumant la config réelle (nombre de
|
- **Quiz** : toujours une carte résumant la config réelle (nombre de
|
||||||
questions, total des points via `quiz_total_points`, minuteur si
|
questions, total des points via `quiz_total_points`, minuteur si
|
||||||
activé) — sanitizée (`sanitize_quiz_config`) avant lecture, jamais un
|
activé) — sanitizée (`sanitize_quiz_config`) avant lecture, jamais un
|
||||||
|
|||||||
@@ -304,6 +304,8 @@ body.objectEditBody > main.content {
|
|||||||
.docLibIcon--paragraphe::before { content: "¶"; }
|
.docLibIcon--paragraphe::before { content: "¶"; }
|
||||||
.docLibIcon--image::before { content: "🖼"; }
|
.docLibIcon--image::before { content: "🖼"; }
|
||||||
.docLibIcon--bouton::before { content: "⬚"; }
|
.docLibIcon--bouton::before { content: "⬚"; }
|
||||||
|
.docLibIcon--liste_puces::before { content: "☰"; }
|
||||||
|
.docLibIcon--liste_numerotee::before { content: "①"; }
|
||||||
|
|
||||||
.docLibIcon--titre::before {
|
.docLibIcon--titre::before {
|
||||||
content: "T";
|
content: "T";
|
||||||
@@ -600,6 +602,18 @@ img.docImage {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docList {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.4em;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--forge-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docList li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.docMinigame {
|
.docMinigame {
|
||||||
border: 1px solid var(--doc-accent);
|
border: 1px solid var(--doc-accent);
|
||||||
|
|
||||||
|
|||||||
@@ -1669,6 +1669,57 @@ function forgeDocRenderButtonProps(panel, el) {
|
|||||||
document.getElementById('docButtonTarget').addEventListener('change', (e) => patch({ target: e.target.value.trim() }));
|
document.getElementById('docButtonTarget').addEventListener('change', (e) => patch({ target: e.target.value.trim() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgeDocRenderListItemHtml(item, index) {
|
||||||
|
return `
|
||||||
|
<div class="docAssocPairRow" data-item-index="${index}">
|
||||||
|
<input type="text" class="docAssocPairInput docListItemInput" data-item-index="${index}" value="${forgeDocEscapeHtml(item)}">
|
||||||
|
<button type="button" class="docQuizRemoveBtn" data-item-index="${index}" aria-label="Supprimer cet élément" title="Supprimer cet élément">✕</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocRenderListProps(panel, el) {
|
||||||
|
// Même patron répéteur que l'Association/Memory (ajouter/supprimer/
|
||||||
|
// modifier une ligne, panneau entièrement rechargé après chaque
|
||||||
|
// modification via patch()) — ici une simple liste de chaînes, pas de
|
||||||
|
// structure à 2 champs comme une paire.
|
||||||
|
const a = el.attributes;
|
||||||
|
const items = a.items || [];
|
||||||
|
const kindLabel = el.kind === 'liste_numerotee' ? 'liste numérotée' : 'liste à puces';
|
||||||
|
|
||||||
|
function patch(partial) {
|
||||||
|
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
|
||||||
|
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
${forgeDocPropsHeader(kindLabel)}
|
||||||
|
<div class="docSectionLabel docQuizQuestionsLabel">Éléments de la liste</div>
|
||||||
|
<div id="docListItemsList">${items.length ? items.map(forgeDocRenderListItemHtml).join('') : '<div class="docEmptyState">Aucun élément — ajoute le premier ci-dessous.</div>'}</div>
|
||||||
|
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docListAddItem">+ Ajouter un élément</button>
|
||||||
|
${forgeDocDeleteButtonHtml()}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('docListAddItem').addEventListener('click', () => {
|
||||||
|
patch({ items: [...items, 'Nouvel élément'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll('#docListItemsList .docQuizRemoveBtn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const index = Number(btn.dataset.itemIndex);
|
||||||
|
patch({ items: items.filter((_, i) => i !== index) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll('.docListItemInput').forEach((input) => {
|
||||||
|
input.addEventListener('change', (e) => {
|
||||||
|
const index = Number(input.dataset.itemIndex);
|
||||||
|
patch({ items: items.map((it, i) => (i === index ? e.target.value : it)) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function forgeDocRenderRowProps(panel, el) {
|
function forgeDocRenderRowProps(panel, el) {
|
||||||
const a = el.attributes;
|
const a = el.attributes;
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
@@ -2725,6 +2776,7 @@ function forgeDocRenderProps(el) {
|
|||||||
if (el.kind === 'titre' || el.kind === 'paragraphe') forgeDocRenderTextProps(panel, el);
|
if (el.kind === 'titre' || el.kind === 'paragraphe') forgeDocRenderTextProps(panel, el);
|
||||||
else if (el.kind === 'image') forgeDocRenderImageProps(panel, el);
|
else if (el.kind === 'image') forgeDocRenderImageProps(panel, el);
|
||||||
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
|
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
|
||||||
|
else if (el.kind === 'liste_puces' || el.kind === 'liste_numerotee') forgeDocRenderListProps(panel, el);
|
||||||
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
|
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
|
||||||
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
||||||
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
||||||
|
|||||||
@@ -180,3 +180,48 @@ def test_move_document_element_to_page_on_missing_id_is_a_noop(tmp_support_slug_
|
|||||||
doc_engine.move_document_element_to_page(slug, 999, page2_id)
|
doc_engine.move_document_element_to_page(slug, 999, page2_id)
|
||||||
assert doc_engine.list_document_elements(slug, page1_id) == []
|
assert doc_engine.list_document_elements(slug, page1_id) == []
|
||||||
assert doc_engine.list_document_elements(slug, page2_id) == []
|
assert doc_engine.list_document_elements(slug, page2_id) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_liste_puces_default_attributes_and_render(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
|
||||||
|
element = doc_engine.get_document_element(slug, element_id)
|
||||||
|
assert element is not None
|
||||||
|
assert element["attributes"]["items"] == ["Premier élément", "Deuxième élément"]
|
||||||
|
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert html.startswith("<ul")
|
||||||
|
assert 'data-kind="liste_puces"' in html
|
||||||
|
assert "<li>Premier élément</li>" in html
|
||||||
|
assert "<li>Deuxième élément</li>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_liste_numerotee_renders_an_ordered_list(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "liste_numerotee", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["Un", "Deux", "Trois"]})
|
||||||
|
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert html.startswith("<ol")
|
||||||
|
assert 'data-kind="liste_numerotee"' in html
|
||||||
|
assert "<li>Un</li><li>Deux</li><li>Trois</li>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_liste_puces_escapes_item_content(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["<script>alert(1)</script>"]})
|
||||||
|
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert "<script>" not in html
|
||||||
|
assert "<script>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_liste_puces_with_no_items_renders_an_empty_list(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
|
||||||
|
element_id = doc_engine.list_document_elements(slug, page_id)[0]["id"]
|
||||||
|
doc_engine.update_document_element_attributes(slug, element_id, {"items": []})
|
||||||
|
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert html == f'<ul class="docList" data-element-id="{element_id}" data-kind="liste_puces"></ul>'
|
||||||
|
|||||||
@@ -69,6 +69,25 @@ def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskCli
|
|||||||
assert resp.get_json()["kind"] == "row"
|
assert resp.get_json()["kind"] == "row"
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_add_creates_a_bullet_list(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "liste_puces", "page_id": _page_id(support)})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload["kind"] == "liste_puces"
|
||||||
|
assert "<ul" in payload["rendered_html"]
|
||||||
|
assert "docList" in payload["rendered_html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_add_creates_a_numbered_list(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/add", data={"kind": "liste_numerotee", "page_id": _page_id(support)}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload["kind"] == "liste_numerotee"
|
||||||
|
assert "<ol" in payload["rendered_html"]
|
||||||
|
|
||||||
|
|
||||||
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
|
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
|
||||||
page_id = _page_id(support)
|
page_id = _page_id(support)
|
||||||
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id})
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id})
|
||||||
|
|||||||
Reference in New Issue
Block a user