FN-7781: add optional workflow step toggles to task edit form
Enables editing a task's optional workflow steps directly from TaskForm edit mode, sourcing the step catalog from the resolved task workflow instead of re-seeding from defaultOn. - TaskForm loads optional-step catalog from the task's resolved workflow when editing and exposes toggles for enabling/disabling optional steps - TaskDetailModal passes through the additional workflow context needed for edit-mode step toggling - Added changeset for @runfusion/fusion (minor) - Updated docs/dashboard-guide.md to describe the new edit-mode workflow step behavior - Added test coverage in TaskForm.test.tsx and TaskDetailModal.models-progress-workflow.test.tsx Files changed: .changeset/fn-7781-edit-workflow-steps.md | 7 ++ docs/dashboard-guide.md | 5 +- .../dashboard/app/components/TaskDetailModal.tsx | 6 ++ packages/dashboard/app/components/TaskForm.tsx | 76 +++++++++++++----- ...skDetailModal.models-progress-workflow.test.tsx | 90 ++++++++++++++++++++++ .../app/components/__tests__/TaskForm.test.tsx | 51 ++++++++++++ 6 files changed, 214 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-7781 Fusion-Task-Lineage: e44d9383-cfe7-4da1-9f20-8191211651cf Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7781-edit-workflow-steps.md
Normal file
7
.changeset/fn-7781-edit-workflow-steps.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Let task edits toggle optional workflow steps directly.
|
||||||
|
category: feature
|
||||||
|
dev: TaskForm edit mode now loads optional-step catalogs from the resolved task workflow without defaultOn re-seeding.
|
||||||
@@ -323,6 +323,9 @@ Workflows define how a task moves through planning, execution, review, workflow
|
|||||||
<!-- FNXC:NewTaskWorkflowDropdown 2026-06-30-18:52: The full New Task dialog workflow picker now matches the icon-rich workflow identity used in Board/List selectors while preserving create-time workflowId semantics. -->
|
<!-- FNXC:NewTaskWorkflowDropdown 2026-06-30-18:52: The full New Task dialog workflow picker now matches the icon-rich workflow identity used in Board/List selectors while preserving create-time workflowId semantics. -->
|
||||||
When creating a task from the full **New Task** dialog, the **Workflow** advanced control opens a styled dropdown instead of a native select. Built-in workflows show the Fusion mark, custom workflows show their configured compact icon when present, **No workflow** remains the explicit opt-out, and leaving the picker untouched still inherits the project/default workflow.
|
When creating a task from the full **New Task** dialog, the **Workflow** advanced control opens a styled dropdown instead of a native select. Built-in workflows show the Fusion mark, custom workflows show their configured compact icon when present, **No workflow** remains the explicit opt-out, and leaving the picker untouched still inherits the project/default workflow.
|
||||||
|
|
||||||
|
<!-- FNXC:WorkflowOptionalSteps 2026-07-10-08:00: Operators can now toggle optional workflow steps from the task Edit form as well as the Workflow tab. Edit mode must load the task's resolved workflow catalog without re-seeding from defaultOn, preserving the task's persisted enabledWorkflowSteps until the operator explicitly toggles a step. -->
|
||||||
|
Optional workflow steps can be toggled from the task **Edit** form's **More options → Workflow Steps** control or from the task's **Workflow** tab. The edit form uses the task's resolved workflow and preserves the task's current stored selection when it opens; workflow-authored `defaultOn` values remain a create-time/runtime default, not an edit-form re-seed.
|
||||||
|
|
||||||
The workflow editor opens as a full-screen modal editor for inspecting built-ins and authoring custom workflows.
|
The workflow editor opens as a full-screen modal editor for inspecting built-ins and authoring custom workflows.
|
||||||
|
|
||||||
Navigation:
|
Navigation:
|
||||||
@@ -1235,7 +1238,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
|
|||||||
- GitLab stale state means Fusion is displaying the last persisted GitLab metadata after a sync/import refresh could not confirm a newer state; no GitLab token or secret is stored on the task.
|
- GitLab stale state means Fusion is displaying the last persisted GitLab metadata after a sync/import refresh could not confirm a newer state; no GitLab token or secret is stored on the task.
|
||||||
- GitLab comment and close/reopen actions use the configured GitLab REST API base URL for GitLab.com or self-managed instances. Group-imported issues are updated only when Fusion has the concrete project identity plus IID, and merge requests are closed/reopened only for GitLab-supported states; Fusion never auto-merges a GitLab merge request.
|
- GitLab comment and close/reopen actions use the configured GitLab REST API base URL for GitLab.com or self-managed instances. Group-imported issues are updated only when Fusion has the concrete project identity plus IID, and merge requests are closed/reopened only for GitLab-supported states; Fusion never auto-merges a GitLab merge request.
|
||||||
- Backstop reconciliation runs every 15 minutes to close tracked GitHub issues for soft-deleted and archived tasks even after restart; the sweep is paginated so large archive backlogs are eventually drained.
|
- Backstop reconciliation runs every 15 minutes to close tracked GitHub issues for soft-deleted and archived tasks even after restart; the sweep is paginated so large archive backlogs are eventually drained.
|
||||||
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**.
|
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**. In task edit mode, **Workflow Steps** appears only when the task's resolved workflow exposes optional steps, so workflows without optional steps do not leave an empty button shell.
|
||||||
- From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults.
|
- From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults.
|
||||||
- The **Plan** tab shows the stored **Original prompt** above the generated `PROMPT.md` content, so the exact task prompt remains visible after planning. It is collapsed by default behind a chevron toggle; expanding it renders the prompt as Markdown (the same renderer used for the generated plan body). It stays read-only — editing or requesting AI revision still applies only to the generated plan.
|
- The **Plan** tab shows the stored **Original prompt** above the generated `PROMPT.md` content, so the exact task prompt remains visible after planning. It is collapsed by default behind a chevron toggle; expanding it renders the prompt as Markdown (the same renderer used for the generated plan body). It stays read-only — editing or requesting AI revision still applies only to the generated plan.
|
||||||
- In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab.
|
- In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab.
|
||||||
|
|||||||
@@ -1055,6 +1055,9 @@ export function TaskDetailContent({
|
|||||||
const [editExecutionMode, setEditExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
|
const [editExecutionMode, setEditExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
|
||||||
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
|
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
|
||||||
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
|
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
|
||||||
|
const handleEditWorkflowStepsChange = useCallback((enabledWorkflowSteps: string[]) => {
|
||||||
|
setEditSelectedWorkflowSteps(enabledWorkflowSteps);
|
||||||
|
}, []);
|
||||||
const [editSourceIssueProvider, setEditSourceIssueProvider] = useState(task.sourceIssue?.provider ?? "");
|
const [editSourceIssueProvider, setEditSourceIssueProvider] = useState(task.sourceIssue?.provider ?? "");
|
||||||
const [editSourceIssueRepository, setEditSourceIssueRepository] = useState(task.sourceIssue?.repository ?? "");
|
const [editSourceIssueRepository, setEditSourceIssueRepository] = useState(task.sourceIssue?.repository ?? "");
|
||||||
const [editSourceIssueExternalId, setEditSourceIssueExternalId] = useState(task.sourceIssue?.externalIssueId ?? "");
|
const [editSourceIssueExternalId, setEditSourceIssueExternalId] = useState(task.sourceIssue?.externalIssueId ?? "");
|
||||||
@@ -3812,6 +3815,9 @@ export function TaskDetailContent({
|
|||||||
onPresetModeChange={setEditPresetMode}
|
onPresetModeChange={setEditPresetMode}
|
||||||
selectedPresetId={editSelectedPresetId}
|
selectedPresetId={editSelectedPresetId}
|
||||||
onSelectedPresetIdChange={setEditSelectedPresetId}
|
onSelectedPresetIdChange={setEditSelectedPresetId}
|
||||||
|
optionalStepsWorkflowId={taskWorkflowBadge?.id}
|
||||||
|
enabledWorkflowSteps={editSelectedWorkflowSteps}
|
||||||
|
onEnabledWorkflowStepsChange={handleEditWorkflowStepsChange}
|
||||||
pendingImages={editPendingImages}
|
pendingImages={editPendingImages}
|
||||||
onImagesChange={setEditPendingImages}
|
onImagesChange={setEditPendingImages}
|
||||||
tasks={tasks.filter((t) => t.id !== task.id)}
|
tasks={tasks.filter((t) => t.id !== task.id)}
|
||||||
|
|||||||
@@ -113,13 +113,17 @@ export interface TaskFormProps {
|
|||||||
// - `undefined` → inherit the project default (preselected + "(default)" badge).
|
// - `undefined` → inherit the project default (preselected + "(default)" badge).
|
||||||
// - `null` → "No workflow" (listed first).
|
// - `null` → "No workflow" (listed first).
|
||||||
// - `string` → a specific workflow id.
|
// - `string` → a specific workflow id.
|
||||||
// The dropdown only renders when `onWorkflowIdChange` is provided (create mode);
|
// The dropdown only renders when `onWorkflowIdChange` is provided (create mode).
|
||||||
// edit-mode workflow management lives in the task detail Workflow tab.
|
|
||||||
selectedWorkflowId?: string | null;
|
selectedWorkflowId?: string | null;
|
||||||
onWorkflowIdChange?: (workflowId: string | null) => void;
|
onWorkflowIdChange?: (workflowId: string | null) => void;
|
||||||
|
/*
|
||||||
|
* FNXC:WorkflowOptionalSteps 2026-07-10-00:00:
|
||||||
|
* Edit mode needs the task's resolved workflow id to fetch the optional-step catalog without enabling workflow selection in the edit form. This id is catalog-only: create mode still resolves from selectedWorkflowId/default workflow and remains the only path that seeds defaultOn steps.
|
||||||
|
*/
|
||||||
|
optionalStepsWorkflowId?: string | null;
|
||||||
// Optional workflow steps the task can opt into. TaskForm fetches + seeds these
|
// Optional workflow steps the task can opt into. TaskForm fetches + seeds these
|
||||||
// from the selected workflow's `defaultOn` and lifts the enabled set to the
|
// from the selected workflow's `defaultOn` in create mode, while edit mode only
|
||||||
// parent (which puts it in the create payload). Only active in create mode.
|
// mutates the provided task-specific ids.
|
||||||
enabledWorkflowSteps?: string[];
|
enabledWorkflowSteps?: string[];
|
||||||
onEnabledWorkflowStepsChange?: (ids: string[], meta?: EnabledWorkflowStepsChangeMeta) => void;
|
onEnabledWorkflowStepsChange?: (ids: string[], meta?: EnabledWorkflowStepsChangeMeta) => void;
|
||||||
|
|
||||||
@@ -215,6 +219,7 @@ export function TaskForm({
|
|||||||
onSelectedPresetIdChange,
|
onSelectedPresetIdChange,
|
||||||
selectedWorkflowId,
|
selectedWorkflowId,
|
||||||
onWorkflowIdChange,
|
onWorkflowIdChange,
|
||||||
|
optionalStepsWorkflowId,
|
||||||
enabledWorkflowSteps,
|
enabledWorkflowSteps,
|
||||||
onEnabledWorkflowStepsChange,
|
onEnabledWorkflowStepsChange,
|
||||||
pendingImages,
|
pendingImages,
|
||||||
@@ -264,6 +269,7 @@ export function TaskForm({
|
|||||||
(branch || "") !== "" ||
|
(branch || "") !== "" ||
|
||||||
(baseBranch || "") !== "" ||
|
(baseBranch || "") !== "" ||
|
||||||
(nodeId || "") !== "" ||
|
(nodeId || "") !== "" ||
|
||||||
|
(mode === "edit" && (enabledWorkflowSteps?.length ?? 0) > 0) ||
|
||||||
githubTrackingEnabled === true ||
|
githubTrackingEnabled === true ||
|
||||||
(githubRepoOverride || "") !== "";
|
(githubRepoOverride || "") !== "";
|
||||||
|
|
||||||
@@ -355,35 +361,44 @@ export function TaskForm({
|
|||||||
selectedWorkflowId === null
|
selectedWorkflowId === null
|
||||||
? null
|
? null
|
||||||
: (selectedWorkflowId ?? settings?.defaultWorkflowId ?? (settings ? "builtin:coding" : null));
|
: (selectedWorkflowId ?? settings?.defaultWorkflowId ?? (settings ? "builtin:coding" : null));
|
||||||
|
const resolvedOptionalWorkflowId = onWorkflowIdChange ? effectiveOptionalWorkflowId : (optionalStepsWorkflowId ?? null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onWorkflowIdChange) return; // edit mode: optional steps are managed in the Workflow tab.
|
const isCreateOptionalStepPicker = Boolean(onWorkflowIdChange);
|
||||||
|
const isEditOptionalStepPicker = mode === "edit" && Boolean(optionalStepsWorkflowId);
|
||||||
|
if (!isCreateOptionalStepPicker && !isEditOptionalStepPicker) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setOptionalSteps([]);
|
setOptionalSteps([]);
|
||||||
if (!effectiveOptionalWorkflowId) {
|
if (!resolvedOptionalWorkflowId) {
|
||||||
// Clear any in-flight loading state (a prior fetch may have been cancelled
|
// Clear any in-flight loading state (a prior fetch may have been cancelled
|
||||||
// mid-flight when switching to "No workflow"), so the loading row never sticks.
|
// mid-flight when switching to "No workflow"), so the loading row never sticks.
|
||||||
setOptionalStepsLoading(false);
|
setOptionalStepsLoading(false);
|
||||||
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
|
if (isCreateOptionalStepPicker) {
|
||||||
|
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setOptionalStepsLoading(true);
|
setOptionalStepsLoading(true);
|
||||||
fetchWorkflowOptionalSteps(effectiveOptionalWorkflowId, projectId)
|
fetchWorkflowOptionalSteps(resolvedOptionalWorkflowId, projectId)
|
||||||
.then((steps) => {
|
.then((steps) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setOptionalSteps(steps);
|
setOptionalSteps(steps);
|
||||||
/*
|
if (isCreateOptionalStepPicker) {
|
||||||
FNXC:FastOptionalSteps 2026-06-30-10:25:
|
/*
|
||||||
Optional-step fetches race user mode changes. When the latest mode is Fast, seed an explicit empty set after loading instead of defaultOn ids so async workflow metadata cannot re-enable optional gates the operator has not manually reselected.
|
FNXC:FastOptionalSteps 2026-06-30-10:25:
|
||||||
*/
|
Optional-step fetches race user mode changes. When the latest mode is Fast, seed an explicit empty set after loading instead of defaultOn ids so async workflow metadata cannot re-enable optional gates the operator has not manually reselected.
|
||||||
const seededSteps = executionModeRef.current === "fast"
|
*/
|
||||||
? []
|
const seededSteps = executionModeRef.current === "fast"
|
||||||
: steps.filter((s) => s.defaultOn).map((s) => s.templateId);
|
? []
|
||||||
onEnabledWorkflowStepsChange?.(seededSteps, { optionalStepsAvailable: steps.length > 0 });
|
: steps.filter((s) => s.defaultOn).map((s) => s.templateId);
|
||||||
|
onEnabledWorkflowStepsChange?.(seededSteps, { optionalStepsAvailable: steps.length > 0 });
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setOptionalSteps([]);
|
setOptionalSteps([]);
|
||||||
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
|
if (isCreateOptionalStepPicker) {
|
||||||
|
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: false });
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setOptionalStepsLoading(false);
|
if (!cancelled) setOptionalStepsLoading(false);
|
||||||
@@ -394,7 +409,7 @@ export function TaskForm({
|
|||||||
// onEnabledWorkflowStepsChange intentionally omitted from deps: a new identity
|
// onEnabledWorkflowStepsChange intentionally omitted from deps: a new identity
|
||||||
// each render must not re-trigger the fetch/re-seed (would clobber user toggles).
|
// each render must not re-trigger the fetch/re-seed (would clobber user toggles).
|
||||||
// Callers must pass a stable callback (NewTaskModal passes a useState setter).
|
// Callers must pass a stable callback (NewTaskModal passes a useState setter).
|
||||||
}, [onWorkflowIdChange, effectiveOptionalWorkflowId, projectId]);
|
}, [onWorkflowIdChange, optionalStepsWorkflowId, mode, resolvedOptionalWorkflowId, projectId]);
|
||||||
|
|
||||||
const enabledOptionalStepIds = enabledWorkflowSteps ?? [];
|
const enabledOptionalStepIds = enabledWorkflowSteps ?? [];
|
||||||
/*
|
/*
|
||||||
@@ -403,10 +418,10 @@ export function TaskForm({
|
|||||||
*/
|
*/
|
||||||
const handleExecutionModeChange = useCallback((nextMode: TaskExecutionModeSelection) => {
|
const handleExecutionModeChange = useCallback((nextMode: TaskExecutionModeSelection) => {
|
||||||
onExecutionModeChange?.(nextMode);
|
onExecutionModeChange?.(nextMode);
|
||||||
if (nextMode === "fast") {
|
if (nextMode === "fast" && onWorkflowIdChange) {
|
||||||
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: optionalSteps.length > 0 });
|
onEnabledWorkflowStepsChange?.([], { optionalStepsAvailable: optionalSteps.length > 0 });
|
||||||
}
|
}
|
||||||
}, [onEnabledWorkflowStepsChange, onExecutionModeChange, optionalSteps.length]);
|
}, [onEnabledWorkflowStepsChange, onExecutionModeChange, onWorkflowIdChange, optionalSteps.length]);
|
||||||
|
|
||||||
const toggleOptionalStep = useCallback(
|
const toggleOptionalStep = useCallback(
|
||||||
(templateId: string) => {
|
(templateId: string) => {
|
||||||
@@ -440,6 +455,7 @@ export function TaskForm({
|
|||||||
(branch || "") !== "" ||
|
(branch || "") !== "" ||
|
||||||
(baseBranch || "") !== "" ||
|
(baseBranch || "") !== "" ||
|
||||||
(nodeId || "") !== "" ||
|
(nodeId || "") !== "" ||
|
||||||
|
(mode === "edit" && (enabledWorkflowSteps?.length ?? 0) > 0) ||
|
||||||
githubTrackingEnabled === true ||
|
githubTrackingEnabled === true ||
|
||||||
(githubRepoOverride || "") !== "";
|
(githubRepoOverride || "") !== "";
|
||||||
|
|
||||||
@@ -1743,6 +1759,26 @@ export function TaskForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === "edit" && onEnabledWorkflowStepsChange && (optionalStepsLoading || optionalSteps.length > 0) && (
|
||||||
|
<div className="form-group" data-testid="task-form-edit-workflow-steps-group">
|
||||||
|
{/* FNXC:WorkflowOptionalSteps 2026-07-10-00:18: Edit-mode Workflow Steps belongs in More options immediately before GitHub Tracking so shared edit/create forms keep the documented advanced-section order while avoiding an empty button shell for workflows with no optional steps. */}
|
||||||
|
<label>{t("taskForm.workflowStepsLabel", "Workflow Steps")}</label>
|
||||||
|
{optionalStepsLoading ? (
|
||||||
|
<small className="workflow-optional-steps-loading" data-testid="task-edit-optional-steps-loading">
|
||||||
|
{t("taskForm.optionalStepsLoading", "Loading optional steps…")}
|
||||||
|
</small>
|
||||||
|
) : (
|
||||||
|
<WorkflowOptionalStepsDropdown
|
||||||
|
steps={optionalSteps}
|
||||||
|
enabledIds={enabledOptionalStepIds}
|
||||||
|
onToggle={toggleOptionalStep}
|
||||||
|
disabled={disabled}
|
||||||
|
triggerTestId="task-form-edit-optional-steps"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(onGithubTrackingEnabledChange || onGithubRepoOverrideChange) && (
|
{(onGithubTrackingEnabledChange || onGithubRepoOverrideChange) && (
|
||||||
<div className="form-group" data-testid="task-form-github-tracking">
|
<div className="form-group" data-testid="task-form-github-tracking">
|
||||||
<label>{t("taskForm.githubTrackingLabel", "GitHub Tracking")}</label>
|
<label>{t("taskForm.githubTrackingLabel", "GitHub Tracking")}</label>
|
||||||
|
|||||||
@@ -1060,6 +1060,96 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(screen.getByText("Workflow")).toBeTruthy();
|
expect(screen.getByText("Workflow")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("saves workflow step toggles from task edit mode", async () => {
|
||||||
|
const { fetchBoardWorkflows, fetchWorkflowOptionalSteps, updateTask } = await import("../../api");
|
||||||
|
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
|
||||||
|
flagEnabled: true,
|
||||||
|
defaultWorkflowId: "wf-edit",
|
||||||
|
workflows: [{ id: "wf-edit", name: "Edit Workflow" }],
|
||||||
|
taskWorkflowIds: { "FN-099": "wf-edit" },
|
||||||
|
} as any);
|
||||||
|
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValueOnce([
|
||||||
|
{ templateId: "code-review", name: "Code Review", phase: "pre-merge", defaultOn: true },
|
||||||
|
{ templateId: "browser-verification", name: "Browser Verification", phase: "pre-merge", defaultOn: false },
|
||||||
|
] as any);
|
||||||
|
vi.mocked(updateTask).mockResolvedValueOnce(makeTask({ enabledWorkflowSteps: ["browser-verification", "code-review"] }) as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
initialTab="definition"
|
||||||
|
task={makeTask({ column: "todo" as any, enabledWorkflowSteps: ["browser-verification"] })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Edit task" }));
|
||||||
|
const trigger = await screen.findByTestId("task-form-edit-optional-steps");
|
||||||
|
expect(trigger).toHaveTextContent("Steps: 1 selected");
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-code-review"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updateTask).toHaveBeenCalledWith(
|
||||||
|
"FN-099",
|
||||||
|
expect.objectContaining({ enabledWorkflowSteps: ["browser-verification", "code-review"] }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reset enabled workflow steps when saving unrelated edit fields", async () => {
|
||||||
|
const { fetchBoardWorkflows, fetchWorkflowOptionalSteps, updateTask } = await import("../../api");
|
||||||
|
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
|
||||||
|
flagEnabled: true,
|
||||||
|
defaultWorkflowId: "wf-edit",
|
||||||
|
workflows: [{ id: "wf-edit", name: "Edit Workflow" }],
|
||||||
|
taskWorkflowIds: { "FN-099": "wf-edit" },
|
||||||
|
} as any);
|
||||||
|
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValueOnce([
|
||||||
|
{ templateId: "code-review", name: "Code Review", phase: "pre-merge", defaultOn: true },
|
||||||
|
{ templateId: "browser-verification", name: "Browser Verification", phase: "pre-merge", defaultOn: false },
|
||||||
|
] as any);
|
||||||
|
vi.mocked(updateTask).mockResolvedValueOnce(makeTask({ title: "Edited title", enabledWorkflowSteps: ["browser-verification"] }) as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
initialTab="definition"
|
||||||
|
task={makeTask({ column: "todo" as any, title: "Original title", enabledWorkflowSteps: ["browser-verification"] })}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Edit task" }));
|
||||||
|
await screen.findByTestId("task-form-edit-optional-steps");
|
||||||
|
const titleInput = screen.getByRole("textbox", { name: /Title/i });
|
||||||
|
fireEvent.change(titleInput, { target: { value: "Edited title" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updateTask).toHaveBeenCalledWith(
|
||||||
|
"FN-099",
|
||||||
|
expect.not.objectContaining({ enabledWorkflowSteps: expect.anything() }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(updateTask).toHaveBeenCalledWith(
|
||||||
|
"FN-099",
|
||||||
|
expect.objectContaining({ title: "Edited title" }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("switches to Workflow tab and calls fetchWorkflowResults", async () => {
|
it("switches to Workflow tab and calls fetchWorkflowResults", async () => {
|
||||||
const { fetchWorkflowResults } = await import("../../api");
|
const { fetchWorkflowResults } = await import("../../api");
|
||||||
const mockFetch = vi.mocked(fetchWorkflowResults);
|
const mockFetch = vi.mocked(fetchWorkflowResults);
|
||||||
|
|||||||
@@ -332,6 +332,57 @@ describe("TaskForm", () => {
|
|||||||
expect(onEnabledWorkflowStepsChange).toHaveBeenLastCalledWith([], expect.objectContaining({ optionalStepsAvailable: true }));
|
expect(onEnabledWorkflowStepsChange).toHaveBeenLastCalledWith([], expect.objectContaining({ optionalStepsAvailable: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders edit-mode workflow steps without clobbering existing task selection", async () => {
|
||||||
|
const { fetchWorkflowOptionalSteps } = await import("../../api");
|
||||||
|
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([
|
||||||
|
{ templateId: "code-review", name: "Code Review", phase: "pre-merge", defaultOn: true },
|
||||||
|
{ templateId: "browser-verification", name: "Browser Verification", phase: "pre-merge", defaultOn: false },
|
||||||
|
] as any);
|
||||||
|
const onEnabledWorkflowStepsChange = vi.fn();
|
||||||
|
|
||||||
|
renderTaskForm({
|
||||||
|
mode: "edit",
|
||||||
|
onWorkflowIdChange: undefined,
|
||||||
|
selectedWorkflowId: undefined,
|
||||||
|
optionalStepsWorkflowId: "wf-edit",
|
||||||
|
enabledWorkflowSteps: ["browser-verification"],
|
||||||
|
onEnabledWorkflowStepsChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-edit", undefined));
|
||||||
|
const trigger = await screen.findByTestId("task-form-edit-optional-steps");
|
||||||
|
expect(trigger).toHaveTextContent("Steps: 1 selected");
|
||||||
|
expect(onEnabledWorkflowStepsChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-code-review"));
|
||||||
|
expect(onEnabledWorkflowStepsChange).toHaveBeenCalledWith(
|
||||||
|
["browser-verification", "code-review"],
|
||||||
|
expect.objectContaining({ optionalStepsAvailable: true }),
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("task-form-inline-optional-steps")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no edit-mode workflow steps shell when the workflow has no optional steps", async () => {
|
||||||
|
const { fetchWorkflowOptionalSteps } = await import("../../api");
|
||||||
|
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([] as any);
|
||||||
|
|
||||||
|
renderTaskForm({
|
||||||
|
mode: "edit",
|
||||||
|
onWorkflowIdChange: undefined,
|
||||||
|
selectedWorkflowId: undefined,
|
||||||
|
optionalStepsWorkflowId: "wf-empty",
|
||||||
|
enabledWorkflowSteps: [],
|
||||||
|
onEnabledWorkflowStepsChange: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||||
|
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-empty", undefined));
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("task-edit-optional-steps-loading")).toBeNull());
|
||||||
|
expect(screen.queryByTestId("task-form-edit-workflow-steps-group")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("task-form-edit-optional-steps")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("calls onExecutionModeChange when execution mode selection changes", () => {
|
it("calls onExecutionModeChange when execution mode selection changes", () => {
|
||||||
const onExecutionModeChange = vi.fn();
|
const onExecutionModeChange = vi.fn();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user