feat(webui): one task board for the whole system, writes pinned to the card's organization (#1873) #1969

Merged
jason.woltje merged 5 commits from sp4-tasks into refactor 2026-11-15 09:57:23 +00:00
7 changed files with 470 additions and 44 deletions

No files matched your search

+14 -4
View File
@@ -641,8 +641,13 @@
}
// Whether this Console decides in one organization, from that organization's own config: the system
// Inbox's hire links must not follow the organization opened last, whose bus may be the stopped one.
const decidesIn = id => (isTester ? Promise.resolve(false) : fetch(`/api/orgs/${enc(id)}/config`, { cache: 'no-store', redirect: 'manual' })
.then(r => (r.ok ? r.json() : null)).then(c => c?.decide === true && c?.tester !== true).catch(() => false));
const orgConfig = id => (isTester ? Promise.resolve(null) : fetch(`/api/orgs/${enc(id)}/config`, { cache: 'no-store', redirect: 'manual' })
.then(r => (r.ok ? r.json() : null)).catch(() => null));
const decidesIn = id => orgConfig(id).then(c => c?.decide === true && c?.tester !== true);
// SP4: whether this Console takes task changes in one organization, from that organization's own
// config (orgSetup, as the per-organization board read it), so the system task board offers a move
// only where the row's organization takes it.
const tasksIn = id => orgConfig(id).then(c => c?.orgSetup === true && c?.tester !== true);
// SP3: the answer panel for the row selected in the Inbox, in that row's organization. That
// organization's config says whether this Console answers decisions there, and its bus inbox says
// whether this decision is eligible (its `console` field), so neither comes from the organization
@@ -1693,7 +1698,12 @@
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { scope: org, hire: org ? hireStartHtml : null, hireIn: org ? null : decidesIn, answer: d => inboxAnswer(d, current), answered: inboxAnswered }));
}
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
// SP4: #/tasks is every organization's tasks on one board; #/orgs/<id>/tasks is that board for one.
if (a === 'tasks' && n === 1 && window.MosaicOrg) {
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
return configRead.then(() => window.MosaicTasksBoard.view(q, { scope: org, writesIn: tasksIn }));
}
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
if (a === 'messages' && n === 1) return messagesView(get, q);
@@ -1721,7 +1731,7 @@
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
// The cross-organization pages (U3) poll at the workspace read's pollMs.
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents');
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks');
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
// Keep focus, open <details> and the copy status across a refresh of the same page.
// In the send form, the focused field and its caret too (its values come from `draft`).
+3 -2
View File
@@ -23,8 +23,9 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
// organization's own bare address (#/orgs/<id>, no trailing slash) is that organization's page;
// #/orgs/<id>/ stays the old dashboard address. SP3: `#/inbox` is a system route too (every
// organization's items); `#/orgs/<id>/inbox` is that list, filtered. SP5: `#/agents` is every
// organization's agents; `#/orgs/<id>/agents` is that table for one organization.
const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && (bare === '/inbox' || bare === '/agents')); };
// organization's agents; `#/orgs/<id>/agents` is that table for one organization. SP4: `#/tasks` is
// one task board for every organization; `#/orgs/<id>/tasks` is that board, scoped to one.
const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && (bare === '/inbox' || bare === '/agents' || bare === '/tasks')); };
const normalize = (hash, seg = current) => {
const path = tail(hash), bare = path.split('?')[0], named = /^#\/orgs\/([^/?]+)$/.exec(hash.split('?')[0])?.[1];
if (named) return `#/orgs/${named}/organization${hash.slice(hash.split('?')[0].length)}`;
+136 -29
View File
@@ -10,6 +10,12 @@
// shown as the bus answered it. Vikunja tasks are read only here: the bus refuses them
// (tracker-not-store), so the board offers no control for them.
// bus.js calls view() for #/tasks in organization mode and repaints it on its own 10 s poll.
// SP4 (#1873): #/tasks is a system page, every organization on one board; #/orgs/<id>/tasks is the
// same board scoped to that organization (bus.js passes the scope). The org and project filters live
// in the address query (?org=, ?project=), so a reload keeps them. A move or edit goes to the row's own
// organization and is offered only where that organization's own config takes task changes (bus.js
// passes writesIn), never judged by the organization opened last. A card opened on the system board
// links to the task in its organization (#/orgs/<id>/tasks?task=<ref>), which opens it there.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
@@ -56,6 +62,11 @@
// `opened` is the task as the edit dialog showed it: Save diffs against it and sends its digest, so a
// poll that reads a newer task while the dialog is open turns a stale save into a task-conflict.
let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null, opened = null;
// `scope` is the one organization of #/orgs/<id>/tasks, or null on the system board. `allowed` holds
// the organizations whose own config takes task changes from this Console, asked on every read.
let scope = null, writesIn = null, allowed = new Set(), autoOpened = null;
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/, TASK_REF = /^(?:task:\d{1,15}|vikunja:\d{1,15}\/\d{1,15})$/;
const ws = () => window.MosaicWorkspace;
const mark = () => window.MosaicWorkspace?.orgMark;
const orgOf = id => snap?.orgs.find(o => o.id === id) || { id, label: id, theme: null };
@@ -69,7 +80,7 @@
const stateOf = t => (STATE_LABEL[t.state] ? t.state : UNREAD);
// An org can answer while its tasks read fails (errors.tasks): its tasks are unavailable, not zero.
const tasksRead = o => o?.state === 'ok' && !o.errors?.tasks;
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in';
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in' && allowed.has(o.id);
const taskWritable = t => t.writable === true && typeof t.digest === 'string' && orgWritable(orgOf(t.org));
const projectName = t => t.project?.name || t.project?.key || null;
const dueText = iso => {
@@ -84,6 +95,7 @@
const PROJECT_SELECTOR = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9._-]{1,128}$/, ROLE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
function readQuery() {
const q = new URLSearchParams({ sections: 'tasks,projects' });
if (scope) q.set('org', scope);
if (PROJECT_SELECTOR.test(filter.project)) q.set('project', filter.project);
if (filter.state) q.set('task-state', filter.state === UNREAD ? 'open' : filter.state);
if (ROLE.test(filter.assignee)) q.set('assignee', filter.assignee);
@@ -97,7 +109,6 @@
mark()?.ready().catch(() => null),
window.MosaicConsole?.ready,
]);
canWrite = window.MosaicConsole?.config?.orgSetup === true;
if (response.type === 'opaqueredirect' || !response.ok) {
const body = await response.json().catch(() => null);
throw Object.assign(new Error('read failed'), { status: response.status, message: typeof body?.message === 'string' ? body.message : null });
@@ -106,16 +117,46 @@
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 : [] }));
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt };
// 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))) : [];
const takes = new Set(asked.filter((o, i) => yes[i]).map(o => o.id));
// The Console-wide word, for the head's reason: no organization that could take a change takes one.
// With none to ask, the open organization's config still says whether this Console writes at all.
const writes = asked.length ? takes.size > 0 : window.MosaicConsole?.config?.orgSetup === true;
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt, takes, asked: asked.map(o => o.id), writes, view: ws()?.orgsOf?.(body) ?? null };
}
// Only the latest read lands: a filter change and the poll can both be reading.
let readSeq = 0;
const reload = async () => { const n = ++readSeq, next = await read(); if (n !== readSeq) return false; snap = next; prune(); return true; };
// A read that a newer one replaced keeps nothing; `newest` lets a view wait for the read that does.
let readSeq = 0, newest = null;
// The address the read starts from goes to prune, so a cleanup never touches a page opened since.
const reload = () => { const n = ++readSeq, at = location.hash; return (newest = read().then(next => { if (n !== readSeq) return false; snap = next; allowed = next.takes; canWrite = next.writes; return prune(at) ? 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.
function prune() {
if (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 = '';
// 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.
// A project is gone only when its organization is off the list, or answered with its projects and
// the project is not among them and no task came back for it (with no other filter on). An organization that is down,
// starting or timed out, or whose projects read failed, lists no projects, so its filter stays.
// `at` is the address the read started from.
function prune(at) {
const gone = {};
if (!scope && filter.org && !snap.orgs.some(o => o.id === filter.org)) { filter.org = ''; gone.org = null; }
if (filter.project && filter.project !== '-') {
const po = snap.orgs.find(o => o.id === filter.project.split(':')[0]);
const answered = !po || (po.state === 'ok' && !po.errors?.projects);
// No task back is proof only when no other filter narrowed the read on the server.
const narrowed = filter.state || filter.assignee || filter.label;
if (answered && !narrowed && !snap.tasks.length && !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 while the
// address is still the one the read started from, and with a repaint in case the render in
// flight was the one dropped.
if (Object.keys(gone).length) setTimeout(() => { if (location.hash === at) ws()?.setQuery?.(gone, true); }, 0);
return 'project' in gone;
}
// ---------- filters ----------
@@ -137,17 +178,17 @@
const chips = [chip('', `All <span class="n">${snap.tasks.length}</span>`)].concat(snap.orgs.map(o => chip(o.id,
`${av(o.id)}${esc(o.label)}<span class="n">${tasksRead(o) ? counts.get(o.id) : 'unavailable'}</span>`,
`${o.label}, ${tasksRead(o) ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join('');
const scope = snap.orgs.filter(o => !filter.org || o.id === filter.org);
const shown = snap.orgs.filter(o => !filter.org || o.id === filter.org);
const projectOpts = opt('', `All ${snap.projects.filter(p => !filter.org || p.org === filter.org).length}`, filter.project) + opt('-', 'No project', filter.project)
+ scope.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
const roles = [...new Set(scope.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort();
+ shown.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
const roles = [...new Set(shown.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort();
if (filter.assignee && filter.assignee !== '-' && !roles.includes(filter.assignee)) roles.push(filter.assignee);
const assigneeOpts = opt('', 'Anyone', filter.assignee) + opt('-', 'Nobody', filter.assignee) + roles.map(r => opt(r, r, filter.assignee)).join('');
const stateOpts = opt('', 'Any', filter.state) + states().map(([v, l]) => opt(v, l, filter.state)).join('') + (snap.tasks.some(t => stateOf(t) === UNREAD) ? opt(UNREAD, 'State not read', filter.state) : '');
const labels = [...new Set(snap.tasks.flatMap(t => t.labels.map(String)))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : '';
return `<div class="tb-toolbar">
<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>
${scope ? '' : `<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>`}
${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
<div class="tb-right"><label class="tb-search">${icon('search')}<input type="search" placeholder="Find a task" aria-label="Find a task" data-tb-q data-focus-key="tb-q" value="${esc(filter.q)}" autocomplete="off"></label></div>
</div>`;
@@ -158,12 +199,15 @@
const prio = t => (Number.isSafeInteger(t.priority) && t.priority > 0 ? `<span class="tb-chip${t.priority >= 4 ? ' warn' : ''}" title="Priority ${t.priority} of 5">P${t.priority}</span>` : '');
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, 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')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span><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"><span>${projectName(t) ? esc(projectName(t)) : '<span class="muted">No project</span>'}</span>${who(t)}${prio(t)}${due(t)}</div>
<div class="bot">${projChip(t) || '<span><span class="muted">No project</span></span>'}${who(t)}${prio(t)}${due(t)}</div>
</article>`;
}
function kanban(rows) {
@@ -184,10 +228,10 @@
};
return `<div class="ws-tbl tb-list" data-tb-list><table><thead><tr><th scope="col">Org</th><th scope="col">Id</th><th scope="col">Task</th><th scope="col" class="hide-m">Project</th><th scope="col">State</th><th scope="col" class="hide-m">Assignee</th><th scope="col" class="hide-m">Priority</th><th scope="col" class="hide-m">Due</th></tr></thead><tbody>
${sorted.map(t => `<tr class="tb-row ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${esc(key(t))}" data-org="${esc(t.org)}">
<td>${av(t.org)}<span class="ws-sr">${esc(orgOf(t.org).label)}</span></td>
<td>${orgChip(t)}</td>
<td class="mono">${esc(shortId(t.ref))}<span class="t2">${t.tracker === 'vikunja' ? 'Vikunja' : 'built-in'}</span></td>
<td><button type="button" class="tb-open t1" data-tb-open="${esc(key(t))}" data-focus-key="lopen:${esc(key(t))}">${title(t)}</button><span class="t2 show-m">${esc(projectName(t) || 'No project')}</span></td>
<td class="hide-m">${projectName(t) ? esc(projectName(t)) : '<span class="muted">none</span>'}</td>
<td class="hide-m">${projChip(t) || '<span class="muted">none</span>'}</td>
<td>${stateCell(t)}</td>
<td class="hide-m">${t.assignees.length ? esc(t.assignees.join(', ')) : '<span class="muted">nobody</span>'}</td>
<td class="hide-m">${prio(t) || '<span class="muted">none</span>'}</td>
@@ -198,13 +242,14 @@
const rows = filtered(), all = snap.tasks.length;
// Project, state, assignee and label filter on the server, so the board never holds the unfiltered
// total; with one of those on, the line says how many match rather than inventing an "of N".
const onServer = new URLSearchParams(readQuery()).size > 1, n = rows.length;
// The scope's org= is where the board reads, not a filter.
const onServer = new URLSearchParams(readQuery()).size > (scope ? 2 : 1), n = rows.length;
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.</p>`
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.</p>` : '';
// An empty result is only exhaustive when every organization's tasks were read.
const unread = snap.orgs.filter(o => !tasksRead(o));
const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`);
const body = !snap.orgs.length ? '<p class="ws-panel tight note">No organizations yet.</p>'
const body = !snap.orgs.length ? `<p class="ws-panel tight note">${scope ? 'This organization is not on your list.' : 'No organizations yet.'}</p>`
: !snap.orgs.some(tasksRead) ? '<p class="ws-panel tight note">No organization answered with its tasks, so no tasks could be read.</p>'
: !all && !onServer ? `<p class="ws-panel tight note">${esc(none('No tasks yet'))}</p>`
: !rows.length ? `<p class="ws-panel tight note">${esc(none('No tasks match these filters'))}</p>`
@@ -230,17 +275,33 @@
const seg = `<span class="ws-seg" role="group" aria-label="Layout"><button type="button" data-tb-layout="kanban" aria-pressed="${lay === 'kanban'}" data-focus-key="tb-layout-kanban">Kanban</button><button type="button" data-tb-layout="list" aria-pressed="${lay === 'list'}" data-focus-key="tb-layout-list">List</button></span>`;
const add = `<button type="button" class="ws-btn" data-tb-new data-focus-key="tb-new"${why ? ` disabled title="${esc(why)}"` : ''}>${icon('plus')}New task</button>`;
let sub = '';
if (snap) {
if (snap && scope) {
const n = snap.tasks.length, name = snap.orgs.find(o => o.id === scope)?.label || scope;
sub = `Tasks from ${esc(name)} only. ${n} ${n === 1 ? 'task' : 'tasks'}. <a href="#/tasks" data-focus-key="tasks-all">Every organization</a>`;
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
} else if (snap) {
const n = snap.tasks.length, m = snap.orgs.filter(tasksRead).length, of = snap.summary?.of ?? snap.orgs.length;
sub = `${n} ${n === 1 ? 'task' : 'tasks'} from ${m} of ${of} ${of === 1 ? 'organization' : 'organizations'}. Each organization keeps its own tasks; a change made here goes only to that organization's bus.`;
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
else {
const no = snap.asked.filter(id => !allowed.has(id)).map(id => esc(orgOf(id).label));
if (no.length) sub += ` This Console takes no task changes in ${no.length < 2 ? no[0] : `${no.slice(0, -1).join(', ')} and ${no.at(-1)}`}, so ${no.length < 2 ? 'its' : 'their'} tasks are read only here.`;
}
}
return `<div class="ws-vh"><h1 tabindex="-1">Tasks</h1><div class="ws-right">${seg}${add}</div>${sub ? `<p class="sub">${sub}</p>` : ''}</div>`;
}
const noticeLine = () => (notice ? `<p class="tb-notice${notice.error ? ' is-error' : ''}" role="${notice.error ? 'alert' : 'status'}" data-tb-notice>${esc(notice.text)}</p>` : '<p class="tb-notice" role="status" data-tb-notice></p>');
// An organization that did not answer is named first, with its reason, as on the Inbox.
function unreadLine() {
const v = snap?.view;
if (!v || !ws()?.unreadNote) return '';
const o = filter.org && !scope ? { ...v, rows: v.rows.filter(r => r.org === filter.org), unread: v.unread.filter(r => r.org === filter.org) } : v;
return ws().unreadNote(o, ['tasks'], 'tasks');
}
function markup() {
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}">${head()}${noticeLine()}${trackers()}${toolbar()}${results()}</section>`;
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}"${scope ? ` data-tb-scope="${esc(scope)}"` : ''}>${head()}${noticeLine()}${unreadLine()}${trackers()}${toolbar()}${results()}</section>`;
}
const failed = err => `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1><p class="sub">The task board could not be read${err?.message && err.message !== 'read failed' ? `: ${esc(err.message)}` : ''}. It tries again on the next refresh.</p></div></section>`;
@@ -266,7 +327,8 @@
// Repaint in place, for the board's own changes (filters, layout, a write).
function paint(part = 'all') {
const root = document.querySelector('[data-tasks-board]');
if (!root || !snap) return;
// Only over a board drawn from a read: never over the not-on-your-list page or a failed read.
if (!root || !snap || !root.querySelector('[data-tb-results]')) return;
const kept = snapshotView(root);
if (part === 'results') root.querySelector('[data-tb-results]').outerHTML = results();
else root.outerHTML = markup();
@@ -274,9 +336,38 @@
}
// bus.js's view for #/tasks. A drag in progress holds the poll's repaint until it ends.
async function view() {
// 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.
// A read still in flight from the board left behind lands nowhere and paints nothing.
if (typeof opts.scope === 'string' && opts.scope && !ORG_ID.test(opts.scope)) { ++readSeq; snap = null; 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;
// The org and project filters come from the address on every render, checked; anything else is all.
const org = String(q.get('org') || ''), project = String(q.get('project') || '');
filter.org = scope || (ORG_ID.test(org) ? org : '');
filter.project = PROJECT_SELECTOR.test(project) || project === '-' ? project : '';
if (filter.org && filter.project && filter.project !== '-' && !filter.project.startsWith(`${filter.org}:`)) filter.project = '';
if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]);
try { await reload(); } catch (err) { snap = null; return failed(err); }
// Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read.
try { let p = reload(); while (!(await p) && newest !== p) p = newest; } catch (err) { snap = null; return failed(err); }
// A not-on-your-list page opened meanwhile dropped every read; bus.js drops this render as not current.
if (!snap) return '';
// #/orgs/<id>/tasks?task=<ref> opens that task once, after bus.js has drawn the board.
const want = scope ? String(q.get('task') || '') : '';
if (!want) autoOpened = null;
else if (autoOpened !== `${scope}|${want}`) {
autoOpened = `${scope}|${want}`;
const k = `${scope}|${want}`;
setTimeout(() => {
if (!document.querySelector(`[data-tasks-board][data-tb-scope="${CSS.escape(scope || '')}"]`) || autoOpened !== k) return;
const t = TASK_REF.test(want) ? find(k) : null;
if (t) { openDialog(editForm(t), `open:${key(t)}`, t); dialogEl().dataset.fromAddress = '1'; }
else { notice = { error: true, text: `Task ${shortId(want)} is not on this board.` }; paint(); }
}, 0);
}
// Taken after the read, just before bus.js replaces the view, so focus, caret or scroll moved while
// the read was in flight is what is kept. bus.js puts focus back by key; this puts back the rest.
const kept = snapshotView(document.querySelector('[data-tasks-board]'));
@@ -317,7 +408,13 @@
d = document.createElement('dialog');
d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h');
document.body.append(d);
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; d.removeAttribute('aria-describedby'); opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
d.addEventListener('close', () => {
const back = d.dataset.return, fromAddress = d.dataset.fromAddress === '1';
d.innerHTML = ''; d.removeAttribute('aria-describedby'); delete d.dataset.fromAddress; opened = null;
if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus();
// A task the address opened leaves the address when it closes, so a reload does not reopen it.
if (fromAddress && /^#\/orgs\/[^/?]+\/tasks\?(?:.*&)?task=/.test(location.hash)) ws()?.setQuery?.({ task: null }, true);
});
}
return d;
}
@@ -348,14 +445,17 @@
function editForm(t, refusal = null) {
const o = orgOf(t.org), w = taskWritable(t);
const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.'
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : '';
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.'
: !allowed.has(t.org) ? `This Console takes no task changes in ${o.label}.` : !w ? 'The bus did not give this task a version to check a change against.' : '';
// On the system board the task also opens in its own organization.
const goto = scope ? '' : `<a class="ws-btn" href="${esc(`#/orgs/${encodeURIComponent(t.org)}/tasks?${new URLSearchParams({ task: t.ref })}`)}" data-tb-goto>Open in ${esc(o.label)}</a>`;
const facts = `<p class="tb-facts">${av(t.org, 'xs')}${esc(o.label)} · <span class="mono">${esc(shortId(t.ref))}</span> ${trk(t.tracker)}${t.requirement ? ` · <span class="mono">${esc(t.requirement)}</span>` : ''}${projectName(t) ? ` · ${esc(projectName(t))}` : ''}</p>`;
if (!w) {
return `<div class="tb-form" data-tb-form="view"><h2 id="tb-dialog-h">${title(t)}</h2>${facts}
<dl class="tb-dl"><dt>State</dt><dd>${esc(stateLabel(t.state) || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
<p class="tb-field-note">${esc(why)}</p>
${refusal ? `<p id="tb-dialog-status" class="tb-form-status" role="status" data-tb-form-status>${esc(refusal)}</p>` : ''}
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
<div class="tb-actions">${goto}<span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
}
return `<form method="dialog" class="tb-form" data-tb-form="edit" data-tb-key="${esc(key(t))}">
<h2 id="tb-dialog-h">Edit ${esc(shortId(t.ref))}</h2>${facts}
@@ -363,11 +463,12 @@
<div class="tb-row2">${stateSelect(t.state)}${prioSelect(t.priority)}<label>Due date<input type="date" name="due_date" value="${dateValue(t.dueDate)}"></label></div>
${roleBoxes([...new Set([...o.roles, ...t.assignees])].sort(), t.assignees)}
<p class="tb-form-status" role="alert" data-tb-form-status></p>
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button><span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button>${goto}<span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
</form>`;
}
function openDialog(html, back, task = null) {
const d = dialogEl();
delete d.dataset.fromAddress;
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
if (d.querySelector('#tb-dialog-status')) d.setAttribute('aria-describedby', 'tb-dialog-status'); else d.removeAttribute('aria-describedby');
if (!d.open) d.showModal();
@@ -474,6 +575,8 @@
const d = e.target.closest?.('#tb-dialog');
if (d) {
if (e.target.closest('[data-tb-cancel]')) { e.preventDefault(); d.close(); }
// The link navigates; the dialog closes so the organization's board opens the task itself.
else if (e.target.closest('[data-tb-goto]')) { d.dataset.return = ''; d.close(); }
else if (e.target.closest('[data-tb-archive]')) { e.preventDefault(); archive(e.target.closest('form')); }
return;
}
@@ -482,8 +585,11 @@
if (lay) { layouts[phone() ? 'phone' : 'wide'] = lay.dataset.tbLayout; save(LAYOUT, JSON.stringify(layouts)); paint(); return; }
const chip = e.target.closest('[data-tb-org]');
if (chip) {
filter.org = chip.dataset.tbOrg;
if (filter.project && filter.project !== '-' && filter.org && !filter.project.startsWith(`${filter.org}:`)) { filter.project = ''; refilter(); } else paint();
// The org filter lives in the address: paint now, then bus.js re-renders from the new query.
const org = chip.dataset.tbOrg, clear = filter.project && filter.project !== '-' && org && !filter.project.startsWith(`${org}:`);
filter.org = org; if (clear) filter.project = '';
paint();
ws()?.setQuery?.({ org: org || null, ...(clear ? { project: null } : {}) }, true);
return;
}
const open = e.target.closest('[data-tb-open]');
@@ -500,6 +606,7 @@
}
if (!inBoard(e) || !snap) return;
const f = e.target.dataset?.tbFilter;
if (f === 'project') { filter.project = e.target.value; paint(); ws()?.setQuery?.({ project: e.target.value || null }, true); return; }
if (f) { filter[f] = e.target.value; refilter(); return; }
if (e.target.matches('[data-tb-move]')) move(e.target.dataset.tbMove, e.target.value);
});
+12 -7
View File
@@ -564,6 +564,13 @@
const missOf = (r, names) => { const n = r?.state === 'ok' && r.errors ? names.find(x => r.errors[x]) : null; return n ? READ_WHY[r.errors[n]] || String(r.errors[n]) : null; };
const missed = (o, names) => o.rows.filter(r => missOf(r, names)).map(r => `${esc(o.name(r.org))} (${esc(missOf(r, names))})`);
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.
// `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);
// The organizations that answered and made every read a figure is built from. Only these count
// toward it: a failed read reaches the merge as an empty list, so its zero is not a figure.
const readers = (o, names) => new Set(o.rows.filter(r => r.state === 'ok' && !missOf(r, names)).map(r => r.org));
@@ -816,19 +823,17 @@
const picked = want === 'none' ? null : rows.find(d => itemKey(d) === want) || (want ? null : rows[0] || null);
const here = path => (scope ? `${orgHref(scope)}${path}` : `#${path}`);
const qs = d => { const p = new URLSearchParams(); if (org && !scope) p.set('org', org.join(',')); if (type) p.set('type', type); p.set('d', d ? itemKey(d) : 'none'); return here(`/inbox?${p}`); };
const orgChip = d => `<span class="ws-chip ws-o ws-ochip" style="${o.style(d.org)}" data-ws-orgchip><i aria-hidden="true"></i>${esc(o.name(d.org))}</span>`;
const projChip = d => (d.project?.name ? `<span class="ws-chip outline ws-pchip" data-ws-projchip>${esc(d.project.name)}</span>` : '');
const chips = d => `${orgChip(o, d.org)}${projChip(d.project?.name)}`;
const row = d => {
const on = picked && itemKey(d) === itemKey(picked), by = roleOf(d);
return `<tr class="ws-row ws-edge ws-o${on ? ' on' : ''}" style="${o.style(d.org)}" data-ws-item="${esc(itemKey(d))}">
<td><a class="q" href="${esc(qs(d))}" data-ws-select="${esc(itemKey(d))}" data-focus-key="${esc(`item:${itemKey(d)}`)}"${on ? ' aria-current="true"' : ''}>${esc(d.question || `Decision ${d.id}`)}</a><span class="ws-ichips">${orgChip(d)}${projChip(d)}<span class="via">${by ? `${esc(by)} · ` : ''}decision <span class="mono">${esc(d.id)}</span>${d.taskRef ? ` · task ${esc(d.taskRef)}` : ''}</span><span class="show-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</span></span></td>
<td><a class="q" href="${esc(qs(d))}" data-ws-select="${esc(itemKey(d))}" data-focus-key="${esc(`item:${itemKey(d)}`)}"${on ? ' aria-current="true"' : ''}>${esc(d.question || `Decision ${d.id}`)}</a><span class="ws-ichips">${chips(d)}<span class="via">${by ? `${esc(by)} · ` : ''}decision <span class="mono">${esc(d.id)}</span>${d.taskRef ? ` · task ${esc(d.taskRef)}` : ''}</span><span class="show-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</span></span></td>
<td class="hide-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</td>
<td class="num">${d.at ? `<time datetime="${esc(d.at)}">${esc(span(d.at))}</time>` : '<span class="muted">not reported</span>'}</td>
</tr>`;
};
// An organization that did not answer is named first, with the reason its read gave.
const why = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`;
const unread = (o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not read: ${andList(o.unread.map(why))}.</p>` : '') + missLine(o, ['inbox'], 'inbox');
const unread = unreadNote(o, ['inbox'], 'inbox');
const filtered = (org && !scope) || type;
const table = rows.length ? `<div class="ws-tbl"><table class="ws-ibx" data-ws-items><caption class="ws-sr">Items that need you</caption><thead><tr><th scope="col">Needs</th><th scope="col" class="hide-m">Class</th><th scope="col" class="num">Waiting</th></tr></thead><tbody>${rows.map(row).join('')}</tbody></table></div>` : `<p class="ws-panel tight note" data-ws-items>${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}</p>`;
// The hire, fire and change form posts to one organization's bus, so the system list links to
@@ -851,7 +856,7 @@
insp = `<aside class="ws-insp ws-o" style="${o.style(d.org)}" aria-label="Decision ${id}" data-ws-insp="${esc(itemKey(d))}">
<header>${av(o.meta(d.org))}<span class="ws-label">Decision <span class="mono">${id}</span></span>${classChip(d.class)}<a class="x" href="${esc(qs(null))}" data-ws-select="none" data-focus-key="insp-close" aria-label="Close the decision">${icon('close')}</a></header>
<div class="body">
<div class="ws-ichips">${orgChip(d)}${projChip(d)}</div>
<div class="ws-ichips">${chips(d)}</div>
<h3>${esc(d.question || `Decision ${d.id}`)}</h3>
<div class="ws-auth">${esc(o.name(d.org))} · ${esc(d.project?.name || 'No project')}${by ? ` · raised by <b>${esc(by)}</b>` : ''}${d.at ? ` at ${esc(hm(d.at))}` : ''}.${d.class === 'gated' ? ' Only you can answer a gated decision.' : ''}</div>
${d.task?.title || d.taskRef ? `<p class="note">Task ${esc(d.taskRef || '')}${d.task?.title ? ` · ${esc(d.task.title)}` : ''}</p>` : ''}
@@ -1055,6 +1060,6 @@
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, get pollMs() { return pollMs; } });
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, get pollMs() { return pollMs; } });
refresh();
})();
@@ -0,0 +1,303 @@
// SP4 (#1873, single pane slice 4): one task board for the whole system at #/tasks. Every
// organization that answers adds its tasks to one board, in the per-organization page's columns,
// each card with its organization chip and its project chip. The organization and project filters
// live in the hash query. A move goes to the row's own organization, offered only where that
// organization's own config takes task changes, never judged by the organization opened last.
// A stopped organization is named at the top with its reason. #/orgs/<id>/tasks is the same board,
// scoped to one organization, and a card opened from the system board opens there.
// Every bus is a fake: acme and beta keep their tasks in the built-in store, gamma's bus is stopped.
// The real GET /api/workspace and the real POST /api/workspace/tasks serve the page.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
const D = n => String(n).repeat(64);
function fixture({ empty = false, archived = [] } = {}) {
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false },
];
const PROJECTS = {
acme: [{ id: 'site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
beta: [{ id: 'shop', repo: 'shop', name: 'Shop', archived: false, at: at(60) }, { id: 'ads', repo: 'ads', name: 'Ads', archived: false, at: at(90) }],
};
const STORE = {
acme: [{ n: 1, title: 'Hero copy', state: 'todo', group: 'site', assignees: ['coder'] }],
beta: [{ n: 2, title: 'Price page', state: 'in-progress', group: 'shop', assignees: [] }, { n: 3, title: 'Spring banner', state: 'todo', group: 'ads', assignees: [] }],
};
if (empty) { STORE.acme = []; STORE.beta = []; }
for (const [org, list] of Object.entries(PROJECTS)) for (const p of list) p.archived = archived.includes(`${org}:${p.id}`);
const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1',
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null } }));
const writes = [];
const taskWrite = org => async (verb, args) => { writes.push([org, verb, args]); return { task_ref: args.task_ref, digest: D(9) }; };
const opts = id => ({
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => tasks(id), messages: async () => ({ messages: [], unread: 0 }) },
chat: { projects: async () => PROJECTS[id] || [], threads: async () => [] },
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme }, roles: { coder: {} } },
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
options: () => opts(org.id),
});
return {
writes,
list: async () => LIST,
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] }, taskWrite: taskWrite(id) };
return {
busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: PROJECTS[id].map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
...opts(id),
bus: { ...opts(id).bus, instances: async () => ({ business: id, instances: {} }) },
taskWrite: taskWrite(id),
};
},
};
}
// 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);
window.fetch = async (url, init = {}) => {
const u = String(url);
if (!/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) return real(url, init);
const c = await (await real(url, init)).json();
const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1];
return new Response(JSON.stringify({ ...c, orgSetup: c.orgSetup === true && !window.__noWrite.includes(org) }), { status: 200, headers: { 'content-type': 'application/json' } });
};
})();`;
async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [] } = {}) {
const orgs = fixture({ empty, archived });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, send: async () => ({}), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
await b.viewport(...viewport);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB(noWrite) });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/${hash}`);
return { b, base, orgs, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const cards = 'JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask).sort())';
const ALL = JSON.stringify(JSON.stringify(['acme|task:1', 'beta|task:2', 'beta|task:3']));
const BETA = JSON.stringify(JSON.stringify(['beta|task:2', 'beta|task:3']));
test('#/tasks shows both organizations\' tasks on one board, each card with its organization and project chips', async t => {
const { b, wait, text } = await setup(t);
await wait(`${cards} === ${ALL}`);
// A system route: the address keeps no organization and the sidebar Tasks is this page.
assert.equal(await b.evaluate('location.hash'), '#/tasks');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks');
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']);
// 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);
// The first load draws the board once, with no "last read failed" banner from a render the next one replaced.
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale")'), null);
});
test('the organization and project filters live in the address and survive a reload', async t => {
const { b, base, wait } = await setup(t);
await wait(`${cards} === ${ALL}`);
await b.evaluate('document.querySelector("[data-tb-org=beta]").click()');
await wait(`location.hash === "#/tasks?org=beta" && ${cards} === ${BETA}`);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "beta:shop"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait(`location.hash === "#/tasks?org=beta&project=beta%3Ashop" && ${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
await b.navigate(`${base}/?reload=1#/tasks?org=beta&project=beta%3Ashop`);
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
assert.equal(await b.evaluate('document.querySelector("[data-tb-org=beta]").getAttribute("aria-pressed")'), 'true');
assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop');
// A hand-edited value that is not an organization id or a project is ignored, not read as a filter.
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 => {
const { b, wait, orgs } = await setup(t, { hash: '#/orgs/acme/organization', noWrite: ['acme'] });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/tasks"');
await wait(`${cards} === ${ALL}`);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")');
// Acme's config takes no task changes, so its row has no State control and its card does not drag.
assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=acme]")'), null);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/moved to Review in Beta Works/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes, [['beta', 'tasks.edit', { task_ref: 'task:2', expect: D(2), state: 'in-review' }]]);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
});
test('a stopped organization is named at the top of the board with its reason', async t => {
const { b, wait, text } = await setup(t);
await wait(`${cards} === ${ALL}`);
const note = await text('[data-ws-unread]');
assert.match(note, /Gamma Home/);
assert.match(note, /stopped by the owner/);
assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector(".tb-toolbar")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true);
});
test('with no tasks anywhere the board says so and still names the stopped organization', async t => {
const { b, wait, text } = await setup(t, { empty: true });
await wait('/No tasks yet/.test(document.querySelector("[data-tb-results]")?.textContent || "")');
assert.match(await text('[data-tb-results]'), /Gamma Home could not be read/);
assert.match(await text('[data-ws-unread]'), /Gamma Home/);
});
test('#/orgs/beta/tasks shows beta\'s tasks only, with a link to every organization', async t => {
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');
// No filter is on, so no "match the filters" line: the scope is not a filter.
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .tb-count")'), null);
assert.equal(await b.evaluate('document.querySelector("[data-tb-org]")'), null);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /Tasks from Beta Works only/);
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .sub a")?.getAttribute("href")'), '#/tasks');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks');
// 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 => {
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization' });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/tasks"');
await wait(`${cards} === ${ALL}`);
await b.evaluate('document.querySelector("[data-tb-open=\'beta|task:3\']").click()');
await wait('document.querySelector("#tb-dialog[open] [data-tb-goto]")');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").getAttribute("href")'), '#/orgs/beta/tasks?task=task%3A3');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").textContent'), 'Open in Beta Works');
await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").click()');
await wait('location.hash.startsWith("#/orgs/beta/tasks") && window.MosaicOrg.id === "beta" && document.querySelector("[data-tasks-board]")?.dataset.tbScope === "beta"');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")?.dataset.tbKey === "beta|task:3"');
// The scoped page offers no second hop to the organization it is already in.
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]")'), null);
// Closing the task takes it off the address, so a reload does not open it again.
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
await wait('location.hash === "#/orgs/beta/tasks" && !document.querySelector("#tb-dialog").open');
});
// Holds each task board read for ?hold= ms (or window.__hold, set later) and counts the reads that
// started and the ones that came back, so a test can act while a read is in flight. A task write is
// held for window.__holdW ms and counted the same way.
const HOLD = `window.__hold = Number(new URLSearchParams(location.search).get('hold')) || 0; window.__started = 0; window.__landed = 0;
window.__holdW = 0; window.__wStarted = 0; window.__wLanded = 0;
(() => {
const real = window.fetch.bind(window);
window.fetch = async (url, init = {}) => {
if (String(url) === '/api/workspace/tasks' && init.method === 'POST' && window.__holdW) {
window.__wStarted++;
await new Promise(r => setTimeout(r, window.__holdW));
try { return await real(url, init); } finally { window.__wLanded++; }
}
if (!/^\\/api\\/workspace\\?sections=tasks(?:%2C|,)projects/.test(String(url))) return real(url, init);
window.__started++;
if (window.__hold) await new Promise(r => setTimeout(r, window.__hold));
try { return await real(url, init); } finally { window.__landed++; }
};
})();`;
const settle = b => b.evaluate('new Promise(r => setTimeout(r, 200))');
test('a project filter stays in the address while its organization does not answer', async t => {
const { b, orgs, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop' });
const SHOP = JSON.stringify(JSON.stringify(['beta|task:2']));
await wait(`${cards} === ${SHOP}`);
const list = await orgs.list();
list[1].busHealthy = false;
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")');
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
list[1].busHealthy = true;
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait(`${cards} === ${SHOP} && !/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")`);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop');
});
test('a gone filter is cleaned from the tasks address only, never from a page opened while it was read', async t => {
const { b, base, wait } = await setup(t);
await wait(`${cards} === ${ALL}`);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?hold=800#/tasks?org=gone`);
await wait('window.__started >= 1');
await b.evaluate('location.hash = "#/inbox?org=acme"');
await wait('window.__landed >= 1');
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/inbox?org=acme');
});
test('a read in flight does not paint the board over the page for an address no organization matches', async t => {
const { b, base, wait } = await setup(t);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?reload=1#/tasks`);
await wait(`${cards} === ${ALL}`);
await b.evaluate('window.__hold = 800; window.__started = 0; window.__landed = 0');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "todo"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('window.__started >= 1');
await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"');
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
await wait('window.__landed >= window.__started');
await settle(b);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/);
assert.equal(await b.evaluate(cards), '[]');
});
test('a move still writing when the not-on-your-list page opens does not paint the board over it', async t => {
const { b, base, wait } = await setup(t);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?reload=1#/tasks`);
await wait(`${cards} === ${ALL}`);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")');
await b.evaluate('window.__holdW = 800; window.__started = 0; window.__landed = 0');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('window.__wStarted >= 1');
await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"');
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
await wait('window.__wLanded >= 1 && window.__landed >= window.__started');
await settle(b);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/);
assert.equal(await b.evaluate(cards), '[]');
});
test('an archived project keeps its filter while it has tasks, and while another filter matches none of them', async t => {
const { b, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop', archived: ['beta:shop'] });
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
// No shop task is done: the read comes back empty because of the state filter, not because shop is gone.
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait(`${cards} === "[]" && /No tasks match/.test(document.querySelector("[data-tb-results]")?.textContent || "")`);
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
});
@@ -73,7 +73,7 @@ async function open(t, { refuse = null, tasksFail = [], send = true, width = 144
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(width, height);
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
const url = `http://127.0.0.1:${server.address().port}/#/tasks`;
await b.navigate(url);
const wait = waitFor(b);
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
@@ -68,7 +68,7 @@ async function open(t, fixture = orgs()) {
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
await b.viewport(1440, 900);
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
const url = `http://127.0.0.1:${server.address().port}/#/tasks`;
await b.navigate(url);
const wait = waitFor(b);
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=forge]")');