feat: commentaires façon Notion — ancres jaunes, tiroir refondu, réactions emoji sur texte (v7.64.0)
FlowDeck CI / lint (push) Successful in 2m5s
FlowDeck CI / test (push) Failing after 3h5m38s
FlowDeck CI / docker (push) Skipped

- Réaction sur texte : bouton 🙂+ (« React to selected text ») dans la toolbar
  de sélection → sélecteur d'émoji EXISTANT en mode réaction (en-tête = texte
  sélectionné + ✋, onglets masqués, ligne Recent au-dessus de la grille
  People, Filter… et barre de catégories avec + custom inchangés)
- Backend : migration 37 text_reactions (UNIQUE plage × emoji × utilisateur →
  POST idempotent) + GET/POST /api/pages/{id}/reactions (GET groupé par plage
  emoji/count/mine, POST toggle, 400 sur plage vide)
- Affichage : plage commentée/réagie peinte en jaune (_paintAnchors après
  render/loadComments/loadReactions), pastille 😀+compte après la plage, clic
  pastille = rouvrir le sélecteur, clic ancre = ouvrir et centrer le fil
- Pastilles = annotations : gtTok/gtMd les ignorent (y compris le chemin
  rapide innerText) et les offsets _srcOffset/_srcNodeLen les excluent → le
  texte sauvé est identique (vérifié dans le e2e)
- Tiroir refondu : carte de fil (avatar, nom, horodatage, ✓ toggle
  resolve/reopen, ⋯ → Copy link #cmt-<id>/Delete), saisie « Add a comment... »
  avec @ et envoi ↑/Entrée ; #cmt-<id> ouvre et centre le fil au chargement ;
  loadComments + loadReactions à l'ouverture (commentCount enfin renseigné)
- Fixes : resolveComment ne pouvait pas rouvrir (toujours resolved:true) ;
  showCommentBtn ignorait les sélections à ancre élément (bouton 💬 jamais
  affiché)
- Tests : tests/test_v764_comments_reactions.py (8, dont toggle API réel),
  e2e/v764_comments_reactions.spec.js (8 étapes) — 3/3 specs e2e verts,
  suite complète 1383 passed / 0 failed, ruff OK, OpenAPI 7.64.0 (526 chemins)
This commit is contained in:
2026-10-08 10:46:14 -04:00
parent 699165469d
commit 400a827e3f
15 changed files with 920 additions and 39 deletions
+139
View File
@@ -0,0 +1,139 @@
"""7.64.0 — commentaires façon Notion (ancres jaunes + tiroir) & réactions sur texte."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
PICKER = (ROOT / "app" / "templates" / "_icon_picker.html").read_text(encoding="utf-8")
PSTORE = (ROOT / "static" / "js" / "_icon_picker_2.js").read_text(encoding="utf-8")
# ── Backend : migration + endpoints ────────────────────────────────────────
def test_text_reactions_table_exists(client): # le fixture client initialise le schéma
from app.db import get_conn
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(text_reactions)").fetchall()}
assert {"id", "page_id", "block_id", "anchor_start", "anchor_end", "emoji",
"user_id", "created_at"} <= cols, cols
def test_reactions_endpoints_registered():
from app.routers.collaboration import router
methods = {(r.path, m) for r in router.routes for m in r.methods}
assert ("/api/pages/{page_id}/reactions", "GET") in methods
assert ("/api/pages/{page_id}/reactions", "POST") in methods
def test_toggle_reaction_api(client):
"""Le POST bascule (added → removed → added) et le GET regroupe par plage."""
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES (?,?,?)",
("v764u", "V764 User", "[email protected]"))
uid = conn.execute("SELECT id FROM users WHERE login='v764u'").fetchone()["id"]
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format) VALUES (?,?,?,?)",
("u764", "React Page", "[]", "blocks"))
pid = cur.lastrowid
conn.commit()
cookies = {"flowdeck_session": SessionManager.create_session(
{"id": uid, "login": "v764u", "is_admin": 0})}
payload = {"block_id": "b1", "anchor_start": 0, "anchor_end": 5, "emoji": "👍"}
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.status_code == 200 and r.json()["status"] == "added"
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.status_code == 200 and r.json()["status"] == "removed"
r = client.post(f"/api/pages/{pid}/reactions", json=payload, cookies=cookies)
assert r.json()["status"] == "added"
r = client.get(f"/api/pages/{pid}/reactions", cookies=cookies)
assert r.status_code == 200
rows = r.json()["reactions"]
assert len(rows) == 1
assert rows[0]["emoji"] == "👍" and rows[0]["count"] == 1 and rows[0]["mine"] == 1
# plage vide → 400
bad = dict(payload, anchor_start=4, anchor_end=4)
r = client.post(f"/api/pages/{pid}/reactions", json=bad, cookies=cookies)
assert r.status_code == 400
# ── Toolbar : bouton réaction ──────────────────────────────────────────────
def test_toolbar_has_react_button():
assert 'fmtReact()' in TPL
assert 'data-tooltip="React to selected text"' in TPL
# position : 3ᵉ ligne, après le surlignage, avant le code
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
order = ["fmtHighlight()", "fmtReact()", "fmtCodeBlock()", "fmtMore($event)"]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
def test_fmt_react_opens_picker_with_selection():
js = SRC[SRC.index("fmtReact(){"):SRC.index("toggleComments(){")]
for token in ("FDIconPicker.openFor", "title:text", "onPick:",
"toggleReaction(bid,span.start,span.end,emoji)", "_selSpan(bel,this._sel.range)"):
assert token in js, token
# ── Ancres commentées + pastilles ──────────────────────────────────────────
def test_anchors_painting():
for token in ("function _wrapAnchored(", "_paintAnchors()", "fd-anchor",
"fd-react-chip", "data-react-bid", "data-react-start", "openThread(cid)",
"loadReactions()", "toggleReaction(bid,start,end,emoji)"):
assert token in SRC, token
# repeint après chaque reconstruction du bloc ET après le chargement
assert "this._paintAnchors();}catch(e){}" in SRC # fin de render()
assert "this._paintAnchors();\n" in SRC # fin de loadComments/loadReactions
# la pastille n'est pas du contenu lu/sauvé
assert "n.classList.contains('fd-react-chip')" in SRC # gtTok
assert "cl.contains('fd-react-chip')" in SRC # gtMd
# offsets source (les annotations ne décalent pas les ancres)
assert "function _srcOffset(" in SRC and "_srcNodeLen(" in SRC
# clic ancre → fil ; clic pastille → sélecteur
assert "t.closest('.fd-react-chip')" in SRC
assert "t.closest('.fd-anchor[data-cid]')" in SRC
# ── Tiroir de commentaires (image 1) ───────────────────────────────────────
def test_drawer_markup():
for token in ('class="ct-head"', 'class="ct-avatar"', 'class="ct-name"',
'class="ct-time"', 'class="ct-actions"', 'class="ct-menu"',
"copyCommentLink(c.id)", "deleteComment(c.id)", "resolveComment(c)",
':data-thread="c.id"', 'placeholder="Add a comment..."',
"commentAt()", "class=\"ct-send\"", "addPageComment()"):
assert token in TPL, token
# le bouton ✓ bascule resolve/reopen (l'objet, pas l'id seul)
assert "resolveComment(c)" in TPL and "async resolveComment(c)" in SRC
# hash #cmt- → centrage du fil
assert "#cmt-" in SRC and "scrollIntoView" in SRC
# styles du tiroir
for sel in (".comment-thread {", ".ct-head {", ".ct-avatar {", ".ct-menu {",
".ct-input-row {", ".ct-send {", ".fd-anchor {", ".fd-react-chip {"):
assert sel in CSS, sel
# ── Sélecteur d'émoji (image 3) : mode réaction ────────────────────────────
def test_picker_reaction_mode():
for token in ("fdip-title", "fdip-title-text", "fdip-title-grip",
"$store.fdIconPicker.title", "Filter...", "Recent"):
assert token in PICKER, token
for token in ("this.title = opts.title || ''", "this.category = 'people'",
"this.title = ''"):
assert token in PSTORE, token
# la ligne « Recent » au-dessus de la grille n'apparaît qu'en mode réaction
assert "$store.fdIconPicker.title && $store.fdIconPicker.category !== 'recent'" in PICKER
# onglets masqués en mode réaction (emoji only)
assert 'x-show="!$store.fdIconPicker.title"' in PICKER