#/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.
184 lines
12 KiB
JavaScript
184 lines
12 KiB
JavaScript
// Starter packs SP6 (#1873): board styles on the task board, in a real browser. A board style
|
|
// relabels, reorders and sets the default layout over the five fixed states (Jason 11-12 Q3), and
|
|
// only while the view is narrowed to one organization or project; the all-orgs view keeps the fixed
|
|
// labels. Every bus is a fake in this file.
|
|
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<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
|
const D = n => String(n).repeat(64);
|
|
const FIXED = ['To do', 'In progress', 'Review', 'Blocked', 'Done'];
|
|
// forge's board: a support desk's words, done first, list by default.
|
|
const FORGE_BOARD = { columns: [
|
|
{ state: 'done', label: 'Solved' }, { state: 'todo', label: 'New ticket' }, { state: 'in-progress', label: 'With an agent' },
|
|
{ state: 'in-review', label: 'Awaiting customer' }, { state: 'blocked', label: 'On hold' },
|
|
], layout: 'list' };
|
|
// forge's billing project has a board of its own, which wins over the org's for that project.
|
|
const BILLING_BOARD = { columns: [
|
|
{ state: 'todo', label: 'Draft' }, { state: 'in-progress', label: 'Invoicing' }, { state: 'in-review', label: 'Checked' },
|
|
{ state: 'blocked', label: 'Disputed' }, { state: 'done', label: 'Paid' },
|
|
], layout: 'kanban' };
|
|
const XSS = '<img src=x onerror=window.pwn=1>';
|
|
|
|
function orgs({ forgeBoard = FORGE_BOARD, billingBoard = BILLING_BOARD } = {}) {
|
|
const ORGS = [
|
|
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
|
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
|
];
|
|
const DOCS = {
|
|
ddigital: { appearance: { theme: 'rosewood' }, roles: { editor: {} } },
|
|
forge: { appearance: { theme: 'cobalt' }, roles: { coder: {} }, ...(forgeBoard ? { board: forgeBoard } : {}),
|
|
projects: { gateway: { root: 'code/gateway', repo: 'gateway' }, billing: { root: 'code/billing', repo: 'billing', ...(billingBoard ? { board: billingBoard } : {}) } } },
|
|
};
|
|
const PROJECTS = {
|
|
ddigital: [{ id: 'spring-launch', repo: 'spring', name: 'Spring launch', at: '2026-11-09T10:00:00.000Z' }],
|
|
forge: [{ id: 'api-gateway', repo: 'gateway', name: 'API gateway', at: '2026-11-09T10:00:00.000Z' }, { id: 'billing', repo: 'billing', name: 'Billing', at: '2026-11-08T10:00:00.000Z' }],
|
|
};
|
|
const STORE = {
|
|
forge: [
|
|
{ n: 41, title: 'Rate limit the public API', state: 'todo', group: 'api-gateway' },
|
|
{ n: 42, title: 'Invoice export', state: 'in-progress', group: 'billing' },
|
|
{ n: 43, title: 'Retire the v1 gateway', state: 'done', group: 'api-gateway' },
|
|
],
|
|
ddigital: [{ n: 7, title: 'Launch email', state: 'in-review', group: 'spring-launch' }],
|
|
};
|
|
const tasks = id => STORE[id].map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: 0, digest: D(t.n % 10), requirement: 'REQ-API-1',
|
|
fields: { title: t.title, state: t.state, group: t.group, assignees: [], due_date: null } }));
|
|
const options = 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 healthy = async () => ({ healthy: true, pid: 4242, boot: null });
|
|
return {
|
|
list: async () => ORGS,
|
|
members: async () => ORGS.map(o => ({ id: o.id, doc: DOCS[o.id], status: healthy, options: () => options(o.id) })),
|
|
resolve: async id => {
|
|
if (!ORGS.some(o => o.id === id)) return null;
|
|
return { busHealthy: true, reads: { business: async () => ({ business: { id, projects: PROJECTS[id].map(p => p.id) } }) }, ...options(id), taskWrite: async (verb, args) => ({ task_ref: args.task_ref, digest: D(9) }) };
|
|
},
|
|
};
|
|
}
|
|
|
|
async function open(t, fixture = orgs()) {
|
|
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture, send: async () => ({}), log: () => {} });
|
|
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}/#/tasks`;
|
|
await b.navigate(url);
|
|
const wait = waitFor(b);
|
|
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=forge]")');
|
|
return { b, wait, url };
|
|
}
|
|
const heads = b => b.evaluate('[...document.querySelectorAll("[data-tasks-board] .tb-col h3")].map(h => h.textContent)');
|
|
const layout = b => b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout');
|
|
const pick = (b, wait, org) => b.evaluate(`document.querySelector("[data-tb-org='${org}']").click()`)
|
|
.then(() => wait(`document.querySelector("[data-tb-org='${org}']").getAttribute("aria-pressed") === "true"`));
|
|
const project = async (b, wait, value, count) => {
|
|
await b.evaluate(`(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = ${JSON.stringify(value)}; s.dispatchEvent(new Event("change", { bubbles: true })); })()`);
|
|
await wait(`document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === ${count}`);
|
|
};
|
|
|
|
// The red-before test: on BASE the view filtered to forge showed the fixed labels.
|
|
test('a view filtered to an organization with a board shows its labels', async t => {
|
|
const { b, wait } = await open(t, orgs({ forgeBoard: { ...FORGE_BOARD, layout: 'kanban' } }));
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
await pick(b, wait, 'forge');
|
|
assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']);
|
|
});
|
|
|
|
test('a filtered view uses the board\'s labels and order in the list, the filters and the dialog', async t => {
|
|
const { b, wait } = await open(t);
|
|
await pick(b, wait, 'forge');
|
|
// forge's board says list; no viewer choice is saved, so the board's layout is the default.
|
|
assert.equal(await layout(b), 'list');
|
|
// The list orders rows by the board's columns (Solved first) and labels each state cell with its label.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-list] tbody tr")].map(r => r.dataset.tbTask)'), ['forge|task:43', 'forge|task:41', 'forge|task:42']);
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-list] select.tb-move")].map(s => s.selectedOptions[0].textContent)'), ['Solved', 'New ticket', 'With an agent']);
|
|
assert.deepEqual(await b.evaluate('[...document.querySelector("[data-tb-list] select.tb-move").options].map(o => [o.value, o.textContent])'),
|
|
[['done', 'Solved'], ['todo', 'New ticket'], ['in-progress', 'With an agent'], ['in-review', 'Awaiting customer'], ['blocked', 'On hold']]);
|
|
// The state filter speaks the board's words too; its values stay the five fixed states.
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=state] option")].map(o => [o.value, o.textContent])'),
|
|
[['', 'Any'], ['done', 'Solved'], ['todo', 'New ticket'], ['in-progress', 'With an agent'], ['in-review', 'Awaiting customer'], ['blocked', 'On hold']]);
|
|
// In Kanban the columns are the board's labels, in the board's order.
|
|
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
|
|
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
|
|
assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']);
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-kanban] [data-tb-col]")].map(c => c.dataset.tbCol)'), ['done', 'todo', 'in-progress', 'in-review', 'blocked']);
|
|
// The edit dialog's state picker uses the same labels.
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:41\']").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] select[name=state]")');
|
|
assert.deepEqual(await b.evaluate('[...document.querySelector("#tb-dialog select[name=state]").options].map(o => o.textContent)'), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']);
|
|
assert.equal(await b.evaluate('document.querySelector("#tb-dialog select[name=state]").value'), 'todo');
|
|
});
|
|
|
|
test('a project filter uses the project\'s board, and the org\'s when the project has none', async t => {
|
|
const { b, wait } = await open(t);
|
|
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
|
|
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
|
|
await project(b, wait, 'forge:billing', 1);
|
|
assert.deepEqual(await heads(b), ['Draft', 'Invoicing', 'Checked', 'Disputed', 'Paid']);
|
|
await project(b, wait, 'forge:api-gateway', 2);
|
|
assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']);
|
|
// ddigital holds no board: its project keeps the fixed labels.
|
|
await project(b, wait, 'ddigital:spring-launch', 1);
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
});
|
|
|
|
test('the all-orgs view keeps today\'s labels, and so does an org without a board', async t => {
|
|
const { b, wait } = await open(t);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed")'), 'true');
|
|
assert.equal(await layout(b), 'kanban');
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=state] option")].map(o => o.textContent)'), ['Any', ...FIXED]);
|
|
await pick(b, wait, 'ddigital');
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
// Back to every organization after a narrowed view: the fixed labels return.
|
|
await pick(b, wait, 'forge');
|
|
await pick(b, wait, '');
|
|
assert.equal(await layout(b), 'kanban');
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
});
|
|
|
|
test('a saved viewer layout wins over the board\'s default', async t => {
|
|
const { b, wait, url } = await open(t);
|
|
await pick(b, wait, 'forge');
|
|
assert.equal(await layout(b), 'list');
|
|
// The viewer picks Kanban, which is saved for this width; after a reload it still wins over forge's list.
|
|
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
|
|
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
|
|
assert.match(await b.evaluate('localStorage.getItem("mosaic-tasks-layout-v1")'), /"wide":"kanban"/);
|
|
await b.navigate(url.replace('/#', '/?reload=1#'));
|
|
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=forge]")');
|
|
await pick(b, wait, 'forge');
|
|
assert.equal(await layout(b), 'kanban');
|
|
assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']);
|
|
});
|
|
|
|
test('a board label holding markup renders as text', async t => {
|
|
const board = { columns: [{ state: 'todo', label: XSS }, ...FORGE_BOARD.columns.filter(c => c.state !== 'todo')], layout: 'kanban' };
|
|
const { b, wait } = await open(t, orgs({ forgeBoard: board }));
|
|
await pick(b, wait, 'forge');
|
|
assert.deepEqual((await heads(b)).slice(0, 2), [XSS, 'Solved']);
|
|
assert.equal(await b.evaluate('document.querySelector("[data-tb-col=todo]").getAttribute("aria-label")'), XSS);
|
|
assert.ok((await b.evaluate('[...document.querySelectorAll("[data-tb-filter=state] option")].map(o => o.textContent)')).includes(XSS));
|
|
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:41\']").click()');
|
|
await wait('document.querySelector("#tb-dialog[open] select[name=state]")');
|
|
assert.ok((await b.evaluate('[...document.querySelector("#tb-dialog select[name=state]").options].map(o => o.textContent)')).includes(XSS));
|
|
assert.equal(await b.evaluate('document.querySelectorAll("img[src=x]").length'), 0);
|
|
await new Promise(r => setTimeout(r, 200));
|
|
assert.equal(await b.evaluate('window.pwn === undefined'), true);
|
|
});
|
|
|
|
test('a board that is not exactly the five states is ignored, never half applied', async t => {
|
|
const broken = { columns: FORGE_BOARD.columns.slice(0, 4), layout: 'list' };
|
|
const { b, wait } = await open(t, orgs({ forgeBoard: broken, billingBoard: null }));
|
|
await pick(b, wait, 'forge');
|
|
assert.equal(await layout(b), 'kanban');
|
|
assert.deepEqual(await heads(b), FIXED);
|
|
});
|