From 32be3a01bc74c97d3a074d3d7b25fe9d9b900c06 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 02:53:05 -0600 Subject: [PATCH] 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//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. --- packages/webui/src/public/bus.js | 18 +- packages/webui/src/public/display.js | 5 +- packages/webui/src/public/tasks-board.js | 130 +++++++++--- packages/webui/src/public/workspace.js | 18 +- .../webui/tests/system-tasks-browser.test.mjs | 194 ++++++++++++++++++ .../webui/tests/tasks-board-browser.test.mjs | 2 +- .../tests/tasks-board-labels-browser.test.mjs | 2 +- 7 files changed, 329 insertions(+), 40 deletions(-) create mode 100644 packages/webui/tests/system-tasks-browser.test.mjs diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 579315329..4548b3386 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -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//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
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`). diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 40eeebc3c..3ea70e74c 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -23,8 +23,9 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( // organization's own bare address (#/orgs/, no trailing slash) is that organization's page; // #/orgs// stays the old dashboard address. SP3: `#/inbox` is a system route too (every // organization's items); `#/orgs//inbox` is that list, filtered. SP5: `#/agents` is every - // organization's agents; `#/orgs//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//agents` is that table for one organization. SP4: `#/tasks` is + // one task board for every organization; `#/orgs//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)}`; diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index f93df90b5..500b6e936 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -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//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//tasks?task=), 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//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 ${snap.tasks.length}`)].concat(snap.orgs.map(o => chip(o.id, `${av(o.id)}${esc(o.label)}${tasksRead(o) ? counts.get(o.id) : 'unavailable'}`, `${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 ? `${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}` : ''; }).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 ? `${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}` : ''; }).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 `
-
${chips}
+ ${scope ? '' : `
${chips}
`} ${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
`; @@ -158,12 +179,15 @@ const prio = t => (Number.isSafeInteger(t.priority) && t.priority > 0 ? `P${t.priority}` : ''); const due = t => { const d = t.dueDate && dueText(t.dueDate); return d ? `${overdue(t) ? 'overdue' : 'due'} ${esc(d)}` : ''; }; 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) : `${esc(orgOf(t.org).label)}`); + const projChip = t => (projectName(t) ? (ws()?.projChip?.(projectName(t)) ?? `${esc(projectName(t))}`) : ''); function card(t) { const w = taskWritable(t), k = esc(key(t)); return `
-
${av(t.org, 'xs')}${esc(orgOf(t.org).label)}${esc(shortId(t.ref))}${trk(t.tracker)}
+
${av(t.org, 'xs')}${orgChip(t)}${esc(shortId(t.ref))}${trk(t.tracker)}
-
${projectName(t) ? esc(projectName(t)) : 'No project'}${who(t)}${prio(t)}${due(t)}
+
${projChip(t) || 'No project'}${who(t)}${prio(t)}${due(t)}
`; } function kanban(rows) { @@ -184,10 +208,10 @@ }; return `
${sorted.map(t => ` - + - + @@ -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 ? '

No organizations yet.

' + const body = !snap.orgs.length ? `

${scope ? 'This organization is not on your list.' : 'No organizations yet.'}

` : !snap.orgs.some(tasksRead) ? '

No organization answered with its tasks, so no tasks could be read.

' : !all && !onServer ? `

${esc(none('No tasks yet'))}

` : !rows.length ? `

${esc(none('No tasks match these filters'))}

` @@ -230,17 +254,33 @@ const seg = ``; const add = ``; 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'}. Every organization`; + 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 `

Tasks

${seg}${add}
${sub ? `

${sub}

` : ''}
`; } const noticeLine = () => (notice ? `

${esc(notice.text)}

` : '

'); + // 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 `
${head()}${noticeLine()}${trackers()}${toolbar()}${results()}
`; + return `
${head()}${noticeLine()}${unreadLine()}${trackers()}${toolbar()}${results()}
`; } const failed = err => `

Tasks

The task board could not be read${err?.message && err.message !== 'read failed' ? `: ${esc(err.message)}` : ''}. It tries again on the next refresh.

`; @@ -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//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//tasks?task= 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 ? '' : `Open in ${esc(o.label)}`; const facts = `

${av(t.org, 'xs')}${esc(o.label)} · ${esc(shortId(t.ref))} ${trk(t.tracker)}${t.requirement ? ` · ${esc(t.requirement)}` : ''}${projectName(t) ? ` · ${esc(projectName(t))}` : ''}

`; if (!w) { return `

${title(t)}

${facts}
State
${esc(stateLabel(t.state) || 'Not read')}
Assignees
${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}
Priority
${prio(t) || 'none'}
Due
${due(t) || 'none'}

${esc(why)}

${refusal ? `

${esc(refusal)}

` : ''} -
`; +
${goto}
`; } return `

Edit ${esc(shortId(t.ref))}

${facts} @@ -363,11 +436,12 @@
${stateSelect(t.state)}${prioSelect(t.priority)}
${roleBoxes([...new Set([...o.roles, ...t.assignees])].sort(), t.assignees)} -
+
${goto}
`; } 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); }); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index f3fd6aeca..f717e6b49 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -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 ? `

${lead} ${andList(m)}.

` : ''; }; + // 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) => `${esc(o.name(id))}`; + const projChip = name => (name ? `${esc(name)}` : ''); + 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 ? `

Not read: ${andList(o.unread.map(unreadWhy))}.

` : '') + 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 => `${esc(o.name(d.org))}`; - const projChip = d => (d.project?.name ? `${esc(d.project.name)}` : ''); + 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 `
- + `; }; // 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 ? `

Not read: ${andList(o.unread.map(why))}.

` : '') + missLine(o, ['inbox'], 'inbox'); + const unread = unreadNote(o, ['inbox'], 'inbox'); const filtered = (org && !scope) || type; const table = rows.length ? `
OrgIdTaskProjectStateAssigneePriorityDue
${av(t.org)}${esc(orgOf(t.org).label)}${orgChip(t)} ${esc(shortId(t.ref))}${t.tracker === 'vikunja' ? 'Vikunja' : 'built-in'} ${esc(projectName(t) || 'No project')}${projectName(t) ? esc(projectName(t)) : 'none'}${projChip(t) || 'none'} ${stateCell(t)} ${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'} ${prio(t) || 'none'}
${esc(d.question || `Decision ${d.id}`)}${orgChip(d)}${projChip(d)}${by ? `${esc(by)} · ` : ''}decision ${esc(d.id)}${d.taskRef ? ` · task ${esc(d.taskRef)}` : ''}${classChip(d.class)}${d.blocking ? ' blocking' : ''}${esc(d.question || `Decision ${d.id}`)}${chips(d)}${by ? `${esc(by)} · ` : ''}decision ${esc(d.id)}${d.taskRef ? ` · task ${esc(d.taskRef)}` : ''}${classChip(d.class)}${d.blocking ? ' blocking' : ''} ${classChip(d.class)}${d.blocking ? ' blocking' : ''} ${d.at ? `` : 'not reported'}
${rows.map(row).join('')}
Items that need you
NeedsClassWaiting
` : `

${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}

`; // The hire, fire and change form posts to one organization's bus, so the system list links to @@ -851,7 +855,7 @@ insp = `