diff --git a/packages/webui/src/public/console-restart.js b/packages/webui/src/public/console-restart.js index 259a19fa8..5ab4a989c 100644 --- a/packages/webui/src/public/console-restart.js +++ b/packages/webui/src/public/console-restart.js @@ -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())); diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 23336f874..7a9978fae 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -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) { diff --git a/packages/webui/src/public/workflows.css b/packages/webui/src/public/workflows.css index 261dd7ad7..44fa603dc 100644 --- a/packages/webui/src/public/workflows.css +++ b/packages/webui/src/public/workflows.css @@ -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)} +} diff --git a/packages/webui/src/public/workflows.js b/packages/webui/src/public/workflows.js index fa1031136..a115d6688 100644 --- a/packages/webui/src/public/workflows.js +++ b/packages/webui/src/public/workflows.js @@ -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 @@ : '

A starter pack brings workflows. Only the owner, on a Console started with send, can apply one.

'); const none = (org, s, label) => `

No workflows in ${esc(label)} yet.

${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]) => `

${esc(label2)}

${rows.length ? `` : '

None at the organization level.

'}
`).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 ` ${edges}${nodes}`; } + + // ---------- 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 ? `

${title}

${list.map(r => box(r, mark)).join('')}` : ''); + return `
Roles, one to eight + ${group('Named in this step', odd, ` ${MISSING}`)}${box('human', ' a person')} + ${group('This organization\'s roles', defs.filter(r => r !== 'human'), '')}${group('Role templates', tpl.filter(r => r !== 'human'), '')}
`; + } + const stateSelect = (value, key) => ``; + function stepForm(org, s, scope, name, d) { + const e = d.edit, n = e.i + 1; + return `
+

${e.mode === 'add' ? `New step ${n}` : `Edit step ${n}`}

+ + ${stateSelect(e.state, 'wfg-f-state')}${rolePick(s, e.roles, 'wfg-f')} +

${esc(e.why ?? '')}

+

+

Apply changes the graph on this page. Save writes it to the organization file.

`; + } + function bar(org, s, scope, name, d) { + if (!s.data.writable) return `

${READ_ONLY}

`; + const dirty = !!d && changed(d), busy = d?.busy ? ' disabled' : ''; + const status = d?.status || notes.get(dkey(org, scope, name)) || ''; + const stale = d?.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.'}

` : ''; + const acts = d?.confirm + ? `Delete ${esc(name)} from the organization file? ` + : `${dirty ? ` ` : ''}${d?.isNew ? '' : ` `}`; + return `
${dirty ? `Unsaved${d.isNew ? ', not in the file yet' : ''}` : ''}${acts} + ${status ? `

${esc(status)}

` : ''}${stale}
`; + } const stepRef = (org, scope, name, steps, n) => `Step ${n}, ${esc(steps[n - 1].title)}`; - function detail(org, s, scope, name, steps, sel) { - if (!sel) return `

Step details

Select a step to see its roles, the state it puts a task in and the step it hands on to.

`; + function actions(steps, sel, d) { + const busy = d?.busy ? ' disabled' : ''; + if (!sel) return `

`; + const dis = c => (c || d?.busy ? ' disabled' : ''); + return `

+ + + +

`; + } + function detail(org, s, scope, name, steps, sel, d) { + const writable = !!s.data.writable; + if (writable && d?.edit) return `
${stepForm(org, s, scope, name, d)}
`; + if (!sel) return `

Step details

Select a step to see its roles, the state it puts a task in and the step it hands on to.

${writable ? actions(steps, 0, d) : ''}
`; const st = steps[sel - 1], roles = rolesOf(st); const roleList = roles.map(r => r === 'human' ? 'human (a person)' : s.known.has(r) ? `${esc(r)}` : `${esc(r)} ${MISSING}`).join(', '); return `

${esc(st.title)}

@@ -319,36 +464,259 @@
Puts the task in
${esc(stateLabel(st))}
Comes after
${sel > 1 ? stepRef(org, scope, name, steps, sel - 1) : 'Nothing: this is the first step'}
Hands on to
${sel < steps.length ? stepRef(org, scope, name, steps, sel + 1) : 'Nothing: this is the last step'}
-

The workflow file records no gate or inputs for a step, and no read says which step a task is on now.

`; + ${writable ? actions(steps, sel, d) : ''}

The workflow file records no gate or inputs for a step, and no read says which step a task is on now.

`; } 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: `

No code project named ${esc(scope)} in the organization file.

` }; - if (!plain(flows) || !Object.hasOwn(flows, name)) return { title: esc(name), sub: '', body: `

No workflow named ${esc(name)} in ${scope ? `project ${esc(scope)}` : 'the organization'}.

` }; - 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 ? `

${esc(note)}

` : ''}

No workflow named ${esc(name)} in ${scope ? `project ${esc(scope)}` : 'the organization'}.

` }; + 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))}, ${esc(name)}, ${count(steps.length)}.${w?.description ? ` ${esc(w.description)}` : ''}`; + const sub = `${esc(scopeLabel(scope))}, ${esc(name)}, ${count(steps.length)}.${w.description ? ` ${esc(w.description)}` : ''}`; const body = steps.length - ? `
${graph(org, s, scope, name, steps, sel)}
${detail(org, s, scope, name, steps, sel)}` - : '

This workflow has no steps.

'; - return { title: esc(w?.title || name), sub, body }; + ? `${bar(org, s, scope, name, d)}
${graph(org, s, scope, name, steps, sel)}
${detail(org, s, scope, name, steps, sel, d)}` + : `${bar(org, s, scope, name, d)}

This workflow has no steps.

`; + return { title: esc(w.title || name), sub, body }; } - // The page for #/orgs//workflows (name null) or #/orgs//workflows/. - 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 `

`; + return `
+

New workflow

+ + +

${NAME_RULE}

+

First step

+ + ${stateSelect(f.state, 'wfg-n-state')}${rolePick(s, f.roles, 'wfg-n')} +

${esc(f.why ?? '')}

+

+

Create opens it in its graph. It is written to the organization file when you save it there.

`; + } + // 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, '', '')) ? `

${esc(notes.get(dkey(org, '', '')))}

` : ''}${writable ? newForm(org, s) : `

${READ_ONLY}

`}`; + 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]) => `

${esc(label2)}

${r.length ? `
    ${r.map(([name, w]) => { + const steps = stepsOf(w), d = drafts.get(dkey(org, scope, name)), pending = d && changed(d); + return `
  • ${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 => `
    1. ${esc(st.title)}
    2. `).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}
`; // 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); 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 (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; + const refused = 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, 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 page's last read: a change made since then is refused, not deleted over. + await removeFlow(org, s, { scope, name, revision: s.data.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; + d.status = `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; + 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 +749,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..94c45c52d 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); 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,284 @@ 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); +});