Files
fusion/packages/dashboard/app/utils/quickAddStart.ts
Fusion Agent 612195fde6 FN-022: add workflow selection and start action
Add workflow-aware task creation controls and a guarded Start action for quick task intake.

- Add workflow selection and resolved board metadata loading to New Task modal.
- Support starting eligible tasks in their workflow destination with clear success or partial-failure feedback.
- Wire move-task handling through modal callers and cover workflow/start behavior with tests and documentation.

Files changed:
 .changeset/fn-022-new-task-workflow-start.md       |   7 +
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/components/AppModals.tsx    |   1 +
 packages/dashboard/app/components/NewTaskModal.tsx | 158 +++++++++++++++--
 .../dashboard/app/components/QuickEntryBox.tsx     |   5 +-
 packages/dashboard/app/components/TaskForm.tsx     |  28 ++-
 .../app/components/__tests__/AppModals.test.tsx    |  33 +++-
 .../app/components/__tests__/NewTaskModal.test.tsx | 192 ++++++++++++++++++++-
 .../app/utils/__tests__/quickAddStart.test.ts      |  16 +-
 packages/dashboard/app/utils/quickAddStart.ts      |  15 ++
 10 files changed, 441 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-022

Fusion-Task-Lineage: ce794b44-3c31-4bfc-a259-ecfd78531be1

Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-20 03:55:38 +00:00

108 lines
5.6 KiB
TypeScript

import type { BoardWorkflowDefinition } from "../api";
export type ValidatedQuickAddWorkflow = BoardWorkflowDefinition;
/**
* FNXC:QuickAddStart 2026-07-22-16:10:
* Start is exposed only after a complete runtime validation, rather than trusting stale
* dashboard metadata. This keeps touch/pen long-press and mouse right-click affordances
* unavailable unless the submitted workflow can prove its ordered routing columns.
*/
export function validateQuickAddStartWorkflow(value: unknown): ValidatedQuickAddWorkflow | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const workflow = value as Partial<BoardWorkflowDefinition>;
if (typeof workflow.id !== "string" || !workflow.id.trim() || workflow.id === "__all_workflows__") return null;
if (!Array.isArray(workflow.columns) || workflow.columns.length === 0) return null;
const ids = new Set<string>();
for (const column of workflow.columns) {
if (!column || typeof column !== "object" || Array.isArray(column)) return null;
if (typeof column.id !== "string" || !column.id.trim() || ids.has(column.id)) return null;
if (!column.flags || typeof column.flags !== "object" || Array.isArray(column.flags)) return null;
ids.add(column.id);
}
return workflow as ValidatedQuickAddWorkflow;
}
function visibleColumns(workflow: ValidatedQuickAddWorkflow) {
return workflow.columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard);
}
/*
* FNXC:QuickAddStart 2026-07-31-23:51:
* Start is reserved for a workflow's first visible manual/waiting intake lane. `hold` alone is
* insufficient because the canonical merged Planning column carries both `intake` and `hold` while
* auto-triaging. Mirror TaskCard.showStartAction's server-derived `manualIntake` fact so absent
* older payload metadata fails closed without exposing an unusable Quick Add action.
*/
export function workflowSupportsQuickAddStart(workflow: ValidatedQuickAddWorkflow | null): boolean {
if (!workflow) return false;
if (workflow.id === "builtin:coding-ideas") return true;
return visibleColumns(workflow)[0]?.flags.manualIntake === true;
}
/**
* FNXC:QuickAddStart 2026-07-22-16:10:
* Custom hold-workflow Start promotion uses the returned task's actual column and moves forward only to
* a later working column. Missing data, holds, complete lanes, or no later destination are
* successful create-only outcomes; Quick Add never guesses `todo` or moves backwards.
*/
export function resolveQuickAddStartTargetColumn(workflow: ValidatedQuickAddWorkflow, createdColumn: unknown): string | null {
if (typeof createdColumn !== "string" || !createdColumn.trim()) return null;
const columns = visibleColumns(workflow);
const createdIndex = columns.findIndex((column) => column.id === createdColumn);
if (createdIndex < 0) return null;
for (const column of columns.slice(createdIndex + 1)) {
if (!column.flags.intake && !column.flags.hold && !column.flags.complete) return column.id;
}
return null;
}
/**
* FNXC:QuickAddStart 2026-07-22-17:45:
* Coding (Ideas) Start must atomically create in Todo, while ordinary Save/Enter still create
* in Ideas. Prove the destination from the captured, ordered visible definition: Ideas must
* precede one non-intake, non-complete Todo lane. Missing, hidden, reordered, or malformed
* metadata fails closed rather than guessing a transition.
*/
/*
FNXC:NewTaskWorkflowStart 2026-08-19-00:17:
The modal and QuickEntryBox must hide Start when the metadata proves manual intake but no later
working lane exists. Resolve that proof from the same ordered workflow snapshot used for the actual
create or move, retaining the Coding (Ideas) atomic-column special case.
*/
export function resolveQuickAddStartWorkflowTarget(workflow: ValidatedQuickAddWorkflow | null): string | null {
if (!workflow || !workflowSupportsQuickAddStart(workflow)) return null;
const initialColumn = resolveQuickAddStartInitialColumn(workflow);
if (initialColumn) return initialColumn;
if (workflow.id === "builtin:coding-ideas") return null;
const intakeColumn = visibleColumns(workflow)[0]?.id;
return intakeColumn ? resolveQuickAddStartTargetColumn(workflow, intakeColumn) : null;
}
export function resolveQuickAddStartInitialColumn(workflow: ValidatedQuickAddWorkflow): string | null {
if (workflow.id !== "builtin:coding-ideas") return null;
const columns = visibleColumns(workflow);
/*
DELIBERATE-LITERAL — these are ONE NAMED BUILTIN's own declared ids, not a lifecycle guard.
Census false positive. The function returns null two lines above for any workflow other than
`builtin:coding-ideas`, so `ideas` and `todo` here are that workflow's OWN column ids, read from
its captured definition — there is no other board whose vocabulary could differ. A custom workflow
never reaches this line.
The lifecycle question this function does ask IS already trait-resolved: the destination is
rejected below unless `!todo.flags.intake && !todo.flags.complete`. Replacing the id lookups with
role resolution would not make it more correct — it would make it match a DIFFERENT column in the
one workflow this is scoped to.
*/
const ideasIndex = columns.findIndex((column) => column.id === "ideas");
/* DELIBERATE-LITERAL — see the note above: this is `builtin:coding-ideas`'s OWN `todo` id, and the
function has already returned null for every other workflow. */
const todoIndex = columns.findIndex((column) => column.id === "todo");
if (ideasIndex < 0 || todoIndex <= ideasIndex) return null;
const todo = columns[todoIndex];
if (!todo || todo.flags.intake || todo.flags.complete) return null;
return todo.id;
}