fix(webui): sp11 item 2, Done this week opens the Tasks board filtered to the tasks it counted: done=week in the address keeps done tasks last changed in the past 7 days, a chip clears it, and the count line says the bus records no time a task was done (#1873, lane sp11-fixes)
This commit is contained in:
1 parent
13615e12b9
commit
fb3bd16052
4 files changed
+49
-12
No files matched your search
@@ -41,7 +41,12 @@
|
||||
// The layout is remembered per viewer and per width: a phone opens on List until its viewer picks
|
||||
// Kanban, a wide screen on Kanban until its viewer picks List.
|
||||
const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })();
|
||||
const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' };
|
||||
// done: 'week' shows the tasks the Dashboard's Done this week tile counts. It stays in the browser:
|
||||
// the rows already carry done and their last change, the same test the tile's count uses.
|
||||
const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '', done: '' };
|
||||
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
const doneThisWeek = t => t.done && t.changedAt && Date.now() - Date.parse(t.changedAt) <= WEEK_MS;
|
||||
const DONE_NOTE = 'Done this week counts done tasks by their last change; the bus records no time a task was done.';
|
||||
// The board style in force: the filtered project's when it has one, else the one organization's the
|
||||
// view is narrowed to (by the org chip or by a project of that org), else none.
|
||||
function activeBoard() {
|
||||
@@ -167,6 +172,7 @@
|
||||
&& (!filter.assignee || (filter.assignee === '-' ? !t.assignees.length : t.assignees.includes(filter.assignee)))
|
||||
&& (!filter.state || stateOf(t) === filter.state)
|
||||
&& (!filter.label || t.labels.map(String).includes(filter.label))
|
||||
&& (!filter.done || doneThisWeek(t))
|
||||
&& (!q || !needle || `${t.title ?? ''} ${shortId(t.ref)} ${projectName(t) ?? ''}`.toLowerCase().includes(needle)));
|
||||
}
|
||||
const select = (name, label, options, focus) => `<label class="tb-sel"><span class="ws-label">${esc(label)}</span><select data-tb-filter="${name}" data-focus-key="${focus}">${options}</select></label>`;
|
||||
@@ -189,6 +195,7 @@
|
||||
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">
|
||||
${scope ? '' : `<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>`}
|
||||
${filter.done ? '<button type="button" class="tb-f plain" data-tb-done aria-pressed="true" aria-label="Clear Done this week: done tasks last changed in the past 7 days" data-focus-key="tb-done">Done this week <span aria-hidden="true">×</span></button>' : ''}
|
||||
${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>`;
|
||||
@@ -244,8 +251,9 @@
|
||||
// total; with one of those on, the line says how many match rather than inventing an "of N".
|
||||
// The scope's org= is where the board reads, not a filter.
|
||||
const onServer = new URLSearchParams(readQuery()).size > (scope ? 2 : 1), n = rows.length;
|
||||
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.</p>`
|
||||
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.</p>` : '';
|
||||
const note = filter.done ? ` ${DONE_NOTE}` : '';
|
||||
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.${note}</p>`
|
||||
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.${note}</p>` : note ? `<p class="tb-count">${note.trim()}</p>` : '';
|
||||
// An empty result is only exhaustive when every organization's tasks were read.
|
||||
const unread = snap.orgs.filter(o => !tasksRead(o));
|
||||
const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`);
|
||||
@@ -349,6 +357,7 @@
|
||||
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 : '';
|
||||
filter.done = q.get('done') === 'week' ? 'week' : '';
|
||||
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))]);
|
||||
// Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read.
|
||||
@@ -592,6 +601,7 @@
|
||||
ws()?.setQuery?.({ org: org || null, ...(clear ? { project: null } : {}) }, true);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-tb-done]')) { filter.done = ''; paint(); ws()?.setQuery?.({ done: null }, true); 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, t); return; }
|
||||
const add = e.target.closest('[data-tb-new]');
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: orgAgents ? agentNote(runningOf(orgAgents), waiting) : '' })}
|
||||
${orgTile('tasks', 'Open tasks', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.openTasks) })}
|
||||
${orgTile('needs', 'Needs you', `#/inbox?${oq}`, whyNot(['inbox']), { big: num(mine?.needsYou), small: num(mine?.needsYou) === 1 ? 'item' : 'items', delta: num(mine?.needsYou) && mine?.oldestNeedsYouAt ? `oldest ${span(mine.oldestNeedsYouAt)}` : 'nothing waiting' })}
|
||||
${orgTile('done', 'Done this week', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })}
|
||||
${orgTile('done', 'Done this week', `#/tasks?${oq}&done=week`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })}
|
||||
</div>`;
|
||||
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
|
||||
// SP11: the sessions are the running rows the tile counts: the organization's instances and the
|
||||
@@ -683,7 +683,7 @@
|
||||
${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })}
|
||||
${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })}
|
||||
${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })}
|
||||
${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })}
|
||||
${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks?done=week', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })}
|
||||
${kpiTile(o, { key: 'projects', name: 'Projects', href: selHref('/projects'), big: fig('projects', ps.projects), small: 'active', parts: ps.projects, delta: `${archived} archived`, miss: notIn(SRC.projects), ...noFig })}
|
||||
</div>`;
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ test('The Dashboard tiles add both answering organizations\' numbers, one row, e
|
||||
assert.equal(links.orgs, '#/dashboard');
|
||||
assert.equal(links.needs, '#/inbox');
|
||||
assert.equal(links.tasks, '#/tasks');
|
||||
assert.equal(links.done, '#/tasks');
|
||||
assert.equal(links.done, '#/tasks?done=week');
|
||||
assert.match(links.projects, /^#\/orgs\/[a-z0-9._-]+\/projects$/);
|
||||
// No sample numbers and no trend line: no tile draws an svg sparkline.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tiles] svg:not(.i)").length'), 0);
|
||||
@@ -143,6 +143,11 @@ test('Following a tile lands on its page showing what the tile counted', async t
|
||||
await wait('location.hash === "#/inbox" && document.querySelectorAll("[data-ws-item]").length === 3');
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait(homeReady);
|
||||
// SP11: Done this week lands on the board showing the one task it counted, beta's Logo.
|
||||
await b.evaluate('document.querySelector("[data-ws-tile=done]").click()');
|
||||
await wait('location.hash === "#/tasks?done=week" && JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)) === JSON.stringify(["beta|local:9"])');
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait(homeReady);
|
||||
// The Organizations tile's list is this page's own table: following it keeps the page and moves focus there.
|
||||
await b.evaluate('document.querySelector("[data-ws-tile=orgs]").click()');
|
||||
await wait('document.activeElement?.closest?.("[data-ws-orgs-h]")');
|
||||
@@ -183,7 +188,7 @@ test('An organization page leads with its own tiles, each linking to the system
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'tasks')), '2');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '2 items');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '1');
|
||||
assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta' });
|
||||
assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta&done=week' });
|
||||
// The empty KPI and metric placeholders are gone; there is one tile row.
|
||||
assert.doesNotMatch(await text('[data-org-dashboard]'), /No KPIs defined|No metrics recorded yet/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-dashboard] .ws-tiles, [data-org-dashboard] [data-org-tiles]").length'), 1);
|
||||
|
||||
@@ -18,7 +18,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++)
|
||||
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
|
||||
const D = n => String(n).repeat(64);
|
||||
|
||||
function fixture({ empty = false, archived = [] } = {}) {
|
||||
function fixture({ empty = false, archived = [], done = false } = {}) {
|
||||
const LIST = [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
|
||||
@@ -33,9 +33,11 @@ function fixture({ empty = false, archived = [] } = {}) {
|
||||
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 = []; }
|
||||
// `done`: one task finished two days ago and one ten days ago; only the first is done this week.
|
||||
if (done) { STORE.acme.push({ n: 4, title: 'Old logo', state: 'done', group: 'site', assignees: [], ago: 60 * 24 * 10 }); STORE.beta.push({ n: 5, title: 'Launch email', state: 'done', group: 'shop', assignees: [], ago: 60 * 24 * 2 }); }
|
||||
for (const [org, list] of Object.entries(PROJECTS)) for (const p of list) p.archived = archived.includes(`${org}:${p.id}`);
|
||||
const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1',
|
||||
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null } }));
|
||||
const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(t.ago ?? 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, ...(t.state === 'done' ? { done: true } : {}) } }));
|
||||
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 => ({
|
||||
@@ -81,8 +83,8 @@ const STUB = noWrite => `window.__noWrite = ${JSON.stringify(noWrite)};
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [] } = {}) {
|
||||
const orgs = fixture({ empty, archived });
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [], done = false } = {}) {
|
||||
const orgs = fixture({ empty, archived, done });
|
||||
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());
|
||||
@@ -118,6 +120,26 @@ test('#/tasks shows both organizations\' tasks on one board, each card with its
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale")'), null);
|
||||
});
|
||||
|
||||
// SP11 (#1873): the Dashboard's Done this week tile opens the board on the tasks it counted: done, and
|
||||
// changed in the last seven days. The bus keeps no time a task was done, so the filter says it reads
|
||||
// the last change, the time the tile counts by. The filter lives in the address and clears.
|
||||
test('#/tasks?done=week shows only the tasks Done this week counted, says what it counts, and clears', async t => {
|
||||
const { b, base, wait, text } = await setup(t, { hash: '#/tasks?done=week', done: true });
|
||||
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`);
|
||||
assert.match(await text('[data-tb-done]'), /^Done this week\s+×$/);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-done]").getAttribute("aria-label")'), /^Clear Done this week: done tasks last changed in the past 7 days$/);
|
||||
assert.match(await text('[data-tasks-board] .tb-count'), /^Showing 1 of 5 tasks\. Done this week counts done tasks by their last change; the bus records no time a task was done\.$/);
|
||||
// It survives a reload and joins the organization filter.
|
||||
await b.navigate(`${base}/#/tasks?org=acme&done=week`);
|
||||
await wait('document.querySelector("[data-tb-done]") && document.querySelector("[data-tasks-board] .ws-panel")?.textContent.includes("No tasks match these filters")');
|
||||
await b.evaluate('location.hash = "#/tasks?done=week"');
|
||||
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`);
|
||||
// Clearing it drops it from the address and shows every task again.
|
||||
await b.evaluate('document.querySelector("[data-tb-done]").click()');
|
||||
await wait(`location.hash === "#/tasks" && ${cards} === ${JSON.stringify(JSON.stringify(['acme|task:1', 'acme|task:4', 'beta|task:2', 'beta|task:3', 'beta|task:5']))}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-done]")'), 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}`);
|
||||
|
||||
Reference in new issue
Block a user