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/2241-hand-a-task-project-badge.added.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 8 additions & 3 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Loading