fix(workspace): menu contextuel après navigation partielle + sync sidebar/header au rename
FlowDeck CI / lint (push) Successful in 52s
FlowDeck CI / test (push) Failing after 3h14m19s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-14 18:20:43 -04:00
parent 41c1d315d3
commit 9ab47d8113
5 changed files with 223 additions and 4 deletions
+1
View File
@@ -986,6 +986,7 @@ async def local_workspace_page(request: Request, folder: int = None):
"workspace_id": ws_id or 0,
"nav_workspace_id": ws_id or 0,
"breadcrumb": breadcrumb,
"breadcrumbs": breadcrumb,
}
template = env.get_template("local_workspace.html")
return HTMLResponse(
+16 -4
View File
@@ -27,9 +27,15 @@
if (window.__fdCtxMenuRegistered) return;
window.__fdCtxMenuRegistered = true;
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdCtxStore) return;
if (window.Alpine) window.Alpine.__fdCtxStore = true;
// Sur un chargement complet de page, ce script inline s'exécute AVANT
// alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'.
// Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et
// 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite,
// sinon le menu contextuel reste mort jusqu'au prochain chargement complet.
function registerFdCtx() {
if (!window.Alpine) return;
if (window.Alpine.__fdCtxStore) return;
window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
open: false, x: 0, y: 0, node: null, page: null,
@@ -166,7 +172,13 @@
this.close();
},
});
});
}
if (window.Alpine) {
registerFdCtx();
} else {
document.addEventListener('alpine:init', registerFdCtx);
}
})();
</script>
+21
View File
@@ -816,6 +816,26 @@
return m ? m[1] : '';
},
/** Reflète le rename d'une page/dossier dans toute l'UI : sidebar gauche
(Récents, Privé, Favoris + arbre du workspace), breadcrumb du header
(event flowdeck:page-renamed) et frame parente (postMessage). */
syncPageTitle: function(pid, title) {
var t = (title || '').trim() || 'Untitled';
document.querySelectorAll('.sidebar-item[data-page-id="page/' + pid + '"]').forEach(function(item) {
var n = item.querySelector('.page-name');
if (n) n.textContent = t;
});
document.querySelectorAll('.sidebar-item.ws-tree-item[data-id="' + pid + '"]').forEach(function(item) {
item.setAttribute('data-name', t);
var n = item.querySelector('.page-name');
if (n) n.textContent = t;
});
window.dispatchEvent(new CustomEvent('flowdeck:page-renamed', { detail: { id: pid, title: t } }));
if (window.parent && window.parent !== window) {
try { window.parent.postMessage({ type: 'fd-page-renamed', id: pid, title: t }, window.location.origin); } catch (e) {}
}
},
/** Refresh CSRF token from the server. Returns a promise resolving to the new token. */
refreshCsrfToken: async function() {
try {
@@ -1182,6 +1202,7 @@
body: JSON.stringify({ name: newName.trim() })
}).then(function(r) {
if (r.ok) {
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(id, newName.trim());
self.refreshSidebarTree();
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
if (window._wsData && window._wsData._reloadAfterAction) {
+14
View File
@@ -1519,6 +1519,20 @@ window._wsInitData = (function() {
if (r.ok) {
node.name = n; node.db_name = n;
this._saveExpandedState();
if (window.appState && window.appState.refreshSidebarTree) window.appState.refreshSidebarTree();
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(node.id, n);
var self = this;
if (this.folderStack && this.folderStack.length) {
this.folderStack.forEach(function(f) { if (String(f.id) === String(node.id)) f.name = n; });
}
if (this.breadcrumb && this.breadcrumb.length) {
this.breadcrumb.forEach(function(b) { if (String(b.id) === String(node.id)) b.name = n; });
}
if (this.previewItem && String(this.previewItem.id) === String(node.id)) {
this.previewItem.name = n;
var pt = document.getElementById('ws-preview-title');
if (pt) pt.textContent = n;
}
}
},
cancelRename() { this.renamingId = null; },
+171
View File
@@ -0,0 +1,171 @@
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const FD_PASS = process.env.FD_PASS || 'e2e-secret-123';
test('diag v5: partial-nav ctx menu + rename sidebar/header sync', async ({ page }) => {
test.setTimeout(180000);
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
// ── 1. Fresh user + workspace ──
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.click('button:has-text("Register")');
await page.fill('#name', 'Diag');
await page.fill('#email', 'diag' + Date.now() + '@flowdeck.local');
await page.fill('#password', FD_PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 20000 });
await page.waitForTimeout(1200);
// Dismiss any onboarding agent dialog that may cover the page
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(300);
// Create + select a workspace via API (CSRF-exempt), no fragile UI dialogs.
const ws = await page.evaluate(async () => {
const r = await fetch('/api/workspaces', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Diag WS ' + Date.now() })
});
const d = await r.json();
const s = await fetch('/api/workspaces/' + d.id + '/select', { method: 'POST' });
return { id: d.id, ok: s.ok };
});
console.log('WS:', JSON.stringify(ws));
await page.waitForTimeout(800);
// Seed items via API.
await page.evaluate(async () => {
const mk = (name, type) => fetch('/api/local-workspace/items', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type })
}).then(r => r.json());
const p = await mk('RenameMe Doc', 'page');
await mk('Folder A', 'folder');
window.__seedPageId = p.id;
});
console.log('STEP1 ok — user + workspace + seeded (all via API)');
// ── 2. PARTIAL nav to /local-workspace (exactly the user's bug path) ──
await page.evaluate(() => window.fdNavigate('/local-workspace'));
await page.waitForURL('**/local-workspace**', { timeout: 10000 });
await page.waitForFunction(() => document.querySelectorAll('[data-ws-id]').length >= 1, { timeout: 10000 });
await page.waitForTimeout(1500);
console.log('STEP2 ok — partial-nav LW tree items:', await page.locator('[data-ws-id]').count());
// ── 3. PARTIAL-nav ctx menu (local-workspace) ──
const rows1 = page.locator('[data-ws-id]');
if (await rows1.count()) {
await rows1.first().click({ button: 'right' });
await page.waitForTimeout(700);
const st = await page.evaluate(() => {
const s = window.Alpine && Alpine.store('fdCtx');
return s ? { open: s.open, node: s.node && s.node.name } : { undefined: true };
});
const vis = await page.locator('.fd-ctx-menu').isVisible().catch(() => false);
console.log('STEP3 LW ctx (partial-nav): visible=' + vis + ' fdCtx=' + JSON.stringify(st));
await page.screenshot({ path: 'shots/diag_v5_lw_ctx.png' }).catch(() => {});
await page.evaluate(() => { const s = window.Alpine && Alpine.store('fdCtx'); if (s) s.close(); });
}
// ── 4. PARTIAL-nav ctx menu (library) ──
await page.evaluate(() => window.fdNavigate('/library'));
await page.waitForFunction(() => typeof window._libData !== 'undefined', { timeout: 10000 });
await page.waitForTimeout(1500);
const libRows = page.locator('.lib-row');
console.log('STEP4 partial /library rows:', await libRows.count(),
'libData:', await page.evaluate(() => typeof window._libData + ':' + typeof (window._libData && window._libData.onRowContextMenu)));
if (await libRows.count()) {
await libRows.first().click({ button: 'right' });
await page.waitForTimeout(700);
const vis = await page.locator('.fd-ctx-menu').isVisible().catch(() => false);
const st = await page.evaluate(() => {
const s = window.Alpine && Alpine.store('fdCtx');
return s ? { open: s.open, node: s.node && s.node.name } : { undefined: true };
});
console.log('STEP5 LIB ctx (partial-nav): visible=' + vis + ' fdCtx=' + JSON.stringify(st));
await page.screenshot({ path: 'shots/diag_v5_lib_ctx.png' }).catch(() => {});
await page.evaluate(() => { const s = window.Alpine && Alpine.store('fdCtx'); if (s) s.close(); });
}
// ── 5. Rename a page via local-workspace tree (partial-nav) ──
await page.evaluate(() => window.fdNavigate('/local-workspace'));
await page.waitForFunction(() => typeof window._wsData !== 'undefined' && typeof window._wsData.commitRename === 'function', { timeout: 10000 });
await page.waitForFunction(() => document.querySelectorAll('[data-ws-id]').length >= 1, { timeout: 10000 });
await page.waitForTimeout(1200);
const rn = await page.evaluate(async () => {
const ws = window._wsData;
const find = (name) => {
const walk = (arr) => { if (!arr) return null; for (const n of arr) { if (n.name === name) return n; const c = walk(n.children); if (c) return c; } return null; };
return walk(ws.displayTree) || walk(ws.tree);
};
const target = find('RenameMe Doc');
if (!target) return { err: 'node RenameMe Doc not found' };
ws.renamingId = target.id;
ws.renamingName = 'Renamed Via LW';
await ws.commitRename(target);
return { id: target.id, name: target.name };
});
console.log('STEP6 rename page:', JSON.stringify(rn));
await page.waitForTimeout(2000);
const chk1 = await page.evaluate((pid) => {
const row = [...document.querySelectorAll('[data-ws-id]')].find(n => String(n.getAttribute('data-ws-id')) === String(pid));
const sb = document.querySelector('.sidebar-item.ws-tree-item[data-id="' + pid + '"] .page-name');
const head = document.querySelector('.header-breadcrumb');
const crumbs = head ? head.innerText.trim().split(/\s/).filter(Boolean) : [];
return {
inPageRow: row ? row.innerText.trim() : null,
sidebarWsName: sb ? sb.textContent : null,
headerCrumbs: crumbs,
};
}, rn.id);
console.log('STEP7 after rename:', JSON.stringify(chk1, null, 2));
// ── 6. Rename the CURRENT folder → in-page breadcrumb must update (no reload) ──
const fld = await page.evaluate(async () => {
const ws = window._wsData;
const walk = (arr) => { if (!arr) return null; for (const n of arr) { if (n.is_folder && n.name === 'Folder A') return n; const c = walk(n.children); if (c) return c; } return null; };
const folder = walk(ws.displayTree) || walk(ws.tree);
if (!folder) return { err: 'folder not found' };
return { id: folder.id, name: folder.name };
});
// Partial-nav INTO the folder so the server renders the breadcrumb (folderStack)
await page.evaluate((fid) => window.fdNavigate('/local-workspace?folder=' + fid), fld.id);
await page.waitForFunction(() => {
const ws = window._wsData;
return ws && ws.folderStack && ws.folderStack.length >= 1;
}, { timeout: 10000 });
await page.waitForTimeout(1200);
const beforeCrumb = await page.evaluate(() => {
const c = document.querySelector('.unified-breadcrumb .bc-current');
return c ? c.textContent.trim() : null;
});
console.log('STEP8 crumb before rename:', beforeCrumb);
const rnf = await page.evaluate(async (fid) => {
const ws = window._wsData;
const cur = ws.folderStack && ws.folderStack[ws.folderStack.length - 1];
if (!cur) return { err: 'no folderStack' };
ws.renamingId = cur.id;
ws.renamingName = 'Folder Renamed';
await ws.commitRename(cur);
return { id: cur.id, name: cur.name, bc: ws.breadcrumb.map(b => b.name) };
}, fld.id);
console.log('STEP9 rename folder:', JSON.stringify(rnf));
await page.waitForTimeout(2000);
const chk2 = await page.evaluate((fid) => {
const crumb = document.querySelector('.unified-breadcrumb .bc-current');
const sb = document.querySelector('.sidebar-item.ws-tree-item[data-id="' + fid + '"] .page-name');
return {
crumbLabel: crumb ? crumb.textContent.trim() : null,
sidebarWsName: sb ? sb.textContent : null,
};
}, fld.id);
console.log('STEP10 after folder rename:', JSON.stringify(chk2, null, 2));
await page.screenshot({ path: 'shots/diag_v5_folder_rename.png' }).catch(() => {});
console.log('ERRORS:');
errors.slice(0, 40).forEach(e => console.log(e));
});