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/<id>/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.
This commit is contained in:
jason.woltje committed 2026-11-15 03:54:28 -06:00
1 parent 32be3a01bc
commit 0f29fe0d92
3 files changed
+32 -11

No files matched your search

+17 -7
View File
@@ -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 ? `<span class="tb-chip${overdue(t) ? ' warn' : ''}" title="Due ${esc(t.dueDate.slice(0, 10))} (UTC)">${overdue(t) ? 'overdue' : 'due'} ${esc(d)}</span>` : ''; };
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) : `<span class="ws-sr">${esc(orgOf(t.org).label)}</span>`);
const orgChip = t => (snap.view && ws()?.orgChip ? ws().orgChip(snap.view, t.org, av(t.org, 'xs') || undefined) : `${av(t.org, 'xs')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span>`);
const projChip = t => (projectName(t) ? (ws()?.projChip?.(projectName(t)) ?? `<span>${esc(projectName(t))}</span>`) : '');
function card(t) {
const w = taskWritable(t), k = esc(key(t));
return `<article class="tb-card ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${k}" data-org="${esc(t.org)}"${w ? ' draggable="true"' : ''}>
<div class="top">${av(t.org, 'xs')}${orgChip(t)}<span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
<div class="top">${orgChip(t)}<span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
<div class="tt"><button type="button" class="tb-open" data-tb-open="${k}" data-focus-key="open:${k}">${title(t)}</button></div>
<div class="bot">${projChip(t) || '<span><span class="muted">No project</span></span>'}${who(t)}${prio(t)}${due(t)}</div>
</article>`;
@@ -317,6 +325,8 @@
// q is the address query; opts.scope the organization of #/orgs/<id>/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 `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1></div><p class="ws-panel tight note">This organization is not on your list.</p></section>`;
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;
+2 -1
View File
@@ -566,7 +566,8 @@
const missLine = (o, names, key, lead = 'Not read in') => { const m = missed(o, names); return m.length ? `<p class="note ws-miss" data-ws-miss="${key}">${lead} ${andList(m)}.</p>` : ''; };
// 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) => `<span class="ws-chip ws-o ws-ochip" style="${o.style(id)}" data-ws-orgchip><i aria-hidden="true"></i>${esc(o.name(id))}</span>`;
// `mark`, already-escaped markup such as the organization's avatar, takes the dot's place.
const orgChip = (o, id, mark = '<i aria-hidden="true"></i>') => `<span class="ws-chip ws-o ws-ochip" style="${o.style(id)}" data-ws-orgchip>${mark}${esc(o.name(id))}</span>`;
const projChip = name => (name ? `<span class="ws-chip outline ws-pchip" data-ws-projchip>${esc(name)}</span>` : '');
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 ? `<p class="note ws-unread-note" data-ws-unread>Not read: ${andList(o.unread.map(unreadWhy))}.</p>` : '') + missLine(o, names, key);
@@ -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 => {