diff --git a/changelog.d/2254-hand-a-task-names-its-target.changed.md b/changelog.d/2254-hand-a-task-names-its-target.changed.md new file mode 100644 index 00000000..69c44240 --- /dev/null +++ b/changelog.d/2254-hand-a-task-names-its-target.changed.md @@ -0,0 +1 @@ +**Hand a task to a Workflow** now names the picked target on its confirm button — `Assign to · `, 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)) diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index c2c21dec..55260ae3 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -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 · `, or `Assign to ` 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. diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index fd8c7a21..ad1eba4d 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -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': '同时运行', @@ -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() } }, @@ -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 }) { diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 2459da33..0ee353e9 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -2432,7 +2432,7 @@ test('#1457: the assign dialog selects the first loaded card and Assign posts it 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) @@ -2465,7 +2465,7 @@ test('#1457: an empty board keeps Assign disabled, so the click is not a silent 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() @@ -2506,7 +2506,7 @@ test('#1458: a failed Tasks load shows the error and a Retry, never the empty-bo 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() @@ -2542,11 +2542,11 @@ test('#1458: Assign stays disabled while the Tasks board is still loading', asyn 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 { @@ -4075,7 +4075,7 @@ function assignCall(graph = { ...delivery(), id: 'lane-a', name: 'Lane A' }, out } 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() @@ -4403,3 +4403,74 @@ test('positive control: with a Workflow the first one is picked and Assign is li 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() + }) +})