fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Failing after 24m19s
FlowDeck CI / docker (push) Skipped

Corrige deux régressions de /local-workspace :

- le chemin du header restait bloqué sur « Home / <workspace> » quel que
  soit le dossier affiché : la route rend désormais breadcrumb_items
  (Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
  collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
  le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
  fois : Alpine.data('wsInitData') retournait le même objet singleton, le
  2e montage (navigation partielle) levait « Cannot redefine property:
  \ » et initTree abandonnait, laissant tout le contenu au state
  brut. La factory retourne désormais une enveloppe fraîche par montage
  qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
  exécution (le 2e montage gardait le folder_id du 1er chargement).

Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
This commit is contained in:
2026-10-09 16:58:04 -04:00
parent 7468e497c6
commit 0218d8f5e5
44 changed files with 12060 additions and 1729 deletions
@@ -0,0 +1,121 @@
/**
* /local-workspace — le chemin du header suit la navigation dans les
* dossiers, et une navigation partielle vers un sous-dossier remonte la
* page Alpine (avant : « Cannot redefine property: $nextTick » → tous les
* composants restés visibles à la fois).
*
* Parcours : sidebar Docs → sidebar Sub → clic dans la vue sur Leaf →
* breadcrumb interne Docs → crumb workspace du header.
*/
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('local-workspace: chemin header + navigation dossiers (partielle et interne)', async ({ page }) => {
test.setTimeout(180000);
const errors = [];
page.on('console', m => { if (m.type() === 'warning' && m.text().includes('Cannot redefine')) errors.push(m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
// ── 1. Utilisateur + workspace + arborescence Docs/Sub/Leaf ──
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.click('button:has-text("Register")');
await page.fill('#name', 'LWBC');
await page.fill('#email', 'lwbc' + Date.now() + '@flowdeck.local');
await page.fill('#password', FD_PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 20000 });
await page.waitForTimeout(800);
await page.keyboard.press('Escape').catch(() => {});
const ids = await page.evaluate(async () => {
const csrf = decodeURIComponent((document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '');
const H = { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf };
const r = await fetch('/api/workspaces', {
method: 'POST', headers: H, body: JSON.stringify({ name: 'LWBC WS ' + Date.now() })
});
const d = await r.json();
await fetch('/api/workspaces/' + d.id + '/select', { method: 'POST', headers: H });
const mk = (name, type, parent) => fetch('/api/local-workspace/items', {
method: 'POST', headers: H, body: JSON.stringify({ name, type, parent_id: parent || null })
}).then(x => x.json());
const docs = await mk('Docs', 'folder');
const sub = await mk('Sub', 'folder', docs.id);
const leaf = await mk('Leaf', 'folder', sub.id);
await mk('Page In Leaf', 'page', leaf.id);
return { wsName: '', docs: docs.id, sub: sub.id, leaf: leaf.id, ws: d.id };
});
await page.goto(`${FD_BASE}/local-workspace`);
await page.waitForFunction(() => typeof window._wsData !== 'undefined', { timeout: 15000 });
const wsName = await page.evaluate(() => {
const el = document.querySelector('.ws-main .unified-breadcrumb .bc-root');
return el ? el.textContent.trim() : '';
});
expect(wsName).toBeTruthy();
const headerPath = () => page.evaluate(() => {
const el = document.querySelector('.header-breadcrumb');
return el ? el.innerText.replace(/\s+/g, ' ').replace(/…/g, '...').trim() : null;
});
expect(await headerPath()).toContain(wsName);
// Composants Alpine peints à l'état brut (x-show non évalué) — hors pages
// légitimement visibles (arbre, ligne « parent », barre de tags).
// checkVisibility() prend en compte les ancêtres (display:contents sur
// .bc-edit-wrap ferme l'input sans que son propre display change).
const rawPainted = () => page.evaluate(() => {
const sels = ['.ws-dropdown', '.ws-select-menu', '.upload-progress', '.undo-toast',
'.move-modal-overlay', '.ws-search-bar', '.ws-select-bar', '.more-menu', '.filter-row',
'.bc-edit-wrap'];
return sels.filter(s => [...document.querySelectorAll(s)]
.some(e => e.checkVisibility({ opacityProperty: true, visibilityProperty: true })));
});
await page.goto(`${FD_BASE}/local-workspace`);
await page.waitForFunction(() => typeof window._wsData !== 'undefined', { timeout: 15000 });
await expect.poll(headerPath, { timeout: 10000 }).toContain(wsName);
// ── 2. Sidebar : clic sur Docs puis sur Sub (navigation partielle) ──
const clickSidebarFolder = async (id) => {
await page.evaluate((fid) => {
const el = document.querySelector('.sidebar-item.ws-tree-item[data-id="' + fid + '"] .tree-folder-link');
if (el) el.click();
}, id);
await page.waitForURL('**folder=' + id, { timeout: 15000 });
await page.waitForTimeout(1200);
};
await clickSidebarFolder(ids.docs);
expect(await headerPath()).toBe(`Home / ${wsName} / Docs`);
expect(await rawPainted()).toEqual([]);
await clickSidebarFolder(ids.sub);
expect(await headerPath()).toBe(`Home / ${wsName} / Docs / Sub`);
expect(await rawPainted()).toEqual([]);
// ── 3. Clic sur un sous-dossier DANS la vue (sans rechargement) ──
await page.evaluate((fid) => {
const el = document.querySelector('.ws-main [data-folder-id="' + fid + '"] .name.folder');
if (el) el.click();
}, ids.leaf);
await expect.poll(headerPath, { timeout: 10000 }).toContain('/ Sub / Leaf');
expect(await headerPath()).toContain(wsName);
expect(await rawPainted()).toEqual([]);
// ── 4. Breadcrumb interne : clic sur Docs → les deux chemins suivent ──
const inPage = () => page.evaluate(() =>
[...document.querySelectorAll('.ws-main .unified-breadcrumb a')].map(a => a.textContent.trim()));
expect(await inPage()).toEqual([expect.stringContaining(wsName), 'Docs', 'Sub', 'Leaf']);
await page.click('.ws-main .unified-breadcrumb a:has-text("Docs")');
await expect.poll(headerPath, { timeout: 10000 }).toBe(`Home / ${wsName} / Docs`);
expect(await inPage()).toEqual([expect.stringContaining(wsName), 'Docs']);
// ── 5. Crumb workspace du header → retour à la racine ──
await page.click('.header-breadcrumb a:has-text("' + wsName + '")');
await page.waitForURL('**/local-workspace', { timeout: 15000 });
await expect.poll(headerPath, { timeout: 10000 }).toBe(`Home / ${wsName}`);
expect(await rawPainted()).toEqual([]);
expect(errors).toEqual([]);
});