FN-196: restore the New Task modal Start button
Restore the New Task modal's discoverable Start action with validated workflow routing. - Keep Start visible but disabled until a description is entered. - Support atomic Coding (Ideas) creation and eligible manual-intake promotion. - Track Start submission state and expand regression coverage and documentation. Files changed: .changeset/fn-196-new-task-modal-start.md | 7 + docs/dashboard-guide.md | 3 + packages/dashboard/app/components/NewTaskModal.tsx | 29 ++- packages/dashboard/app/components/TaskForm.tsx | 5 + .../app/components/__tests__/NewTaskModal.test.tsx | 219 +++++++++++++++++++-- .../app/components/__tests__/TaskForm.test.tsx | 21 +- 6 files changed, 263 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-196 Fusion-Task-Lineage: f3b7ff36-8a41-4189-b1c2-6dcf1f18287a Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-196-new-task-modal-start.md
Normal file
7
.changeset/fn-196-new-task-modal-start.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Show the New Task Start button with quick entry parity.
|
||||
category: fix
|
||||
dev: Aligns NewTaskModal and TaskForm with quickAddStart eligibility parity.
|
||||
@@ -728,6 +728,9 @@ FNXC:QuickAddStart 2026-07-24-11:20: Start is now a visible action-row button fo
|
||||
|
||||
Quick Add shows a visible **Start** button in its action row — next to Models/Agent, beside the right-aligned Save — only when the exact selected workflow has complete runtime metadata: a real non-sentinel id, nonempty ordered columns with unique nonblank ids, and an object `flags` value on every column. It is eligible only for validated `builtin:coding-ideas` or a validated workflow whose first visible column is a manual/waiting intake (`manualIntake`); a hold alone is not enough because auto-triaging Planning lanes can also hold cards. Start snapshots that exact definition and id before duplicate confirmation and submits it unchanged; later selection or metadata refreshes cannot alter routing. For Coding (Ideas), Start proves that visible ordered metadata places a non-intake, non-complete **Todo** after **Ideas**, then includes Todo in the original Board/List create request—there is no follow-up move. Missing, hidden, malformed, reordered, or ambiguous metadata renders no Start button at all, so Save stays the only create affordance there. With an empty description Start is visible but disabled. Ordinary Save and Enter still create in Ideas. Other eligible manual-intake workflows retain their matching-returned-task promotion through the host Board/List move path only to the first later visible working column, skipping intake, hold, and complete columns; no forward target also remains create-only.
|
||||
|
||||
<!-- FNXC:NewTaskWorkflowStart 2026-08-27-10:50: FN-196 restores the New Task dialog's visible-but-disabled Start affordance whenever the same validated metadata proves an atomic create-time column or a move target. Ineligible or malformed metadata renders no Start shell. -->
|
||||
The full **New Task** dialog exposes the same **Start** affordance under the same eligibility rules as Quick Add: it is visible but disabled until a description is entered, and absent entirely for ineligible or malformed workflow metadata. Coding (Ideas)-shaped workflows create atomically in their proven working column; other eligible manual-intake workflows promote once through the supplied move path.
|
||||
|
||||
Quick Add's paperclip accepts supported photos and files: PNG, JPEG, GIF, WebP, MP4, WebM, QuickTime video, plain text, Markdown, JSON, YAML, TOML, CSV, and XML. Select files, paste them into the Quick Add input, or drag them onto the box; pending attachments upload to the newly created task sequentially. Image attachments show compact previews that open in a movable, resizable window (a full-screen sheet on mobile); file attachments show an accessible filename and remove action without an image-open control. Unsupported selections are ignored, and if one upload fails after task creation, the task remains created while Quick Add reports the filenames that need retrying. The same bottom action row places the GitHub tracking override beside the paperclip; Priority is an icon-only control whose glyph changes by selected level (down arrow for low, flag for normal, up arrow for high, alert for urgent) and is color-coded by urgency (low blue/info, normal muted, high amber/warning, urgent red/error), and Fast is an icon-only lightning control. These icon-only controls keep accessible labels and the same create-payload behavior as the previous text chips.
|
||||
|
||||
Quick entry, inline quick-create, and the full **New Task** dialog all check for similar active tasks before creating. When possible duplicates exist, the warning lists each match by task description (falling back to title, then “No description”) and lets you open an existing task, cancel, or create anyway with the duplicates acknowledged.
|
||||
|
||||
@@ -377,6 +377,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
/*
|
||||
FNXC:NewTaskWorkflowStart 2026-08-27-10:50:
|
||||
FN-196 keeps Start visible during ordinary creation, so its in-flight label must track the
|
||||
submitted action rather than shared submit state. State, not the pending workflow ref, rerenders
|
||||
the button while duplicate acknowledgement preserves a pending Start label.
|
||||
*/
|
||||
const [startSubmitInFlight, setStartSubmitInFlight] = useState(false);
|
||||
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
|
||||
const [executorModel, setExecutorModel] = useState("");
|
||||
const [credentialInstanceId, setCredentialInstanceId] = useState<string | undefined>(undefined);
|
||||
@@ -588,12 +595,21 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
? boardWorkflows.workflows.find((workflow) => workflow.id === resolvedStartWorkflowId)
|
||||
: undefined;
|
||||
const validatedStartWorkflow = validateQuickAddStartWorkflow(startWorkflowCandidate);
|
||||
const startInitialColumn = validatedStartWorkflow
|
||||
? resolveQuickAddStartInitialColumn(validatedStartWorkflow)
|
||||
: null;
|
||||
const startWorkflowTarget = resolveQuickAddStartWorkflowTarget(validatedStartWorkflow);
|
||||
/*
|
||||
FNXC:NewTaskWorkflowStart 2026-08-27-10:50:
|
||||
FN-196 keeps Start hidden only when server-derived workflow metadata cannot prove a destination.
|
||||
Eligible workflows render a disabled empty-description affordance so it remains discoverable and
|
||||
matches Quick Add; an atomic initial column does not require a follow-up move callback.
|
||||
*/
|
||||
const canStartTask = Boolean(
|
||||
validatedStartWorkflow
|
||||
&& workflowSupportsQuickAddStart(validatedStartWorkflow)
|
||||
&& startWorkflowTarget
|
||||
&& onMoveTask,
|
||||
&& (startInitialColumn || onMoveTask),
|
||||
);
|
||||
const canStartTaskNow = canStartTask && Boolean(description.trim()) && !isSubmitting;
|
||||
|
||||
@@ -685,6 +701,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setInitialDefaultValues({ executorModel: "", validatorModel: "", githubTrackingEnabled: false });
|
||||
setGithubRepoOverride("");
|
||||
setDuplicateMatches(null);
|
||||
setStartSubmitInFlight(false);
|
||||
githubGeneratedDescriptionRef.current = "";
|
||||
}, [pendingImages]);
|
||||
|
||||
@@ -859,6 +876,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const trimmedDesc = description.trim();
|
||||
if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection) return;
|
||||
|
||||
setStartSubmitInFlight(Boolean(startWorkflow));
|
||||
setIsSubmitting(true);
|
||||
let keepSubmittingForDuplicateChoice = false;
|
||||
try {
|
||||
@@ -880,6 +898,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
if (!keepSubmittingForDuplicateChoice) {
|
||||
pendingWorkflowSelectionRef.current = undefined;
|
||||
pendingStartWorkflowRef.current = null;
|
||||
setStartSubmitInFlight(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
@@ -906,6 +925,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
pendingWorkflowSelectionRef.current = undefined;
|
||||
pendingStartWorkflowRef.current = null;
|
||||
setDuplicateMatches(null);
|
||||
setStartSubmitInFlight(false);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -923,6 +943,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
addToast(getErrorMessage(err) || t("newTaskModal.failedToCreate", "Failed to create task"), "error");
|
||||
} finally {
|
||||
pendingWorkflowSelectionRef.current = undefined;
|
||||
pendingStartWorkflowRef.current = null;
|
||||
setStartSubmitInFlight(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, duplicateMatches, performCreate, addToast, t]);
|
||||
@@ -931,6 +953,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
pendingWorkflowSelectionRef.current = undefined;
|
||||
pendingStartWorkflowRef.current = null;
|
||||
setDuplicateMatches(null);
|
||||
setStartSubmitInFlight(false);
|
||||
setIsSubmitting(false);
|
||||
}, []);
|
||||
|
||||
@@ -1259,8 +1282,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
onCreateSubmit={() => { void handleSubmit(); }}
|
||||
createSubmitLabel={isSubmitting ? t("newTaskModal.creating", "Creating...") : t("newTaskModal.createTask", "Create Task")}
|
||||
createSubmitDisabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
|
||||
onStartSubmit={canStartTask && (Boolean(description.trim()) || isSubmitting) ? handleStartSubmit : undefined}
|
||||
startSubmitLabel={isSubmitting ? t("newTaskModal.starting", "Starting...") : t("newTaskModal.startTask", "Start")}
|
||||
onStartSubmit={canStartTask ? handleStartSubmit : undefined}
|
||||
startSubmitLabel={startSubmitInFlight ? t("newTaskModal.starting", "Starting...") : t("newTaskModal.startTask", "Start")}
|
||||
startSubmitDisabled={!canStartTaskNow}
|
||||
renderBelowPrimary={<>{quickFields}{workspaceRepositories.length > 0 && <fieldset className="form-group" data-testid="repository-scope-selector"><legend>{t("newTaskModal.repositoryScope", "Repository scope")}</legend><p className="form-hint">{t("newTaskModal.repositoryScopeHint", "Select the repositories this task intends to change.")}</p>{workspaceRepositories.map((repository) => <label key={repository} className="checkbox-label"><input type="checkbox" checked={selectedRepositoryScope.includes(repository)} onChange={() => setSelectedRepositoryScope((current) => current.includes(repository) ? current.filter((item) => item !== repository) : [...current, repository])} />{repository}</label>)}</fieldset>}</>}
|
||||
hideDependencies={true}
|
||||
|
||||
@@ -184,6 +184,11 @@ export interface TaskFormProps {
|
||||
* Start is supplied only by a host that has validated server-derived manual-intake metadata and
|
||||
* a safe destination. Keeping this optional prevents an ineligible workflow from leaving an
|
||||
* empty button shell in either the desktop modal or mobile sheet.
|
||||
*
|
||||
* FNXC:NewTaskWorkflowStart 2026-08-27-10:50:
|
||||
* FN-196 requires an eligible host to pass this callback even before description entry. The
|
||||
* visible disabled button matches Quick Add's discoverable Start contract; only ineligible
|
||||
* metadata omits the callback and leaves no action-row shell.
|
||||
*/
|
||||
onStartSubmit?: () => void;
|
||||
startSubmitLabel?: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ComponentProps } from "react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { NewTaskModal } from "../NewTaskModal";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
import { apiFetchGitHubIssues, apiFetchGitHubPulls, checkDuplicateTasks, fetchAgents, fetchBoardWorkflows, fetchGitRemotes, type BoardWorkflowsPayload } from "../../api";
|
||||
import { apiFetchGitHubIssues, apiFetchGitHubPulls, checkDuplicateTasks, fetchAgents, fetchBoardWorkflows, fetchGitRemotes, type BoardWorkflowsPayload, type DuplicateMatch } from "../../api";
|
||||
import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
|
||||
import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow";
|
||||
import { GITHUB_SETUP_WARNING_DELAY_MS, GITHUB_SETUP_WARNING_MISSING_SINCE_KEY } from "../../hooks/useGithubSetupWarningDelay";
|
||||
@@ -1766,8 +1766,7 @@ describe("NewTaskModal", () => {
|
||||
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce(codingIdeasBoardPayload);
|
||||
const onCreateTask = vi.fn().mockResolvedValue({ ...makeTask("FN-START"), column: "todo", workflowId: "builtin:coding-ideas" });
|
||||
const onMoveTask = vi.fn();
|
||||
const { props } = renderNewTaskModal({ onCreateTask, onMoveTask });
|
||||
const { props } = renderNewTaskModal({ onCreateTask });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("builtin:coding-ideas");
|
||||
@@ -1781,7 +1780,6 @@ describe("NewTaskModal", () => {
|
||||
column: "todo",
|
||||
description: "Start this idea",
|
||||
})));
|
||||
expect(onMoveTask).not.toHaveBeenCalled();
|
||||
expect(props.addToast).toHaveBeenCalledWith("Queued FN-START for planning", "success");
|
||||
});
|
||||
|
||||
@@ -1822,17 +1820,27 @@ describe("NewTaskModal", () => {
|
||||
expect(onMoveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exposes the same eligible Start action in the desktop floating host", async () => {
|
||||
it("keeps the eligible Start node mounted while editing in the desktop floating host", async () => {
|
||||
mockViewportMode = "desktop";
|
||||
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce(codingIdeasBoardPayload);
|
||||
renderNewTaskModal({ onMoveTask: vi.fn() });
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("builtin:coding-ideas");
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Desktop start" } });
|
||||
expect(await screen.findByTestId("task-form-inline-start")).toBeVisible();
|
||||
expect(screen.getByTestId("task-form-description-actions")).toContainElement(screen.getByTestId("task-form-inline-start"));
|
||||
const start = await screen.findByTestId("task-form-inline-start");
|
||||
const description = screen.getByPlaceholderText("What needs to be done?");
|
||||
expect(start).toBeVisible();
|
||||
expect(start).toBeDisabled();
|
||||
expect(screen.getByTestId("task-form-description-actions")).toContainElement(start);
|
||||
|
||||
fireEvent.change(description, { target: { value: "Start from desktop" } });
|
||||
expect(screen.getByTestId("task-form-inline-start")).toBeEnabled();
|
||||
expect(screen.getByTestId("task-form-inline-start")).toBe(start);
|
||||
|
||||
fireEvent.change(description, { target: { value: "" } });
|
||||
expect(screen.getByTestId("task-form-inline-start")).toBeDisabled();
|
||||
expect(screen.getByTestId("task-form-inline-start")).toBe(start);
|
||||
});
|
||||
|
||||
it("creates a custom manual-intake task before moving it once to the validated target", async () => {
|
||||
@@ -1896,17 +1904,124 @@ describe("NewTaskModal", () => {
|
||||
expect(onMoveTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not leave a Start shell for an empty description", async () => {
|
||||
it("renders Start disabled while empty and preserves its node while editing", async () => {
|
||||
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce(codingIdeasBoardPayload);
|
||||
renderNewTaskModal({ onMoveTask: vi.fn() });
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("builtin:coding-ideas");
|
||||
const start = await screen.findByTestId("task-form-inline-start");
|
||||
expect(start).toBeDisabled();
|
||||
|
||||
const description = screen.getByPlaceholderText("What needs to be done?");
|
||||
fireEvent.change(description, { target: { value: "Start this idea" } });
|
||||
expect(screen.getByTestId("task-form-inline-start")).toBeEnabled();
|
||||
expect(screen.getByTestId("task-form-inline-start")).toBe(start);
|
||||
|
||||
fireEvent.change(description, { target: { value: "" } });
|
||||
expect(screen.getByTestId("task-form-inline-start")).toBeDisabled();
|
||||
expect(screen.getByTestId("task-form-inline-start")).toBe(start);
|
||||
});
|
||||
|
||||
it("keeps Start disabled for whitespace-only descriptions", async () => {
|
||||
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce(codingIdeasBoardPayload);
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("builtin:coding-ideas");
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: " " } });
|
||||
expect(await screen.findByTestId("task-form-inline-start")).toBeDisabled();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:NewTaskWorkflowStart 2026-08-27-11:04:
|
||||
FN-196's no-shell contract applies to every modal host. The shared action row deliberately
|
||||
retains other controls, so these checks fence the optional Start slot itself rather than treating
|
||||
an icon-only Fast control as a phantom Start button.
|
||||
*/
|
||||
function expectNoStartActionShell() {
|
||||
const actions = screen.getByTestId("task-form-description-actions");
|
||||
expect(screen.queryByTestId("task-form-inline-start")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Start" })).toBeNull();
|
||||
expect(actions.querySelector('button[data-testid="task-form-inline-start"]')).toBeNull();
|
||||
expect(actions.querySelector('button[aria-label="Start"], button[title="Start"]')).toBeNull();
|
||||
}
|
||||
|
||||
it.each(["mobile", "desktop"] as const)("leaves no Start shell on %s when a manual-intake workflow needs a missing move callback", async (viewportMode) => {
|
||||
mockViewportMode = viewportMode;
|
||||
await mockStartWorkflows("WF-MANUAL", "Manual intake");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [{
|
||||
id: "WF-MANUAL",
|
||||
name: "Manual intake",
|
||||
columns: [
|
||||
{ id: "waiting", name: "Waiting", flags: { intake: true, manualIntake: true } },
|
||||
{ id: "building", name: "Building", flags: {} },
|
||||
],
|
||||
}],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("WF-MANUAL");
|
||||
expectNoStartActionShell();
|
||||
});
|
||||
|
||||
it("keeps the label tied to the submitted action while creation is in flight", async () => {
|
||||
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce(codingIdeasBoardPayload);
|
||||
let resolveDuplicateCheck!: (matches: DuplicateMatch[]) => void;
|
||||
vi.mocked(checkDuplicateTasks).mockImplementationOnce(() => new Promise<DuplicateMatch[]>((resolve) => { resolveDuplicateCheck = resolve; }));
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("builtin:coding-ideas");
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Create normally" } });
|
||||
fireEvent.click(screen.getByTestId("task-form-inline-create"));
|
||||
expect(screen.getByTestId("task-form-inline-start")).toBeDisabled();
|
||||
expect(screen.getByTestId("task-form-inline-start")).toHaveAccessibleName("Start");
|
||||
resolveDuplicateCheck([]);
|
||||
await waitFor(() => expect(props.onCreateTask).toHaveBeenCalled());
|
||||
|
||||
let resolveStartDuplicateCheck!: (matches: DuplicateMatch[]) => void;
|
||||
vi.mocked(checkDuplicateTasks).mockImplementationOnce(() => new Promise<DuplicateMatch[]>((resolve) => { resolveStartDuplicateCheck = resolve; }));
|
||||
await waitFor(() => expect(screen.queryByTestId("task-form-inline-start")).toBeNull());
|
||||
await chooseWorkflowOption("builtin:coding-ideas");
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Start now" } });
|
||||
fireEvent.click(screen.getByTestId("task-form-inline-start"));
|
||||
expect(screen.getByTestId("task-form-inline-start")).toHaveAccessibleName("Starting...");
|
||||
resolveStartDuplicateCheck([]);
|
||||
});
|
||||
|
||||
it("resolves Start from the project default when no workflow is selected", async () => {
|
||||
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({ ...codingIdeasBoardPayload, defaultWorkflowId: "builtin:coding-ideas" });
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
expect(await screen.findByTestId("task-form-inline-start")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("waits for a usable board-workflow payload before exposing Start", async () => {
|
||||
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
|
||||
let resolveBoardWorkflows!: (payload: BoardWorkflowsPayload) => void;
|
||||
vi.mocked(fetchBoardWorkflows).mockImplementationOnce(() => new Promise<BoardWorkflowsPayload>((resolve) => { resolveBoardWorkflows = resolve; }));
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("builtin:coding-ideas");
|
||||
expect(screen.queryByTestId("task-form-inline-start")).toBeNull();
|
||||
resolveBoardWorkflows(codingIdeasBoardPayload);
|
||||
expect(await screen.findByTestId("task-form-inline-start")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hides Start when No workflow is selected", async () => {
|
||||
it.each(["mobile", "desktop"] as const)("leaves no Start shell on %s when No workflow is selected", async (viewportMode) => {
|
||||
mockViewportMode = viewportMode;
|
||||
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce(codingIdeasBoardPayload);
|
||||
renderNewTaskModal({ onMoveTask: vi.fn() });
|
||||
@@ -1914,10 +2029,11 @@ describe("NewTaskModal", () => {
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("__none__");
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "No workflow Start" } });
|
||||
expect(screen.queryByTestId("task-form-inline-start")).toBeNull();
|
||||
expectNoStartActionShell();
|
||||
});
|
||||
|
||||
it("hides Start for auto-triage and malformed workflow metadata", async () => {
|
||||
it.each(["mobile", "desktop"] as const)("leaves no Start shell on %s for selected auto-triage metadata", async (viewportMode) => {
|
||||
mockViewportMode = viewportMode;
|
||||
await mockStartWorkflows("builtin:coding", "Coding");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
|
||||
flagEnabled: true,
|
||||
@@ -1929,11 +2045,80 @@ describe("NewTaskModal", () => {
|
||||
}],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
const { onMoveTask } = renderNewTaskModal({ onMoveTask: vi.fn() }).props;
|
||||
renderNewTaskModal({ onMoveTask: vi.fn() });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
expect(screen.queryByTestId("task-form-inline-start")).toBeNull();
|
||||
expect(onMoveTask).not.toHaveBeenCalled();
|
||||
await chooseWorkflowOption("builtin:coding");
|
||||
expectNoStartActionShell();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:NewTaskWorkflowStart 2026-08-27-11:13:
|
||||
FN-196 keeps the All workflows dialog intentionally lane-agnostic: an unset picker resolves the
|
||||
project default, so an auto-triage default must not borrow a visible Start affordance from any
|
||||
Ideas lane that happened to open the dialog.
|
||||
*/
|
||||
it.each(["mobile", "desktop"] as const)("leaves no Start shell on %s when All workflows resolves to an auto-triage default", async (viewportMode) => {
|
||||
mockViewportMode = viewportMode;
|
||||
await mockStartWorkflows("builtin:coding", "Coding");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [{
|
||||
id: "builtin:coding",
|
||||
name: "Coding",
|
||||
columns: [{ id: "planning", name: "Planning", flags: { intake: true, manualIntake: false } }, { id: "done", name: "Done", flags: { complete: true } }],
|
||||
}],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
expectNoStartActionShell();
|
||||
});
|
||||
|
||||
it.each(["mobile", "desktop"] as const)("leaves no Start shell on %s for malformed workflow columns", async (viewportMode) => {
|
||||
mockViewportMode = viewportMode;
|
||||
await mockStartWorkflows("WF-MALFORMED", "Malformed workflow");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [{
|
||||
id: "WF-MALFORMED",
|
||||
name: "Malformed workflow",
|
||||
columns: [{ id: "broken", name: "Broken" }],
|
||||
}],
|
||||
taskWorkflowIds: {},
|
||||
} as BoardWorkflowsPayload);
|
||||
renderNewTaskModal({ onMoveTask: vi.fn() });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("WF-MALFORMED");
|
||||
expectNoStartActionShell();
|
||||
});
|
||||
|
||||
it.each(["mobile", "desktop"] as const)("leaves no Start shell on %s when board metadata rejects", async (viewportMode) => {
|
||||
mockViewportMode = viewportMode;
|
||||
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
|
||||
vi.mocked(fetchBoardWorkflows).mockRejectedValueOnce(new Error("metadata unavailable"));
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("builtin:coding-ideas");
|
||||
await waitFor(() => expect(screen.queryByTestId("task-form-inline-start")).toBeNull());
|
||||
expectNoStartActionShell();
|
||||
});
|
||||
|
||||
it.each(["mobile", "desktop"] as const)("leaves no Start shell on %s when the board-workflow payload is unusable", async (viewportMode) => {
|
||||
mockViewportMode = viewportMode;
|
||||
await mockStartWorkflows("builtin:coding-ideas", "Coding (Ideas)");
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({ ...codingIdeasBoardPayload, flagEnabled: false });
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy());
|
||||
await chooseWorkflowOption("builtin:coding-ideas");
|
||||
await waitFor(() => expect(screen.queryByTestId("task-form-inline-start")).toBeNull());
|
||||
expectNoStartActionShell();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -879,16 +879,35 @@ describe("TaskForm", () => {
|
||||
|
||||
describe("TaskForm description-adjacent actions layout (FN-781)", () => {
|
||||
|
||||
it("does not render description-actions in edit mode", () => {
|
||||
it("renders Start only for create hosts that supply a callback", () => {
|
||||
const { unmount } = renderTaskForm({
|
||||
onStartSubmit: vi.fn(),
|
||||
startSubmitLabel: "Starting...",
|
||||
startSubmitDisabled: true,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("task-form-inline-start")).toBeDisabled();
|
||||
expect(screen.getByTestId("task-form-inline-start")).toHaveAccessibleName("Starting...");
|
||||
|
||||
unmount();
|
||||
renderTaskForm({});
|
||||
expect(screen.queryByTestId("task-form-inline-start")).toBeNull();
|
||||
expect(screen.getByTestId("task-form-description-actions").querySelector("button:empty")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render description-actions or Start in edit mode", () => {
|
||||
renderTaskForm({
|
||||
mode: "edit",
|
||||
title: "My task",
|
||||
onTitleChange: vi.fn(),
|
||||
description: "Some task",
|
||||
onPlanningMode: vi.fn(),
|
||||
onStartSubmit: vi.fn(),
|
||||
startSubmitLabel: "Start",
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("task-form-description-actions")).toBeNull();
|
||||
expect(screen.queryByTestId("task-form-inline-start")).toBeNull();
|
||||
expect(screen.queryByTestId("task-form-inline-optional-steps")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user