diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 786ad45..3f053d3 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -1900,7 +1900,10 @@ async def list_all_tags_global(request: Request): "WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name", (uid,), ).fetchall() - return {"tags": [dict(r) for r in rows]} + return JSONResponse( + {"tags": [dict(r) for r in rows]}, + headers={"Cache-Control": "no-store"}, + ) # ═══════════ Workspace Tags API ═══════════ @@ -1915,14 +1918,17 @@ async def list_tags(request: Request): return {"tags": []} with get_conn() as conn: rows = conn.execute( - "SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count " - "FROM tags t " - "LEFT JOIN page_tags pt ON pt.tag_id = t.id " - "LEFT JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? " - "WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name", + "SELECT t.id, t.name, t.color, " + "(SELECT COUNT(*) FROM page_tags pt " + " JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? " + " WHERE pt.tag_id = t.id) as count " + "FROM tags t WHERE t.user_id = ? ORDER BY t.name", (ws_id, uid), ).fetchall() - return {"tags": [dict(r) for r in rows]} + return JSONResponse( + {"tags": [dict(r) for r in rows]}, + headers={"Cache-Control": "no-store"}, + ) @router.get("/api/local-workspace/items/{item_id:int}/tags") diff --git a/app/templates/local_workspace.html b/app/templates/local_workspace.html index 6177814..fb5c1a9 100644 --- a/app/templates/local_workspace.html +++ b/app/templates/local_workspace.html @@ -1207,6 +1207,7 @@ window._wsInitData = (function() { this._updateNodeTags(this.tree, node.id, d.tag, 'add'); this.displayTree = [...this.tree]; this.tagsVersion++; + this._bumpTagCount(d.tag, 1); await this.loadWorkspaceTags(); this._computeFilteredTableItems(); this._ctxRefreshAvail(); @@ -2269,12 +2270,31 @@ window._wsInitData = (function() { // ── Tag management ── async loadWorkspaceTags() { try { - var r = await fetch('/api/local-workspace/tags'); + var r = await fetch('/api/local-workspace/tags', { cache: 'no-store' }); var d = await r.json(); this.workspaceTags = d.tags || []; } catch(e) { this.workspaceTags = []; } }, + // Mise à jour OPTIMISTE du compteur d'un tag dans la barre de tags : + // rafraîchit immédiatement la pastille (delta +1/-1) sans attendre le + // re-fetch (qui viendra ensuite confirmer/surécrire avec les valeurs serveur). + _bumpTagCount(tag, delta) { + if (!tag) return; + var tags = (this.workspaceTags || []).slice(); + var i = -1; + for (var k = 0; k < tags.length; k++) { + if (String(tags[k].id) === String(tag.id)) { i = k; break; } + } + if (i >= 0) { + tags[i] = Object.assign({}, tags[i], { count: Math.max(0, (tags[i].count || 0) + delta) }); + } else if (delta > 0) { + tags.push({ id: tag.id, name: tag.name || '', color: tag.color || '#787774', count: delta }); + } + // Réassigne un nouveau tableau → réactivité Alpine + this.workspaceTags = tags; + }, + toggleTagFilter(tagName) { this.tagFilter = (this.tagFilter === tagName) ? '' : tagName; this.filterType = ''; @@ -2306,6 +2326,7 @@ window._wsInitData = (function() { this._updateNodeTags(this.tree, itemId, d.tag, 'add'); this.displayTree = [...this.tree]; this.tagsVersion++; + this._bumpTagCount(d.tag, 1); await this.loadWorkspaceTags(); this._computeFilteredTableItems(); } @@ -2322,6 +2343,7 @@ window._wsInitData = (function() { this._updateNodeTags(this.tree, itemId, null, 'remove', tagId); this.displayTree = [...this.tree]; this.tagsVersion++; + this._bumpTagCount({ id: tagId }, -1); await this.loadWorkspaceTags(); this._computeFilteredTableItems(); } diff --git a/e2e/diag_tags.spec.js b/e2e/diag_tags.spec.js new file mode 100644 index 0000000..92ab0cd --- /dev/null +++ b/e2e/diag_tags.spec.js @@ -0,0 +1,113 @@ +const { test } = 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 tags: chip counts update dynamically on add/remove', async ({ page }) => { + test.setTimeout(120000); + const errors = []; + page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); }); + page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message)); + + 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(1000); + await page.keyboard.press('Escape').catch(() => {}); + await page.waitForTimeout(300); + + const ws = await page.evaluate(async () => { + const r = await fetch('/api/workspaces', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Tag WS ' + Date.now() }) + }); + const d = await r.json(); + await fetch('/api/workspaces/' + d.id + '/select', { method: 'POST' }); + return d; + }); + console.log('WS:', JSON.stringify(ws)); + await page.waitForTimeout(600); + + // Seed 2 files + const seeds = await page.evaluate(async () => { + const mk = (name) => fetch('/api/local-workspace/items', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, type: 'page' }) + }).then(r => r.json()); + const a = await mk('Doc A'); + const b = await mk('Doc B'); + return { a: a.id, b: b.id }; + }); + console.log('SEEDED:', JSON.stringify(seeds)); + + // Tag Doc A via API to set up the scenario + const tagRes = await page.evaluate(async (id) => { + const r = await fetch('/api/local-workspace/items/' + id + '/tags', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'important', color: '#E03E3E' }) + }); + return r.status; + }, seeds.a); + console.log('TAG A status:', tagRes); + + // Load local-workspace (full load first for simplicity) + await page.goto(`${FD_BASE}/local-workspace`); + await page.waitForFunction(() => typeof window._wsData !== 'undefined', { timeout: 10000 }); + await page.waitForTimeout(2500); + + const readChip = async (label) => { + return page.evaluate((tagName) => { + const ws = window._wsData; + const t = (ws.workspaceTags || []).find(x => String(x.id) === String(tagName)); + const chip = [...document.querySelectorAll('.tag-filter-bar .tag-chip')] + .find(b => b.textContent.includes(tagName)); + return { + dataCount: t ? t.count : null, + chipText: chip ? chip.textContent.trim() : null, + wsTags: (ws.workspaceTags || []).map(x => x.id + ':' + x.count), + }; + }, 'important'); + }; + + console.log('INIT:', JSON.stringify(await readChip())); + + // Add SAME tag to Doc B via CONTEXT MENU handler (ctxAddNewTag) → 1 → 2 + const ctxAdd = await page.evaluate(async (id) => { + const ws = window._wsData; + const walk = (arr) => { if (!arr) return null; for (const n of arr) { if (n.id === id) return n; const c = walk(n.children); if (c) return c; } return null; }; + const node = walk(ws.displayTree) || walk(ws.tree); + if (!node) return { err: 'node not found' }; + const store = window.Alpine && Alpine.store('fdCtx'); + if (!store) return { err: 'no fdCtx store' }; + store.node = node; + await ws.ctxAddNewTag('important', '#E03E3E'); + return { id: node.id, name: node.name }; + }, seeds.b); + console.log('CTX ADD B:', JSON.stringify(ctxAdd)); + await page.waitForTimeout(1000); + console.log('AFTER CTX ADD B:', JSON.stringify(await readChip())); + + // Remove tag from Doc A via CONTEXT MENU handler (ctxRemoveTag) → 2 → 1 + const ctxRm = await page.evaluate(async (id) => { + const ws = window._wsData; + const walk = (arr) => { if (!arr) return null; for (const n of arr) { if (n.id === id) return n; const c = walk(n.children); if (c) return c; } return null; }; + const node = walk(ws.displayTree) || walk(ws.tree); + if (!node) return { err: 'node not found' }; + const tag = (node.tags || []).find(t => t.name === 'important'); + if (!tag) return { err: 'tag not on node' }; + const store = window.Alpine && Alpine.store('fdCtx'); + store.node = node; + ws.ctxRemoveTag(tag.id); + return { id: node.id, removedTagId: tag.id }; + }, seeds.a); + console.log('CTX RM A:', JSON.stringify(ctxRm)); + await page.waitForTimeout(1000); + console.log('AFTER CTX RM A:', JSON.stringify(await readChip())); + + console.log('ERRORS:'); + errors.slice(0, 20).forEach(e => console.log(e)); +}); \ No newline at end of file