diff --git a/changelog.d/1514.fixed.md b/changelog.d/1514.fixed.md new file mode 100644 index 000000000..111409de6 --- /dev/null +++ b/changelog.d/1514.fixed.md @@ -0,0 +1 @@ +Settings → Workflow graphs → Edit now draws the Add link panel with the dialog's own form: a 14px section heading, a responsive field grid, a `Pick a node` placeholder in both node selects, and a primary `Add link` button that stays disabled until both ends are chosen. Node handles are smaller and theme-accent, sit beside the connection line instead of on it, and only gain full opacity and a halo on hover, focus or while a wire is dragged; the settings page also injects the workflow stylesheet when it opens without the sidebar section ([#1514](https://github.com/ThinkFlowLab/FlyBot/issues/1514)). diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 6725787a4..db49166c1 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -82,6 +82,7 @@ window.__ModuleLoader__.load({ 'Add link': '添加连线', 'From node': '起点', 'To node': '终点', + 'Pick a node': '选择一个节点', Link: '连线', Condition: '条件', Loop: '循环', @@ -717,7 +718,7 @@ window.__ModuleLoader__.load({ onMouseLeave: editing ? () => onTip?.(null) : null, }, editing ? h('button', { - type: 'button', className: 'wf-port out', 'data-port': 'out', 'aria-label': node.name, + type: 'button', className: `wf-port out${gesture?.kind === 'wire' && gesture.from === node.id ? ' wiring' : ''}`, 'data-port': 'out', 'aria-label': node.name, onPointerDown: (event) => { event.preventDefault?.(); event.stopPropagation?.(); setGesture({ kind: 'wire', from: node.id }) }, }) : null, editing ? h('button', { @@ -924,6 +925,11 @@ window.__ModuleLoader__.load({ return () => { gone = true } }, []) + useEffect(() => { + ensureCss() + return undefined + }, []) + const workflows = pack?.workflows ?? [] const current = workflows.find(item => item.id === workflowId) ?? workflows[0] const nodes = current?.nodes ?? [] @@ -1053,14 +1059,14 @@ window.__ModuleLoader__.load({ 'aria-label': tr('From node'), value: edgeForm.from, onChange: (event) => setEdgeForm({ ...edgeForm, from: event.target.value }), - }, h('option', { value: '' }), nodes.map(node => h('option', { key: node.id, value: node.id }, node.name)))), + }, h('option', { value: '' }, tr('Pick a node')), nodes.map(node => h('option', { key: node.id, value: node.id }, node.name)))), h('label', { className: 'wf-field' }, h('span', null, tr('To node')), h('select', { 'aria-label': tr('To node'), value: edgeForm.to, onChange: (event) => setEdgeForm({ ...edgeForm, to: event.target.value }), - }, h('option', { value: '' }), nodes.map(node => h('option', { key: node.id, value: node.id }, node.name)))), + }, h('option', { value: '' }, tr('Pick a node')), nodes.map(node => h('option', { key: node.id, value: node.id }, node.name)))), edgeForm.kind === 'loop' ? h('label', { className: 'wf-field' }, h('span', null, tr('Max iterations')), h('input', { @@ -1083,7 +1089,7 @@ window.__ModuleLoader__.load({ value: edgeForm.until.eq, onChange: (event) => setEdgeForm({ ...edgeForm, until: { ...edgeForm.until, eq: event.target.value } }), })) : null, - h('button', { type: 'button', onClick: addEdge }, tr('Add link')))) : null) : null) + h('button', { type: 'button', className: 'wf-btn wf-pri', disabled: !edgeForm.from || !edgeForm.to, onClick: addEdge }, tr('Add link')))) : null) : null) } function GraphCanvas({ workflow, editing, selectedNode, onSelectNode, onCommit, roster }) { @@ -1164,7 +1170,7 @@ window.__ModuleLoader__.load({ }, }, editing ? h('button', { - type: 'button', className: 'wf-port out', 'data-port': 'out', + type: 'button', className: `wf-port out${gesture?.kind === 'wire' && gesture.from === node.id ? ' wiring' : ''}`, 'data-port': 'out', onPointerDown: (event) => { event.preventDefault() event.stopPropagation() @@ -1181,6 +1187,15 @@ window.__ModuleLoader__.load({ return right > 40 ? right : 0 } + function ensureCss() { + if (typeof document === 'undefined') return + if (document.getElementById('flybot-workflow-css')) return + const style = document.createElement('style') + style.id = 'flybot-workflow-css' + style.textContent = CSS + document.head.appendChild(style) + } + function Section({ roster, call, actions }) { const [pack, setPack] = useState(null) const [openId, setOpenId] = useState(null) @@ -1208,12 +1223,7 @@ window.__ModuleLoader__.load({ } useEffect(() => { - if (typeof document === 'undefined') return undefined - if (document.getElementById('flybot-workflow-css')) return undefined - const style = document.createElement('style') - style.id = 'flybot-workflow-css' - style.textContent = CSS - document.head.appendChild(style) + ensureCss() return undefined }, []) @@ -1596,7 +1606,8 @@ window.__ModuleLoader__.load({ .wf-wires path{fill:none;stroke:var(--bt-line,rgba(20,20,20,.5));stroke-width:2} .wf-wires .wf-end{pointer-events:auto;fill:var(--bt-card,#fff);stroke:var(--bt-accent,#4d6bfe);cursor:crosshair} .wf-abs{position:absolute} -.wf-port{position:absolute;left:50%;bottom:-6px;width:12px;height:12px;margin-left:-6px;border-radius:50%;border:2px solid var(--bt-accent,#4d6bfe);background:var(--bt-card,#fff);padding:0;cursor:crosshair} +.wf-port{position:absolute;right:-5px;bottom:-5px;width:10px;height:10px;border-radius:50%;border:1.5px solid var(--bt-accent,#4d6bfe);background:var(--bt-card,#fff);padding:0;cursor:crosshair;opacity:.55;z-index:2;transition:opacity .15s,box-shadow .15s} +.wf-port:hover,.wf-port:focus-visible,.wf-port.wiring{opacity:1;box-shadow:0 0 0 3px rgba(77,107,254,.22)} .wf-mini{position:absolute;top:-8px;right:-8px;width:18px;height:18px;border-radius:50%;border:1px solid var(--bt-line,rgba(20,20,20,.16));background:var(--bt-card,#fff);cursor:pointer;line-height:16px;padding:0} .wf-node{width:168px;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);border-radius:12px;padding:8px 9px;display:flex;align-items:center;gap:8px;position:relative} @keyframes wf-node-live{0%,100%{border-color:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 2px rgba(77,107,254,.18),0 0 0 0 rgba(77,107,254,.2)}50%{border-color:rgba(77,107,254,.85);box-shadow:0 0 0 3px rgba(77,107,254,.35),0 0 14px 2px rgba(77,107,254,.22)}} @@ -1662,6 +1673,12 @@ window.__ModuleLoader__.load({ .wf-inspector-content{display:flex;flex-direction:column;gap:12px} .wf-inspector-empty{display:flex;align-items:center;justify-content:center} .wf-inspector-empty p{color:var(--bt-ink-3,#9a9a9a);text-align:center} +.wf-edge-form{border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;padding:16px;background:var(--bt-card,#fff)} +.wf-edge-form h3{font-size:14px;margin:0 0 12px} +.wf-edge-form .wf-edge-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px} +.wf-edge-form .wf-field{margin-top:0} +.wf-edge-form .wf-btn{align-self:end;justify-self:start} +.wf-btn:disabled{opacity:.5;cursor:not-allowed} .wf-par-toggle{display:flex;align-items:center;gap:6px;font-size:12px} ` diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index e21fa68ad..2281f335f 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -1042,6 +1042,7 @@ test('Chinese labels follow the host Tasks translation', () => { try { assert.equal(client.tr('Edit'), '编辑') assert.equal(client.tr('Hand a task to a Workflow'), '把任务交给 Workflow') + assert.equal(client.tr('Pick a node'), '选择一个节点') assert.equal(client.columnOf({ status: 'working' }), 'in-progress') assert.equal(client.shortId('task-abcdef123456'), '#123456') } finally { @@ -1150,3 +1151,124 @@ test('a stage control that never answers settles as an error', async () => { const value = await client.settledCall(async () => new Promise(() => {}), 'workflow/pause', { workflowId: 'delivery' }, 'The run did not answer', 5) assert.deepEqual(value, { ok: false, error: 'The run did not answer' }) }) + +const cssRule = (source, selector) => { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = new RegExp(`${escaped}\\{([^}]*)\\}`).exec(source) + assert.ok(match, `missing CSS rule ${selector}`) + return match[1] +} +const pixel = (declaration, rule) => { + const match = new RegExp(`${declaration}:(\\d+(?:\\.\\d+)?)px`).exec(rule) + assert.ok(match, `missing ${declaration} in ${rule}`) + return Number(match[1]) +} + +test('the settings Add link panel uses the dialog form, a Pick a node placeholder and a disabled submit', async () => { + const calls = [] + const graph = delivery() + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: [graph] } + return { ok: true, workflows: [graph] } + } + const settings = mount(client.WorkflowSettings, { call, roster: { bots: [] } }) + await settle() + button(settings.tree, 'Edit').props.onClick() + await settle() + const form = findAll(settings.tree, node => node.props?.className === 'wf-edge-form')[0] + assert.ok(form, 'the Add link panel carries wf-edge-form') + assert.equal(textOf(findAll(form, node => node.type === 'h3')[0]), 'Add link') + const pick = label => findAll(settings.tree, node => node.props?.['aria-label'] === label)[0] + for (const label of ['From node', 'To node']) { + const options = findAll(pick(label), node => node.type === 'option') + assert.equal(options[0].props.value, '') + assert.equal(textOf(options[0]), 'Pick a node', `${label} starts on a placeholder, not a blank option`) + } + assert.equal(button(settings.tree, 'Add link').props.className, 'wf-btn wf-pri') + assert.equal(button(settings.tree, 'Add link').props.disabled, true, 'the submit waits for both ends') + pick('From node').props.onChange({ target: { value: 'coder' } }) + await settle() + assert.equal(button(settings.tree, 'Add link').props.disabled, true, 'one end alone does not enable the submit') + pick('To node').props.onChange({ target: { value: 'planner' } }) + await settle() + assert.equal(button(settings.tree, 'Add link').props.disabled, false) + button(settings.tree, 'Add link').props.onClick() + const linked = calls.filter(item => item.name === 'workflow/graph').at(-1) + assert.ok(linked.body.edges.some(edge => edge.from === 'coder' && edge.to === 'planner')) + settings.unmount() +}) + +test('graph handles are small, off the wire and lit only on hover or while dragging a wire', async () => { + const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + const graph = delivery() + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph] } + return { ok: true, workflows: [graph] } + } + const settings = mount(client.WorkflowSettings, { call, roster: { bots: [] } }) + await settle() + assert.equal(findAll(settings.tree, node => node.props?.['data-port'] === 'out').length, 0, 'no handles outside Edit mode') + button(settings.tree, 'Edit').props.onClick() + await settle() + const ports = findAll(settings.tree, node => node.props?.['data-port'] === 'out') + assert.equal(ports.length, graph.nodes.length) + assert.match(ports[0].props.className, /\bwf-port out\b/) + assert.equal(ports.some(node => node.props.className.includes('wiring')), false) + ports[0].props.onPointerDown({ preventDefault() {}, stopPropagation() {} }) + await settle() + const lit = findAll(settings.tree, node => node.props?.['data-port'] === 'out' && node.props.className.includes('wiring')) + assert.equal(lit.length, 1, 'an active wire gesture lights one handle') + settings.unmount() + + const port = cssRule(source, '.wf-port') + assert.ok(pixel('width', port) <= 10, `the handle is ${pixel('width', port)}px wide`) + assert.ok(pixel('height', port) <= 10) + assert.ok(pixel('border', port) <= 1.5) + assert.match(port, /right:-5px/, 'the handle sits beside the connection line, not on it') + assert.match(source, /\.wf-port:hover[^{]*\{[^}]*opacity:1/) + assert.match(source, /\.wf-port:focus-visible[^{]*\{[^}]*opacity:1/) + assert.match(source, /\.wf-port\.wiring\{[^}]*opacity:1/) + const form = cssRule(source, '.wf-edge-form') + assert.match(form, /padding:16px/) + assert.match(cssRule(source, '.wf-edge-form h3'), /font-size:14px/) + assert.ok(pixel('font-size', cssRule(source, '.wf-edge-form h3')) <= 14) + assert.match(cssRule(source, '.wf-edge-form .wf-edge-fields'), /display:grid/) + assert.match(cssRule(source, '.wf-edge-form .wf-field'), /margin-top:0/) +}) + +test('the settings page injects the workflow stylesheet without the sidebar section', async () => { + const prev = globalThis.document + const stub = (existing) => { + const appended = [] + return { + appended, + getElementById: id => (existing && id === 'flybot-workflow-css' ? existing : null), + createElement: () => ({ id: '', textContent: '' }), + head: { appendChild(element) { appended.push(element) } }, + querySelector() { return null }, + addEventListener() {}, + removeEventListener() {}, + } + } + try { + const doc = stub(null) + globalThis.document = doc + const view = mount(client.WorkflowSettings, { call: async () => ({ workflows: [], templates: [] }), roster: { bots: [] } }) + await settle() + const style = doc.appended.find(element => element.id === 'flybot-workflow-css') + assert.ok(style, 'WorkflowSettings appended #flybot-workflow-css') + assert.match(style.textContent, /\.wf-port\{/) + assert.match(style.textContent, /\.wf-edge-form\{/) + view.unmount() + + const again = stub({ id: 'flybot-workflow-css' }) + globalThis.document = again + const second = mount(client.WorkflowSettings, { call: async () => ({ workflows: [], templates: [] }), roster: { bots: [] } }) + await settle() + assert.equal(again.appended.length, 0, 'an existing stylesheet is not appended twice') + second.unmount() + } finally { + globalThis.document = prev + } +})