Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2254-hand-a-task-names-its-target.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Hand a task to a Workflow** now names the picked target on its confirm button — `Assign to <lane> · <Project>`, or the Bot when the card goes to one Bot — so two lanes that share a name are told apart at the click; the confirm body is unchanged. ([#2254](https://github.com/ThinkFlowLab/FlyBot/issues/2254))
2 changes: 1 addition & 1 deletion docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,6 @@ A connector a Bot switched off is not reopened by a Workflow, whichever surface

A workflow record carries `projectId`. `saveGraph` is the only writer: a non-empty value must name an existing Project (`fb.projects.read`, which reads the host's `readProject`) or the whole save is refused and nothing is written; `null` clears it; omitting the field keeps what is stored.

- The Settings page names the Project on every sidebar row (a workflow with none reads `Unassigned`, zh 未归属), offers one Project filter above the list (`All Projects` plus each Project plus the unassigned rows), and carries a Project picker in the graph editor; the picker saves through the same `workflow/graph` call that carries the nodes and edges, and an empty choice sends `null`. The picker is fed by the host `projects` endpoint, so it lists what the core store holds and never a second copy. The **Hand a task to a Workflow** dialog repeats the same badge on every lane target, so two lanes that share a name but belong to different Projects no longer read alike; a lane with no Project draws no badge there, the same way the dialog's own target row stays one line (#2241).
- The Settings page names the Project on every sidebar row (a workflow with none reads `Unassigned`, zh 未归属), offers one Project filter above the list (`All Projects` plus each Project plus the unassigned rows), and carries a Project picker in the graph editor; the picker saves through the same `workflow/graph` call that carries the nodes and edges, and an empty choice sends `null`. The picker is fed by the host `projects` endpoint, so it lists what the core store holds and never a second copy. The **Hand a task to a Workflow** dialog repeats the same badge on every lane target, so two lanes that share a name but belong to different Projects no longer read alike; a lane with no Project draws no badge there, the same way the dialog's own target row stays one line (#2241). The dialog's confirm button repeats the target it is about to use — `Assign to <lane> · <Project>`, or `Assign to <Bot>` for a Bot — so two lanes that share a name are still told apart at the moment of the click, while the confirm body stays `{ workflowId, taskId, title }` and a dialog with no target keeps the plain `Assign` disabled (#2254).

A run is bound at creation, through `fb.projects.bindRun(runId, projectId)` — the seam beside `fb.tasks.onProjectProvider`. `assign()` resolves the Project once (the call's own `projectId` wins over the workflow's) and carries it with the task, so a parked run binds when it actually starts. The binding goes through the host's `bindProject({ kind: 'run', … })`, the same path the `project-bind` endpoint uses, so the Project board and Settings cannot disagree. An unknown Project, or a host without the seam, leaves the run unbound and logs once; it never stops the run from starting.
11 changes: 10 additions & 1 deletion plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ window.__ModuleLoader__.load({
'Pick who runs it': '选择执行者',
Cancel: '取消',
Assign: '确认分配',
'Assign to {target}': '确认分配给 {target}',
Workflow: 'Workflow',
'One Bot (as before)': '单个 Bot(原有方式)',
'Concurrent runs': '同时运行',
Expand Down Expand Up @@ -1444,6 +1445,13 @@ window.__ModuleLoader__.load({
seen.add(bot.name)
return true
})
// P-463: two lanes may share one name and differ only by Project, and the badge that tells
// them apart sits on the target row — not on the button pressed last. Name the picked
// target on the button itself: the lane with its Project, or the Bot.
const targetBot = target.startsWith('bot:') ? bots.find(bot => `bot:${bot.id}` === target) : null
const targetLane = targetBot ? null : workflows.find(item => item.id === target)
const laneProject = targetLane ? projectName(targetLane.projectId) : ''
const targetName = targetBot ? targetBot.name : targetLane ? (laneProject ? `${targetLane.name} · ${laneProject}` : targetLane.name) : ''
const shownTasks = tasksExpanded ? tasks : tasks.slice(0, ASSIGN_VISIBLE)
const shownBots = botsExpanded ? bots : bots.slice(0, ASSIGN_VISIBLE)
return h('div', { className: 'wf-mask', onClick: (event) => { if (event.target === event.currentTarget) onClose() } },
Expand Down Expand Up @@ -1501,7 +1509,8 @@ window.__ModuleLoader__.load({
: null)),
h('div', { className: 'wf-modal-footer' },
h('button', { type: 'button', className: 'wf-btn', onClick: onClose }, tr('Cancel')),
h('button', { type: 'button', className: 'wf-btn wf-dark', disabled: !canAssign, onClick: () => { if (canAssign) onConfirm({ taskId, target }) } }, tr('Assign')))))
h('button', { type: 'button', className: 'wf-btn wf-dark', disabled: !canAssign, onClick: () => { if (canAssign) onConfirm({ taskId, target }) } },
targetName ? tr('Assign to {target}', { target: targetName }) : tr('Assign')))))
}

function CreateModal({ roster, templates, onClose, onCreate, onTemplate, error }) {
Expand Down
83 changes: 77 additions & 6 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2432,7 +2432,7 @@
assert.equal(selected.length, 1, 'exactly one card is preselected')
assert.ok(textOf(selected[0]).includes('Real board card one'))

const assign = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Assign')[0]
const assign = findAll(view.tree, node => node.type === 'button' && textOf(node).startsWith('Assign'))[0]
assert.ok(assign, 'Assign button')
assert.equal(assign.props.disabled, false)

Expand Down Expand Up @@ -2465,7 +2465,7 @@
findAll(view.tree, node => node.props?.className === 'wf-row wf-assign-row')[0].props.onClick()
await settle()
assert.ok(textOf(view.tree).includes('No open tasks on the Tasks board.'))
const assign = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Assign')[0]
const assign = findAll(view.tree, node => node.type === 'button' && textOf(node).startsWith('Assign'))[0]
assert.equal(assign.props.disabled, true)
assign.props.onClick()
await settle()
Expand Down Expand Up @@ -2506,7 +2506,7 @@
assert.ok(text.includes('host is busy'), 'the host message is shown')
assert.ok(findAll(view.tree, node => String(node.props?.className ?? '').includes('wf-err')).length > 0)

const assign = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Assign')[0]
const assign = findAll(view.tree, node => node.type === 'button' && textOf(node).startsWith('Assign'))[0]
assert.equal(assign.props.disabled, true, 'Assign is not clickable while the load failed')
assign.props.onClick()
await settle()
Expand Down Expand Up @@ -2542,11 +2542,11 @@
await settle()
findAll(view.tree, node => node.props?.className === 'wf-row wf-assign-row')[0].props.onClick()
await settle()
const early = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Assign')[0]
const early = findAll(view.tree, node => node.type === 'button' && textOf(node).startsWith('Assign'))[0]
assert.equal(early.props.disabled, true, 'no click before the cards are there')
release()
await settle()
const later = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Assign')[0]
const later = findAll(view.tree, node => node.type === 'button' && textOf(node).startsWith('Assign'))[0]
assert.equal(later.props.disabled, false, 'clickable once a card is selected')
view.unmount()
} finally {
Expand Down Expand Up @@ -3009,7 +3009,7 @@
// The hint's text is read when the drag is refused, so the host translation is put in place
// after the drawer mounted: a stub standing in for the whole host at mount time is read by the
// page's own reads (avatars, tool links) and hides the workflow list.
globalThis.__flybot__ = { ...(previous ?? {}), t: (key) => (key === 'Tasks' ? '任务' : key) }

Check warning on line 3012 in plugins/workflow/test/client.test.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
try {
nodeAt('r1').props.onPointerDown({ clientX: 40, clientY: 160, target: { dataset: {} } })
canvas().props.onPointerMove({ clientX: 96, clientY: 208 })
Expand Down Expand Up @@ -4075,7 +4075,7 @@
}

const dialogOf = view => findAll(view.tree, node => node.props?.role === 'dialog')[0]
const assignOf = tree => findAll(tree, node => node.type === 'button' && textOf(node) === 'Assign')[0]
const assignOf = tree => findAll(tree, node => node.type === 'button' && textOf(node).startsWith('Assign'))[0]

async function openAssign(view) {
findAll(view.tree, node => node.props?.className === 'wf-row wf-assign-row')[0].props.onClick()
Expand Down Expand Up @@ -4403,3 +4403,74 @@
view.unmount()
})
})

// #2254: step 2 can hold two lanes with one name from two Projects, and the #2241 badge sits on
// the target row only. The confirm button read "Assign" alone, so the last thing read before the
// click could not say which lane the card goes to. It now repeats the picked target — the lane
// with its Project, or the Bot — and the submit body stays exactly { workflowId, taskId, title }.
const P463_LANES = [
{ ...delivery(), id: 'lane-alpha', name: 'Release lane', projectId: 'project-alpha' },
{ ...delivery(), id: 'lane-beta', name: 'Release lane', projectId: 'project-beta' },
]

function p463Call(outcome = () => ({ ok: true, workflows: P463_LANES })) {
const calls = []
const call = async (name, body) => {
calls.push({ name, body })
if (name === 'workflow/state') return { workflows: P463_LANES, templates: [] }
if (name === 'workflow/assign') return outcome()
return { ok: true, workflows: P463_LANES }
}
return { call, calls }
}

test('#2254: two lanes with one name: the confirm button names the picked lane and its Project', async () => {
const { call } = p463Call()
await withLaneHost(async () => {
const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} })
await settle()
let dialog = await openAssign(view)
const before = textOf(assignOf(dialog))
assert.ok(before.includes('Alpha'), `the default lane's Project is on the button, saw ${before}`)
assert.equal(before.includes('Beta'), false, `and the other lane's Project is not, saw ${before}`)
targetsOf(dialog)[1].props.onClick()
await settle()
dialog = dialogOf(view)
const after = textOf(assignOf(dialog))
assert.ok(after.includes('Beta'), `the second lane's Project replaces it, saw ${after}`)
assert.equal(after.includes('Alpha'), false, `the first lane's Project is gone, saw ${after}`)
assert.ok(after.includes('Release lane'), `the button still names the lane, saw ${after}`)
view.unmount()
})
})

test('#2254: a Bot target names the Bot on the confirm button', async () => {
const { call } = assignCall()
await withTaskHost(async () => {
const view = mount(client.Section, { roster: { bots: [{ id: 'b1', name: 'Writer Bot' }] }, call, actions: {} })
await settle()
const dialog = await openAssign(view)
targetsOf(dialog).find(node => textOf(node).includes('Writer Bot')).props.onClick()
await settle()
assert.equal(textOf(assignOf(dialogOf(view))), 'Assign to Writer Bot', 'the Bot is what the button names')
view.unmount()
})
})

test('#2254: the named confirm button posts the same assign body', async () => {
const { call, calls } = p463Call()
await withLaneHost(async () => {
const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} })
await settle()
const dialog = await openAssign(view)
targetsOf(dialog)[1].props.onClick()
await settle()
assert.ok(textOf(assignOf(dialogOf(view))).includes('Beta'), 'precondition: the second lane is the target')
assignOf(dialogOf(view)).props.onClick()
await settle()
const posts = calls.filter(item => item.name === 'workflow/assign')
assert.equal(posts.length, 1, 'one assign is posted')
assert.deepEqual(posts[0].body, { workflowId: 'lane-beta', taskId: 'task-live-1', title: 'Real board card one' }, 'the submit body is unchanged')
view.unmount()
})
})
Loading