feat(webui): a workflow is edited in its graph, steps added, removed and moved, held as Unsaved until Save, with new and delete workflow (#1873) #1974

Merged
jason.woltje merged 4 commits from sp10-workflow-edit into refactor 2026-11-15 16:07:18 +00:00
5 changed files with 871 additions and 76 deletions

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null);
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.()
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.()
|| document.querySelector('.settings-dot')
|| [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim()));
+2 -1
View File
@@ -118,7 +118,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
return read;
};
let previous = location.hash, skipGuard = false;
const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim());
const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim());
// A switch scopes later reads to the new organization; listeners drop what they held for the old one.
function rescope(seg) {
const from = current;
@@ -127,6 +127,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
}
function discard() {
window.MosaicChat?.forget?.();
window.MosaicWorkflows?.forget?.();
for (const el of document.querySelectorAll('#reply, #conv-reply, #doc-source')) el.value = '';
}
function confirmSwitch(next) {
+38
View File
@@ -54,3 +54,41 @@
.wfg-cardlist strong{color:var(--action,var(--accent))}
.wfg-card [data-wfg-none]{margin:0 0 8px}
.wfg-card p:last-child{margin-bottom:0}
/* SP10 (#1873): editing a workflow in its graph. The bar above the graph holds Unsaved, Discard, Save and
Delete workflow; the step panel holds the step actions and the step form; the list page holds New workflow. */
html[data-organizations] :is(.wfg-bar,.wfg-step-acts,.wfg-form,.wfg-new) .ws-btn{min-height:32px}
html[data-organizations] :is(.wfg-bar,.wfg-form) .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}
html[data-organizations] :is(.wfg-bar,.wfg-form) .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))}
html[data-organizations] :is(.wfg-bar,.wfg-step-acts) .ws-btn.danger{color:var(--danger)}
html[data-organizations] :is(.wfg-bar,.wfg-step-acts,.wfg-form,.wfg-new) .ws-btn:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
.wfg-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin:0 0 10px}
.wfg-acts{display:inline-flex;flex-wrap:wrap;align-items:center;gap:8px;margin-left:auto}
.wfg-bar:not(.is-dirty) .wfg-acts:empty{display:none}
.wfg-badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600;color:var(--strong,var(--text));background:color-mix(in srgb,var(--accent) 18%,var(--surface));border:1px solid color-mix(in srgb,var(--accent) 45%,var(--line))}
.wfg-confirm{display:inline-flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:13.5px}
.wfg-status,.wfg-stale{flex-basis:100%;margin:0;font-size:13.5px;overflow-wrap:anywhere}
.wfg-stale{color:var(--danger)}
.wfg-ro{margin:0 0 10px}
.wfg-step-acts{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px}
.wfg-form{display:grid;gap:10px;min-width:0}
.wfg-form h2{margin:0}
.wfg-form h3{font-size:14px;margin:6px 0 0}
.wfg-form>label,.wfg-form label:has(>select){display:grid;gap:4px;font-size:13px;color:var(--muted)}
.wfg-form input:not([type=checkbox]),.wfg-form select{font:14px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:6px;padding:6px 8px;min-width:0;max-width:100%}
.wfg-form :is(input,select):focus-visible{outline:2px solid var(--focus);outline-offset:1px}
.wfg-form .note{margin:0}
.wfg-roles-pick{border:1px solid var(--line);border-radius:8px;padding:8px 12px 10px;margin:0;min-width:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 12px}
.wfg-roles-pick legend{font-size:13px;color:var(--muted);padding:0 4px}
.wfg-pick-h{grid-column:1/-1;margin:6px 0 0;font-size:12px;color:var(--muted)}
.wfg-pick{display:flex;align-items:baseline;gap:6px;font-size:13.5px;color:var(--text);overflow-wrap:anywhere;min-width:0}
.wfg-pick input{margin:0;flex:none}
.wfg-why{margin:0;color:var(--danger);font-size:13.5px}
.wfg-why:empty{display:none}
.wfg-form-acts{display:flex;flex-wrap:wrap;gap:8px;margin:0}
.wfg-new{margin:0 0 12px}
.wfg-newform{margin:0 0 16px}
@media (max-width:700px){
.wfg-acts{margin-left:0}
.wfg-roles-pick{grid-template-columns:minmax(0,1fr)}
}
+440 -65
View File
@@ -33,7 +33,8 @@
const r = await fetch(url, method === 'GET' ? { cache: 'no-store' } : { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
let value = null;
try { value = await r.json(); } catch { value = null; }
if (!r.ok) throw Error(guidance[value?.error] ?? `The bus refused: ${value?.error ?? 'outcome unknown'}.`);
// The refusal code rides on the error, so a caller with its own words for one (the graph editor) can use them.
if (!r.ok) throw Object.assign(Error(guidance[value?.error] ?? `The bus refused: ${value?.error ?? 'outcome unknown'}.`), { code: value?.error ?? null });
return value;
}
const plain = v => v !== null && typeof v === 'object' && !Array.isArray(v);
@@ -122,6 +123,40 @@
}
const flowsIn = (s, scope) => scope ? s.data.projects[scope] : s.data.workflows;
const target = (s, scope) => scope ? { project: scope } : {};
// SP10: the one check, body and write both editors use, the organization page's form and the graph.
// A step is {title, state, roles: [names]}. The order of the checks is the form's: the name, a name the
// scope already holds, a role that names no current role, then the shape the bus checks (organization-config
// checkWorkflows: 1 to 20 steps, each with a title and 1 to 8 distinct roles), named here before the bus
// refuses it in general words.
const rolesIn = text => String(text ?? '').split(',').map(r => r.trim()).filter(Boolean);
function stepRefusal(st, n) {
if (!String(st.title ?? '').trim()) return `Step ${n} needs a title.`;
if (!st.roles.length) return `Step ${n} needs at least one role.`;
if (st.roles.length > 8) return `Step ${n} names ${st.roles.length} roles. A step takes at most eight.`;
if (new Set(st.roles).size !== st.roles.length) return `Step ${n} names the same role twice.`;
return '';
}
function refusal(s, { isNew, scope, name, steps }) {
if (!NAME.test(name)) return NAME_RULE;
// A save is a set: a new workflow under a name the scope already holds would replace it without a word.
if (isNew && Object.hasOwn(flowsIn(s, scope) ?? {}, name)) return `A workflow named ${name} already exists here. Edit it instead.`;
// E2E6 P2-1: a step role that names no current role is named before the bus refuses it generically.
const unknown = steps.flatMap((st, i) => st.roles.filter(r => !s.known.has(r)).map(r => [i + 1, r]));
if (unknown.length) return `${unknown.map(([i, r]) => `Step ${i}: ${r}`).join('; ')} ${unknown.length === 1 ? 'names' : 'name'} no current role. Use an organization role, a role template or human.`;
if (!steps.length) return 'A workflow needs at least one step.';
if (steps.length > 20) return 'A workflow has at most 20 steps.';
for (const [i, st] of steps.entries()) { const why = stepRefusal(st, i + 1); if (why) return why; }
return '';
}
const shape = ({ title = '', description = '', steps }) => ({
...(title.trim() ? { title: title.trim() } : {}),
...(description.trim() ? { description: description.trim() } : {}),
steps: steps.map(st => ({ title: st.title.trim(), state: st.state, roles: [...st.roles] })),
});
const flowUrl = (org, name) => `${base(org)}/workflows/${encodeURIComponent(name)}`;
// The revision is the one the editor opened on: a change made since then is a revision-conflict, not overwritten.
const saveFlow = (org, s, { scope, name, revision, workflow }) => call(flowUrl(org, name), 'POST', { revision, workflow, ...target(s, scope) });
const removeFlow = (org, s, { scope, name, revision }) => call(flowUrl(org, name), 'DELETE', { revision, ...target(s, scope) });
async function reload(org, s, status) {
try { await read(org, s); s.status = status; } catch (error) { s.status = `${status} ${error.message}`; }
}
@@ -149,7 +184,7 @@
} else if (button.hasAttribute('data-wf-delete-yes')) {
button.disabled = true;
try {
await call(`${base(org)}/workflows/${encodeURIComponent(name)}`, 'DELETE', { revision: s.data.revision, ...target(s, scope) });
await removeFlow(org, s, { scope, name, revision: s.data.revision });
s.confirm = null;
await reload(org, s, `Deleted ${name}.`);
} catch (error) { s.status = error.message; }
@@ -176,22 +211,14 @@
if (!s?.editing) return;
capture(root, s);
const e = s.editing;
if (!NAME.test(e.name)) { s.status = NAME_RULE; redraw(org); return; }
// A save is a set: a new workflow under a name the scope already holds would replace it without a word.
if (e.isNew && Object.hasOwn(flowsIn(s, e.scope) ?? {}, e.name)) { s.status = `A workflow named ${e.name} already exists here. Edit it instead.`; redraw(org); return; }
// E2E6 P2-1: a step role that names no current role is named before the bus refuses it generically.
const unknown = e.draft.steps.flatMap((st, i) => st.roles.split(',').map(r => r.trim()).filter(r => r && !s.known.has(r)).map(r => [i + 1, r]));
if (unknown.length) { s.status = `${unknown.map(([i, r]) => `Step ${i}: ${r}`).join('; ')} ${unknown.length === 1 ? 'names' : 'name'} no current role. Use an organization role, a role template or human.`; redraw(org); return; }
const workflow = {
...(e.draft.title.trim() ? { title: e.draft.title.trim() } : {}),
...(e.draft.description.trim() ? { description: e.draft.description.trim() } : {}),
steps: e.draft.steps.map(st => ({ title: st.title.trim(), state: st.state, roles: st.roles.split(',').map(r => r.trim()).filter(Boolean) })),
};
const draft = { ...e.draft, steps: e.draft.steps.map(st => ({ ...st, roles: rolesIn(st.roles) })) };
const why = refusal(s, { isNew: e.isNew, scope: e.scope, name: e.name, steps: draft.steps });
if (why) { s.status = why; redraw(org); return; }
const workflow = shape(draft);
const button = ev.target.querySelector('button[type="submit"]');
button.disabled = true;
try {
// The revision the editor opened on: a change made since then is a revision-conflict, not overwritten.
await call(`${base(org)}/workflows/${encodeURIComponent(e.name)}`, 'POST', { revision: e.revision ?? s.data.revision, workflow, ...target(s, e.scope) });
await saveFlow(org, s, { scope: e.scope, name: e.name, revision: e.revision ?? s.data.revision, workflow });
s.editing = null;
await reload(org, s, `Saved ${e.name}.`);
} catch (error) { s.status = error.message; }
@@ -240,46 +267,63 @@
: '<p class="note">A starter pack brings workflows. Only the owner, on a Console started with send, can apply one.</p>');
const none = (org, s, label) => `<p class="note" data-wfg-none>No workflows in ${esc(label)} yet.</p>${applyLink(org, s)}`;
function listBody(org, s, label) {
if (total(s) === 0) return none(org, s, label);
return listed(s).filter(([scope, , rows]) => !scope || rows.length).map(([scope, label2, rows]) => `<section class="wfg-scope" data-wfg-scope-list="${esc(scope)}"><h2>${esc(label2)}</h2>${rows.length ? `<ul class="wfg-list">${rows.map(([name, w]) => {
const steps = stepsOf(w);
return `<li class="wfg-item" data-wfg-item="${esc(name)}" data-wfg-scope="${esc(scope)}"><div class="wfg-item-h"><a href="${esc(flowHref(org, scope, name))}" data-wfg-open data-focus-key="${esc(`wfg-open:${scope}:${name}`)}">${esc(w?.title || name)}</a>${w?.title ? ` <code>${esc(name)}</code>` : ''} <span class="muted">${count(steps.length)}</span></div>
${w?.description ? `<p class="wfg-desc">${esc(w.description)}</p>` : ''}<ol class="wfg-chain" data-wfg-chain aria-label="Steps in order">${steps.map(st => `<li>${esc(st.title)}</li>`).join('')}</ol></li>`;
}).join('')}</ul>` : '<p class="note">None at the organization level.</p>'}</section>`).join('');
}
// Layout: left to right on a wide screen, top to bottom on a narrow one. The graph scrolls inside its
// own frame when it is wider than the frame; the page never does.
const NARROW = '(max-width: 700px)';
const narrow = () => window.matchMedia(NARROW).matches;
const clip = (text, n) => (text.length > n ? `${text.slice(0, n - 1)}…` : text);
// Up to two lines of about `n` characters, broken at spaces; the second ends in … when the title is longer.
function wrap(text, n) {
// SP10 (sp9 follow-up): box text is fitted by its measured width in the font it is drawn in, not by a
// character count, so a line ends inside its box with an ellipsis and a short name shows whole. The clip
// path stays as a backstop. Without a canvas the old per-character estimate is used.
let ctx;
function measure(text, font, per) {
if (ctx === undefined) { try { ctx = document.createElement('canvas').getContext('2d'); } catch { ctx = null; } }
if (!ctx) return text.length * per;
ctx.font = font;
return ctx.measureText(text).width;
}
const token = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
const fonts = () => ({
title: [`600 15px ${token('--font', 'sans-serif')}`, 8.6],
roles: [`13px ${token('--mono', 'monospace')}`, 7.9],
miss: [`12px ${token('--font', 'sans-serif')}`, 6.8],
});
function fit(text, [font, per], max) {
if (measure(text, font, per) <= max) return text;
let lo = 0, hi = text.length;
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (measure(`${text.slice(0, mid)}…`, font, per) <= max) lo = mid; else hi = mid - 1; }
return `${text.slice(0, lo)}…`;
}
// Up to two lines broken at spaces; the second ends in … when the title is longer.
function wrap(text, f, max) {
const words = String(text ?? '').split(/\s+/).filter(Boolean), lines = [''];
let over = false;
for (const w of words) {
const cur = lines[lines.length - 1];
if (!cur || (cur + ' ' + w).length <= n) lines[lines.length - 1] = cur ? `${cur} ${w}` : w;
const cur = lines[lines.length - 1], next = cur ? `${cur} ${w}` : w;
if (!cur || measure(next, f[0], f[1]) <= max) lines[lines.length - 1] = next;
else if (lines.length < 2) lines.push(w);
else { lines[1] = `${lines[1]} ${w}`; }
else { lines[1] = `${lines[1]} ${w}`; over = true; }
}
if (lines[0].length > n) return [clip(lines[0], n)];
if (lines[1] && lines[1].length > n) lines[1] = clip(lines[1], n);
if (measure(lines[0], f[0], f[1]) > max) return [fit(lines[0], f, max)];
if (lines[1] && (over || measure(lines[1], f[0], f[1]) > max)) lines[1] = fit(lines[1], f, max);
return lines;
}
function graph(org, s, scope, name, steps, sel) {
const vertical = narrow();
const vertical = narrow(), f = fonts();
const W = vertical ? 300 : 188, GAP = vertical ? 32 : 36, PAD = 14;
const per = vertical ? 36 : 21;
// Roles are monospace (13px, about 7.9px a character), so a character budget fits the box; the clip path below catches the rest.
const rper = Math.floor((W - 24) / 7.9);
// Text runs from x+12 and ends 12px before the box edge; 2px more absorbs a font that loads after the measure.
const room = W - 26;
// One role a line, at most three lines: with four or more, two roles and "+N more". Every role is in the label and the detail panel.
const roleLines = st => { const r = rolesOf(st); return !r.length ? [] : r.length <= 3 ? r : [...r.slice(0, 2), `+${r.length - 2} more`]; };
const titleEnd = st => 46 + (wrap(st.title, per).length - 1) * 19;
// A role that names no current role: "No current role:" and then each such role on its own line, at most
// two lines, so the whole name shows (a name longer than the box ends in …).
const missing = st => rolesOf(st).filter(r => !s.known.has(r));
const missLines = st => { const m = missing(st); return m.length <= 2 ? m : [m[0], `+${m.length - 1} more`]; };
const titleEnd = st => 46 + (wrap(st.title, f.title, room).length - 1) * 19;
const rolesY = st => titleEnd(st) + 24;
const missY = st => rolesY(st) + (Math.max(roleLines(st).length, 1) - 1) * 17 + 18;
// Each box is as tall as the tallest step needs: meta, one or two title lines, its role lines, and the missing-role line if any.
const H = Math.max(...steps.map(st => (rolesOf(st).some(r => !s.known.has(r)) ? missY(st) : missY(st) - 18) + 16));
// Each box is as tall as the tallest step needs: meta, one or two title lines, its role lines, and the missing-role lines if any.
const bottom = st => (missing(st).length ? missY(st) + missLines(st).length * 15 : missY(st) - 18);
const H = Math.max(...steps.map(st => bottom(st) + 16));
const pos = i => (vertical ? [PAD, PAD + i * (H + GAP)] : [PAD + i * (W + GAP), PAD]);
const width = vertical ? W + 2 * PAD : PAD * 2 + steps.length * W + (steps.length - 1) * GAP;
const height = vertical ? PAD * 2 + steps.length * H + (steps.length - 1) * GAP : H + 2 * PAD;
@@ -289,28 +333,129 @@
return `<path class="wfg-edge" d="${d}" data-wfg-edge="${i + 1}-${i + 2}" marker-end="url(#wfg-arrow)"/>`;
}).join('');
const nodes = steps.map((st, i) => {
const [x, y] = pos(i), n = i + 1, roles = rolesOf(st), missing = roles.filter(r => !s.known.has(r));
const lines = wrap(st.title, per), on = sel === n;
const label = `Step ${n} of ${steps.length}: ${st.title ?? ''}. ${stateLabel(st)}. Roles ${roles.join(', ')}${missing.length ? `; ${missing.join(', ')} ${MISSING}` : ''}.`;
const [x, y] = pos(i), n = i + 1, roles = rolesOf(st), miss = missing(st);
const lines = wrap(st.title, f.title, room), on = sel === n;
const label = `Step ${n} of ${steps.length}: ${st.title ?? ''}. ${stateLabel(st)}. Roles ${roles.join(', ')}${miss.length ? `; ${miss.join(', ')} ${MISSING}` : ''}.`;
const rl = roleLines(st), more = roles.length > 3;
const roleText = rl.length
? rl.map((r, j) => `<tspan x="${x + 12}" dy="${j ? 17 : 0}"${more && j === rl.length - 1 ? ' data-wfg-more' : ''}>${esc(more && j === rl.length - 1 ? r : clip(r, rper))}</tspan>`).join('')
? rl.map((r, j) => `<tspan x="${x + 12}" dy="${j ? 17 : 0}"${more && j === rl.length - 1 ? ' data-wfg-more' : ''}>${esc(more && j === rl.length - 1 ? r : fit(r, f.roles, room))}</tspan>`).join('')
: '<tspan class="wfg-none">No roles recorded</tspan>';
const missText = miss.length
? `<text class="wfg-miss" data-wfg-missing x="${x + 12}" y="${y + missY(st)}"><tspan x="${x + 12}" dy="0">No current role:</tspan>${missLines(st).map((r, j) => `<tspan x="${x + 12}" dy="15"${miss.length > 2 && j === 1 ? ' data-wfg-more' : ''}>${esc(miss.length > 2 && j === 1 ? r : fit(r, f.miss, room))}</tspan>`).join('')}</text>`
: '';
return `<a class="wfg-node${on ? ' is-sel' : ''}" href="${esc(flowHref(org, scope, name, n))}" data-wfg-step="${n}" data-focus-key="${esc(`wfg-step:${n}`)}" aria-label="${esc(label)}"${on ? ' aria-current="step"' : ''}>
<title>${esc(`${st.title ?? ''}${roles.length ? ` (${roles.join(', ')})` : ''}`)}</title><rect x="${x}" y="${y}" width="${W}" height="${H}" rx="10"/>
<clipPath id="wfg-clip-${n}"><rect x="${x + 2}" y="${y}" width="${W - 10}" height="${H}"/></clipPath>
<g clip-path="url(#wfg-clip-${n})">
<text class="wfg-meta" x="${x + 12}" y="${y + 22}">Step ${n} · ${esc(stateLabel(st))}</text>
<text class="wfg-meta" x="${x + 12}" y="${y + 22}">${esc(fit(`Step ${n} · ${stateLabel(st)}`, f.miss, room))}</text>
<text class="wfg-title" data-wfg-title x="${x + 12}" y="${y + 46}">${lines.map((l, j) => `<tspan x="${x + 12}" dy="${j ? 19 : 0}">${esc(l)}${j < lines.length - 1 ? ' ' : ''}</tspan>`).join('')}</text>
<text class="wfg-roles" data-wfg-roles x="${x + 12}" y="${y + rolesY(st)}">${roleText}</text>
${missing.length ? `<text class="wfg-miss" data-wfg-missing x="${x + 12}" y="${y + missY(st)}">${esc(clip(`No current role: ${missing.join(", ")}`, rper + 2))}</text>` : ''}</g></a>`;
${missText}</g></a>`;
}).join('');
return `<svg class="wfg-graph${vertical ? ' is-vertical' : ''}" data-wfg-graph role="group" aria-label="${esc(`${count(steps.length)}, in order`)}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs><marker id="wfg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path class="wfg-arrow" d="M0 0L10 5L0 10z"/></marker></defs>${edges}${nodes}</svg>`;
}
// ---------- SP10 (#1873): editing a workflow in its graph ----------
// A change is held in the page, not written, until Save: the graph draws the pending workflow, the bar says
// Unsaved, and Save sends one POST with the revision the page read (saveFlow, the form's write). A refusal is
// shown in words and the pending change kept. The poll redraws from the pending workflow, never over it.
// When the file changes while a change is pending: if this workflow is as the page read it, the new revision
// is taken (a workflow write sets only this workflow, so nothing else is overwritten); if this workflow
// changed, the bar says so and offers Discard. Drafts live per organization until saved or discarded, and
// the organization-switch and restart guards ask before dropping one (hasDraft).
const drafts = new Map(), notes = new Map(), newForms = new Map();
const dkey = (org, scope, name) => JSON.stringify([org, scope, name]);
const norm = w => ({
title: typeof w?.title === 'string' ? w.title : '', description: typeof w?.description === 'string' ? w.description : '',
steps: stepsOf(w).map(st => ({ title: String(st.title ?? ''), state: st.state, roles: rolesOf(st) })),
});
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const changed = d => d.isNew || !same(d.flow, d.base);
const editDirty = e => !!e && !same({ title: e.title, state: e.state, roles: e.roles }, e.orig);
const READ_ONLY = 'Only the owner, on a Console started with send, can change workflows.';
// The graph's own words for a refusal where the form's would mislead: the change is kept, not lost.
const kept = {
'revision-conflict': 'Not saved: the organization file changed since this page read it. Your change is kept. Discard to load the current version.',
'organization-busy': 'Not saved: the organization is busy with another write. Your change is kept. Try Save again shortly.',
'no-bus-host': 'Not saved: the organization bus is not running. Your change is kept. Save again once it runs.',
'invalid-request': 'Not saved: the bus refused the workflow. Check each step has a title, a state and one to eight distinct roles. Your change is kept.',
};
const say = text => { const live = document.getElementById('announce'); if (live) { live.textContent = ''; live.textContent = text; } };
// A pending change measured against the fresh read; drops one with nothing left to keep.
function settle(org, s, scope, name, fresh) {
const key = dkey(org, scope, name), d = drafts.get(key);
if (!d) return null;
if (d.busy) return d;
const now = fresh ? norm(fresh) : null;
if (d.isNew) { d.stale = !!fresh; if (!fresh) d.revision = s.data.revision; }
else if (now && same(now, d.base)) { d.revision = s.data.revision; d.stale = false; }
else if (now && !changed(d)) {
Object.assign(d, { base: now, flow: structuredClone(now), revision: s.data.revision, stale: false });
if (d.edit?.mode === 'edit' && d.edit.i >= now.steps.length) d.edit = null;
} else d.stale = true;
if (!changed(d) && !d.edit && !d.confirm && !d.status) { drafts.delete(key); return null; }
return d;
}
function draftFor(org, s, scope, name) {
const key = dkey(org, scope, name);
if (drafts.has(key)) return drafts.get(key);
const fresh = flowsIn(s, scope)?.[name];
if (!fresh) return null;
const base = norm(fresh), d = { isNew: false, base, flow: structuredClone(base), revision: s.data.revision, edit: null, confirm: false, status: '', busy: false, stale: false };
drafts.set(key, d);
notes.delete(key);
return d;
}
// The role picker: human, the organization's roles, the role templates, and first any role the step names
// that resolves to none of those (checked and marked, so leaving it in is a choice the save check names).
function rolePick(s, chosen, prefix) {
const defs = [...(s.data.roles.definitions || [])].sort(), tpl = [...(s.data.roles.templates || [])].filter(r => !defs.includes(r)).sort();
const odd = chosen.filter(r => !s.known.has(r));
const box = (r, mark = '') => `<label class="wfg-pick"><input type="checkbox" name="role" value="${esc(r)}"${chosen.includes(r) ? ' checked' : ''} data-focus-key="${esc(`${prefix}:role:${r}`)}"> <code>${esc(r)}</code>${mark}</label>`;
const group = (title, list, mark) => (list.length ? `<p class="wfg-pick-h">${title}</p>${list.map(r => box(r, mark)).join('')}` : '');
return `<fieldset class="wfg-roles-pick" data-wfg-roles-pick><legend>Roles, one to eight</legend>
${group('Named in this step', odd, ` <span class="wf-missing" data-wfg-pick-missing>${MISSING}</span>`)}${box('human', ' <span class="muted">a person</span>')}
${group('This organization\'s roles', defs.filter(r => r !== 'human'), '')}${group('Role templates', tpl.filter(r => r !== 'human'), '')}</fieldset>`;
}
const stateSelect = (value, key) => `<label>State<select name="state" data-focus-key="${esc(key)}">${STATES.map(([v, l]) => `<option value="${v}"${value === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
function stepForm(org, s, scope, name, d) {
const e = d.edit, n = e.i + 1;
return `<form class="wfg-form" data-wfg-form data-live-key="${esc(`wfg-form:${org}:${scope}:${name}:${e.mode}:${e.i}`)}" data-live-hold novalidate>
<h2 data-wfg-form-title>${e.mode === 'add' ? `New step ${n}` : `Edit step ${n}`}</h2>
<label>Title<input name="title" maxlength="120" value="${esc(e.title)}" autocomplete="off" data-focus-key="wfg-f-title"></label>
${stateSelect(e.state, 'wfg-f-state')}${rolePick(s, e.roles, 'wfg-f')}
<p class="wfg-why" data-wfg-why>${esc(e.why ?? '')}</p>
<p class="wfg-form-acts"><button type="submit" class="ws-btn primary" data-wfg-apply data-focus-key="wfg-f-apply">${e.mode === 'add' ? 'Add step' : 'Apply'}</button> <button type="button" class="ws-btn" data-wfg-cancel data-focus-key="wfg-f-cancel">Cancel</button></p>
<p class="note">Apply changes the graph on this page. Save writes it to the organization file.</p></form>`;
}
function bar(org, s, scope, name, d) {
if (!s.data.writable) return `<p class="note wfg-ro" data-wfg-readonly>${READ_ONLY}</p>`;
const dirty = !!d && changed(d), busy = d?.busy ? ' disabled' : '';
const status = d?.status || notes.get(dkey(org, scope, name)) || '';
const stale = d?.stale ? `<p class="wfg-stale" data-wfg-stale>${d.isNew ? `A workflow named ${esc(name)} now exists here. Save will be refused. Discard to drop this one.` : 'The organization file changed this workflow since the page read it. Save will be refused. Discard to load the current version.'}</p>` : '';
const acts = d?.confirm
? `<span class="wfg-confirm" data-wfg-confirm>Delete ${esc(name)} from the organization file? <button type="button" class="ws-btn danger" data-wfg-delete-yes data-focus-key="wfg-delete-yes"${busy}>Delete workflow</button> <button type="button" class="ws-btn" data-wfg-delete-no data-focus-key="wfg-delete-no"${busy}>Keep</button></span>`
: `${dirty ? `<button type="button" class="ws-btn" data-wfg-discard data-focus-key="wfg-discard"${busy}>Discard</button> <button type="button" class="ws-btn primary" data-wfg-save data-focus-key="wfg-save"${busy}>${d.busy ? 'Saving…' : 'Save'}</button>` : ''}${d?.isNew ? '' : ` <button type="button" class="ws-btn danger" data-wfg-delete data-focus-key="wfg-delete"${busy}>Delete workflow</button>`}`;
return `<div class="wfg-bar${dirty ? ' is-dirty' : ''}" data-wfg-bar>${dirty ? `<span class="wfg-badge" data-wfg-unsaved>Unsaved${d.isNew ? ', not in the file yet' : ''}</span>` : ''}<span class="wfg-acts">${acts}</span>
${status ? `<p class="wfg-status" data-wfg-status>${esc(status)}</p>` : ''}${stale}</div>`;
}
const stepRef = (org, scope, name, steps, n) => `<a href="${esc(flowHref(org, scope, name, n))}" data-wfg-go="${n}">Step ${n}, ${esc(steps[n - 1].title)}</a>`;
function detail(org, s, scope, name, steps, sel) {
if (!sel) return `<section class="panel wfg-detail" data-wfg-detail><h2>Step details</h2><p class="note">Select a step to see its roles, the state it puts a task in and the step it hands on to.</p></section>`;
function actions(steps, sel, d) {
const busy = d?.busy ? ' disabled' : '';
if (!sel) return `<p class="wfg-step-acts" data-wfg-actions><button type="button" class="ws-btn" data-wfg-add data-focus-key="wfg-add"${busy}>Add a step at the end</button></p>`;
const dis = c => (c || d?.busy ? ' disabled' : '');
return `<p class="wfg-step-acts" data-wfg-actions><button type="button" class="ws-btn" data-wfg-edit data-focus-key="wfg-edit"${busy}>Edit step</button>
<button type="button" class="ws-btn" data-wfg-add data-focus-key="wfg-add"${dis(steps.length >= 20)}>Add a step after</button>
<button type="button" class="ws-btn" data-wfg-up data-focus-key="wfg-up"${dis(sel === 1)}>Move earlier</button>
<button type="button" class="ws-btn" data-wfg-down data-focus-key="wfg-down"${dis(sel === steps.length)}>Move later</button>
<button type="button" class="ws-btn danger" data-wfg-remove data-focus-key="wfg-remove"${dis(steps.length === 1)}>Remove step</button></p>`;
}
function detail(org, s, scope, name, steps, sel, d) {
const writable = !!s.data.writable;
if (writable && d?.edit) return `<section class="panel wfg-detail" data-wfg-detail>${stepForm(org, s, scope, name, d)}</section>`;
if (!sel) return `<section class="panel wfg-detail" data-wfg-detail><h2>Step details</h2><p class="note">Select a step to see its roles, the state it puts a task in and the step it hands on to.</p>${writable ? actions(steps, 0, d) : ''}</section>`;
const st = steps[sel - 1], roles = rolesOf(st);
const roleList = roles.map(r => r === 'human' ? '<code>human</code> (a person)' : s.known.has(r) ? `<code>${esc(r)}</code>` : `<code>${esc(r)}</code> <span class="wf-missing" data-wfg-missing>${MISSING}</span>`).join(', ');
return `<section class="panel wfg-detail" data-wfg-detail><h2 data-wfg-detail-title>${esc(st.title)}</h2><dl class="wfg-dl">
@@ -319,36 +464,266 @@
<dt>Puts the task in</dt><dd data-wfg-field="state">${esc(stateLabel(st))}</dd>
<dt>Comes after</dt><dd data-wfg-field="after">${sel > 1 ? stepRef(org, scope, name, steps, sel - 1) : 'Nothing: this is the first step'}</dd>
<dt>Hands on to</dt><dd data-wfg-field="next">${sel < steps.length ? stepRef(org, scope, name, steps, sel + 1) : 'Nothing: this is the last step'}</dd>
</dl><p class="note">The workflow file records no gate or inputs for a step, and no read says which step a task is on now.</p></section>`;
</dl>${writable ? actions(steps, sel, d) : ''}<p class="note">The workflow file records no gate or inputs for a step, and no read says which step a task is on now.</p></section>`;
}
function graphBody(org, s, name, q) {
const scope = q.get('project') || '';
const flows = scope ? s.data.projects[scope] : s.data.workflows;
if (scope && !Object.hasOwn(s.data.projects, scope)) return { title: esc(name), sub: '', body: `<p class="note" data-wfg-unknown>No code project named ${esc(scope)} in the organization file.</p>` };
if (!plain(flows) || !Object.hasOwn(flows, name)) return { title: esc(name), sub: '', body: `<p class="note" data-wfg-unknown>No workflow named ${esc(name)} in ${scope ? `project ${esc(scope)}` : 'the organization'}.</p>` };
const w = flows[name], steps = stepsOf(w);
const flows = flowsIn(s, scope), fresh = plain(flows) && Object.hasOwn(flows, name) ? flows[name] : null;
const d = s.data.writable ? settle(org, s, scope, name, fresh) : null;
const note = notes.get(dkey(org, scope, name));
if (!fresh && !d) return { title: esc(name), sub: '', body: `${note ? `<p class="wfg-status" data-wfg-status>${esc(note)}</p>` : ''}<p class="note" data-wfg-unknown>No workflow named ${esc(name)} in ${scope ? `project ${esc(scope)}` : 'the organization'}.</p>` };
const w = d ? d.flow : norm(fresh), steps = w.steps;
const raw = Number(q.get('step')), sel = Number.isInteger(raw) && raw >= 1 && raw <= steps.length ? raw : 0;
const sub = `${esc(scopeLabel(scope))}, <code>${esc(name)}</code>, ${count(steps.length)}.${w?.description ? ` ${esc(w.description)}` : ''}`;
const sub = `${esc(scopeLabel(scope))}, <code>${esc(name)}</code>, ${count(steps.length)}.${w.description ? ` ${esc(w.description)}` : ''}`;
const body = steps.length
? `<div class="wfg-frame" data-wfg-frame>${graph(org, s, scope, name, steps, sel)}</div>${detail(org, s, scope, name, steps, sel)}`
: '<p class="note">This workflow has no steps.</p>';
return { title: esc(w?.title || name), sub, body };
? `${bar(org, s, scope, name, d)}<div class="wfg-frame" data-wfg-frame>${graph(org, s, scope, name, steps, sel)}</div>${detail(org, s, scope, name, steps, sel, d)}`
: `${bar(org, s, scope, name, d)}<p class="note">This workflow has no steps.</p>`;
return { title: esc(w.title || name), sub, body };
}
// The page for #/orgs/<org>/workflows (name null) or #/orgs/<org>/workflows/<name>.
async function page(org, name, q) {
// The list page's New workflow form: where, a name, and the first step. Create opens it in the graph, unsaved.
function newForm(org, s) {
const f = newForms.get(org);
if (!f) return `<p class="wfg-new"><button type="button" class="ws-btn" data-wfg-new data-focus-key="wfg-new">New workflow</button></p>`;
return `<form class="wfg-form wfg-newform panel" data-wfg-form data-wfg-newform data-live-key="${esc(`wfg-new:${org}`)}" data-live-hold novalidate>
<h2>New workflow</h2>
<label>Where<select name="scope" data-focus-key="wfg-n-scope">${scopes(s).map(([v, l]) => `<option value="${esc(v)}"${f.scope === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
<label>Name<input name="name" maxlength="64" value="${esc(f.name)}" autocomplete="off" placeholder="lowercase letters, digits, dashes" data-focus-key="wfg-n-name"></label>
<p class="note">${NAME_RULE}</p>
<h3>First step</h3>
<label>Title<input name="title" maxlength="120" value="${esc(f.title)}" autocomplete="off" data-focus-key="wfg-n-title"></label>
${stateSelect(f.state, 'wfg-n-state')}${rolePick(s, f.roles, 'wfg-n')}
<p class="wfg-why" data-wfg-why>${esc(f.why ?? '')}</p>
<p class="wfg-form-acts"><button type="submit" class="ws-btn primary" data-wfg-create data-focus-key="wfg-n-create">Create</button> <button type="button" class="ws-btn" data-wfg-new-cancel data-focus-key="wfg-n-cancel">Cancel</button></p>
<p class="note">Create opens it in its graph. It is written to the organization file when you save it there.</p></form>`;
}
// The rows each scope lists: the file's workflows, and any new one not saved yet.
function rowsWithDrafts(org, s) {
return listed(s).map(([scope, label, rows]) => {
const names = new Set(rows.map(([n]) => n));
const fresh = [...drafts.entries()].map(([k, d]) => [JSON.parse(k), d]).filter(([[o, sc, n], d]) => o === org && sc === scope && d.isNew && !names.has(n)).map(([[, , n], d]) => [n, d.flow]);
return [scope, label, [...rows, ...fresh].sort(([p], [q]) => (p < q ? -1 : p > q ? 1 : 0))];
});
}
function listBody(org, s, label) {
const writable = !!s.data.writable, rows = writable ? rowsWithDrafts(org, s) : listed(s);
const top = `${notes.has(dkey(org, '', '')) ? `<p class="wfg-status" data-wfg-status>${esc(notes.get(dkey(org, '', '')))}</p>` : ''}${writable ? newForm(org, s) : `<p class="note wfg-ro" data-wfg-readonly>${READ_ONLY}</p>`}`;
if (!rows.some(([, , r]) => r.length)) return `${top}${none(org, s, label)}`;
return top + rows.filter(([scope, , r]) => !scope || r.length).map(([scope, label2, r]) => `<section class="wfg-scope" data-wfg-scope-list="${esc(scope)}"><h2>${esc(label2)}</h2>${r.length ? `<ul class="wfg-list">${r.map(([name, w]) => {
const steps = stepsOf(w), d = drafts.get(dkey(org, scope, name)), pending = d && changed(d);
return `<li class="wfg-item" data-wfg-item="${esc(name)}" data-wfg-scope="${esc(scope)}"><div class="wfg-item-h"><a href="${esc(flowHref(org, scope, name))}" data-wfg-open data-focus-key="${esc(`wfg-open:${scope}:${name}`)}">${esc(w?.title || name)}</a>${w?.title ? ` <code>${esc(name)}</code>` : ''} <span class="muted">${count(steps.length)}</span>${pending ? ` <span class="wfg-badge" data-wfg-unsaved>Unsaved${d.isNew ? ', not in the file yet' : ''}</span>` : ''}</div>
${w?.description ? `<p class="wfg-desc">${esc(w.description)}</p>` : ''}<ol class="wfg-chain" data-wfg-chain aria-label="Steps in order">${steps.map(st => `<li>${esc(st.title)}</li>`).join('')}</ol></li>`;
}).join('')}</ul>` : '<p class="note">None at the organization level.</p>'}</section>`).join('');
}
// The page for #/orgs/<org>/workflows (name null) or #/orgs/<org>/workflows/<name>. page() reads; compose()
// draws from what was read, so an edit on the page redraws at once (paint) without waiting for a read.
const shown = { org: null, name: null, info: null };
function compose(org, name, q, info) {
const attr = name === null ? 'data-wfg-list' : 'data-wfg-page';
const info = await orgOf(org);
if (!info.row) return `<article class="ws wfg" ${attr} data-wfg-org="${esc(org)}">${head('Organization unavailable', info.list ? 'This organization is not on your list.' : 'The organization list could not be read.')}</article>`;
const wrapIt = (title, sub, body) => `<article class="ws wfg" ${attr} data-wfg-org="${esc(org)}">${head(title, sub)}${info.unread}${body}</article>`;
const wrapIt = (title, sub, body) => `<article class="ws wfg" ${attr} data-wfg-org="${esc(org)}"${name === null ? '' : ` data-wfg-name="${esc(name)}"`}>${head(title, sub)}${info.unread}${body}</article>`;
// Review P3: a change held for this organization that the page cannot draw now is said to be kept.
const held = [...drafts].filter(([k, d]) => JSON.parse(k)[0] === org && (changed(d) || editDirty(d.edit))).map(([k]) => JSON.parse(k)[2]);
const keptNote = held.length ? `<p class="note" data-wfg-held>Unsaved change to ${held.map(n => esc(n)).join(', ')} is kept on this page. It comes back when the workflows can be read.</p>` : '';
// E2E6 P2-8: a stopped bus is not read; the note above says why.
if (info.stopped) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, '');
await load(org);
if (info.stopped) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, keptNote);
const s = states.get(org);
if (!s?.data) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, `<p class="note" data-wfg-error>${esc(s?.error ?? 'Workflows could not be read.')}</p>`);
if (name === null) return wrapIt('Workflows', `The steps a task goes through in ${esc(info.label)}, in order, with the roles that do each. Read only; the organization page changes them.`, listBody(org, s, info.label));
if (!s?.data) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, `<p class="note" data-wfg-error>${esc(s?.error ?? 'Workflows could not be read.')}</p>${keptNote}`);
if (name === null) return wrapIt('Workflows', `The steps a task goes through in ${esc(info.label)}, in order, with the roles that do each.${s.data.writable ? ' Open one to change it.' : ''}`, listBody(org, s, info.label));
const g = graphBody(org, s, name, q);
return wrapIt(g.title, g.sub, g.body);
}
async function page(org, name, q) {
const info = await orgOf(org);
if (!info.row) return `<article class="ws wfg" ${name === null ? 'data-wfg-list' : 'data-wfg-page'} data-wfg-org="${esc(org)}">${head('Organization unavailable', info.list ? 'This organization is not on your list.' : 'The organization list could not be read.')}</article>`;
if (!info.stopped) await load(org);
Object.assign(shown, { org, name, info });
return compose(org, name, q, info);
}
const query = () => new URLSearchParams(location.hash.split('?')[1] || '');
// Redraw the open workflow page from the page's state, then put focus on `focus` (a data-focus-key).
function paint(focus) {
const el = document.querySelector('#bus-view [data-wfg-list], #bus-view [data-wfg-page]');
if (!el || !shown.info || el.dataset.wfgOrg !== shown.org || (el.dataset.wfgName ?? null) !== shown.name) return;
el.outerHTML = compose(shown.org, shown.name, query(), shown.info);
const el2 = focus && document.querySelector(`#bus-view [data-focus-key="${CSS.escape(focus)}"]`);
if (!el2) return;
el2.focus();
// A text field takes focus with the caret after its text, ready to change it.
if (el2.matches('input:not([type=checkbox])')) el2.setSelectionRange(el2.value.length, el2.value.length);
}
// The open graph page: its organization, scope, name, read and pending change.
function here(el) {
const root = el.closest('[data-wfg-page]');
if (!root || root.dataset.wfgOrg !== shown.org) return null;
const org = shown.org, name = shown.name, scope = query().get('project') || '', s = states.get(org);
if (!s?.data?.writable) return null;
return { org, name, scope, s, key: dkey(org, scope, name) };
}
const selected = () => { const n = Number(query().get('step')); return Number.isInteger(n) && n > 0 ? n : 0; };
const select = step => WS()?.setQuery?.({ step: step || null }, false);
function openForm(d, mode, i, st) {
const roles = st ? [...st.roles] : [];
d.edit = { mode, i, title: st?.title ?? '', state: st?.state ?? 'todo', roles, why: '', orig: { title: st?.title ?? '', state: st?.state ?? 'todo', roles: [...roles] } };
}
function readForm(form, into) {
into.title = form.elements.title.value;
into.state = form.elements.state.value;
into.roles = [...form.querySelectorAll('input[name="role"]:checked')].map(i => i.value);
if (form.elements.scope) into.scope = form.elements.scope.value;
if (form.elements.name) into.name = form.elements.name.value.trim();
}
function why(form, into, text) {
into.why = text;
// Direct text: the form may be the held node a repaint grafted back.
const p = form.querySelector('[data-wfg-why]');
if (p) p.textContent = text;
say(text);
}
document.addEventListener('click', async ev => {
const btn = ev.target.closest('[data-wfg-new],[data-wfg-new-cancel],[data-wfg-edit],[data-wfg-add],[data-wfg-up],[data-wfg-down],[data-wfg-remove],[data-wfg-cancel],[data-wfg-discard],[data-wfg-save],[data-wfg-delete],[data-wfg-delete-no],[data-wfg-delete-yes]');
if (!btn || btn.disabled) return;
if (btn.matches('[data-wfg-new],[data-wfg-new-cancel]')) {
const org = btn.closest('[data-wfg-list]')?.dataset.wfgOrg;
if (!org || org !== shown.org) return;
if (btn.hasAttribute('data-wfg-new')) { newForms.set(org, { scope: '', name: '', title: '', state: 'todo', roles: [], why: '' }); notes.delete(dkey(org, '', '')); paint('wfg-n-name'); }
else { newForms.delete(org); paint('wfg-new'); }
return;
}
const at = here(btn);
if (!at) return;
const { org, name, scope, s, key } = at;
const sel = selected();
if (btn.hasAttribute('data-wfg-discard')) {
const d = drafts.get(key);
drafts.delete(key);
if (d?.isNew) { location.hash = `${orgHash(org)}/workflows`; return; }
say(`Discarded the change to ${name}.`);
return paint('wfg-delete');
}
if (btn.hasAttribute('data-wfg-save')) return save(at, btn);
if (btn.matches('[data-wfg-delete-yes]')) return remove(at);
const d = draftFor(org, s, scope, name) ?? drafts.get(key);
if (!d) return;
d.status = '';
const steps = d.flow.steps;
if (btn.hasAttribute('data-wfg-edit') && steps[sel - 1]) { openForm(d, 'edit', sel - 1, steps[sel - 1]); return paint('wfg-f-title'); }
if (btn.hasAttribute('data-wfg-add')) { openForm(d, 'add', sel ? sel : steps.length, null); return paint('wfg-f-title'); }
if (btn.hasAttribute('data-wfg-cancel')) { const back = d.edit?.mode === 'edit' ? d.edit.i + 1 : sel; d.edit = null; select(back); return paint(back ? `wfg-step:${back}` : 'wfg-add'); }
if (btn.matches('[data-wfg-up],[data-wfg-down]') && steps[sel - 1]) {
const to = btn.hasAttribute('data-wfg-up') ? sel - 1 : sel + 1;
if (to < 1 || to > steps.length) return;
[steps[sel - 1], steps[to - 1]] = [steps[to - 1], steps[sel - 1]];
select(to);
say(`Moved step ${sel} to position ${to}.`);
const key2 = btn.hasAttribute('data-wfg-up') ? (to > 1 ? 'wfg-up' : 'wfg-down') : (to < steps.length ? 'wfg-down' : 'wfg-up');
return paint(key2);
}
if (btn.hasAttribute('data-wfg-remove') && steps[sel - 1] && steps.length > 1) {
const [gone] = steps.splice(sel - 1, 1);
const next = Math.min(sel, steps.length);
select(next);
say(`Removed step ${sel}, ${gone.title}.`);
return paint(`wfg-step:${next}`);
}
if (btn.hasAttribute('data-wfg-delete')) { d.confirm = true; return paint('wfg-delete-no'); }
if (btn.hasAttribute('data-wfg-delete-no')) { d.confirm = false; return paint('wfg-delete'); }
});
async function save({ org, name, scope, s, key }) {
const d = drafts.get(key);
if (!d || d.busy) return;
// Review P2: the open step form's edits are not in the workflow yet; saving now would drop them unsaid.
const refused = (editDirty(d.edit) ? `Not saved: step ${d.edit.i + 1} has edits not applied. Apply or cancel the step first.` : '')
|| refusal(s, { isNew: d.isNew, scope, name, steps: d.flow.steps });
if (refused) { d.status = refused; say(refused); return paint('wfg-save'); }
d.busy = true; d.status = 'Saving…'; paint();
try {
await saveFlow(org, s, { scope, name, revision: d.revision, workflow: shape(d.flow) });
drafts.delete(key);
notes.set(key, `Saved ${name}.`);
try { await read(org, states.get(org) ?? s); } catch { /* the next poll reads it */ }
say(`Saved ${name}.`);
paint(selected() ? `wfg-step:${selected()}` : 'wfg-delete');
} catch (error) {
d.busy = false;
d.status = kept[error.code] ?? `Not saved: ${error.message} Your change is kept.`;
say(d.status);
paint('wfg-save');
}
}
async function remove({ org, name, scope, s, key }) {
const d = drafts.get(key);
if (!d || d.busy) return;
d.busy = true; paint();
try {
// The revision of the workflow the page shows (settle moves it on only while this workflow is unchanged):
// a change made to it since is refused, not deleted over.
await removeFlow(org, s, { scope, name, revision: d.revision });
drafts.delete(key);
notes.set(dkey(org, '', ''), `Deleted ${name}.`);
say(`Deleted ${name}.`);
try { await read(org, s); } catch { /* the next poll reads it */ }
location.hash = `${orgHash(org)}/workflows`;
} catch (error) {
d.busy = false; d.confirm = false;
// Recheck B: Discard shows only with a pending change; without one the next read draws the current version.
d.status = error.code === 'revision-conflict' ? `Not deleted: the organization file changed this workflow since the page read it. ${changed(d) ? 'Discard to load the current version.' : 'The page shows the current version at its next read.'}` : `Not deleted: ${error.message}`;
say(d.status);
paint('wfg-delete');
}
}
// Every change in a form goes into the page's state at once, so any repaint draws what was entered.
document.addEventListener('input', ev => {
const form = ev.target.closest('[data-wfg-form]');
if (!form) return;
if (form.matches('[data-wfg-newform]')) { const f = newForms.get(shown.org); if (f) readForm(form, f); return; }
const at = here(form), d = at && drafts.get(at.key);
if (d?.edit) readForm(form, d.edit);
});
document.addEventListener('submit', ev => {
const form = ev.target.closest?.('[data-wfg-form]');
if (!form) return;
ev.preventDefault();
if (form.matches('[data-wfg-newform]')) {
const org = shown.org, s = states.get(org), f = newForms.get(org);
if (!f || !s?.data?.writable) return;
readForm(form, f);
const step = { title: f.title, state: f.state, roles: f.roles };
const refused = refusal(s, { isNew: true, scope: f.scope, name: f.name, steps: [step] })
|| (drafts.get(dkey(org, f.scope, f.name))?.isNew ? `A new workflow named ${f.name} is already open here, not saved yet.` : '');
if (refused) return why(form, f, refused);
drafts.set(dkey(org, f.scope, f.name), { isNew: true, base: null, flow: { title: '', description: '', steps: [{ ...step, title: step.title.trim() }] }, revision: s.data.revision, edit: null, confirm: false, status: '', busy: false, stale: false });
newForms.delete(org);
notes.delete(dkey(org, f.scope, f.name));
location.hash = flowHref(org, f.scope, f.name, 1);
return;
}
const at = here(form), d = at && drafts.get(at.key);
if (!d?.edit) return;
readForm(form, d.edit);
const e = d.edit, st = { title: e.title.trim(), state: e.state, roles: e.roles };
const refused = stepRefusal(st, e.i + 1);
if (refused) return why(form, e, refused);
if (e.mode === 'add') d.flow.steps.splice(e.i, 0, st); else d.flow.steps[e.i] = st;
d.edit = null; d.status = '';
select(e.i + 1);
say(`${e.mode === 'add' ? 'Added' : 'Changed'} step ${e.i + 1}. Not saved yet.`);
paint('wfg-save');
});
// Escape in a step form is Cancel.
document.addEventListener('keydown', ev => {
if (ev.key !== 'Escape') return;
const form = ev.target.closest?.('[data-wfg-form]:not([data-wfg-newform])');
form?.querySelector('[data-wfg-cancel]')?.click();
});
// The organization-switch and restart guards (display.js, console-restart.js) ask before these are lost.
function hasDraft() {
return [...drafts.values()].some(d => changed(d) || editDirty(d.edit))
|| [...newForms.values()].some(f => f.name || f.title.trim() || f.roles.length);
}
function forget() { drafts.clear(); newForms.clear(); }
// The organization page's card: each workflow as a link to its graph, from the page's one workflow read.
function card(org, { stopped = false, label = org } = {}) {
const all = `<a href="${esc(`${orgHash(org)}/workflows`)}" data-wfg-all data-focus-key="wfg-all">All workflows</a>`;
@@ -381,5 +756,5 @@
window.matchMedia(NARROW).addEventListener('change', () => {
if (document.querySelector('[data-wfg-graph]')) document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
});
window.MosaicWorkflows = { load, section, page, card };
window.MosaicWorkflows = { load, section, page, card, hasDraft, forget };
})();
+390 -9
View File
@@ -3,6 +3,9 @@
// hand-off), and the organization page carries a small card. The data is what GET /api/orgs/<id>/workflows
// returns from the organization file: steps in order, each with a title, a state and its roles. Nothing
// else is drawn. A stopped organization is not read and says why.
// SP10 (#1873): the graph page edits the workflow. The change is held in the page until Save, which sends one
// POST with the revision the page read; a refusal is shown in words and the change kept; the poll never draws
// over a pending change. The fixture's write stub keeps the organization file and its revision, as the bus does.
import test from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
@@ -38,7 +41,28 @@ const LIST = [
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true },
];
function organizations(reads) {
// The organization files as the bus holds them: each organization's document and revision, the writes the
// page sent, and a refusal to give the next write. A write with another revision is a revision-conflict.
const hex = n => n.toString(16).padStart(64, '0');
function store(writable) {
const st = { writable, docs: structuredClone(DOCS), revs: { acme: 'a'.repeat(64), bare: 'b'.repeat(64), gamma: 'c'.repeat(64) }, writes: [], refuse: null, n: 0 };
const fail = code => Object.assign(new Error(code), { code });
st.write = (id, verb, args) => {
st.writes.push({ id, verb, args: structuredClone(args) });
if (verb !== 'workflow.set.console') throw fail('not-configured');
if (st.refuse) { const code = st.refuse; st.refuse = null; throw fail(code); }
if (args.revision !== st.revs[id]) throw fail('revision-conflict');
const doc = st.docs[id], scope = args.project ? doc.projects[args.project] : doc;
scope.workflows ??= {};
if (args.workflow === null) delete scope.workflows[args.name]; else scope.workflows[args.name] = structuredClone(args.workflow);
st.revs[id] = hex(++st.n);
return { revision: st.revs[id], added: [] };
};
// Another writer changes the file outside the page.
st.change = (id, fn) => { fn(st.docs[id]); st.revs[id] = hex(1000 + ++st.n); };
return st;
}
function organizations(reads, st) {
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme }, roles: {}, projects: {} },
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
@@ -49,10 +73,10 @@ function organizations(reads) {
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
const workflowsRead = async () => { reads.push(id); return { revision: 'a'.repeat(64), document: structuredClone(DOCS[id] ?? {}), templates: ['writer'] }; };
const workflowsRead = async () => { reads.push(id); if (st.failRead) throw Object.assign(new Error('org-unavailable'), { code: 'org-unavailable' }); return { revision: st.revs[id], document: structuredClone(st.docs[id] ?? {}), templates: ['writer'] }; };
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, workflowsRead, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true, workflowsRead, packsWrite: async () => ({}), decisionWrite: async () => ({}), send: async () => ({}),
busHealthy: true, workflowsRead, packsWrite: st.writable ? async (verb, args) => st.write(id, verb, args) : null, decisionWrite: async () => ({}), send: async () => ({}),
requestRead: async verb => { if (verb === 'packs.console') return { packs: [] }; if (verb === 'autonomy.get.console') return { revision: 'e'.repeat(64), autonomy: {}, levels: {} }; throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); },
reads: { business: async () => ({ business: { id, projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
@@ -62,9 +86,9 @@ function organizations(reads) {
};
}
async function setup(t, { path, viewport = [1440, 900], mode = null } = {}) {
const reads = [];
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: organizations(reads) });
async function setup(t, { path, viewport = [1440, 900], mode = null, writable = true } = {}) {
const reads = [], st = store(writable);
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: organizations(reads, st) });
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] });
t.after(() => b.close());
@@ -72,7 +96,7 @@ async function setup(t, { path, viewport = [1440, 900], mode = null } = {}) {
// The Console's own mode wins over the OS setting (Harbor in the dark by default), so a theme test sets it per organization.
if (mode) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `try { for (const o of ['acme','bare','gamma']) localStorage.setItem('mosaic-console-appearance:' + o, JSON.stringify({ theme: 'harbor', mode: ${JSON.stringify(mode)} })); } catch {}` });
await b.navigate(`http://127.0.0.1:${server.address().port}/${path}`);
return { b, reads, wait: waitFor(b) };
return { b, reads, st, wait: waitFor(b) };
}
const boxes = `[...document.querySelectorAll('[data-wfg-graph] [data-wfg-step]')]`;
@@ -113,6 +137,8 @@ test('the graph draws one box per step in order with its roles and one arrowed e
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>[...g.querySelectorAll('[data-wfg-roles] tspan')].map(t=>t.textContent))`), [['pm'], ['coder'], ['reviewer'], ['human', 'merge-gate']]);
// A role that resolves to no definition or template (and is not human) is marked on its box.
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelectorAll('[data-wfg-missing]').length)`), [0, 0, 0, 1]);
// sp9 follow-up: the missing role's whole name shows at 1440, on its own line under "No current role:".
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-step="4"] [data-wfg-missing] tspan')].map(t=>t.textContent)`), ['No current role:', 'merge-gate']);
// At 1440 the steps run left to right.
const xs = await b.evaluate(`${boxes}.map(g=>g.querySelector('rect').getBoundingClientRect().left)`);
assert.deepEqual([...xs].sort((p, q) => p - q), xs);
@@ -196,8 +222,10 @@ test('a step with more roles than fit shows three lines, the last counting the r
// The box names every role for a pointer or a screen reader.
const named = await b.evaluate(`${one}.querySelector('title').textContent + ' ' + (${one}.getAttribute('aria-label') ?? '')`);
for (const r of roles) assert.ok(named.includes(r), r);
// Every text line ends inside its own box.
const out = await b.evaluate(`${boxes}.flatMap(g=>{const r=g.querySelector('rect').getBoundingClientRect();return [...g.querySelectorAll('text tspan, text:not(:has(tspan))')].filter(t=>{const q=t.getBoundingClientRect();const c=g.querySelector('clipPath rect')?.getBoundingClientRect()??r;return Math.min(q.right,c.right)>r.right+0.5||Math.min(q.bottom,c.bottom)>r.bottom+0.5}).map(t=>t.textContent)})`);
// Every text line ends inside its own box: its drawn extent, not what the clip path lets show, ends left
// of the clip edge (8px inside the box) and above the box bottom.
const out = await b.evaluate(`${boxes}.flatMap(g=>{const r=g.querySelector('rect'),right=r.x.baseVal.value+r.width.baseVal.value-8,bottom=r.y.baseVal.value+r.height.baseVal.value;
return [...g.querySelectorAll('text')].flatMap(t=>{const spans=[...t.querySelectorAll('tspan')];return (spans.length?spans:[t]).filter(e=>e.getNumberOfChars()>0).filter(e=>{let n=e.getNumberOfChars();while(n>1&&/\\s/.test(e.textContent[n-1]))n--;const z=e.getExtentOfChar(n-1),a=e.getExtentOfChar(0);return z.x+z.width>right+0.5||z.y+z.height>bottom+0.5||a.x<r.x.baseVal.value}).map(e=>e.textContent)})})`);
assert.deepEqual(out, []);
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true);
});
@@ -286,3 +314,356 @@ test('at 390 the graph runs top to bottom inside its box and the page does not s
});
}
});
// ---------- SP10: editing a workflow in its graph ----------
const REV_A = 'a'.repeat(64);
const titles = `${boxes}.map(g=>g.querySelector('[data-wfg-title]').textContent)`;
const press = sel => `(()=>{const e=document.querySelector(${JSON.stringify(sel)});if(!e)throw Error('no '+${JSON.stringify(sel)});if(e.disabled)throw Error('disabled '+${JSON.stringify(sel)});e.click();return true})()`;
const pick = step => `document.querySelector('[data-wfg-step="${step}"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}))`;
// Sets a form field as typing would: the value, then the input event the page listens for.
const fill = (sel, value) => `(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`;
const role = (form, name) => `${form} input[name="role"][value="${name}"]`;
const text = sel => `(document.querySelector(${JSON.stringify(sel)})?.textContent ?? '').replace(/\\s+/g,' ').trim()`;
test('editing a step changes the graph on the page, and Save sends one POST with the revision the page read', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=4' });
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve the merge' && document.querySelector('[data-wfg-edit]')`, 'step 4 with Edit');
// Nothing pending: no Unsaved, no Save.
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-unsaved],[data-wfg-save],[data-wfg-discard]')`), null);
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form] [data-wfg-form-title]')?.textContent === 'Edit step 4'`, 'step form');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-f-title');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]').value`), 'Approve the merge');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] select[name="state"]').value`), 'done');
// The picker offers human, the organization's roles and the templates; the step's unknown role is first, checked and marked.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-form] input[name="role"]')].map(i=>[i.value,i.checked])`), [['merge-gate', true], ['human', true], ['coder', false], ['pm', false], ['reviewer', false], ['writer', false]]);
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-form] [data-wfg-pick-missing]')`));
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Approve and merge'));
await b.evaluate(press(role('[data-wfg-form]', 'merge-gate')));
await b.evaluate(press(role('[data-wfg-form]', 'reviewer')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[3] === 'Approve and merge' && document.querySelector('[data-wfg-unsaved]')`, 'pending change drawn');
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-step="4"] [data-wfg-roles] tspan')].map(t=>t.textContent)`), ['human', 'reviewer']);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="4"] [data-wfg-missing]')`), null);
assert.equal(await b.evaluate(text('[data-wfg-unsaved]')), 'Unsaved');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-save');
assert.equal(st.writes.length, 0, 'Apply writes nothing');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')}) && !document.querySelector('[data-wfg-unsaved]')`, 'saved');
assert.equal(st.writes.length, 1);
assert.deepEqual(st.writes[0], { id: 'acme', verb: 'workflow.set.console', args: { revision: REV_A, name: 'change', workflow: {
description: 'One change from task to merge.',
steps: [
{ title: 'Write the task', state: 'todo', roles: ['pm'] },
{ title: 'Implement the change', state: 'in-progress', roles: ['coder'] },
{ title: 'Review the change', state: 'in-review', roles: ['reviewer'] },
{ title: 'Approve and merge', state: 'done', roles: ['human', 'reviewer'] },
] } } });
// The page now draws the file as saved, read again.
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve and merge'`, 'detail after save');
assert.deepEqual(st.docs.acme.workflows.change.steps[3], { title: 'Approve and merge', state: 'done', roles: ['human', 'reviewer'] });
});
test('add, move, remove and discard change only the page until Save', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=2' });
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'step 2');
// Add after the selected step.
await b.evaluate(press('[data-wfg-add]'));
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'New step 3'`, 'add form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Test the change'));
await b.evaluate(`(()=>{const e=document.querySelector('[data-wfg-form] select[name="state"]');e.value='in-review';e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
// Apply with no role is refused in words, and the form stays.
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${text('[data-wfg-why]')} === 'Step 3 needs at least one role.'`, 'no role refused');
await b.evaluate(press(role('[data-wfg-form]', 'coder')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${boxes}.length === 5 && ${titles}[2] === 'Test the change'`, 'step added');
assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=3');
assert.equal(await b.evaluate(text('[data-wfg-field="state"]')), 'Review');
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-unsaved]')`));
// Move earlier and later.
await b.evaluate(press('[data-wfg-up]'));
await wait(`${titles}[1] === 'Test the change' && /step=2/.test(location.hash)`, 'moved earlier');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-up');
await b.evaluate(press('[data-wfg-down]'));
await wait(`${titles}[2] === 'Test the change' && /step=3/.test(location.hash)`, 'moved later');
// Selecting another step goes through a full render (a read); the pending change stays.
await b.evaluate(pick(1));
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Write the task' && ${boxes}.length === 5`, 'step 1, change kept');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-up]').disabled`), true, 'the first step cannot move earlier');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 4`, 'step removed');
assert.deepEqual(await b.evaluate(titles), ['Implement the change', 'Test the change', 'Review the change', 'Approve the merge']);
// With no step selected, Add puts a step at the end.
await b.evaluate(`location.hash = '#/orgs/acme/workflows/change'`);
await wait(`${text('[data-wfg-add]')} === 'Add a step at the end'`, 'add at end');
await b.evaluate(press('[data-wfg-add]'));
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'New step 5'`, 'add-at-end form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Announce it'));
await b.evaluate(press(role('[data-wfg-form]', 'pm')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${boxes}.length === 5 && ${titles}[4] === 'Announce it' && /step=5/.test(location.hash)`, 'added at end');
// Discard drops all of it; nothing was written.
await b.evaluate(press('[data-wfg-discard]'));
await wait(`${boxes}.length === 4 && !document.querySelector('[data-wfg-unsaved]')`, 'discarded');
assert.deepEqual(await b.evaluate(titles), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']);
assert.equal(st.writes.length, 0);
});
test('a refused save says why next to Save and keeps the pending change', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=1' });
await wait(`document.querySelector('[data-wfg-edit]')`, 'step 1');
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Write the brief'));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[0] === 'Write the brief'`, 'pending');
// Step 4 still names merge-gate: the page refuses before sending, in the same words as the form editor.
await b.evaluate(press('[data-wfg-save]'));
await wait(`${text('[data-wfg-status]')} === 'Step 4: merge-gate names no current role. Use an organization role, a role template or human.'`, 'unknown role refused');
assert.equal(st.writes.length, 0);
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]')`));
// Fix step 4, then the bus is down.
await b.evaluate(pick(4));
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'step 4');
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'Edit step 4'`, 'form 4');
await b.evaluate(press(role('[data-wfg-form]', 'merge-gate')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`!document.querySelector('[data-wfg-step="4"] [data-wfg-missing]')`, 'step 4 fixed');
st.refuse = 'no-bus-host';
await b.evaluate(press('[data-wfg-save]'));
await wait(`/bus is not running/.test(${text('[data-wfg-status]')})`, 'bus down refused');
assert.match(await b.evaluate(text('[data-wfg-status]')), /Your change is kept/);
assert.equal(st.writes.length, 1);
assert.equal(st.writes[0].args.revision, REV_A);
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]') && !!document.querySelector('[data-wfg-save]')`));
assert.equal(st.docs.acme.workflows.change.steps[0].title, 'Write the task', 'nothing written');
// The status sits in the bar with Save.
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-bar] [data-wfg-status]') && !!document.querySelector('[data-wfg-bar] [data-wfg-save]')`));
// Another writer changes this workflow: the save is refused as stale, the change kept.
st.change('acme', doc => { doc.workflows.change.description = 'Changed elsewhere.'; });
await b.evaluate(press('[data-wfg-save]'));
await wait(`/changed since this page read it/.test(${text('[data-wfg-status]')})`, 'stale refused');
assert.match(await b.evaluate(text('[data-wfg-status]')), /Your change is kept\. Discard to load the current version\./);
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]')`));
assert.equal(st.writes.length, 2);
});
test('the poll does not draw over a pending change, takes a new revision when only other workflows changed, and says when this one changed', { timeout: 60000 }, async t => {
const { b, wait, st, reads } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=2' });
// The site project's workflow names only current roles, so a save goes to the bus.
await wait(`document.querySelector('[data-wfg-edit]')`, 'step 2');
const repaint = async () => { const n = reads.length; await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`); await wait(`true`); for (let i = 0; i < 100 && reads.length === n; i++) await new Promise(r => setTimeout(r, 30)); await new Promise(r => setTimeout(r, 150)); };
// A form being typed in survives a repaint as typed.
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Ship the release'));
await repaint();
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]')?.value`), 'Ship the release');
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[1] === 'Ship the release' && document.querySelector('[data-wfg-unsaved]')`, 'pending');
await repaint();
assert.equal(await b.evaluate(`${titles}[1]`), 'Ship the release', 'the poll keeps the pending change');
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-unsaved]')`));
// Another workflow changes in the file: this one is as read, so the page takes the new revision and no warning shows.
st.change('acme', doc => { doc.workflows.intake.title = 'Intake, renamed'; });
const fresh = st.revs.acme;
await repaint();
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-stale]')`), null);
assert.equal(await b.evaluate(`${titles}[1]`), 'Ship the release');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved on the new revision');
assert.equal(st.writes.at(-1).args.revision, fresh);
assert.equal(st.docs.acme.workflows.intake.title, 'Intake, renamed', 'the other workflow is untouched');
// Now this workflow changes underneath a pending change: the page says so and offers Discard.
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form again');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Build it'));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[1] === 'Build it'`, 'pending again');
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed elsewhere.'; });
await repaint();
await wait(`!!document.querySelector('[data-wfg-stale]')`, 'stale note');
assert.match(await b.evaluate(text('[data-wfg-stale]')), /changed this workflow since the page read it.*Discard to load the current version/);
assert.equal(await b.evaluate(`${titles}[1]`), 'Build it');
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-discard]')`));
await b.evaluate(press('[data-wfg-discard]'));
await wait(`${titles}[1] === 'Ship the release' && !document.querySelector('[data-wfg-stale],[data-wfg-unsaved]')`, 'discarded to current');
assert.match(await b.evaluate(`document.querySelector('#bus-view .sub').textContent`), /Changed elsewhere\./);
});
test('a reader who cannot write sees no edit controls and one line saying who can', { timeout: 60000 }, async t => {
const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows/change?step=2', writable: false });
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'step 2');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-bar],[data-wfg-actions],[data-wfg-edit],[data-wfg-add],[data-wfg-save],[data-wfg-delete],[data-wfg-form]')`), null);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('#bus-view [data-wfg-readonly]')].map(n=>n.textContent)`), ['Only the owner, on a Console started with send, can change workflows.']);
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view button').length`), 0);
await b.evaluate(`location.hash = '#/orgs/acme/workflows'`);
await wait(`document.querySelector('[data-wfg-list] [data-wfg-item]')`, 'list');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-new]')`), null);
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-list] [data-wfg-readonly]')`));
});
test('a new workflow starts on the list page, opens in its graph unsaved, saves, and deletes after a confirm', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows' });
await wait(`document.querySelector('[data-wfg-new]')`, 'New workflow');
await b.evaluate(press('[data-wfg-new]'));
await wait(`document.querySelector('[data-wfg-newform]')`, 'new form');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-n-name');
const create = async () => b.evaluate(press('[data-wfg-create]'));
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'Release Plan'));
await create();
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'A workflow name is 1 to 64 lowercase letters, digits or dashes, starting with a letter.'`, 'name refused');
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'change'));
await create();
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'A workflow named change already exists here. Edit it instead.'`, 'taken refused');
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'release'));
await b.evaluate(fill('[data-wfg-newform] input[name="title"]', 'Cut the release'));
await create();
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'Step 1 needs at least one role.'`, 'role refused');
await b.evaluate(press(role('[data-wfg-newform]', 'human')));
await create();
await wait(`location.hash === '#/orgs/acme/workflows/release?step=1' && ${boxes}.length === 1`, 'opens in the graph');
assert.equal(await b.evaluate(text('[data-wfg-unsaved]')), 'Unsaved, not in the file yet');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-delete]')`), null, 'nothing to delete yet');
assert.equal(st.writes.length, 0);
// The list shows it, marked.
await b.evaluate(`location.hash = '#/orgs/acme/workflows'`);
await wait(`document.querySelector('[data-wfg-item="release"] [data-wfg-unsaved]')`, 'listed unsaved');
await b.evaluate(press('[data-wfg-item="release"] a[data-wfg-open]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-save]')`, 'back in the graph');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved release\\./.test(${text('[data-wfg-status]')}) && document.querySelector('[data-wfg-delete]')`, 'saved');
assert.deepEqual(st.writes[0].args, { revision: REV_A, name: 'release', workflow: { steps: [{ title: 'Cut the release', state: 'todo', roles: ['human'] }] } });
// Delete asks first; Keep keeps it.
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-confirm]')`, 'confirm');
assert.match(await b.evaluate(text('[data-wfg-confirm]')), /Delete release from the organization file\?/);
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-delete-no');
await b.evaluate(press('[data-wfg-delete-no]'));
await wait(`!document.querySelector('[data-wfg-confirm]') && document.activeElement?.dataset.focusKey === 'wfg-delete'`, 'kept');
assert.equal(st.writes.length, 1);
const saved = st.revs.acme;
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm again');
await b.evaluate(press('[data-wfg-delete-yes]'));
await wait(`location.hash === '#/orgs/acme/workflows' && /Deleted release\\./.test(${text('[data-wfg-list] [data-wfg-status]')}) && !document.querySelector('[data-wfg-item="release"]')`, 'deleted');
assert.deepEqual(st.writes[1].args, { revision: saved, name: 'release', workflow: null });
assert.equal(Object.hasOwn(st.docs.acme.workflows, 'release'), false);
});
test('the keyboard alone opens a step, edits it and saves', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site' });
await wait(`${boxes}.length === 2`, 'graph');
await b.evaluate(`document.querySelector('[data-wfg-step="1"]').focus();true`);
await b.key('Enter');
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Build' && document.activeElement?.dataset.focusKey === 'wfg-step:1'`, 'Enter opens step 1');
const tabTo = async (sel, what) => {
for (let i = 0; i < 20; i++) { if (await b.evaluate(`document.activeElement?.matches(${JSON.stringify(sel)}) ?? false`)) return; await b.key('Tab'); }
throw new Error(`Tab never reached ${what}`);
};
await tabTo('[data-wfg-edit]', 'Edit step');
await b.key('Enter');
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'Edit step 1' && document.activeElement?.dataset.focusKey === 'wfg-f-title'`, 'form with focus on the title');
await b.call('Input.insertText', { text: ' now' });
await tabTo(`[data-wfg-form] input[name="role"][value="reviewer"]`, 'the reviewer box');
await b.key(' ', 'Space');
await tabTo('[data-wfg-apply]', 'Apply');
await b.key('Enter');
await wait(`${titles}[0] === 'Build now' && document.activeElement?.dataset.focusKey === 'wfg-save'`, 'applied, focus on Save');
await b.key('Enter');
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved');
assert.deepEqual(st.writes[0].args, { revision: REV_A, name: 'change', project: 'site', workflow: { title: 'Site release', steps: [
{ title: 'Build now', state: 'in-progress', roles: ['coder', 'reviewer'] }, { title: 'Ship', state: 'done', roles: ['human'] }] } });
});
test('switching organization with a pending workflow change asks first, through the existing draft guard', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
await wait(`document.querySelector('[data-wfg-edit]') && document.querySelector('#breadcrumbs [data-crumb-org]')`, 'step 1');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), true);
await b.evaluate(`location.hash = '#/orgs/bare/workflows'`);
await wait(`document.querySelector('dialog[aria-label="Unsaved draft"][open]')`, 'guard dialog');
await b.evaluate(press('dialog [data-stay]'));
await wait(`!document.querySelector('dialog[open]') && /#\\/orgs\\/acme\\/workflows\\/change/.test(location.hash) && ${boxes}.length === 1`, 'stayed with the change');
await b.evaluate(`location.hash = '#/orgs/bare/workflows'`);
await wait(`document.querySelector('dialog[aria-label="Unsaved draft"][open]')`, 'guard dialog again');
await b.evaluate(press('dialog [data-discard]'));
await wait(`location.hash === '#/orgs/bare/workflows' && document.querySelector('[data-wfg-list][data-wfg-org="bare"]')`, 'switched');
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), false);
assert.equal(st.writes.length, 0);
});
test('Save with a step form holding edits not applied refuses in words and sends nothing; after Apply it sends them', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=2' });
await wait(`document.querySelector('[data-wfg-up]')`, 'step 2');
// One change pending, so Save shows; then a step form is opened and typed in, not applied.
await b.evaluate(press('[data-wfg-up]'));
await wait(`${titles}[0] === 'Ship' && document.querySelector('[data-wfg-save]')`, 'moved');
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Ship the release'));
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Apply or cancel the step first/.test(${text('[data-wfg-status]')})`, 'save refused');
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not saved: step 1 has edits not applied. Apply or cancel the step first.');
assert.equal(st.writes.length, 0);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]')?.value`), 'Ship the release', 'the typed title stays');
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[0] === 'Ship the release'`, 'applied');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-status]')`), null, 'Apply clears the refusal');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved');
assert.equal(st.writes.length, 1);
assert.deepEqual(st.writes[0].args.workflow.steps.map(s => s.title), ['Ship the release', 'Build']);
});
test('Delete on a view another writer has changed is refused, not deleted over', { timeout: 60000 }, async t => {
const { b, wait, st, reads } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
await wait(`document.querySelector('[data-wfg-remove]')`, 'step 1');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed elsewhere.'; });
const n = reads.length;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
await wait(`!!document.querySelector('[data-wfg-stale]')`, 'stale note');
assert.ok(reads.length > n);
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm');
await b.evaluate(press('[data-wfg-delete-yes]'));
await wait(`/^Not deleted/.test(${text('[data-wfg-status]')})`, 'delete refused');
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not deleted: the organization file changed this workflow since the page read it. Discard to load the current version.');
assert.equal(st.writes.length, 1);
assert.equal(st.writes[0].args.revision, REV_A);
assert.equal(st.writes[0].args.workflow, null);
assert.equal(st.docs.acme.projects.site.workflows.change.description, 'Changed elsewhere.', 'the newer workflow is still in the file');
assert.ok(await b.evaluate(`${boxes}.length === 1 && !!document.querySelector('[data-wfg-unsaved]')`), 'the pending change stays');
// With no pending change there is no Discard, so the refusal does not point at one.
await b.evaluate(press('[data-wfg-discard]'));
await wait(`${boxes}.length === 2 && !document.querySelector('[data-wfg-unsaved]')`, 'discarded');
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm again');
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed again.'; });
await b.evaluate(press('[data-wfg-delete-yes]'));
await wait(`/^Not deleted/.test(${text('[data-wfg-status]')})`, 'second delete refused');
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not deleted: the organization file changed this workflow since the page read it. The page shows the current version at its next read.');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-discard]')`), null);
assert.equal(st.docs.acme.projects.site.workflows.change.description, 'Changed again.');
});
test('a failed read says a pending change is kept, and it comes back when the read works', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
await wait(`document.querySelector('[data-wfg-remove]')`, 'step 1');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
st.failRead = true;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
await wait(`!!document.querySelector('[data-wfg-error]') && !!document.querySelector('[data-wfg-held]')`, 'error with kept note');
assert.equal(await b.evaluate(text('[data-wfg-held]')), 'Unsaved change to change is kept on this page. It comes back when the workflows can be read.');
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), true);
st.failRead = false;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
await wait(`${boxes}.length === 1 && !!document.querySelector('[data-wfg-unsaved]') && !document.querySelector('[data-wfg-held]')`, 'back');
assert.equal(st.writes.length, 0);
});