Compare commits

...
2 Commits
Author SHA1 Message Date
bruno f132885b08 feat: bouton + à gauche du handle de bloc — insère un bloc dessous (v7.60.0)
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 19m27s
FlowDeck CI / docker (push) Skipped
- nouveau bouton .block-insert-btn émis AVANT le handle (6 points) dans le
  wrapper de chaque bloc de premier niveau : clic → E.addAfter(idx) →
  addAt(idx+1) → bloc inséré sous le courant et focusé (mécanisme déjà en
  place, réutilisé tel quel)
- position : left:-64px (gouttière .blocks-container = 64px), -56px dans le
  breakpoint où la gouttière passe à 32px ; apparaît au survol/focus comme le
  handle ; masqué avec lui sur écran étroit (2 breakpoints)
- aria-label + title « Add block below »
- tests : tests/test_v760_block_insert_btn.py (2 tests : ordre dans le
  wrapper, câblage E.addAfter, positions/masquages CSS)
- livraison : VERSION + app/main = 7.60.0, OpenAPI 525 chemins, CHANGELOG
2026-10-07 22:25:27 -04:00
bruno a79bdc1641 fix: « Turn into » du menu contextuel ne fonctionnait pas (v7.59.3)
FlowDeck CI / lint (push) Successful in 2m20s
FlowDeck CI / test (push) Failing after 3h12m11s
FlowDeck CI / docker (push) Skipped
- cause : la garde de turnInto() faisait _BLOCK_TURN_TYPES.indexOf(type) sur
  un tableau d'objets {id,name} → toujours -1 → retour immédiat après
  l'ouverture du sous-menu, aucun changement de format
- correction : validation par id (_BLOCK_TURN_TYPES.some(t=>t.id===type))
- même fonction : convertir un texte en To-do / Toggle vidait le contenu
  (exclusion par erreur) ; seuls les types sans champ texte (image, embed,
  meeting, divider, table_of_contents, database, button, columns) le vident
- tests : tests/test_v7593_turn_into.py (4 tests, dont 1 comportemental node
  qui exécute la vraie turnInto() extraite de la source)
- livraison : VERSION + app/main = 7.59.3, OpenAPI 525 chemins, CHANGELOG
2026-10-07 21:32:22 -04:00
8 changed files with 184 additions and 4 deletions
+33
View File
@@ -1,5 +1,38 @@
# Changelog - FlowDeck
## v7.60.0 (2026-10-07) — Éditeur : bouton + à gauche du handle de bloc
### Added
- **Bouton `+` à gauche du handle** (les 6 points qui ouvrent le menu ⋮⋮ /
servent au drag) : clique → **nouveau bloc inséré sous le bloc courant**,
via le mécanisme déjà en place (`E.addAfter(idx)` → `addAt(idx+1)`).
- Positionné dans la gouttière (`left:-64px`, gouttière `.blocks-container`
de 64px, `-56px` quand elle passe à 32px), apparaît au survol/focus du bloc
comme le handle, masqué avec lui sur écran étroit (≤768px et le second
breakpoint — aucun débordement horizontal ajouté au conteneur.
- `aria-label` + `title` « Add block below » (accessibilité).
- **Tests** — `tests/test_v760_block_insert_btn.py` : **2 tests** (ordre
dans le wrapper, câblage `E.addAfter`, positions/masquages CSS).
## v7.59.3 (2026-10-07) — Correction : « Turn into » du menu contextuel
### Fixed
- **« Turn into… » ne faisait rien** — la garde validait le type avec
`_BLOCK_TURN_TYPES.indexOf(type)` alors que ce tableau contient des **objets**
`{id, name}` : `indexOf` d'une chaîne renvoyait toujours `-1` → `turnInto()`
retournait immédiatement après l'ouverture du sous-menu. Correction :
`_BLOCK_TURN_TYPES.some(t=>t.id===type)`.
- **Texte conservé** — convertir un bloc en **To-do** ou **Toggle** vidait le
contenu (ces deux types étaient exclus de la conservation par erreur) ; seul
un type sans champ texte (`image`, `embed`, `divider`, `button`, `columns`,
…) le vide désormais, comme dans `Notion`.
- **Tests** — `tests/test_v7593_turn_into.py` : **4 tests**, dont 1
comportemental **node** qui exécute la vraie `turnInto()` extraite de la
source : conversion effective du type, texte conservé (To-do = étiquette +
case décochée), contenu vidé pour un type sans champ texte.
## v7.59.2 (2026-10-07) — Corrections : aide, « Insert below », listes numérotées
### Fixed
+1 -1
View File
@@ -1 +1 @@
7.59.2
7.60.0
+1 -1
View File
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.59.2",
version="7.60.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.59.2"
"version": "7.60.0"
},
"paths": {
"/auth/register": {
+33
View File
@@ -2341,6 +2341,36 @@ button.breadcrumb-current { max-width: 260px; }
cursor: grabbing;
}
/* + « ajouter un bloc dessous » — juste à gauche du handle (gouttière 64px) */
.block-insert-btn {
position: absolute;
left: -64px;
top: 5px;
width: 22px;
height: 22px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 3px;
color: var(--text-dim);
font-size: 17px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.block-wrapper:hover .block-insert-btn,
.block-wrapper:focus-within .block-insert-btn {
opacity: 1;
}
.block-insert-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ── v5.10.0: selection multiple + menu contexte bloc ── */
.block-wrapper.selected {
background: rgba(35,131,226,0.06);
@@ -2934,6 +2964,7 @@ button.breadcrumb-current { max-width: 260px; }
.page-title-input { font-size: 28px; }
.blocks-container { padding: 8px 16px 96px; }
.block-handle { display: none; }
.block-insert-btn { display: none; }
.block-actions { display: none; }
.editor-statusbar { left: 0; }
}
@@ -3409,6 +3440,7 @@ button.breadcrumb-current { max-width: 260px; }
.page-cover-area { padding: 32px 32px 8px; }
.blocks-container { padding: 8px 32px 80px; }
.block-handle { left: -32px; }
.block-insert-btn { left: -56px; }
.page-title-block::before { left: -32px; }
.page-title-input { font-size: 32px; }
@@ -3499,6 +3531,7 @@ button.breadcrumb-current { max-width: 260px; }
.page-title-input { font-size: 24px; }
.blocks-container { padding: 8px 16px 96px; }
.block-handle { display: none; }
.block-insert-btn { display: none; }
.block-actions { display: none; }
.page-title-block::before { display: none; }
.page-title-block .page-icon-emoji { margin-left: 0; }
+4 -1
View File
@@ -609,7 +609,10 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
numAttr=` data-num="${idx-s+1}"`;
}
const actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
// + « ajouter un bloc dessous », juste avant le handle (gouttière 64px)
const insertBtn=idx>=0?`<button type="button" class="block-insert-btn" title="Add block below" aria-label="Add block below" onclick="E.addAfter(${idx})">+</button>`:'';
return `<div class="block-wrapper" data-id="${b.id}">
${insertBtn}
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
${actions}
<${tag}${style}${bidAttr}${numAttr} class="${cls}">${inner}</${tag}>
@@ -1523,7 +1526,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
// ── v5.10.0: Turn into / couleurs / liens / déplacement ──
_blockTargets(idx){const sel=_selIndicesSorted();return(sel.includes(idx))?sel:[idx];},
turnInto(idx,type){if(idx<0||idx>=this.blocks.length)return;if(_BLOCK_TURN_TYPES.indexOf(type)<0)return;const targets=this._blockTargets(idx);const focusId=this.blocks[idx].id;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(b.type===type)return;const keep=(b.content||'').trim();b.type=type;if(type!=='to_do'&&type!=='toggle'&&keep&&['image','embed','meeting','divider','table_of_contents','database','button','columns'].indexOf(b.type)<0){b.content=keep;}else{b.content='';}if(type==='toggle'){b.expanded=true;if(!Array.isArray(b.children))b.children=[];}if(type==='to_do')b.checked=false;if(type==='table'&&!Array.isArray(b.rows))b.rows=_tblNew(3,3);if(type==='code'&&!b.language)b.language='Plain Text';if(type==='columns'&&(!Array.isArray(b.children)||!b.children.length))b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='button'){b.automation_id=0;b.automation_name='';}});
turnInto(idx,type){if(idx<0||idx>=this.blocks.length)return;if(!_BLOCK_TURN_TYPES.some(t=>t.id===type))return;const targets=this._blockTargets(idx);const focusId=this.blocks[idx].id;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(b.type===type)return;const keep=(b.content||'').trim();b.type=type;if(keep&&['image','embed','meeting','divider','table_of_contents','database','button','columns'].indexOf(b.type)<0){b.content=keep;}else{b.content='';}if(type==='toggle'){b.expanded=true;if(!Array.isArray(b.children))b.children=[];}if(type==='to_do')b.checked=false;if(type==='table'&&!Array.isArray(b.rows))b.rows=_tblNew(3,3);if(type==='code'&&!b.language)b.language='Plain Text';if(type==='columns'&&(!Array.isArray(b.children)||!b.children.length))b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='button'){b.automation_id=0;b.automation_name='';}});
this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();
setTimeout(()=>{const bid=this.blocks[idx]?this.blocks[idx].id:focusId;if(type==='table'){_tblFocus(bid,0,0);}else{const el=this.getEl(bid);if(el){el.focus();ce(el);}}},60);},
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
+82
View File
@@ -0,0 +1,82 @@
"""7.59.3 — « Turn into » du menu contextuel : garde cassée + texte conservé."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
EDITOR_JS = ROOT / "static" / "js" / "page_editor_scripts.js"
SRC = EDITOR_JS.read_text(encoding="utf-8")
def _slice(start: str, end: str) -> str:
i = SRC.index(start)
j = SRC.index(end, i)
return SRC[i:j].rstrip()
def test_turn_into_guard_matches_objects_not_strings():
# BUG : indexOf() sur un tableau d'objets → toujours -1 → retour immédiat.
assert "_BLOCK_TURN_TYPES.indexOf(type)" not in SRC
assert "_BLOCK_TURN_TYPES.some(t=>t.id===type)" in SRC
def test_turn_into_menu_wiring():
# menu ⋮⋮ → « Turn into » → sous-menu → choix de type
assert 'data-bm-act="turn"' in SRC
assert "data-bm-act=\\\"turn-type\\\"" in SRC or 'data-bm-act="turn-type"' in SRC
assert "act==='turn-type'" in SRC
assert "_blockTurnMenuOpen(" in SRC
def test_turn_into_keeps_text_for_to_do_and_toggle():
# Convertir un texte en To-do / Toggle ne doit plus effacer le contenu.
line = [ln for ln in SRC.splitlines()
if ln.strip().startswith("turnInto(idx,type){")][0]
assert "type!=='to_do'&&type!=='toggle'" not in line
assert "if(keep&&['image','embed','meeting','divider'" in line
# ── Comportement réel : la méthode extraite de la source, exécutée sous node ─
def test_turn_into_converts_blocks_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental turnInto ignoré")
types = _slice("const _BLOCK_TURN_TYPES=[", "];")
method = _slice("turnInto(idx,type){", "setBlockColor(idx,key,val){")
script = f"""
function _rtSync(){{}}
function _selClear(){{}}
{types}];
const E = {{
blocks: [{{id:'b1',type:'paragraph',content:'Bonjour'}}],
_blockTargets(i){{return [i];}},
sync(){{}}, pushHistory(){{}}, dirty:false,
autoSave(){{}}, render(){{}}, getEl(){{return null;}},
{method}
}};
E.turnInto(0,'heading_2');
const heading = {{type:E.blocks[0].type, content:E.blocks[0].content}};
E.turnInto(0,'to_do');
const to_do = {{type:E.blocks[0].type, content:E.blocks[0].content, checked:E.blocks[0].checked}};
E.turnInto(0,'button'); // type sans texte → contenu vidé
const button = {{type:E.blocks[0].type, content:E.blocks[0].content}};
console.log(JSON.stringify({{heading:heading, to_do:to_do, button:button}}));
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
res = json.loads(out.stdout)
# 1) le changement de format a lieu (avant la correction : aucune conversion)
assert res["heading"] == {"type": "heading_2", "content": "Bonjour"}
# 2) To-do : texte conservé comme étiquette, case décochée
assert res["to_do"] == {"type": "to_do", "content": "Bonjour", "checked": False}
# 3) Button (type sans texte) → contenu vidé
assert res["button"] == {"type": "button", "content": ""}
+29
View File
@@ -0,0 +1,29 @@
"""7.60.0 — bouton + à gauche du handle : ajoute un bloc sous le courant."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
EDITOR_JS = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
APP_CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def test_button_rendered_left_of_the_handle():
# le + est émis AVANT le handle dans le wrapper du bloc
i_btn = EDITOR_JS.index("class=\"block-insert-btn\"")
i_handle = EDITOR_JS.index("<div class=\"block-handle\">", i_btn)
assert i_btn < i_handle
# clic → insertion sous le bloc courant (mécanisme déjà existant)
assert 'class="block-insert-btn" title="Add block below" aria-label="Add block below" onclick="E.addAfter(${idx})"' in EDITOR_JS
# réservé aux blocs de premier niveau (idx valide), comme .block-actions
assert "const insertBtn=idx>=0?" in EDITOR_JS
assert "addAfter(idx,type){return this.addAt(idx+1,type);}" in EDITOR_JS
def test_button_style_and_visibility():
assert ".block-insert-btn {" in APP_CSS
assert "left: -64px;" in APP_CSS # gouttière .blocks-container (64px)
assert ".block-wrapper:hover .block-insert-btn," in APP_CSS
assert ".block-insert-btn:hover {" in APP_CSS
# suit le handle quand la gouttière passe à 32px, masqué partout où il l'est
assert ".block-insert-btn { left: -56px; }" in APP_CSS
assert APP_CSS.count(".block-insert-btn { display: none; }") == 2