FN-7017: expose optional workflow steps in task creation
Surface active workflow optional steps on task creation surfaces so users can opt into workflow-specific steps before creating tasks. - Add optional-step dropdown state and creation payload handling to quick entry. - Move New Task optional-step controls into the inline quick-button row. - Cover default-on, workflow selection, duplicate-confirmed create, empty-step rendering, and mobile touch-target behavior with tests. - Document create-time optional-step controls and add the published package changeset. Files changed: .../fn-7017-optional-steps-quick-dropdown.md | 7 + docs/dashboard-guide.md | 4 +- docs/workflow-steps.md | 2 + .../dashboard/app/components/QuickEntryBox.css | 6 +- .../dashboard/app/components/QuickEntryBox.tsx | 65 ++++++++- packages/dashboard/app/components/TaskForm.tsx | 34 ++--- .../app/components/__tests__/NewTaskModal.test.tsx | 21 ++- .../components/__tests__/QuickEntryBox.test.tsx | 145 ++++++++++++++++++++- .../app/components/__tests__/TaskForm.test.tsx | 1 + 9 files changed, 258 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-7017 Fusion-Task-Lineage: 1b7d21e6-ff7f-4d27-b285-4fd865eafce6
This commit is contained in:
7
.changeset/fn-7017-optional-steps-quick-dropdown.md
Normal file
7
.changeset/fn-7017-optional-steps-quick-dropdown.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add optional workflow-step quick dropdowns to task creation surfaces.
|
||||
category: feature
|
||||
dev: Surfaces active workflow optional steps in QuickEntryBox and NewTaskModal create payloads.
|
||||
@@ -301,6 +301,8 @@ When inline quick-create, Planning Mode, or Subtask Breakdown is opened from a w
|
||||
|
||||
The **New Task** dialog's workflow selector also defaults to the current or last selected Board/List workflow lane for the current project. If no valid lane has been selected, or the remembered lane was deleted, the selector falls back to the project default workflow and task creation omits an explicit `workflowId`.
|
||||
|
||||
Optional workflow steps declared by the active workflow are available from the quick-add action row and the **New Task** dialog's inline quick buttons. For example, the coding workflow's browser verification option appears as a quick drop-down when that workflow is active; each option is seeded from the workflow step's `defaultOn` setting and is sent with the task's `enabledWorkflowSteps` payload at creation time.
|
||||
|
||||
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.
|
||||
|
||||
Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer. History rows are deduplicated by session id even if the initial load and live session updates arrive out of order, and deleting a history entry now waits for the server delete to persist (failures keep the row visible and surface an error instead of silently disappearing until refresh).
|
||||
@@ -322,7 +324,7 @@ Rules:
|
||||
- `Merge target / base branch` stays optional for all modes and uses the same branch-dropdown + `Custom…` fallback behavior as Planning Mode.
|
||||
- In **More options → Model Configuration**, **Auto-merge** is a per-task override with three states: **Default** (follow project setting), **Enabled**, or **Disabled**.
|
||||
|
||||
The dialog also exposes the board quick-add AI handoffs: **Plan** opens Planning Mode with the current description, and **Subtask** opens Subtask Breakdown with the current description when **Settings → Experimental Features → Subtask Breakdown** is enabled. The Subtask handoff is hidden by default; visible handoff buttons remain disabled until the description has content, matching the quick-add row behavior. **Execution mode** is available in the New Task dialog as well as quick entry, so users can choose Fast or standard execution before creating a task from either surface.
|
||||
The dialog also exposes the board quick-add AI handoffs: **Plan** opens Planning Mode with the current description, and **Subtask** opens Subtask Breakdown with the current description when **Settings → Experimental Features → Subtask Breakdown** is enabled. The Subtask handoff is hidden by default; visible handoff buttons remain disabled until the description has content, matching the quick-add row behavior. **Execution mode** and optional workflow-step selection are available in the New Task dialog as well as quick entry, so users can choose Fast or standard execution and opt into workflow-specific creation-time steps before creating a task from either surface.
|
||||
|
||||
The full **New Task** dialog includes a compact **GitHub issue or PR** picker near the description. It detects GitHub remotes for the current project, auto-selects a single remote or `origin`, and asks you to choose a remote when multiple non-`origin` remotes are available. Selecting an issue replaces the description with a prompt that tells the executor to fetch/read the issue and includes `Source: <issue-url>`; selecting a pull request creates a PR-focused prompt with `PR: <pr-url>` and explicit instructions to inspect the PR conversation, review comments, checks, and changed files, then resolve or address actionable review comments. If you already typed a description, Fusion asks before replacing it. This picker only seeds the prompt; it does not import, close, or comment on GitHub items. On mobile, the full-screen New Task sheet keeps the GitHub picker, dependency picker, agent picker, quick handoff buttons, and action row tappable and scrollable even when the keyboard reduces the visual viewport.
|
||||
|
||||
|
||||
@@ -244,6 +244,8 @@ Workflows can advertise optional workflow-step templates with `optionalSteps: [{
|
||||
|
||||
`defaultOn` is optional and seeds the UI toggle when a task is created or edited before the task has made its own selection. Unknown or removed template ids are skipped during resolution so stale declarations do not render blank controls or break workflow loading.
|
||||
|
||||
Create-time optional-step controls appear in the quick-add action row and the **New Task** dialog inline quick buttons for the active workflow. Workflows with no optional steps render no trigger, and selected ids are submitted through `enabledWorkflowSteps` when the task is created.
|
||||
|
||||
The built-in coding workflow (`builtin:coding`) declares `browser-verification` as an optional step. It remains opt-in by default, so browser verification only runs for tasks whose `enabledWorkflowSteps` includes `browser-verification`.
|
||||
|
||||
## What They Are
|
||||
|
||||
@@ -457,8 +457,10 @@ The global `.description-with-refine textarea { padding-right: 70px }` (styles.c
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.quick-entry-actions .btn {
|
||||
min-height: 36px;
|
||||
.quick-entry-actions .btn,
|
||||
.quick-entry-actions .wf-optional-steps-dropdown-trigger {
|
||||
/* FNXC:WorkflowOptionalSteps 2026-06-25-00:00: The optional-steps trigger is not a `.btn`, so opt it into the same mobile quick-entry touch-target contract as the surrounding action buttons without adding a wrapper that could leave an empty shell for zero-step workflows. */
|
||||
min-height: calc(var(--space-2xl) + var(--space-xs));
|
||||
}
|
||||
|
||||
.quick-entry-box .dep-dropdown {
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core";
|
||||
import type { Task, Settings, TaskPriority } from "@fusion/core";
|
||||
import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep } from "@fusion/core";
|
||||
import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch } from "../api";
|
||||
import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
|
||||
import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api";
|
||||
import { DuplicateWarningModal } from "./DuplicateWarningModal";
|
||||
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
@@ -15,6 +15,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
|
||||
|
||||
const STORAGE_KEY = "kb-quick-entry-text";
|
||||
const MOBILE_BREAKPOINT_PX = 768;
|
||||
@@ -172,6 +173,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
|
||||
const [optionalSteps, setOptionalSteps] = useState<ResolvedWorkflowOptionalStep[]>([]);
|
||||
const [enabledOptionalStepIds, setEnabledOptionalStepIds] = useState<string[]>([]);
|
||||
const [isFastMode, setIsFastMode] = useState(false);
|
||||
const [githubTrackingOverride, setGithubTrackingOverride] = useState<boolean | null>(null);
|
||||
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||||
@@ -253,6 +256,51 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalSteps 2026-06-25-00:00:
|
||||
Quick-add creation needs the active workflow's optional steps in the immediate action row, seeded from each step's `defaultOn`, and forwarded through `enabledWorkflowSteps`. `null` workflow is an explicit no-workflow opt-out, while `undefined` inherits the project default so Board and List quick-add match TaskForm's create-time resolution.
|
||||
*/
|
||||
const effectiveOptionalWorkflowId =
|
||||
workflowId === null
|
||||
? null
|
||||
: (workflowId ?? settings?.defaultWorkflowId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setOptionalSteps([]);
|
||||
setEnabledOptionalStepIds([]);
|
||||
|
||||
if (!effectiveOptionalWorkflowId) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
fetchWorkflowOptionalSteps(effectiveOptionalWorkflowId, projectId)
|
||||
.then((steps) => {
|
||||
if (cancelled) return;
|
||||
setOptionalSteps(steps);
|
||||
setEnabledOptionalStepIds(steps.filter((step) => step.defaultOn).map((step) => step.templateId));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setOptionalSteps([]);
|
||||
setEnabledOptionalStepIds([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveOptionalWorkflowId, projectId]);
|
||||
|
||||
const toggleOptionalStep = useCallback((templateId: string) => {
|
||||
setEnabledOptionalStepIds((prev) => (
|
||||
prev.includes(templateId)
|
||||
? prev.filter((id) => id !== templateId)
|
||||
: [...prev, templateId]
|
||||
));
|
||||
}, []);
|
||||
|
||||
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
|
||||
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
|
||||
const planningSelectionValue = getModelSelectionValue(planningProvider, planningModelId);
|
||||
@@ -492,6 +540,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setPlanningProvider(undefined);
|
||||
setPlanningModelId(undefined);
|
||||
setSelectedPresetId(undefined);
|
||||
setEnabledOptionalStepIds(optionalSteps.filter((step) => step.defaultOn).map((step) => step.templateId));
|
||||
setIsFastMode(false);
|
||||
setGithubTrackingOverride(null);
|
||||
setPriority(DEFAULT_TASK_PRIORITY);
|
||||
@@ -510,7 +559,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
if (typeof window !== "undefined") {
|
||||
removeScopedItem(STORAGE_KEY, projectId);
|
||||
}
|
||||
}, [pendingImages, projectId]);
|
||||
}, [pendingImages, projectId, optionalSteps]);
|
||||
|
||||
const handleImageFiles = useCallback((files: FileList | null | undefined) => {
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -563,6 +612,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
|
||||
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
|
||||
planningModelId: hasPlanningOverride ? planningModelId : undefined,
|
||||
enabledWorkflowSteps: enabledOptionalStepIds.length ? enabledOptionalStepIds : undefined,
|
||||
...(isFastMode ? { executionMode: "fast" } : {}),
|
||||
githubTracking: githubTrackingOverride !== null ? { enabled: githubTrackingOverride } : undefined,
|
||||
priority,
|
||||
@@ -607,6 +657,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
hasPlanningOverride,
|
||||
planningProvider,
|
||||
planningModelId,
|
||||
enabledOptionalStepIds,
|
||||
isFastMode,
|
||||
settings,
|
||||
githubTrackingOverride,
|
||||
@@ -1663,6 +1714,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
portalRoot,
|
||||
)}
|
||||
|
||||
<WorkflowOptionalStepsDropdown
|
||||
steps={optionalSteps}
|
||||
enabledIds={enabledOptionalStepIds}
|
||||
onToggle={toggleOptionalStep}
|
||||
disabled={isSubmitting || isDisabled}
|
||||
triggerTestId="quick-entry-optional-steps-trigger"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
|
||||
@@ -876,6 +876,9 @@ export function TaskForm({
|
||||
|
||||
FNXC:NewTaskDialogAffordances 2026-06-23-21:20:
|
||||
The regular New Task dialog must visibly expose the screenshot quick-add button contract in the immediate action cluster while Advanced remains the deep configuration editor. TaskForm hosts the cluster so create payload state has one source of truth; NewTaskModal only supplies the submit handler and its existing dependency/agent quick controls.
|
||||
|
||||
FNXC:NewTaskDialogAffordances 2026-06-25-00:00:
|
||||
Optional workflow steps are now an inline create-time quick button, seeded by the selected workflow's `defaultOn` values and lifted to NewTaskModal through the existing enabledWorkflowSteps state. The Advanced workflow section remains the place to choose the workflow itself, not a duplicate optional-steps trigger.
|
||||
*/}
|
||||
{mode === "create" && (
|
||||
<div className="task-form-description-actions" data-testid="task-form-description-actions">
|
||||
@@ -993,6 +996,20 @@ export function TaskForm({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{optionalStepsLoading ? (
|
||||
<small className="workflow-optional-steps-loading" data-testid="task-optional-steps-loading">
|
||||
{t("taskForm.optionalStepsLoading", "Loading optional steps…")}
|
||||
</small>
|
||||
) : (
|
||||
<WorkflowOptionalStepsDropdown
|
||||
steps={optionalSteps}
|
||||
enabledIds={enabledOptionalStepIds}
|
||||
onToggle={toggleOptionalStep}
|
||||
disabled={disabled}
|
||||
triggerTestId="task-form-inline-optional-steps"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
@@ -1588,23 +1605,6 @@ export function TaskForm({
|
||||
<small className="workflow-select-help" data-testid="task-workflow-help">
|
||||
{t("taskForm.workflowHelp", "The selected workflow's steps run automatically around this task's execution.")}
|
||||
</small>
|
||||
{optionalStepsLoading ? (
|
||||
<small className="workflow-optional-steps-loading" data-testid="task-optional-steps-loading">
|
||||
{t("taskForm.optionalStepsLoading", "Loading optional steps…")}
|
||||
</small>
|
||||
) : (
|
||||
optionalSteps.length > 0 && (
|
||||
<div className="task-form-optional-steps" data-testid="task-form-optional-steps">
|
||||
<WorkflowOptionalStepsDropdown
|
||||
steps={optionalSteps}
|
||||
enabledIds={enabledOptionalStepIds}
|
||||
onToggle={toggleOptionalStep}
|
||||
disabled={disabled}
|
||||
triggerTestId="task-optional-steps-trigger"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -818,7 +818,7 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } });
|
||||
|
||||
const trigger = await screen.findByTestId("task-optional-steps-trigger");
|
||||
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
|
||||
expect(trigger).toHaveTextContent("Steps: none");
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification"));
|
||||
@@ -831,6 +831,21 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps exactly one inline optional-steps trigger when Advanced is expanded", async () => {
|
||||
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]);
|
||||
|
||||
renderNewTaskModal();
|
||||
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } });
|
||||
|
||||
await screen.findByTestId("task-form-inline-optional-steps");
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
|
||||
expect(screen.getAllByTestId("task-form-inline-optional-steps")).toHaveLength(1);
|
||||
expect(screen.queryByTestId("task-form-optional-steps")).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds defaultOn steps as pre-enabled and submits them without toggling", async () => {
|
||||
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
|
||||
@@ -840,7 +855,7 @@ describe("NewTaskModal", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } });
|
||||
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } });
|
||||
|
||||
const trigger = await screen.findByTestId("task-optional-steps-trigger");
|
||||
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
|
||||
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
@@ -861,7 +876,7 @@ describe("NewTaskModal", () => {
|
||||
// "No workflow" → null selection → no optional-steps fetch, no dropdown.
|
||||
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "__none__" } });
|
||||
|
||||
expect(screen.queryByTestId("task-optional-steps-trigger")).toBeNull();
|
||||
expect(screen.queryByTestId("task-form-inline-optional-steps")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
await waitFor(() => {
|
||||
const call = vi.mocked(props.onCreateTask).mock.calls.at(-1)?.[0];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { QuickEntryBox } from "../QuickEntryBox";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment } from "../../api";
|
||||
import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../../api";
|
||||
import { useNodes } from "../../hooks/useNodes";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
@@ -190,6 +190,7 @@ vi.mock("../../api", () => ({
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
|
||||
uploadAttachment: vi.fn().mockResolvedValue({}),
|
||||
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
@@ -401,6 +402,17 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
vi.mocked(uploadAttachment).mockResolvedValue({} as any);
|
||||
vi.mocked(checkDuplicateTasks).mockResolvedValue([]);
|
||||
vi.mocked(fetchSettings).mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 30000,
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: true,
|
||||
} as any);
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([]);
|
||||
|
||||
Object.defineProperty(URL, "createObjectURL", {
|
||||
configurable: true,
|
||||
@@ -1583,6 +1595,127 @@ describe("QuickEntryBox", () => {
|
||||
innerWidthSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe("optional workflow steps", () => {
|
||||
const DEFAULT_ON_STEP = {
|
||||
templateId: "browser-verification",
|
||||
name: "Browser verification",
|
||||
description: "Run browser checks",
|
||||
phase: "pre-merge" as const,
|
||||
defaultOn: true,
|
||||
};
|
||||
const MANUAL_STEP = {
|
||||
templateId: "manual-smoke",
|
||||
name: "Manual smoke",
|
||||
description: "Run a manual smoke pass",
|
||||
phase: "pre-merge" as const,
|
||||
defaultOn: false,
|
||||
};
|
||||
|
||||
it("renders in the quick action row and submits the toggled enabled set via Save", async () => {
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([DEFAULT_ON_STEP, MANUAL_STEP]);
|
||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||
renderQuickEntryBox({ onCreate, workflowId: "wf-explicit" });
|
||||
|
||||
const trigger = await screen.findByTestId("quick-entry-optional-steps-trigger");
|
||||
expect(screen.getByTestId("quick-entry-actions").contains(trigger)).toBe(true);
|
||||
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
|
||||
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification"));
|
||||
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-manual-smoke"));
|
||||
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Create with optional step" } });
|
||||
clickSave();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabledWorkflowSteps: ["manual-smoke"] }),
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId("quick-entry-input")).toHaveValue(""));
|
||||
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
|
||||
|
||||
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Create after reset" } });
|
||||
clickSave();
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ enabledWorkflowSteps: ["browser-verification"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("submits defaultOn optional steps via Enter key", async () => {
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([DEFAULT_ON_STEP]);
|
||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||
renderQuickEntryBox({ onCreate, workflowId: "wf-explicit" });
|
||||
|
||||
await screen.findByTestId("quick-entry-optional-steps-trigger");
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "Create from enter" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabledWorkflowSteps: ["browser-verification"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves null, undefined, and explicit workflow ids for optional-step fetches", async () => {
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([]);
|
||||
const { unmount: unmountNull } = renderQuickEntryBox({ workflowId: null });
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
expect(fetchWorkflowOptionalSteps).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("quick-entry-optional-steps-trigger")).toBeNull();
|
||||
unmountNull();
|
||||
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchSettings).mockResolvedValue({ defaultWorkflowId: "wf-default" } as any);
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([DEFAULT_ON_STEP]);
|
||||
const { unmount: unmountDefault } = renderQuickEntryBox({ workflowId: undefined });
|
||||
await screen.findByTestId("quick-entry-optional-steps-trigger");
|
||||
expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-default", TEST_PROJECT_ID);
|
||||
unmountDefault();
|
||||
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([DEFAULT_ON_STEP]);
|
||||
renderQuickEntryBox({ workflowId: "wf-explicit" });
|
||||
await screen.findByTestId("quick-entry-optional-steps-trigger");
|
||||
expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-explicit", TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
it("renders no trigger or action-row shell when the workflow has zero optional steps", async () => {
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([]);
|
||||
renderQuickEntryBox({ workflowId: "wf-empty" });
|
||||
|
||||
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-empty", TEST_PROJECT_ID));
|
||||
expect(screen.queryByTestId("quick-entry-optional-steps-trigger")).toBeNull();
|
||||
expect(screen.getByTestId("quick-entry-actions").querySelector(".wf-optional-steps-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("carries enabledWorkflowSteps through duplicate-confirmed create anyway", async () => {
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([DEFAULT_ON_STEP]);
|
||||
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
|
||||
{ id: "FN-456", title: "Duplicate", description: "duplicate", column: "todo", score: 0.9 },
|
||||
]);
|
||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||
renderQuickEntryBox({ onCreate, workflowId: "wf-explicit" });
|
||||
|
||||
await screen.findByTestId("quick-entry-optional-steps-trigger");
|
||||
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Duplicate optional step" } });
|
||||
clickSave();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Create anyway" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: ["browser-verification"],
|
||||
acknowledgedDuplicates: ["FN-456"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rich creation features", () => {
|
||||
it("shows inline deps/models/save controls when expanded", () => {
|
||||
renderQuickEntryBox({});
|
||||
@@ -3902,6 +4035,16 @@ describe("QuickEntryBox", () => {
|
||||
expect(touchRule).toMatch(/touch-action:\s*manipulation;/);
|
||||
});
|
||||
|
||||
it("keeps the optional-steps trigger in the mobile quick-entry touch target rule", () => {
|
||||
const optionalTriggerRule = cssRuleBody(
|
||||
QUICK_ENTRY_BOX_CSS,
|
||||
".quick-entry-actions .btn,\n .quick-entry-actions .wf-optional-steps-dropdown-trigger",
|
||||
);
|
||||
|
||||
expect(optionalTriggerRule).not.toBeNull();
|
||||
expect(cssDeclarationValue(optionalTriggerRule!, "min-height")).toBe("calc(var(--space-2xl) + var(--space-xs))");
|
||||
});
|
||||
|
||||
it("keeps inline deps/models controls in touch-target button classes on mobile", () => {
|
||||
vi.spyOn(window, "innerWidth", "get").mockReturnValue(375);
|
||||
|
||||
|
||||
@@ -756,6 +756,7 @@ describe("TaskForm description-adjacent actions layout (FN-781)", () => {
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("task-form-description-actions")).toBeNull();
|
||||
expect(screen.queryByTestId("task-form-inline-optional-steps")).toBeNull();
|
||||
});
|
||||
|
||||
it("Plan and Subtask buttons are disabled when description is empty", () => {
|
||||
|
||||
Reference in New Issue
Block a user