fix(webui): U4b review fixes for the task board (#1873)
- Edit and archive require the digest the board read (expect); the route refuses a write without it. - The edit dialog saves against the task it showed, so a poll that reads a newer task turns a stale save into task-conflict; the dialog reopens on the current task. - An answer the board cannot read, or one marked sent=unknown, is shown as an open outcome, not as a refusal. - The per-org cap applies after the filters and keeps the most recently changed tasks; projects, metrics and the inbox count every task. The board sends project, state, assignee and label filters to the server. - A poll keeps the focus, caret and scroll the person has at the moment it answers, not when it started. - An org whose tasks read fails shows "tasks not read" and is not writable. - browser.test.mjs pins tasks-board.js in the script order.
This commit is contained in:
1 parent
ac3a863bec
commit
4c74e7ea49
6 files changed
+246
-86
No files matched your search
@@ -1,7 +1,9 @@
|
||||
// U4b (#1873, PRD 1.20): one task board across every organization (sample 06).
|
||||
//
|
||||
// Reads: GET /api/workspace?sections=tasks,projects, where each org's tasks come from its own tracker
|
||||
// module (the built-in store or Vikunja), merged and tagged with the org. An org whose bus is down,
|
||||
// module (the built-in store or Vikunja), merged and tagged with the org. The project, state, assignee
|
||||
// and label filters go to the server, which applies them before its per-org cap, so a filter reaches
|
||||
// every task an org holds; the org chips, the search, Nobody and No project narrow in the page. An org whose bus is down,
|
||||
// slow or invalid keeps its U1 state on the Trackers line and the others still render.
|
||||
// Writes: POST /api/workspace/tasks, one org per request, through that org's bus and its person
|
||||
// verbs (tasks.create, tasks.edit, tasks.archive). The bus runs its own human check; a refusal is
|
||||
@@ -28,7 +30,9 @@
|
||||
const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })();
|
||||
const layoutNow = () => { const v = layouts[phone() ? 'phone' : 'wide']; return v === 'kanban' || v === 'list' ? v : phone() ? 'list' : 'kanban'; };
|
||||
const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' };
|
||||
let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null;
|
||||
// `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;
|
||||
|
||||
const mark = () => window.MosaicWorkspace?.orgMark;
|
||||
const orgOf = id => snap?.orgs.find(o => o.id === id) || { id, label: id, theme: null };
|
||||
@@ -40,7 +44,9 @@
|
||||
const shortId = ref => { let m = /^task:(\d+)$/.exec(ref); if (m) return `#${m[1]}`; m = /^vikunja:\d+\/(\d+)$/.exec(ref); return m ? `V-${m[1]}` : ref; };
|
||||
const trk = tracker => (tracker === 'vikunja' ? '<span class="tb-trk vik">Vikunja</span>' : '<span class="tb-trk">built-in</span>');
|
||||
const stateOf = t => (STATE_LABEL[t.state] ? t.state : UNREAD);
|
||||
const orgWritable = o => canWrite && o?.state === 'ok' && o.tracker === 'built-in';
|
||||
// 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 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 => {
|
||||
@@ -52,9 +58,18 @@
|
||||
const overdue = t => t.dueDate && t.state !== 'done' && Date.parse(t.dueDate) < Date.now();
|
||||
|
||||
// ---------- reads ----------
|
||||
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 (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);
|
||||
if (/^[1-9]\d{0,14}$/.test(filter.label)) q.set('label', filter.label);
|
||||
return q.toString();
|
||||
}
|
||||
async function read() {
|
||||
const [response, list] = await Promise.all([
|
||||
fetch('/api/workspace?sections=tasks,projects', { cache: 'no-store', redirect: 'manual' }),
|
||||
fetch(`/api/workspace?${readQuery()}`, { cache: 'no-store', redirect: 'manual' }),
|
||||
data.organizations().then(b => (Array.isArray(b) ? b : null)).catch(() => null),
|
||||
mark()?.ready().catch(() => null),
|
||||
window.MosaicConsole?.ready,
|
||||
@@ -70,7 +85,10 @@
|
||||
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 };
|
||||
}
|
||||
const reload = async () => { snap = await read(); prune(); };
|
||||
// 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; };
|
||||
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 = '';
|
||||
@@ -94,8 +112,8 @@
|
||||
for (const t of snap.tasks) counts.set(t.org, (counts.get(t.org) ?? 0) + 1);
|
||||
const chip = (id, inner, label) => `<button type="button" class="tb-f${id ? '' : ' plain'}" data-tb-org="${esc(id)}" aria-pressed="${filter.org === id}" data-focus-key="tb-org:${esc(id)}"${label ? ` aria-label="${esc(label)}"` : ''}>${inner}</button>`;
|
||||
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">${o.state === 'ok' ? counts.get(o.id) : 'unavailable'}</span>`,
|
||||
`${o.label}, ${o.state === 'ok' ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join('');
|
||||
`${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 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('');
|
||||
@@ -155,9 +173,13 @@
|
||||
}
|
||||
function results() {
|
||||
const rows = filtered(), all = snap.tasks.length;
|
||||
const line = rows.length !== all ? `<p class="tb-count">Showing ${rows.length} of ${all} tasks.</p>` : '';
|
||||
// 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;
|
||||
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>` : '';
|
||||
const body = !snap.orgs.length ? '<p class="ws-panel tight note">No organizations yet.</p>'
|
||||
: !all ? `<p class="ws-panel tight note">${snap.summary?.read ? 'No tasks yet.' : 'No organization answered, so no tasks could be read.'}</p>`
|
||||
: !all ? `<p class="ws-panel tight note">${snap.orgs.some(tasksRead) ? 'No tasks yet.' : 'No organization answered with its tasks, so no tasks could be read.'}</p>`
|
||||
: !rows.length ? '<p class="ws-panel tight note">No tasks match these filters.</p>'
|
||||
: layoutNow() === 'list' ? list(rows) : kanban(rows);
|
||||
return `<div data-tb-results>${line}${body}</div>`;
|
||||
@@ -166,9 +188,10 @@
|
||||
// ---------- page ----------
|
||||
function trackers() {
|
||||
const items = snap.orgs.map(o => {
|
||||
const s = ORG_STATE[o.state];
|
||||
const st = s ? ` <span class="ws-dot ${s[0]}"><b aria-hidden="true">${s[1]}</b>${esc(s[2])}</span>${o.reason ? `<span class="tb-reason">${esc(o.reason)}</span>` : ''}` : '';
|
||||
const cut = o.tasksHeld ? `<span class="tb-reason">shows the first 500 of ${esc(o.tasksHeld)} tasks</span>` : '';
|
||||
const s = ORG_STATE[o.state] ?? (o.state === 'ok' && o.errors?.tasks ? ['s-error', '▲', 'tasks not read'] : null);
|
||||
const why = ORG_STATE[o.state] ? o.reason : o.errors?.tasks;
|
||||
const st = s ? ` <span class="ws-dot ${s[0]}"><b aria-hidden="true">${s[1]}</b>${esc(s[2])}</span>${why ? `<span class="tb-reason">${esc(why)}</span>` : ''}` : '';
|
||||
const cut = o.tasksHeld ? `<span class="tb-reason">shows the 500 most recently changed of ${esc(o.tasksHeld)} matching tasks</span>` : '';
|
||||
return `<span class="tb-tracker${s ? ' is-down' : ''}" data-tb-tracker="${esc(o.id)}" data-state="${esc(o.state)}">${av(o.id)}${esc(o.label)} ${o.tracker ? trk(o.tracker) : ''}${st}${cut}</span>`;
|
||||
}).join('');
|
||||
return `<div class="tb-trackers"><span class="ws-label">Trackers</span>${items}<span class="note">Ids keep their tracker's form: V-12 is Vikunja, #41 is built in.</span></div>`;
|
||||
@@ -181,7 +204,7 @@
|
||||
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) {
|
||||
const n = snap.tasks.length, m = snap.summary?.read ?? 0, of = snap.summary?.of ?? snap.orgs.length;
|
||||
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.';
|
||||
@@ -204,12 +227,14 @@
|
||||
scroll: root ? [...root.querySelectorAll('.tb-board,.tb-list')].map(e => [e.matches('.tb-board') ? 'board' : 'list', e.scrollLeft]) : [],
|
||||
};
|
||||
}
|
||||
function putBack(root, kept) {
|
||||
// With focus false (after bus.js's own repaint, which puts focus back by key) the caret is put back
|
||||
// only if focus is still on the search box it was taken from.
|
||||
function putBack(root, kept, { focus = true } = {}) {
|
||||
if (!root || !kept) return;
|
||||
for (const [which, left] of kept.scroll) { const e = root.querySelector(which === 'board' ? '.tb-board' : '.tb-list'); if (e) e.scrollLeft = left; }
|
||||
const el = kept.focus ? root.querySelector(`[data-focus-key="${CSS.escape(kept.focus)}"]`) : null;
|
||||
if (el && document.activeElement !== el) el.focus({ preventScroll: true });
|
||||
if (el && kept.caret && el.matches('[data-tb-q]')) el.setSelectionRange(kept.caret[0], kept.caret[1]);
|
||||
if (focus && el && document.activeElement !== el) el.focus({ preventScroll: true });
|
||||
if (el && document.activeElement === el && kept.caret && el.matches('[data-tb-q]')) el.setSelectionRange(kept.caret[0], kept.caret[1]);
|
||||
}
|
||||
// Repaint in place, for the board's own changes (filters, layout, a write).
|
||||
function paint(part = 'all') {
|
||||
@@ -224,21 +249,28 @@
|
||||
// bus.js's view for #/tasks. A drag in progress holds the poll's repaint until it ends.
|
||||
async function view() {
|
||||
if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]);
|
||||
const kept = snapshotView(document.querySelector('[data-tasks-board]'));
|
||||
try { await reload(); } catch (err) { snap = null; return failed(err); }
|
||||
setTimeout(() => putBack(document.querySelector('[data-tasks-board]'), kept), 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]'));
|
||||
setTimeout(() => putBack(document.querySelector('[data-tasks-board]'), kept, { focus: false }), 0);
|
||||
return markup();
|
||||
}
|
||||
|
||||
// ---------- writes ----------
|
||||
const UNKNOWN = Object.freeze({ ok: false, error: 'outcome-unknown', unknown: true, message: 'The Console\'s answer could not be read. The change may or may not have been written; the board shows it on its next read.' });
|
||||
async function write(body) {
|
||||
let response, answer = null;
|
||||
try {
|
||||
response = await fetch('/api/workspace/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });
|
||||
answer = await response.json().catch(() => null);
|
||||
} catch { return { ok: false, error: 'outcome-unknown', message: 'The Console did not answer. The change may or may not have been written; the board shows it on its next read.' }; }
|
||||
} catch { return UNKNOWN; }
|
||||
if (response.type === 'opaqueredirect') return { ok: false, error: 'signed-out', message: 'The Console asked to sign in again. Nothing was written.' };
|
||||
if (response.ok && answer?.ok) return answer;
|
||||
return { ok: false, error: answer?.error || `http-${response.status}`, message: answer?.message || `The Console refused the change (${response.status}). Nothing was written.` };
|
||||
// A refusal names its code. An answer that cannot be read, or one that says the outcome is open,
|
||||
// is not a refusal: the change may have reached the bus.
|
||||
if (typeof answer?.error !== 'string' || answer.sent === 'unknown') return { ...UNKNOWN, message: answer?.sent === 'unknown' && answer.message ? answer.message : UNKNOWN.message };
|
||||
return { ok: false, error: answer.error, message: answer.message || `The Console refused the change (${response.status}). Nothing was written.` };
|
||||
}
|
||||
const find = k => snap?.tasks.find(t => key(t) === k) || null;
|
||||
async function move(k, state) {
|
||||
@@ -246,7 +278,7 @@
|
||||
if (!t || !taskWritable(t) || t.state === state) return;
|
||||
notice = { text: `Moving ${shortId(t.ref)} to ${STATE_LABEL[state]}…` }; paint();
|
||||
const r = await write({ op: 'edit', org: t.org, task_ref: t.ref, expect: t.digest, state });
|
||||
notice = r.ok ? { text: `${shortId(t.ref)} moved to ${STATE_LABEL[state]} in ${orgOf(t.org).label}.` } : { error: true, text: `${shortId(t.ref)} was not moved. ${r.message}` };
|
||||
notice = r.ok ? { text: `${shortId(t.ref)} moved to ${STATE_LABEL[state]} in ${orgOf(t.org).label}.` } : { error: true, text: r.unknown ? `${shortId(t.ref)}: ${r.message}` : `${shortId(t.ref)} was not moved. ${r.message}` };
|
||||
try { await reload(); } catch {}
|
||||
paint();
|
||||
}
|
||||
@@ -258,7 +290,7 @@
|
||||
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 = ''; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
|
||||
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
|
||||
}
|
||||
return d;
|
||||
}
|
||||
@@ -306,9 +338,9 @@
|
||||
<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>
|
||||
</form>`;
|
||||
}
|
||||
function openDialog(html, back) {
|
||||
function openDialog(html, back, task = null) {
|
||||
const d = dialogEl();
|
||||
d.innerHTML = html; d.dataset.return = back || '';
|
||||
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
|
||||
if (!d.open) d.showModal();
|
||||
d.querySelector('input:not([type=checkbox]),select,button')?.focus();
|
||||
}
|
||||
@@ -335,7 +367,7 @@
|
||||
const group = String(f.get('group') || ''); if (group) body.group = group;
|
||||
const d = due0(String(f.get('due_date') || '')); if (d) body.due_date = d;
|
||||
} else {
|
||||
t = find(form.dataset.tbKey);
|
||||
t = opened && key(opened) === form.dataset.tbKey ? opened : null;
|
||||
if (!t) { formStatus(form, 'The task is no longer on the board. Nothing was written.'); return; }
|
||||
body = editBody(form, t);
|
||||
if (Object.keys(body).length === 4) { formStatus(form, 'Nothing changed.'); return; }
|
||||
@@ -349,9 +381,9 @@
|
||||
if (r.error === 'task-conflict' && t) {
|
||||
try { await reload(); } catch {}
|
||||
const now = find(key(t));
|
||||
if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); }
|
||||
if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); }
|
||||
paint();
|
||||
}
|
||||
} else if (r.unknown) { try { await reload(); } catch {} paint(); }
|
||||
return;
|
||||
}
|
||||
const label = orgOf(body.org).label;
|
||||
@@ -361,7 +393,7 @@
|
||||
paint();
|
||||
}
|
||||
async function archive(form) {
|
||||
const t = find(form.dataset.tbKey);
|
||||
const t = opened && key(opened) === form.dataset.tbKey ? opened : null;
|
||||
if (!t) return;
|
||||
if (!form.querySelector('[data-tb-archive]').hasAttribute('data-armed')) {
|
||||
form.querySelector('[data-tb-archive]').setAttribute('data-armed', '');
|
||||
@@ -369,7 +401,7 @@
|
||||
return;
|
||||
}
|
||||
const r = await write({ op: 'archive', org: t.org, task_ref: t.ref, expect: t.digest });
|
||||
if (!r.ok) { formStatus(form, r.message); return; }
|
||||
if (!r.ok) { formStatus(form, r.message); if (r.unknown) { try { await reload(); } catch {} paint(); } return; }
|
||||
notice = { text: `${shortId(t.ref)} archived in ${orgOf(t.org).label}.` };
|
||||
dialogEl().close();
|
||||
try { await reload(); } catch {}
|
||||
@@ -389,9 +421,13 @@
|
||||
const lay = e.target.closest('[data-tb-layout]');
|
||||
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 = ''; paint(); return; }
|
||||
if (chip) {
|
||||
filter.org = chip.dataset.tbOrg;
|
||||
if (filter.project && filter.project !== '-' && filter.org && !filter.project.startsWith(`${filter.org}:`)) { filter.project = ''; refilter(); } else paint();
|
||||
return;
|
||||
}
|
||||
const open = e.target.closest('[data-tb-open]');
|
||||
if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey); return; }
|
||||
if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey, t); return; }
|
||||
const add = e.target.closest('[data-tb-new]');
|
||||
if (add && !add.disabled) openDialog(createForm(filter.org), 'tb-new');
|
||||
});
|
||||
@@ -404,7 +440,7 @@
|
||||
}
|
||||
if (!inBoard(e) || !snap) return;
|
||||
const f = e.target.dataset?.tbFilter;
|
||||
if (f) { filter[f] = e.target.value; paint(); 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);
|
||||
});
|
||||
document.addEventListener('input', e => {
|
||||
|
||||
@@ -25,9 +25,11 @@ const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
const PER_ORG_CONCURRENCY = 3;
|
||||
// U4b (#1873): the task board's states, the built-in store's (broker STORE_STATES), in board order.
|
||||
export const TASK_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']);
|
||||
// U4b: at most this many tasks per organization reach one snapshot. The bus's tasks read has no
|
||||
// cursor for the merged form, so the cap bounds the answer and the board's DOM; an org past it is
|
||||
// marked with how many it holds, and the board says the list is cut.
|
||||
// U4b: at most this many tasks per organization reach one answer. The bus's tasks read has no
|
||||
// cursor for the merged form, so the cap bounds the answer and the board's DOM. It applies last:
|
||||
// projects, metrics, sessions and the inbox count every task, and the filters run before it, so a
|
||||
// filter reaches every task the org holds. An org past it keeps its most recently changed tasks
|
||||
// and says how many matched (`tasksHeld`); the board says the list is cut.
|
||||
export const TASKS_PER_ORG = 500;
|
||||
const ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
@@ -208,10 +210,7 @@ function orgRows(org, data, doc, nowMs) {
|
||||
const byId = new Map(projects.map(p => [p.id, p]));
|
||||
const ref = p => (p ? { id: p.id, key: p.repo ?? null, name: p.name ?? null } : null);
|
||||
const threadProject = new Map(arr(data.threads).map(t => [t.id, byId.get(t.project) ?? null]));
|
||||
const allTasks = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone && t.archived !== true);
|
||||
// U4b: past the cap, the org says how many it holds and the snapshot keeps the first TASKS_PER_ORG.
|
||||
if (allTasks.length > TASKS_PER_ORG) org.tasksHeld = allTasks.length;
|
||||
const taskRows = allTasks.slice(0, TASKS_PER_ORG);
|
||||
const taskRows = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone && t.archived !== true);
|
||||
const bots = new Map(Object.entries(doc?.roles ?? {}).filter(([, r]) => r?.tracker?.botId !== undefined).map(([role, r]) => [r.tracker.botId, role]));
|
||||
const tasks = taskRows.map(t => {
|
||||
const f = t.fields ?? {};
|
||||
@@ -337,11 +336,20 @@ export async function readWorkspace({ organizations, session, query = parseWorks
|
||||
if (query.sections.has('inbox')) body.inbox = merged.inbox.sort((a, b) => (a.class === 'gated' ? 0 : 1) - (b.class === 'gated' ? 0 : 1) || (a.at ?? '').localeCompare(b.at ?? ''));
|
||||
if (query.sections.has('tasks')) {
|
||||
const p = query.project;
|
||||
body.tasks = merged.tasks.filter(t => (!p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
|
||||
const matched = merged.tasks.filter(t => (!p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
|
||||
&& (!query.taskState || query.taskState.has(t.state ?? 'open'))
|
||||
&& (!query.assignee || t.assignees.some(a => query.assignee.has(a)))
|
||||
&& (!query.label || t.labels.some(l => query.label.has(l))))
|
||||
.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
||||
&& (!query.label || t.labels.some(l => query.label.has(l))));
|
||||
// The cap, per org, after the filters: the most recently changed tasks stay.
|
||||
const byOrg = Map.groupBy(matched, t => t.org), kept = [];
|
||||
for (const r of read) {
|
||||
const rows = byOrg.get(r.org.org) ?? [];
|
||||
if (rows.length > TASKS_PER_ORG) {
|
||||
r.org.tasksHeld = rows.length;
|
||||
kept.push(...rows.sort((a, b) => (b.changedAt ?? '').localeCompare(a.changedAt ?? '') || b.ref.localeCompare(a.ref, undefined, { numeric: true })).slice(0, TASKS_PER_ORG));
|
||||
} else kept.push(...rows);
|
||||
}
|
||||
body.tasks = kept.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
||||
}
|
||||
if (query.sections.has('metrics')) {
|
||||
const m = merged.metrics;
|
||||
|
||||
@@ -24,8 +24,10 @@ const DUE = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/;
|
||||
// assignees, priority and due date; a create also names the requirement and the project (group).
|
||||
const OPS = {
|
||||
create: { verb: 'tasks.create', keys: ['title', 'requirement', 'group', 'state', 'assignees', 'priority', 'due_date'], need: ['title', 'requirement'] },
|
||||
edit: { verb: 'tasks.edit', keys: ['task_ref', 'expect', 'title', 'state', 'assignees', 'priority', 'due_date'], need: ['task_ref'] },
|
||||
archive: { verb: 'tasks.archive', keys: ['task_ref', 'expect'], need: ['task_ref'] },
|
||||
// An edit or archive names the digest the board read, so a change made since is a task-conflict
|
||||
// and never a silent overwrite. The bus checks it; the route refuses a write without one.
|
||||
edit: { verb: 'tasks.edit', keys: ['task_ref', 'expect', 'title', 'state', 'assignees', 'priority', 'due_date'], need: ['task_ref', 'expect'] },
|
||||
archive: { verb: 'tasks.archive', keys: ['task_ref', 'expect'], need: ['task_ref', 'expect'] },
|
||||
};
|
||||
const FIELD_CHECKS = {
|
||||
task_ref: v => typeof v === 'string' && STORE_REF.test(v),
|
||||
|
||||
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { close } from './fixture.mjs';
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
const D = n => String(n).repeat(64);
|
||||
|
||||
function boardOrgs({ refuse = null } = {}) {
|
||||
function boardOrgs({ refuse = null, tasksFail = [] } = {}) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
@@ -30,9 +30,11 @@ function boardOrgs({ refuse = null } = {}) {
|
||||
{ n: 41, title: 'Rate limit the public API', state: 'todo', group: 'api-gateway', assignees: ['coder'], priority: 4, due: '2026-11-20T00:00:00.000Z' },
|
||||
{ n: 42, title: 'Invoice export', state: 'in-progress', group: 'billing', assignees: [], priority: 0, due: null },
|
||||
{ n: 43, title: 'Retire the v1 gateway', state: 'done', group: 'api-gateway', assignees: ['reviewer'], priority: 2, due: null },
|
||||
];
|
||||
].map(t => ({ ...t, rev: 0 }));
|
||||
// A task's digest changes with every write to it, as the store's does.
|
||||
const digest = t => D((t.n + t.rev) % 10);
|
||||
const TASKS = {
|
||||
forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: D(t.n % 10), requirement: 'REQ-API-1',
|
||||
forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: digest(t), requirement: 'REQ-API-1',
|
||||
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: t.due } })),
|
||||
ddigital: () => [
|
||||
{ task_ref: 'vikunja:3/112', project_key: 'spring', priority: 3, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Launch email, draft 2', done: false, assignees: [7], bucket: 12, labels: ['launch'] } },
|
||||
@@ -44,17 +46,18 @@ function boardOrgs({ refuse = null } = {}) {
|
||||
writes.push([org, verb, args]);
|
||||
if (refuse) throw Object.assign(new Error('refused'), { code: refuse });
|
||||
const t = store.find(s => `task:${s.n}` === args.task_ref);
|
||||
if (verb === 'tasks.edit' && t && args.state) t.state = args.state;
|
||||
return { task_ref: args.task_ref, digest: D(9) };
|
||||
if (t && args.expect && args.expect !== digest(t)) throw Object.assign(new Error('conflict'), { code: 'task-conflict' });
|
||||
if (verb === 'tasks.edit' && t) { for (const f of ['title', 'state', 'assignees']) if (args[f] !== undefined) t[f] = args[f]; t.rev++; }
|
||||
return { task_ref: args.task_ref, digest: t ? digest(t) : D(9) };
|
||||
};
|
||||
const options = id => ({
|
||||
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => TASKS[id](), messages: async () => ({ messages: [], unread: 0 }) },
|
||||
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => { if (tasksFail.includes(id)) throw Object.assign(new Error('tasks'), { code: 'tracker-unavailable' }); return TASKS[id](); }, messages: async () => ({ messages: [], unread: 0 }) },
|
||||
chat: { projects: async () => PROJECTS[id], threads: async () => [] },
|
||||
});
|
||||
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
|
||||
const down = async () => ({ healthy: false, pid: null, boot: null });
|
||||
return {
|
||||
writes,
|
||||
writes, store,
|
||||
list: async () => ORGS,
|
||||
members: async () => ORGS.map(o => ({ id: o.id, doc: DOCS[o.id], status: o.busHealthy ? healthy : down, options: () => options(o.id) })),
|
||||
resolve: async id => {
|
||||
@@ -65,7 +68,7 @@ function boardOrgs({ refuse = null } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function open(t, { refuse = null, send = true, width = 1440, height = 900, orgs = boardOrgs({ refuse }) } = {}) {
|
||||
async function open(t, { refuse = null, tasksFail = [], send = true, width = 1440, height = 900, orgs = boardOrgs({ refuse, tasksFail }) } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, ...(send ? { send: async () => ({}) } : {}), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
@@ -105,12 +108,13 @@ test('org and project filters narrow the board', async t => {
|
||||
await b.evaluate('document.querySelector("[data-tb-org=forge]").click()');
|
||||
await wait('document.querySelector("[data-tb-org=forge]").getAttribute("aria-pressed") === "true"');
|
||||
assert.deepEqual((await cards(b)).sort(), ['forge|task:41', 'forge|task:42', 'forge|task:43']);
|
||||
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 3 of 5 tasks.');
|
||||
// The project list follows the org filter.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=project] option")].map(o => o.value).sort()'), ['', '-', 'forge:api-gateway', 'forge:billing']);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
||||
assert.deepEqual(await cards(b), ['forge|task:42']);
|
||||
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 1 of 5 tasks.');
|
||||
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 1 task that matches the filters.');
|
||||
// Back to every org; the project filter stays on forge's project.
|
||||
await b.evaluate('document.querySelector("[data-tb-org=\'\']").click()');
|
||||
await wait('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed") === "true"');
|
||||
@@ -190,3 +194,94 @@ test('a Console without --send shows a read-only board and the write route refus
|
||||
assert.equal(status, 403);
|
||||
assert.deepEqual(orgs.writes, []);
|
||||
});
|
||||
|
||||
// bus.js repaints the view on its own 10 s poll; this resolves once that repaint has replaced the board.
|
||||
const POLLED = '(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()';
|
||||
|
||||
test('an edit dialog left open while another writer changes the task saves against what it showed and gets a conflict', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
||||
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
||||
// Another writer renames the task and assigns it; a poll then reads the new version.
|
||||
const t42 = orgs.store.find(s => s.n === 42);
|
||||
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
|
||||
assert.equal(await b.evaluate(POLLED), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export');
|
||||
// Save sends the digest and the changes against the task as the dialog showed it.
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('/changed after the board read it/.test(document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent)');
|
||||
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]);
|
||||
assert.equal(t42.title, 'Invoice export (renamed elsewhere)');
|
||||
// The dialog reopens on the current task, so the next save is checked against what it now shows.
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), false);
|
||||
});
|
||||
|
||||
test('a poll that answers after focus has moved leaves focus where the person put it', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
|
||||
// Hold the next workspace read until focus has moved from the search to a row's State control.
|
||||
await b.evaluate(`(() => {
|
||||
const real = window.fetch;
|
||||
window.__held = null;
|
||||
window.fetch = (u, o) => String(u).startsWith('/api/workspace?') && !window.__held
|
||||
? new Promise(go => { window.__held = () => real(u, o).then(go); })
|
||||
: real(u, o);
|
||||
document.querySelector("[data-tb-q]").focus();
|
||||
})()`);
|
||||
// bus.js polls every 10 s, longer than waitFor allows.
|
||||
assert.equal(await b.evaluate('(async () => { for (let i = 0; i < 140 && !window.__held; i++) await new Promise(r => setTimeout(r, 100)); return !!window.__held; })()'), true);
|
||||
await b.evaluate('(window.__board = document.querySelector("[data-tasks-board]"), document.querySelector("[data-tb-move=\'forge|task:43\']").focus())');
|
||||
await b.evaluate('window.__held()');
|
||||
await wait('document.querySelector("[data-tasks-board]") !== window.__board');
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 100))');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'move:forge|task:43');
|
||||
});
|
||||
|
||||
test('an org whose tasks read fails shows its tasks as not read, not as none', async t => {
|
||||
const { b } = await open(t, { tasksFail: ['forge'] });
|
||||
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118']);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=forge]").textContent'), /Forge.*tasks not read.*tracker-unavailable/s);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-org=forge]").textContent'), /Forge\s*unavailable/);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /2 tasks from 1 of 3 organizations/);
|
||||
// No org that can take a write is left, so New task is off.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
|
||||
});
|
||||
|
||||
test('a board with no tasks read at all says so instead of showing an empty board', async t => {
|
||||
const { b } = await open(t, { tasksFail: ['forge', 'ddigital'] });
|
||||
assert.deepEqual(await cards(b), []);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]").textContent'), /No organization answered with its tasks, so no tasks could be read\./);
|
||||
});
|
||||
|
||||
test('a committed write whose answer cannot be read is shown as unknown, not as refused', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
|
||||
// The write reaches the bus; a proxy then replaces the answer with a page that is not JSON.
|
||||
await b.evaluate(`(() => { const real = window.fetch; window.fetch = async (u, o) => { const r = await real(u, o); return String(u) === '/api/workspace/tasks' ? new Response('<html>Bad gateway</html>', { status: 502, headers: { 'content-type': 'text/html' } }) : r; }; })()`);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('/may or may not have been written/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
const text = await b.evaluate('document.querySelector("[data-tb-notice]").textContent');
|
||||
assert.doesNotMatch(text, /was not moved|Nothing was written/);
|
||||
assert.match(text, /^#41: /);
|
||||
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
|
||||
// The board reads again and shows where the bus has the task.
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']").value === "in-review"');
|
||||
});
|
||||
|
||||
test('a filter change asks the server for the matching tasks', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await b.evaluate(`(() => { const real = window.fetch; window.__reads = []; window.fetch = (u, o) => { if (String(u).startsWith('/api/workspace?')) window.__reads.push(String(u)); return real(u, o); }; })()`);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('window.__reads.length');
|
||||
const q = new URLSearchParams(await b.evaluate('window.__reads[0].split("?")[1]'));
|
||||
assert.equal(q.get('project'), 'forge:billing');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
||||
assert.deepEqual(await cards(b), ['forge|task:42']);
|
||||
});
|
||||
@@ -103,32 +103,34 @@ test('parseTaskWrite takes the three ops strictly and refuses before any bus', (
|
||||
{ op: 'create', org: 'forge', verb: 'tasks.create', args: { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' } });
|
||||
assert.deepEqual(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), state: 'in-review' }),
|
||||
{ op: 'edit', org: 'forge', verb: 'tasks.edit', args: { task_ref: 'task:4', expect: D(1), state: 'in-review' } });
|
||||
assert.deepEqual(parseTaskWrite({ op: 'archive', org: 'forge', task_ref: 'task:4' }), { op: 'archive', org: 'forge', verb: 'tasks.archive', args: { task_ref: 'task:4' } });
|
||||
assert.equal(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', due_date: null }).args.due_date, null);
|
||||
assert.deepEqual(parseTaskWrite({ op: 'archive', org: 'forge', task_ref: 'task:4', expect: D(1) }), { op: 'archive', org: 'forge', verb: 'tasks.archive', args: { task_ref: 'task:4', expect: D(1) } });
|
||||
assert.equal(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), due_date: null }).args.due_date, null);
|
||||
const refused = [
|
||||
[{ op: 'delete', org: 'forge', task_ref: 'task:1' }, /^op takes create, edit, archive$/],
|
||||
[{ op: 'unarchive', org: 'forge', task_ref: 'task:1' }, /^op takes/],
|
||||
[{ op: 'edit', org: 'Forge!', task_ref: 'task:1', title: 'x' }, /^org takes/],
|
||||
[{ op: 'edit', task_ref: 'task:1', title: 'x' }, /^org takes/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', description: 'x' }, /^edit does not take description$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', description: 'x' }, /^edit does not take description$/],
|
||||
[{ op: 'archive', org: 'forge', task_ref: 'task:1', title: 'x' }, /^archive does not take title$/],
|
||||
[{ op: 'create', org: 'forge', title: 'x' }, /^create needs requirement$/],
|
||||
[{ op: 'edit', org: 'forge', title: 'x' }, /^edit needs task_ref$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1' }, /^edit needs a field to change$/],
|
||||
// An edit or archive without the digest the board read is refused before any bus (no silent overwrite).
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x' }, /^edit needs expect$/],
|
||||
[{ op: 'archive', org: 'forge', task_ref: 'task:1' }, /^archive needs expect$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(2) }, /^edit needs a field to change$/],
|
||||
// A Vikunja task is never written from the board: its ref is refused here.
|
||||
[{ op: 'edit', org: 'ddigital', task_ref: 'vikunja:3/112', title: 'x' }, /^task_ref is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:0', title: 'x' }, /^task_ref is not valid$/],
|
||||
[{ op: 'edit', org: 'ddigital', expect: D(1), task_ref: 'vikunja:3/112', title: 'x' }, /^task_ref is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:0', title: 'x' }, /^task_ref is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: 'abc', title: 'x' }, /^expect is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', title: ' ' }, /^title is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x'.repeat(251) }, /^title is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', state: 'doing' }, /^state is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', priority: 6 }, /^priority is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', priority: 1.5 }, /^priority is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', assignees: ['coder', 'bad name'] }, /^assignees is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', assignees: 'coder' }, /^assignees is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', due_date: '2026-11-20' }, /^due_date is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', due_date: '2026-02-30T00:00:00.000Z' }, /^due_date is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: ' ' }, /^title is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: 'x'.repeat(251) }, /^title is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', state: 'doing' }, /^state is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 6 }, /^priority is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 1.5 }, /^priority is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: ['coder', 'bad name'] }, /^assignees is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: 'coder' }, /^assignees is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-11-20' }, /^due_date is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-02-30T00:00:00.000Z' }, /^due_date is not valid$/],
|
||||
[{ op: 'create', org: 'forge', title: 'x', requirement: 'req-1' }, /^requirement is not valid$/],
|
||||
[{ op: 'create', org: 'forge', title: 'x', requirement: 'REQ-TASK-1', group: 'a\nb' }, /^group is not valid$/],
|
||||
];
|
||||
@@ -138,7 +140,7 @@ test('parseTaskWrite takes the three ops strictly and refuses before any bus', (
|
||||
test('route: the fence, the owner and --send come before any org is resolved', async t => {
|
||||
const r = await registry(t);
|
||||
const { post } = await serve(t, { organizations: r.organizations });
|
||||
const body = { op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x' };
|
||||
const body = { op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(1), title: 'x' };
|
||||
const fenced = [
|
||||
[{ origin: null }, 'cross-origin request refused'],
|
||||
[{ origin: 'http://evil.test' }, 'cross-origin request refused'],
|
||||
@@ -197,14 +199,14 @@ test('route: each write reaches only the org it names, through that org\'s real
|
||||
assert.equal(stale.status, 409); assert.equal(stale.body.error, 'task-conflict'); assert.equal(stale.body.sent, false);
|
||||
assert.match(stale.body.message, /Nothing was written/);
|
||||
assert.equal(r.forge.read()[0].fields.title, 'Rate limits');
|
||||
const missing = await post({ op: 'edit', org: 'forge', task_ref: 'task:9', title: 'x' });
|
||||
const missing = await post({ op: 'edit', org: 'forge', task_ref: 'task:9', expect: D(1), title: 'x' });
|
||||
assert.equal(missing.status, 404); assert.equal(missing.body.error, 'task-not-found');
|
||||
const role = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', assignees: ['nobody'] });
|
||||
const role = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: moved.body.digest, assignees: ['nobody'] });
|
||||
assert.equal(role.status, 400); assert.equal(role.body.error, 'unknown-role');
|
||||
const archived = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: moved.body.digest });
|
||||
assert.equal(archived.status, 200);
|
||||
assert.deepEqual(r.forge.read(), [], 'an archived task leaves the read');
|
||||
const again = await post({ op: 'archive', org: 'forge', task_ref: 'task:1' });
|
||||
const again = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: archived.body.digest });
|
||||
assert.equal(again.status, 409); assert.equal(again.body.error, 'task-already-archived');
|
||||
|
||||
// A Vikunja organization's bus refuses the store verbs; the board never writes to Vikunja.
|
||||
@@ -221,22 +223,22 @@ test('route: each write reaches only the org it names, through that org\'s real
|
||||
// An org outside the session, an archived org, an unreadable registry, a down bus, a Console
|
||||
// without the write: each is refused before or without a bus call.
|
||||
const before = r.sent.length;
|
||||
const outside = await post({ op: 'edit', org: 'acme', task_ref: 'task:1', title: 'x' });
|
||||
const outside = await post({ op: 'edit', org: 'acme', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(outside.status, 404); assert.deepEqual(outside.body, { error: 'org-unavailable', sent: false });
|
||||
const old = await post({ op: 'edit', org: 'old', task_ref: 'task:1', title: 'x' });
|
||||
const old = await post({ op: 'edit', org: 'old', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(old.status, 409); assert.equal(old.body.error, 'org-archived');
|
||||
const broken = await post({ op: 'edit', org: 'broken', task_ref: 'task:1', title: 'x' });
|
||||
const broken = await post({ op: 'edit', org: 'broken', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(broken.status, 404); assert.deepEqual(broken.body, { error: 'org-unavailable', sent: false });
|
||||
const down = await post({ op: 'edit', org: 'home', task_ref: 'task:1', title: 'x' });
|
||||
const down = await post({ op: 'edit', org: 'home', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(down.status, 503); assert.equal(down.body.error, 'no-bus-host'); assert.equal(down.body.sent, false);
|
||||
const quiet = await post({ op: 'edit', org: 'quiet', task_ref: 'task:1', title: 'x' });
|
||||
const quiet = await post({ op: 'edit', org: 'quiet', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(quiet.status, 403); assert.equal(quiet.body.error, 'read-only');
|
||||
assert.equal(r.sent.length, before);
|
||||
|
||||
// A lost answer is not a refusal: the change may have been written, and the person is told so.
|
||||
const lost = await post({ op: 'edit', org: 'lost', task_ref: 'task:1', title: 'x' });
|
||||
const lost = await post({ op: 'edit', org: 'lost', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(lost.status, 503); assert.equal(lost.body.error, 'outcome-unknown'); assert.equal(lost.body.sent, 'unknown');
|
||||
const odd = await post({ op: 'edit', org: 'odd', task_ref: 'task:1', title: 'x' });
|
||||
const odd = await post({ op: 'edit', org: 'odd', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(odd.status, 502); assert.equal(odd.body.error, 'brand-new-code'); assert.equal(odd.body.sent, false);
|
||||
|
||||
// Every resolve named exactly the org in the body, and each bus call went to that org.
|
||||
@@ -314,14 +316,31 @@ test('read: filters, the per-org cap, and rows the read refuses', async () => {
|
||||
for (const search of ['task-state=doing', 'assignee=', 'assignee=a b', 'label=0', 'label=x', 'task-state=todo&task-state=done'])
|
||||
assert.throws(() => parseWorkspaceQuery(search), WorkspaceQueryError, search);
|
||||
|
||||
// Past the cap the org keeps TASKS_PER_ORG rows and says how many it holds; another org is untouched.
|
||||
const many = Array.from({ length: TASKS_PER_ORG + 7 }, (_, i) => store(i + 1));
|
||||
const capped = await readTasks([member('big', { tasks: many }), member('small', { tasks: [store(1)] })]);
|
||||
assert.equal(capped.tasks.filter(x => x.org === 'big').length, TASKS_PER_ORG);
|
||||
// Past the cap the org keeps its TASKS_PER_ORG most recently changed rows and says how many
|
||||
// matched; another org is untouched. Task n changed n minutes after midnight.
|
||||
const minute = n => new Date(Date.parse('2026-11-09T00:00:00.000Z') + n * 60000).toISOString();
|
||||
const many = Array.from({ length: TASKS_PER_ORG + 7 }, (_, i) => store(i + 1, i + 1 > TASKS_PER_ORG ? { group: 'late', state: 'blocked' } : { group: 'p1' }, { at: minute(i + 1), project_key: 'k' }));
|
||||
const projects = [{ id: 'p1', repo: 'one', name: 'One', archived: false, at: minute(0) }, { id: 'late', repo: 'late', name: 'Late', archived: false, at: minute(0) }];
|
||||
const big = [member('big', { tasks: many, projects }), member('small', { tasks: [store(1)] })];
|
||||
const capped = await readTasks(big, 'sections=tasks,projects,metrics');
|
||||
const bigRows = capped.tasks.filter(x => x.org === 'big');
|
||||
assert.equal(bigRows.length, TASKS_PER_ORG);
|
||||
assert.equal(capped.orgs.find(o => o.org === 'big').tasksHeld, TASKS_PER_ORG + 7);
|
||||
assert.equal(capped.orgs.find(o => o.org === 'small').tasksHeld, undefined);
|
||||
assert.equal(bigRows.some(x => x.ref === 'task:7'), false, 'the oldest changes are the ones cut');
|
||||
assert.equal(bigRows.some(x => x.ref === 'task:8'), true);
|
||||
// Store refs sort by number, so task:10 follows task:9.
|
||||
assert.deepEqual(capped.tasks.filter(x => x.org === 'big').slice(8, 11).map(x => x.ref), ['task:9', 'task:10', 'task:11']);
|
||||
assert.deepEqual(bigRows.slice(1, 4).map(x => x.ref), ['task:9', 'task:10', 'task:11']);
|
||||
// Projects and metrics count every task the org holds, not only the ones the answer carries.
|
||||
const proj = Object.fromEntries(capped.projects.filter(x => x.org === 'big').map(x => [x.id, x]));
|
||||
assert.equal(proj.p1.openTasks, TASKS_PER_ORG); assert.equal(proj.late.openTasks, 7);
|
||||
assert.equal(capped.metrics.orgs.find(m => m.org === 'big').openTasks, TASKS_PER_ORG + 7);
|
||||
// A filter runs before the cap, so it reaches tasks past row TASKS_PER_ORG.
|
||||
for (const search of ['sections=tasks&project=big:late', 'sections=tasks&task-state=blocked']) {
|
||||
const filtered = await readTasks(big, search);
|
||||
assert.deepEqual(filtered.tasks.map(x => x.ref), Array.from({ length: 7 }, (_, i) => `task:${TASKS_PER_ORG + i + 1}`), search);
|
||||
assert.equal(filtered.orgs.find(o => o.org === 'big').tasksHeld, undefined, search);
|
||||
}
|
||||
});
|
||||
|
||||
test('read: a Vikunja org\'s tasks come through the real tasks adapter from a fake Vikunja on port 0', async t => {
|
||||
|
||||
Reference in new issue
Block a user