fix(workspace): compteurs de tags dynamiques + counts scoped par workspace
This commit is contained in:
@@ -1900,7 +1900,10 @@ async def list_all_tags_global(request: Request):
|
|||||||
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
||||||
(uid,),
|
(uid,),
|
||||||
).fetchall()
|
).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 ═══════════
|
# ═══════════ Workspace Tags API ═══════════
|
||||||
@@ -1915,14 +1918,17 @@ async def list_tags(request: Request):
|
|||||||
return {"tags": []}
|
return {"tags": []}
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count "
|
"SELECT t.id, t.name, t.color, "
|
||||||
"FROM tags t "
|
"(SELECT COUNT(*) FROM page_tags pt "
|
||||||
"LEFT JOIN page_tags pt ON pt.tag_id = t.id "
|
" JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
|
||||||
"LEFT JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
|
" WHERE pt.tag_id = t.id) as count "
|
||||||
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
"FROM tags t WHERE t.user_id = ? ORDER BY t.name",
|
||||||
(ws_id, uid),
|
(ws_id, uid),
|
||||||
).fetchall()
|
).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")
|
@router.get("/api/local-workspace/items/{item_id:int}/tags")
|
||||||
|
|||||||
@@ -1207,6 +1207,7 @@ window._wsInitData = (function() {
|
|||||||
this._updateNodeTags(this.tree, node.id, d.tag, 'add');
|
this._updateNodeTags(this.tree, node.id, d.tag, 'add');
|
||||||
this.displayTree = [...this.tree];
|
this.displayTree = [...this.tree];
|
||||||
this.tagsVersion++;
|
this.tagsVersion++;
|
||||||
|
this._bumpTagCount(d.tag, 1);
|
||||||
await this.loadWorkspaceTags();
|
await this.loadWorkspaceTags();
|
||||||
this._computeFilteredTableItems();
|
this._computeFilteredTableItems();
|
||||||
this._ctxRefreshAvail();
|
this._ctxRefreshAvail();
|
||||||
@@ -2269,12 +2270,31 @@ window._wsInitData = (function() {
|
|||||||
// ── Tag management ──
|
// ── Tag management ──
|
||||||
async loadWorkspaceTags() {
|
async loadWorkspaceTags() {
|
||||||
try {
|
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();
|
var d = await r.json();
|
||||||
this.workspaceTags = d.tags || [];
|
this.workspaceTags = d.tags || [];
|
||||||
} catch(e) { this.workspaceTags = []; }
|
} 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) {
|
toggleTagFilter(tagName) {
|
||||||
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
|
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
|
||||||
this.filterType = '';
|
this.filterType = '';
|
||||||
@@ -2306,6 +2326,7 @@ window._wsInitData = (function() {
|
|||||||
this._updateNodeTags(this.tree, itemId, d.tag, 'add');
|
this._updateNodeTags(this.tree, itemId, d.tag, 'add');
|
||||||
this.displayTree = [...this.tree];
|
this.displayTree = [...this.tree];
|
||||||
this.tagsVersion++;
|
this.tagsVersion++;
|
||||||
|
this._bumpTagCount(d.tag, 1);
|
||||||
await this.loadWorkspaceTags();
|
await this.loadWorkspaceTags();
|
||||||
this._computeFilteredTableItems();
|
this._computeFilteredTableItems();
|
||||||
}
|
}
|
||||||
@@ -2322,6 +2343,7 @@ window._wsInitData = (function() {
|
|||||||
this._updateNodeTags(this.tree, itemId, null, 'remove', tagId);
|
this._updateNodeTags(this.tree, itemId, null, 'remove', tagId);
|
||||||
this.displayTree = [...this.tree];
|
this.displayTree = [...this.tree];
|
||||||
this.tagsVersion++;
|
this.tagsVersion++;
|
||||||
|
this._bumpTagCount({ id: tagId }, -1);
|
||||||
await this.loadWorkspaceTags();
|
await this.loadWorkspaceTags();
|
||||||
this._computeFilteredTableItems();
|
this._computeFilteredTableItems();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user