fix(editor): repair block identity for template pages (duplicate/reordered lines)
Template-created pages (weekly report, project doc, meeting notes, to-do list, ...) were persisted without block ids. The editor assigned ids client-side, but the realtime room loaded the raw id-less content and sent it back on 'sync'; applySync then merged id-less server blocks with local blocks, producing data-bid='undefined' collisions and duplicated/shuffled lines as soon as the user edited. Editing an empty page was unaffected because the server state was empty. Fixes: - board.use_page_template: materialize unique block ids (recursively) when instantiating built-in or user templates. - realtime_server: unique block_id() (uuid) + ensure_block_ids() on room load and on insert ops. - editor: recursive ensureBlockIds() in init; gtTok() now restores [[fddate:...]] tokens (date chips survived as labels before). - realtime client: applySync() normalizes ids, no longer appends unknown local blocks (duplication), and keeps local content when server is empty. Tests: pytest (templates + realtime) and Playwright e2e covering to-do list, weekly report date chip, Enter ordering and legacy id-less repair.
This commit is contained in:
+22
-1
@@ -42,6 +42,25 @@ def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None:
|
|||||||
raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit")
|
raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_block_ids(blocks) -> None:
|
||||||
|
"""Assign unique ids to blocks missing one, recursively.
|
||||||
|
|
||||||
|
Built-in page templates ship without ids (the editor used to assign them
|
||||||
|
client-side only). Without persisted ids, the realtime layer and the editor
|
||||||
|
disagree on block identity, which duplicated lines / shuffled blocks when
|
||||||
|
editing a template-created page. We now materialize ids at creation time.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
if not isinstance(blocks, list):
|
||||||
|
return
|
||||||
|
for b in blocks:
|
||||||
|
if isinstance(b, dict):
|
||||||
|
if not b.get("id"):
|
||||||
|
b["id"] = "b" + uuid.uuid4().hex[:12]
|
||||||
|
if isinstance(b.get("children"), list):
|
||||||
|
_ensure_block_ids(b["children"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/wiki/pages")
|
@router.get("/api/wiki/pages")
|
||||||
async def wiki_page_search(request: Request, q: str = Query(default="")):
|
async def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||||
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
|
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
|
||||||
@@ -274,9 +293,11 @@ async def use_page_template(request: Request, template_id: int):
|
|||||||
if body_ws:
|
if body_ws:
|
||||||
ws_key = body_ws
|
ws_key = body_ws
|
||||||
try:
|
try:
|
||||||
json.loads(blocks_json)
|
parsed_blocks = json.loads(blocks_json)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
raise HTTPException(500, "Template content corrupted") from None
|
raise HTTPException(500, "Template content corrupted") from None
|
||||||
|
_ensure_block_ids(parsed_blocks)
|
||||||
|
blocks_json = json.dumps(parsed_blocks)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
if ws_id is not None:
|
if ws_id is not None:
|
||||||
next_order = conn.execute(
|
next_order = conn.execute(
|
||||||
|
|||||||
@@ -25,8 +25,26 @@ def color_for(uid: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def block_id() -> str:
|
def block_id() -> str:
|
||||||
import time
|
import uuid
|
||||||
return f"b{int(time.time()*1000)}"
|
return "b" + uuid.uuid4().hex[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_block_ids(blocks: list[dict]) -> list[dict]:
|
||||||
|
"""Assign unique ids to blocks missing one, recursively.
|
||||||
|
|
||||||
|
Template-created pages may have been persisted without block ids; without
|
||||||
|
them the room state is not addressable by ops and the editor ends up with
|
||||||
|
``data-bid="undefined"`` blocks (duplicated / reordered lines).
|
||||||
|
"""
|
||||||
|
if not isinstance(blocks, list):
|
||||||
|
return blocks
|
||||||
|
for b in blocks:
|
||||||
|
if isinstance(b, dict):
|
||||||
|
if not b.get("id"):
|
||||||
|
b["id"] = block_id()
|
||||||
|
if isinstance(b.get("children"), list):
|
||||||
|
ensure_block_ids(b["children"])
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
def apply_op(blocks: list[dict], op: dict) -> list[dict]:
|
def apply_op(blocks: list[dict], op: dict) -> list[dict]:
|
||||||
@@ -37,6 +55,7 @@ def apply_op(blocks: list[dict], op: dict) -> list[dict]:
|
|||||||
if not blk.get("id"):
|
if not blk.get("id"):
|
||||||
blk = dict(blk)
|
blk = dict(blk)
|
||||||
blk["id"] = block_id()
|
blk["id"] = block_id()
|
||||||
|
ensure_block_ids([blk])
|
||||||
idx = op.get("index")
|
idx = op.get("index")
|
||||||
if not isinstance(idx, int):
|
if not isinstance(idx, int):
|
||||||
idx = len(blocks)
|
idx = len(blocks)
|
||||||
@@ -126,7 +145,7 @@ class RealtimeManager:
|
|||||||
room.title = row["title"] or ""
|
room.title = row["title"] or ""
|
||||||
if (row["content_format"] or "") == "blocks" and row["content"]:
|
if (row["content_format"] or "") == "blocks" and row["content"]:
|
||||||
try:
|
try:
|
||||||
room.blocks = json.loads(row["content"])
|
room.blocks = ensure_block_ids(json.loads(row["content"]))
|
||||||
except Exception:
|
except Exception:
|
||||||
room.blocks = []
|
room.blocks = []
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ window.__fdRT = (function () {
|
|||||||
const blk = op.block || {};
|
const blk = op.block || {};
|
||||||
const id = blk.id || ('rb' + Date.now().toString(36));
|
const id = blk.id || ('rb' + Date.now().toString(36));
|
||||||
blk.id = id;
|
blk.id = id;
|
||||||
|
if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
|
||||||
let idx = op.index != null ? op.index : blocks.length;
|
let idx = op.index != null ? op.index : blocks.length;
|
||||||
idx = Math.max(0, Math.min(idx, blocks.length));
|
idx = Math.max(0, Math.min(idx, blocks.length));
|
||||||
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
||||||
@@ -268,8 +269,13 @@ window.__fdRT = (function () {
|
|||||||
/* ── application des changements distants ── */
|
/* ── application des changements distants ── */
|
||||||
function applySync(blocks, title) {
|
function applySync(blocks, title) {
|
||||||
if (!E || !E.blocks) return;
|
if (!E || !E.blocks) return;
|
||||||
|
// v5.13.1: guarantee ids before comparing/merging. Server rooms may still
|
||||||
|
// carry legacy id-less blocks; without this they collide on
|
||||||
|
// data-bid="undefined" and the merge below duplicated every line.
|
||||||
|
blocks = (blocks || []).slice();
|
||||||
|
if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
|
||||||
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||||
const srvIds = JSON.stringify((blocks || []).map(b => b.id));
|
const srvIds = JSON.stringify(blocks.map(b => b.id));
|
||||||
if (curIds === srvIds) {
|
if (curIds === srvIds) {
|
||||||
base = clone(E.blocks);
|
base = clone(E.blocks);
|
||||||
E.dirty = false;
|
E.dirty = false;
|
||||||
@@ -280,14 +286,17 @@ window.__fdRT = (function () {
|
|||||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||||
let out;
|
let out;
|
||||||
try {
|
try {
|
||||||
out = (blocks || []).map(b => {
|
if (!blocks.length) {
|
||||||
const cb = curById[b.id];
|
// serveur vide : ne pas effacer le contenu local (page neuve).
|
||||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
out = (E.blocks || []).slice();
|
||||||
return b;
|
} else {
|
||||||
});
|
out = blocks.map(b => {
|
||||||
|
const cb = curById[b.id];
|
||||||
|
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) { return; }
|
} catch (e) { return; }
|
||||||
const seen = {}; (blocks || []).forEach(b => { seen[b.id] = 1; });
|
|
||||||
(E.blocks || []).forEach(b => { if (!seen[b.id]) out.push(b); });
|
|
||||||
E.blocks = out;
|
E.blocks = out;
|
||||||
const tEl = document.getElementById('_titleEl');
|
const tEl = document.getElementById('_titleEl');
|
||||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ window.__fdEditorScriptsLoaded = true;
|
|||||||
{name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}];
|
{name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}];
|
||||||
|
|
||||||
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
|
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
|
||||||
|
// v5.13.1: guarantee every block (and nested child) has a stable id.
|
||||||
|
// Template blocks may be persisted without ids; leaving them undefined
|
||||||
|
// makes `data-bid="undefined"` collide and corrupts editing.
|
||||||
|
function ensureBlockIds(list){if(!Array.isArray(list))return list;list.forEach(function(b){if(b&&typeof b==='object'){if(!b.id)b.id=genId();if(Array.isArray(b.children))ensureBlockIds(b.children);}});return list;}
|
||||||
|
window.ensureBlockIds=ensureBlockIds;
|
||||||
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
|
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
|
||||||
function gt(e){return (e&&e.innerText!==undefined)?e.innerText:(e?e.textContent:'');}
|
function gt(e){return (e&&e.innerText!==undefined)?e.innerText:(e?e.textContent:'');}
|
||||||
// v5.11.0: read a contenteditable block back to plain text, converting
|
// v5.11.0: read a contenteditable block back to plain text, converting
|
||||||
@@ -39,6 +44,7 @@ window.__fdEditorScriptsLoaded = true;
|
|||||||
if(n.nodeType===3){out+=n.nodeValue;continue;}
|
if(n.nodeType===3){out+=n.nodeValue;continue;}
|
||||||
if(n.nodeType===1){
|
if(n.nodeType===1){
|
||||||
if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;}
|
if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;}
|
||||||
|
if(n.classList&&n.classList.contains('fd-wiki-date')){out+=n.getAttribute('data-token')||'';continue;}
|
||||||
if(n.tagName==='BR'){out+='\n';continue;}
|
if(n.tagName==='BR'){out+='\n';continue;}
|
||||||
walk(n);
|
walk(n);
|
||||||
}
|
}
|
||||||
@@ -1049,7 +1055,7 @@ window.__fdEditorScriptsLoaded = true;
|
|||||||
const dataEl=document.getElementById('page-data');
|
const dataEl=document.getElementById('page-data');
|
||||||
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';this.contentFormat=fmt;const raw=data.content||'';
|
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';this.contentFormat=fmt;const raw=data.content||'';
|
||||||
if(fmt==='file'){this.fileData=data;this.fileUrl=data.file_url||'';this.loadFileContent(data);}
|
if(fmt==='file'){this.fileData=data;this.fileUrl=data.file_url||'';this.loadFileContent(data);}
|
||||||
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}}
|
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);ensureBlockIds(this.blocks);}catch(e){this.blocks=[];}}
|
||||||
else if(raw&&raw.trim())this.blocks=this.md2b(raw);
|
else if(raw&&raw.trim())this.blocks=this.md2b(raw);
|
||||||
// v5.12.0: page lock + layout options
|
// v5.12.0: page lock + layout options
|
||||||
this.isLocked=!!data.is_locked;this.lockedBy=data.locked_by||null;
|
this.isLocked=!!data.is_locked;this.lockedBy=data.locked_by||null;
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`);
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupWorkspace(page, name) {
|
||||||
|
return await page.evaluate(async (n) => {
|
||||||
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||||
|
const r = await fetch('/api/workspaces', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({ name: n }),
|
||||||
|
});
|
||||||
|
const ws = await r.json();
|
||||||
|
await fetch(`/api/workspaces/${ws.id}/select`, { method: 'POST' });
|
||||||
|
return ws;
|
||||||
|
}, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFromTemplate(page, key, wsId) {
|
||||||
|
return await page.evaluate(async ({ k, id }) => {
|
||||||
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||||
|
const r = await fetch('/board/api/page-templates/0/use', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({ key: k, workspace_id: id }),
|
||||||
|
});
|
||||||
|
return await r.json();
|
||||||
|
}, { k: key, id: wsId });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Template page editor', () => {
|
||||||
|
test('to-do list: block ids remain valid after realtime sync', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const ws = await setupWorkspace(page, 'Edit Repro WS ' + Date.now());
|
||||||
|
const tpl = await createFromTemplate(page, 'todo_list', ws.id);
|
||||||
|
console.log('created page', tpl);
|
||||||
|
|
||||||
|
await page.goto(`${FD_BASE}/pages/${tpl.id}`);
|
||||||
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||||
|
await page.waitForTimeout(3000); // let WS sync land
|
||||||
|
|
||||||
|
const bids = await page.$$eval('#_blocksCt [data-bid]', (els) =>
|
||||||
|
els.map((e) => e.getAttribute('data-bid'))
|
||||||
|
);
|
||||||
|
console.log('BIDS:', JSON.stringify(bids));
|
||||||
|
expect(bids.length).toBeGreaterThan(0);
|
||||||
|
expect(bids).not.toContain('undefined');
|
||||||
|
expect(new Set(bids).size).toBe(bids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('weekly report: date chip survives editing', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const ws = await setupWorkspace(page, 'Edit Repro WS2 ' + Date.now());
|
||||||
|
const tpl = await createFromTemplate(page, 'weekly_report', ws.id);
|
||||||
|
|
||||||
|
await page.goto(`${FD_BASE}/pages/${tpl.id}`);
|
||||||
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Type in the first heading then read back the saved content token
|
||||||
|
const h1 = page.locator('#_blocksCt .block-content').first();
|
||||||
|
await h1.click();
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await page.keyboard.type(' (edited)');
|
||||||
|
await page.waitForTimeout(2500); // autosave
|
||||||
|
|
||||||
|
const stored = await page.evaluate(async (pid) => {
|
||||||
|
const r = await fetch('/board/api/pages/' + pid);
|
||||||
|
return (await r.json()).content;
|
||||||
|
}, tpl.id);
|
||||||
|
console.log('STORED:', stored);
|
||||||
|
expect(stored).toContain('fddate');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('to-do list: typing + Enter keeps order and does not duplicate', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const ws = await setupWorkspace(page, 'Edit Repro WS3 ' + Date.now());
|
||||||
|
const tpl = await createFromTemplate(page, 'todo_list', ws.id);
|
||||||
|
|
||||||
|
await page.goto(`${FD_BASE}/pages/${tpl.id}`);
|
||||||
|
await page.waitForSelector('#_blocksCt .block-todo [contenteditable][data-bid]', { timeout: 15000 });
|
||||||
|
await page.waitForTimeout(2500); // let WS sync land
|
||||||
|
|
||||||
|
const first = page.locator('#_blocksCt .block-todo [contenteditable][data-bid]').first();
|
||||||
|
await first.click();
|
||||||
|
await page.keyboard.type('Task A');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForTimeout(250); // editor refocuses the new block after render
|
||||||
|
await page.keyboard.type('Task B');
|
||||||
|
await page.waitForTimeout(2500); // autosave
|
||||||
|
|
||||||
|
const stored = await page.evaluate(async (pid) => {
|
||||||
|
const r = await fetch('/board/api/pages/' + pid);
|
||||||
|
return JSON.parse((await r.json()).content);
|
||||||
|
}, tpl.id);
|
||||||
|
|
||||||
|
const texts = stored.map((b) => b.content);
|
||||||
|
console.log('TODO STORED:', JSON.stringify(texts));
|
||||||
|
// heading + 3 to_do (template) + 1 inserted by Enter
|
||||||
|
expect(texts).toEqual(['To-do', 'Task A', 'Task B', '', '']);
|
||||||
|
const ids = stored.map((b) => b.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
expect(ids).not.toContain(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy id-less page is repaired on load (no undefined ids / duplicates)', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const ws = await setupWorkspace(page, 'Edit Repro Legacy ' + Date.now());
|
||||||
|
|
||||||
|
// create an empty page, then write id-less blocks directly to simulate a
|
||||||
|
// page created before block ids were persisted (old templates).
|
||||||
|
const pid = await page.evaluate(async (wsId) => {
|
||||||
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||||
|
const r = await fetch('/api/local-workspace/items', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({ name: 'Legacy', type: 'page', workspace_id: wsId }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
await fetch('/board/api/pages/' + d.id + '/blocks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: 'Legacy',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'heading_1', content: 'To-do' },
|
||||||
|
{ type: 'to_do', content: '', checked: false },
|
||||||
|
{ type: 'to_do', content: '', checked: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return d.id;
|
||||||
|
}, ws.id);
|
||||||
|
|
||||||
|
await page.goto(`${FD_BASE}/pages/${pid}`);
|
||||||
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||||
|
await page.waitForTimeout(3000); // let realtime sync land
|
||||||
|
|
||||||
|
const bids = await page.$$eval('#_blocksCt [data-bid]', (els) =>
|
||||||
|
els.map((e) => e.getAttribute('data-bid'))
|
||||||
|
);
|
||||||
|
console.log('LEGACY BIDS:', JSON.stringify(bids));
|
||||||
|
expect(bids.length).toBe(3);
|
||||||
|
expect(bids).not.toContain('undefined');
|
||||||
|
expect(new Set(bids).size).toBe(bids.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -283,6 +283,23 @@ def test_use_builtin_template_creates_page(client):
|
|||||||
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c}).status_code == 404
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_use_builtin_template_assigns_block_ids(client):
|
||||||
|
"""Template blocks must be persisted with unique ids (v5.13.1).
|
||||||
|
|
||||||
|
Without ids the realtime room and the editor disagree on block identity,
|
||||||
|
which duplicated/shuffled lines when editing a template page.
|
||||||
|
"""
|
||||||
|
s, c, uid = _login(client)
|
||||||
|
r = client.post("/board/api/page-templates/0/use", json={"key": "weekly_report"},
|
||||||
|
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
|
||||||
|
assert r.status_code == 200
|
||||||
|
page = client.get(f"/board/api/pages/{r.json()['id']}").json()
|
||||||
|
blocks = json.loads(page["content"])
|
||||||
|
ids = [b.get("id") for b in blocks]
|
||||||
|
assert all(ids), f"missing ids: {ids}"
|
||||||
|
assert len(set(ids)) == len(ids), f"duplicate ids: {ids}"
|
||||||
|
|
||||||
|
|
||||||
def test_use_template_persists_workspace_id(client):
|
def test_use_template_persists_workspace_id(client):
|
||||||
s, c, uid = _login(client)
|
s, c, uid = _login(client)
|
||||||
# Create a local workspace owned by this user
|
# Create a local workspace owned by this user
|
||||||
|
|||||||
Reference in New Issue
Block a user