'}`).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 ``;
}).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) => `${esc(more && j === rl.length - 1 ? r : clip(r, rper))}`).join('')
+ ? rl.map((r, j) => `${esc(more && j === rl.length - 1 ? r : fit(r, f.roles, room))}`).join('')
: 'No roles recorded';
+ const missText = miss.length
+ ? `No current role:${missLines(st).map((r, j) => ` 2 && j === 1 ? ' data-wfg-more' : ''}>${esc(miss.length > 2 && j === 1 ? r : fit(r, f.miss, room))}`).join('')}`
+ : '';
return `${esc(`${st.title ?? ''}${roles.length ? ` (${roles.join(', ')})` : ''}`)}
- Step ${n} · ${esc(stateLabel(st))}
+ ${esc(fit(`Step ${n} · ${stateLabel(st)}`, f.miss, room))}${lines.map((l, j) => `${esc(l)}${j < lines.length - 1 ? ' ' : ''}`).join('')}${roleText}
- ${missing.length ? `${esc(clip(`No current role: ${missing.join(", ")}`, rper + 2))}` : ''}`;
+ ${missText}`;
}).join('');
return ``;
}
+
+ // ---------- 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 = '') => ``;
+ const group = (title, list, mark) => (list.length ? `
${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.'}
${esc(w?.title || name)}${w?.title ? ` ${esc(name)}` : ''} ${count(steps.length)}${pending ? ` Unsaved${d.isNew ? ', not in the file yet' : ''}` : ''}
+ ${w?.description ? `
${esc(w.description)}
` : ''}${steps.map(st => `
${esc(st.title)}
`).join('')}
`;
+ }).join('')}
` : '
None at the organization level.
'}`).join('');
+ }
+
+ // The page for #/orgs//workflows (name null) or #/orgs//workflows/. 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 `${head('Organization unavailable', info.list ? 'This organization is not on your list.' : 'The organization list could not be read.')}`;
- const wrapIt = (title, sub, body) => `${head(title, sub)}${info.unread}${body}`;
+ const wrapIt = (title, sub, body) => `${head(title, sub)}${info.unread}${body}`;
+ // 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 ? `
Unsaved change to ${held.map(n => esc(n)).join(', ')} is kept on this page. It comes back when the workflows can be read.
` : '';
// 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)}.`, `
${esc(s?.error ?? 'Workflows could not be read.')}
`);
- 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)}.`, `
${esc(s?.error ?? 'Workflows could not be read.')}
${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 `${head('Organization unavailable', info.list ? 'This organization is not on your list.' : 'The organization list could not be read.')}`;
+ 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 = `All workflows`;
@@ -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 };
})();
diff --git a/packages/webui/tests/workflows-browser.test.mjs b/packages/webui/tests/workflows-browser.test.mjs
index e97db689a..9b7ea502a 100644
--- a/packages/webui/tests/workflows-browser.test.mjs
+++ b/packages/webui/tests/workflows-browser.test.mjs
@@ -3,6 +3,9 @@
// hand-off), and the organization page carries a small card. The data is what GET /api/orgs//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.xe.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);
+});