feat(webui): one task board for every organization, with organization and project filters in the address (#1873, lane sp4)

#/tasks is a system route that shows every organization's tasks on one board,
with the per-organization columns, organization and project chips on each card,
and the organization and project filters kept in the address. An organization
that did not answer is named first with its reason. A move or edit goes to the
row's own organization and is offered only where that organization's own config
takes task changes. #/orgs/<id>/tasks is the same board scoped to one
organization, and a card opened from the system board links to its task there.
A render replaced by a newer one now draws from that newer read instead of
throwing on an empty board.
This commit is contained in:
jason.woltje committed 2026-11-15 03:54:28 -06:00
1 parent f97e0d3aeb
commit 32be3a01bc
7 files changed
+329 -40

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)}`;
+105 -25
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,15 +117,25 @@
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 answers for itself, from its own config. 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;
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; })); };
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 (!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 = '';
}
@@ -137,17 +158,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 +179,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) : `<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">${av(t.org, 'xs')}${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 +208,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>
@@ -204,7 +228,7 @@
// 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 +254,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>`;
@@ -274,9 +314,33 @@
}
// 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 = {}) {
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); }
// #/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 +381,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 +418,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 +436,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 +548,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 +558,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 +579,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);
});
+11 -7
View File
@@ -564,6 +564,12 @@
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.
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>`;
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 +822,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 +855,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 +1059,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,194 @@
// 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 } = {}) {
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 = []; }
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`.
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 = [] } = {}) {
const orgs = fixture({ empty });
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']);
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');
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');
});
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, 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');
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}`);
});
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');
});
@@ -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]")');