diff --git a/changelog.d/2245-assign-requires-target.fixed.md b/changelog.d/2245-assign-requires-target.fixed.md new file mode 100644 index 000000000..65d7803df --- /dev/null +++ b/changelog.d/2245-assign-requires-target.fixed.md @@ -0,0 +1 @@ +**Hand a task to a Workflow** keeps **Assign** disabled while no target is picked: with no Workflow defined the dialog no longer looks ready and no longer posts an empty `workflowId` that the host answers with "No such workflow". diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index e82c8edc5..c2c21decc 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -16,6 +16,8 @@ A host call on that page that throws, rejects, or never answers ends on the page A confirmation dialog the host refuses — Rename, Delete, Create, or Hand a task to a Workflow — keeps the dialog open and prints the host's own sentence inside the dialog as the **only** `role="alert"` for that refusal: while one of those four dialogs holds the sentence the section draws no `.wf-err` copy of it, because a live region behind the mask is still read out and one refusal would be announced twice (#2193, #2202, #2237). Opening any of the four clears the previous refusal; closing the assign dialog drops its refusal rather than leaving it on the page. A refusal with no dialog open still reports once on the page, and the success path is unchanged. +With no Workflow defined, step 2 of **Hand a task to a Workflow** has no Workflow to default to and **Assign** stays disabled until a target is picked (the Bots in the same list are still offered), so a click can no longer post an empty `workflowId` the host answers with "No such workflow" (#2245). + The **Add link** row applies the drawer's pair rule before it saves (#2173): a pair the graph already lists is a no-op, another kind over that same pair names the kind already there instead of submitting, and the **To** list marks the targets the chosen node already reaches. The host still refuses a duplicated pair — the form is what stops producing one. A Condition (`when`) or Loop (`until`) link takes its output name from the **From** node's declared `outputs` (#2178): the field is a select over exactly those names, **Add link** waits for one, and a node that declares none keeps the text box for an older graph. The host still refuses a name it cannot honour — `cleanWhen` and `PORT_RE` are unchanged. diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 3db270c22..fd8c7a210 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -1433,7 +1433,10 @@ window.__ModuleLoader__.load({ if (taskId && tasks.some(task => task.id === taskId)) return setTaskId(tasks[0].id) }, [tasks, taskId]) - const canAssign = tasksStatus === 'ready' && tasks.some(task => task.id === taskId) + // #2245: with no Workflow defined `target` stays '', and the dialog used to light Assign + // anyway — the click then posted `workflowId: ''`, which the host answers with "No such + // workflow". A target has to exist before the button can be pressed. + const canAssign = tasksStatus === 'ready' && target !== '' && tasks.some(task => task.id === taskId) const seen = new Set() const bots = (roster?.bots ?? []).filter(bot => { if (!bot || bot.remote === true || !bot.id || !bot.name) return false diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index c8ca26db7..2459da33a 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -4345,3 +4345,61 @@ test('#2244: three Bots keep today\'s pick list and the bot:2 body', async () => } finally { ui.view.unmount() } }) }) + +// #2245 / P-461: with no Workflow defined step 2 has nothing to pick, so `target` stays ''. The +// dialog still lit **Assign** and posting it sent `workflowId: ''`, which the host answers with +// "No such workflow": a button that looks ready and can only fail. It now stays disabled until a +// target is picked — a Workflow, or a Bot through the same list. +function noLaneCall() { + const calls = [] + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: [], templates: [] } + return { ok: true, workflows: [] } + } + return { call, calls } +} + +const targetCards = dialog => findAll(dialog, node => String(node.props?.className ?? '').split(' ').includes('wf-tgt')) +const pickedTargets = dialog => targetCards(dialog).filter(node => String(node.props?.className ?? '').split(' ').includes('sel')) + +test('no Workflow yet: step 2 offers no target and Assign stays disabled', async () => { + const { call } = noLaneCall() + await withTaskHost(async () => { + const view = mount(client.Section, { roster: { bots: [{ id: 'b1', name: 'Writer Bot' }] }, call, actions: {} }) + await settle() + const dialog = await openAssign(view) + assert.ok(dialog, 'the dialog opens even though no Workflow exists') + assert.ok(targetCards(dialog).length > 0, 'step 2 still offers the Bots it can hand the task to') + assert.deepEqual(pickedTargets(dialog), [], 'nothing is selected: there is no Workflow to default to') + assert.equal(assignOf(dialog).props.disabled, true, 'so Assign must stay disabled') + view.unmount() + }) +}) + +test('no Workflow yet: clicking Assign posts nothing', async () => { + const { call, calls } = noLaneCall() + await withTaskHost(async () => { + const view = mount(client.Section, { roster: { bots: [{ id: 'b1', name: 'Writer Bot' }] }, call, actions: {} }) + await settle() + const dialog = await openAssign(view) + assignOf(dialog).props.onClick() + await settle() + assert.deepEqual(calls.filter(item => item.name === 'workflow/assign').map(item => item.body), [], 'a click with no target must not reach the host') + assert.ok(dialogOf(view), 'and the dialog stays open') + view.unmount() + }) +}) + +test('positive control: with a Workflow the first one is picked and Assign is live', async () => { + const graph = { ...delivery(), id: 'lane-a', name: 'Lane A' } + const { call } = assignCall(graph, () => ({ ok: true, workflows: [graph] })) + await withTaskHost(async () => { + const view = mount(client.Section, { roster: { bots: [{ id: 'b1', name: 'Writer Bot' }] }, call, actions: {} }) + await settle() + const dialog = await openAssign(view) + assert.equal(pickedTargets(dialog).length, 1, 'the first Workflow is picked by default') + assert.equal(assignOf(dialog).props.disabled, false, 'and Assign is live') + view.unmount() + }) +})