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..9bdc95273 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,16 +117,46 @@ 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 is asked through its own config. The server sets + // orgSetup for the whole Console today, so the answer only differs where an organization's config + // read fails or 404s. 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; + // The address the read starts from goes to prune, so a cleanup never touches a page opened since. + const reload = () => { const n = ++readSeq, at = location.hash; return (newest = read().then(next => { if (n !== readSeq) return false; snap = next; allowed = next.takes; canWrite = next.writes; return prune(at) ? reload() : 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 (filter.project && filter.project !== '-' && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) filter.project = ''; + // The address drops it too, so it never names a filter the board is not using. A dropped project + // was filtered on the server, so it returns true and the read runs again without it. + // A project is gone only when its organization is off the list, or answered with its projects and + // the project is not among them and no task came back for it (with no other filter on). An organization that is down, + // starting or timed out, or whose projects read failed, lists no projects, so its filter stays. + // `at` is the address the read started from. + function prune(at) { + const gone = {}; + if (!scope && filter.org && !snap.orgs.some(o => o.id === filter.org)) { filter.org = ''; gone.org = null; } + if (filter.project && filter.project !== '-') { + const po = snap.orgs.find(o => o.id === filter.project.split(':')[0]); + const answered = !po || (po.state === 'ok' && !po.errors?.projects); + // No task back is proof only when no other filter narrowed the read on the server. + const narrowed = filter.state || filter.assignee || filter.label; + if (answered && !narrowed && !snap.tasks.length && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) { filter.project = ''; gone.project = null; } + } + // After this render: bus.js drops a render whose address changed while it ran. Only while the + // address is still the one the read started from, and with a repaint in case the render in + // flight was the one dropped. + if (Object.keys(gone).length) setTimeout(() => { if (location.hash === at) ws()?.setQuery?.(gone, true); }, 0); + return 'project' in gone; } // ---------- filters ---------- @@ -137,17 +178,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 +199,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, av(t.org, 'xs') || undefined) : `${av(t.org, 'xs')}${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)}
+
${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 +228,10 @@ }; return `
${sorted.map(t => ` - + - + @@ -198,13 +242,14 @@ const rows = filtered(), all = snap.tasks.length; // 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; + // 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 ? `

Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.

` : n !== all ? `

Showing ${n} of ${all} tasks.

` : ''; // 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 +275,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.

`; @@ -266,7 +327,8 @@ // Repaint in place, for the board's own changes (filters, layout, a write). function paint(part = 'all') { const root = document.querySelector('[data-tasks-board]'); - if (!root || !snap) return; + // Only over a board drawn from a read: never over the not-on-your-list page or a failed read. + if (!root || !snap || !root.querySelector('[data-tb-results]')) return; const kept = snapshotView(root); if (part === 'results') root.querySelector('[data-tb-results]').outerHTML = results(); else root.outerHTML = markup(); @@ -274,9 +336,38 @@ } // 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 = {}) { + // An address that names an organization no id can match is not the system board. + // A read still in flight from the board left behind lands nowhere and paints nothing. + if (typeof opts.scope === 'string' && opts.scope && !ORG_ID.test(opts.scope)) { ++readSeq; snap = null; return `

Tasks

This organization is not on your list.

`; } + 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); } + // A not-on-your-list page opened meanwhile dropped every read; bus.js drops this render as not current. + if (!snap) return ''; + // #/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 +408,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 +445,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 +463,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 +575,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 +585,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 +606,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..a6cdba793 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -564,6 +564,13 @@ 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. + // `mark`, already-escaped markup such as the organization's avatar, takes the dot's place. + const orgChip = (o, id, mark = '') => `${mark}${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 +823,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 +856,7 @@ insp = `