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
No files matched your search
@@ -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()));
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user