From 0f29fe0d92a92abc7c5bd6656c9db62dd20157ce Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 03:08:49 -0600 Subject: [PATCH] fix(webui): sp4 review round, one organization chip per card with its avatar, a filter that is gone leaves the address, an impossible organization address is not the system board (#1873, lane sp4) - The card's organization chip carries the avatar in place of its dot; the bare avatar before it is gone (prototype shot 13). - A filter naming an organization or project that is not on the list is dropped from the address after the render, and a dropped project reads again without its server filter. - #/orgs//tasks with an id no organization can have says "This organization is not on your list." instead of showing every organization's tasks. - The write check's comment and the test's fake say orgSetup is Console-wide today. --- packages/webui/src/public/tasks-board.js | 24 +++++++++++++------ packages/webui/src/public/workspace.js | 3 ++- .../webui/tests/system-tasks-browser.test.mjs | 16 ++++++++++--- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index 500b6e936..ea526421e 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -117,8 +117,9 @@ if (!Array.isArray(body?.orgs) || !Array.isArray(body.tasks) || !Array.isArray(body.projects)) throw new Error('read failed'); const named = new Map((list || []).map(o => [o.id, o])); const orgs = body.orgs.map(o => ({ ...o, id: o.org, label: named.get(o.org)?.label || o.org, theme: named.get(o.org)?.theme ?? o.theme ?? null, roles: Array.isArray(o.roles) ? o.roles : [] })); - // Each organization that could take a change answers for itself, from its own config. With no - // way to ask, none is asked and the board is read only. + // Each organization that could take a change is asked through its own config. The server sets + // orgSetup for the whole Console today, so the answer only differs where an organization's config + // read fails or 404s. With no way to ask, none is asked and the board is read only. const asked = orgs.filter(o => tasksRead(o) && o.tracker === 'built-in'); const ask = typeof writesIn === 'function' ? writesIn : null; const yes = ask ? await Promise.all(asked.map(o => Promise.resolve(ask(o.id)).then(v => v === true, () => false))) : []; @@ -131,12 +132,19 @@ // Only the latest read lands: a filter change and the poll can both be reading. // A read that a newer one replaced keeps nothing; `newest` lets a view wait for the read that does. let readSeq = 0, newest = null; - const reload = () => { const n = ++readSeq; return (newest = read().then(next => { if (n !== readSeq) return false; snap = next; allowed = next.takes; canWrite = next.writes; prune(); return true; })); }; + const reload = () => { const n = ++readSeq; return (newest = read().then(next => { if (n !== readSeq) return false; snap = next; allowed = next.takes; canWrite = next.writes; return prune() ? reload() : true; })); }; async function refilter() { paint(); try { if (await reload()) paint(); } catch { /* the next poll shows the failed read */ } } // A filter naming an org or project that is gone falls back to all. + // The address drops it too, so it never names a filter the board is not using. A dropped project + // was filtered on the server, so it returns true and the read runs again without it. function prune() { - if (!scope && filter.org && !snap.orgs.some(o => o.id === filter.org)) filter.org = ''; - if (filter.project && filter.project !== '-' && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) filter.project = ''; + const gone = {}; + if (!scope && filter.org && !snap.orgs.some(o => o.id === filter.org)) { filter.org = ''; gone.org = null; } + if (filter.project && filter.project !== '-' && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) { filter.project = ''; gone.project = null; } + // After this render: bus.js drops a render whose address changed while it ran. Only if the address + // has not moved on, and with a repaint in case the render in flight was the one dropped. + if (Object.keys(gone).length) { const at = location.hash; setTimeout(() => { if (location.hash === at) ws()?.setQuery?.(gone, true); }, 0); } + return 'project' in gone; } // ---------- filters ---------- @@ -180,12 +188,12 @@ const due = t => { const d = t.dueDate && dueText(t.dueDate); return d ? `${overdue(t) ? 'overdue' : 'due'} ${esc(d)}` : ''; }; const title = t => esc(t.title || 'Untitled'); // The shared chips (workspace.js), so a card reads as the Inbox's rows do. - const orgChip = t => (snap.view && ws()?.orgChip ? ws().orgChip(snap.view, t.org) : `${esc(orgOf(t.org).label)}`); + const orgChip = t => (snap.view && ws()?.orgChip ? ws().orgChip(snap.view, t.org, av(t.org, 'xs') || undefined) : `${av(t.org, 'xs')}${esc(orgOf(t.org).label)}`); const projChip = t => (projectName(t) ? (ws()?.projChip?.(projectName(t)) ?? `${esc(projectName(t))}`) : ''); function card(t) { const w = taskWritable(t), k = esc(key(t)); return `
-
${av(t.org, 'xs')}${orgChip(t)}${esc(shortId(t.ref))}${trk(t.tracker)}
+
${orgChip(t)}${esc(shortId(t.ref))}${trk(t.tracker)}
${projChip(t) || 'No project'}${who(t)}${prio(t)}${due(t)}
`; @@ -317,6 +325,8 @@ // q is the address query; opts.scope the organization of #/orgs//tasks; opts.writesIn asks one // organization's own config whether it takes task changes. async function view(q = new URLSearchParams(), opts = {}) { + // An address that names an organization no id can match is not the system board. + if (typeof opts.scope === 'string' && opts.scope && !ORG_ID.test(opts.scope)) return `

Tasks

This organization is not on your list.

`; const s = typeof opts.scope === 'string' && ORG_ID.test(opts.scope) ? opts.scope : null; if (s !== scope) snap = null; scope = s; writesIn = typeof opts.writesIn === 'function' ? opts.writesIn : null; diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index f717e6b49..a6cdba793 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -566,7 +566,8 @@ const missLine = (o, names, key, lead = 'Not read in') => { const m = missed(o, names); return m.length ? `

${lead} ${andList(m)}.

` : ''; }; // SP3/SP4: a row's organization and project as chips, and the organizations that did not answer // named first with the reason their read gave. The Inbox and the system task board share these. - const orgChip = (o, id) => `${esc(o.name(id))}`; + // `mark`, already-escaped markup such as the organization's avatar, takes the dot's place. + const orgChip = (o, id, mark = '') => `${mark}${esc(o.name(id))}`; const projChip = name => (name ? `${esc(name)}` : ''); const unreadWhy = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`; const unreadNote = (o, names, key) => (o.unread.length ? `

Not read: ${andList(o.unread.map(unreadWhy))}.

` : '') + missLine(o, names, key); diff --git a/packages/webui/tests/system-tasks-browser.test.mjs b/packages/webui/tests/system-tasks-browser.test.mjs index 977fbd1ae..9e57c5107 100644 --- a/packages/webui/tests/system-tasks-browser.test.mjs +++ b/packages/webui/tests/system-tasks-browser.test.mjs @@ -66,6 +66,8 @@ function fixture({ empty = false } = {}) { // Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL. // Every organization's config says this Console takes task changes, except those named in `noWrite`. +// The real server sets orgSetup for the whole Console, so this per-organization difference is faked: +// it proves the board asks each row's own organization, which matters where one config read fails. const STUB = noWrite => `window.__noWrite = ${JSON.stringify(noWrite)}; (() => { const real = window.fetch.bind(window); @@ -102,8 +104,9 @@ test('#/tasks shows both organizations\' tasks on one board, each card with its assert.equal(await text('#bus-view h1'), 'Tasks'); // The per-organization page's columns. assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done']); - assert.equal(await text('[data-tb-task="acme|task:1"] [data-ws-orgchip]'), 'Acme Studio'); - assert.equal(await text('[data-tb-task="beta|task:2"] [data-ws-orgchip]'), 'Beta Works'); + // One chip names the organization, with its avatar in place of the dot. + assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="acme|task:1"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Acme Studio'); + assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="beta|task:2"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Beta Works'); assert.equal(await text('[data-tb-task="acme|task:1"] [data-ws-projchip]'), 'Website'); assert.equal(await text('[data-tb-task="beta|task:3"] [data-ws-projchip]'), 'Ads'); assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true); @@ -126,6 +129,9 @@ test('the organization and project filters live in the address and survive a rel await b.navigate(`${base}/?reload=2#/tasks?org=Not%20An%20Id&project=nope`); await wait(`${cards} === ${ALL}`); assert.equal(await b.evaluate('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed")'), 'true'); + // An organization or project that is not on the list falls back to all, and the address drops it. + await b.navigate(`${base}/?reload=3#/tasks?org=gone&project=beta%3Agone`); + await wait(`${cards} === ${ALL} && location.hash === "#/tasks"`); }); test('a move on beta\'s row while acme was opened last reaches beta, and acme\'s own config keeps its rows read only', async t => { @@ -161,7 +167,7 @@ test('with no tasks anywhere the board says so and still names the stopped organ }); test('#/orgs/beta/tasks shows beta\'s tasks only, with a link to every organization', async t => { - const { b, wait } = await setup(t, { hash: '#/orgs/beta/tasks' }); + const { b, base, wait } = await setup(t, { hash: '#/orgs/beta/tasks' }); await wait(`${cards} === ${BETA}`); assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/tasks'); assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.tbScope'), 'beta'); @@ -172,6 +178,10 @@ test('#/orgs/beta/tasks shows beta\'s tasks only, with a link to every organizat // The sidebar Tasks link opens the system board. await b.evaluate('document.querySelector("#sections a[href=\'#/tasks\']").click()'); await wait(`location.hash === "#/tasks" && ${cards} === ${ALL}`); + // An address that names no possible organization is not the system board. + await b.navigate(`${base}/?reload=1#/orgs/Not%20An%20Id/tasks`); + await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")'); + assert.equal(await b.evaluate(cards), '[]'); }); test('a card opened from the system board opens the task in its organization', async t => {