From 65914e2fa53c26c0bc43ae3420b379c7ea8bf1c2 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:04:27 +0800 Subject: [PATCH] workflow: Hand a task names each lane's Project Two lanes from different Projects read the same inside the assign dialog: the target row carried the name and the chain, never the Project the list row already draws. The dialog now repeats that badge from the same projects listing, and a lane with no Project draws none. --- .../2241-hand-a-task-project-badge.added.md | 1 + docs/subsystems/workflow.md | 2 +- plugins/workflow/client.js | 11 ++- plugins/workflow/test/client.test.js | 78 +++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 changelog.d/2241-hand-a-task-project-badge.added.md diff --git a/changelog.d/2241-hand-a-task-project-badge.added.md b/changelog.d/2241-hand-a-task-project-badge.added.md new file mode 100644 index 000000000..d4be3d645 --- /dev/null +++ b/changelog.d/2241-hand-a-task-project-badge.added.md @@ -0,0 +1 @@ +Hand a task to a Workflow now names each lane's Project on its target row, so two Workflows that share a name but belong to different Projects are told apart before a card is handed over; a lane with no Project draws no badge (#2241). diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 15d2373a3..fe0a1bd4c 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -131,6 +131,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 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). 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 7091a2b51..986409649 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -1414,7 +1414,10 @@ window.__ModuleLoader__.load({ h('button', { type: 'button', className: 'wf-btn wf-danger', onClick: onConfirm }, tr('Delete'))))) } - function AssignModal({ workflows, tasks, tasksStatus, tasksError, error, roster, workflowId, onClose, onConfirm, onRetry }) { + function AssignModal({ workflows, tasks, tasksStatus, tasksError, error, projects, roster, workflowId, onClose, onConfirm, onRetry }) { + // #2241: two lanes drawn from different Projects read the same inside this dialog, so the + // target row repeats the badge the list row already draws — the same `projects` listing. + const projectName = (id) => (projects ?? []).find(project => project.id === id)?.name ?? '' const [taskId, setTaskId] = useState(tasks[0]?.id ?? '') const [target, setTarget] = useState(workflowId || workflows[0]?.id || '') // #1457: the dialog opens before loadTasks() answers, so the first client render can have no @@ -1464,7 +1467,9 @@ window.__ModuleLoader__.load({ }, h('span', { className: 'wf-tgt-thumb' }, h(MiniFlowchart, { workflow: item, roster, compact: true })), h('span', null, - h('span', { className: 'wf-t1' }, item.name, h('span', { className: 'wf-badge' }, tr('Workflow'))), + h('span', { className: 'wf-t1' }, item.name, + h('span', { className: 'wf-badge' }, tr('Workflow')), + projectName(item.projectId) ? h('span', { className: 'wf-proj' }, projectName(item.projectId)) : null), h('span', { className: 'wf-chain' }, item.chain)), h('span', { className: 'wf-rad' }))), bots.map(bot => h('button', { @@ -2293,7 +2298,7 @@ window.__ModuleLoader__.load({ onRematch: rematchTask, })) : null, assignOpen ? portal(h(AssignModal, { - workflows, tasks, tasksStatus, tasksError, error, roster, workflowId: openId || workflows[0]?.id, + workflows, tasks, tasksStatus, tasksError, error, projects, roster, workflowId: openId || workflows[0]?.id, // #2237: this dialog owns its refusal, so closing it drops the sentence rather than // leaving it on the page for the next open to inherit. onClose: () => { setAssignOpen(false); setError('') }, onConfirm: confirmAssign, onRetry: loadTasks, diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index debf8a53c..71308c23b 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -4137,3 +4137,81 @@ test('#2237: re-opening the assign dialog after a refusal starts clean', async ( view.unmount() }) }) + +// #2241: inside Hand a task to a Workflow two lanes from different Projects looked exactly alike — +// the target row carried the name and the chain, never the Project the list row already draws +// (`wf-proj`, from the same `projects` listing). +const P450_LANES = [ + { ...delivery(), id: 'lane-alpha', name: 'Lane A', projectId: 'project-alpha' }, + { ...delivery(), id: 'lane-beta', name: 'Lane B', projectId: 'project-beta' }, + { ...delivery(), id: 'lane-plain', name: 'Lane C' }, +] +const P450_PROJECTS = [{ id: 'project-alpha', name: 'Alpha' }, { id: 'project-beta', name: 'Beta' }] + +async function withLaneHost(fn) { + const prev = globalThis.__flybot__ + globalThis.__flybot__ = { + host: async (channel) => (channel === 'tasks' + ? [ASSIGN_TASK] + : channel === 'projects' ? { projects: P450_PROJECTS } : []), + } + try { return await fn() } finally { globalThis.__flybot__ = prev } +} + +function laneCall() { + const calls = [] + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: P450_LANES, templates: [] } + return { ok: true, workflows: P450_LANES } + } + return { call, calls } +} + +const targetsOf = tree => findAll(tree, node => String(node.props?.className ?? '').split(' ').includes('wf-tgt')) + +test('#2241: the Hand a task dialog names each lane\'s Project', async () => { + const { call } = laneCall() + await withLaneHost(async () => { + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + const dialog = await openAssign(view) + assert.ok(dialog, 'the assign dialog opens') + const names = targetsOf(dialog).map(textOf) + assert.equal(names.length, 3, `one target per lane, saw ${names.join(' | ')}`) + assert.ok(names[0].includes('Alpha'), `the first lane names its Project, saw ${names[0]}`) + assert.ok(names[1].includes('Beta'), `the second lane names its Project, saw ${names[1]}`) + view.unmount() + }) +}) + +test('#2241: a lane with no Project carries no Project badge', async () => { + const { call } = laneCall() + await withLaneHost(async () => { + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + const dialog = await openAssign(view) + const names = targetsOf(dialog).map(textOf) + assert.equal(names[2].includes('Alpha'), false, `an unassigned lane names no other Project, saw ${names[2]}`) + assert.equal(names[2].includes('Beta'), false, `an unassigned lane names no other Project, saw ${names[2]}`) + assert.equal(findAll(dialog, node => String(node.props?.className ?? '').split(' ').includes('wf-proj')).length, 2, 'only the two lanes that have a Project draw the badge') + view.unmount() + }) +}) + +test('#2241: picking a lane still posts the same assign body', async () => { + const { call, calls } = laneCall() + await withLaneHost(async () => { + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + const dialog = await openAssign(view) + targetsOf(dialog)[0].props.onClick() + await settle() + 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-alpha', taskId: 'task-live-1', title: 'Real board card one' }, 'the submit body is unchanged') + view.unmount() + }) +})