FN-6903: preserve workflow quick-create visibility
Optimistically attach newly quick-created tasks to the active workflow lane until workflow metadata refetches. - Update board and list quick-create paths to cache the selected workflow for created tasks immediately.\n- Refresh board-workflows metadata after optimistic assignment so authoritative state reconciles.\n- Cover board/list workflow-lane quick-create visibility with regression tests.\n- Document the workflow-filtered quick-create behavior and add a patch changeset.\n\nFiles changed:\n .../fn-6903-workflow-lane-create-visibility.md | 5 +\n docs/dashboard-guide.md | 2 +-\n packages/dashboard/app/components/Board.tsx | 34 ++-\n packages/dashboard/app/components/ListView.tsx | 36 ++-\n ...d-quickcreate-workflow-lane-visibility.test.tsx | 319 +++++++++++++++++++++\n 5 files changed, 390 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-6903 Fusion-Task-Lineage: dd2c6fa1-f2e8-4777-aa3c-69d000efb1b1
This commit is contained in:
5
.changeset/fn-6903-workflow-lane-create-visibility.md
Normal file
5
.changeset/fn-6903-workflow-lane-create-visibility.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Tasks created from a selected non-default workflow lane now appear on that lane immediately instead of vanishing until the board-workflows metadata refetch catches up.
|
||||
@@ -238,7 +238,7 @@ Planning Mode now includes branch controls on the summary screen before you crea
|
||||
|
||||
These values are sent with the Planning Mode create-task request as `branchSelection`, so created tasks persist branch/base-branch settings consistently with other branch-aware task creation flows.
|
||||
|
||||
When Planning Mode or Subtask Breakdown is opened from a workflow-filtered board lane, the create request also carries that active workflow selection. Single-task planning saves, planning breakdown saves, and subtask-breakdown saves create their tasks directly on the selected workflow lane instead of briefly landing on the default board.
|
||||
When inline quick-create, Planning Mode, or Subtask Breakdown is opened from a workflow-filtered board/list lane, the create request also carries that active workflow selection. Quick-created tasks appear on the selected workflow lane immediately while board-workflows metadata refreshes, and planning saves, planning breakdown saves, and subtask-breakdown saves create their tasks directly on the selected workflow lane instead of briefly landing on the default board.
|
||||
|
||||
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).
|
||||
|
||||
|
||||
@@ -491,6 +491,38 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
});
|
||||
}, [boardWorkflows, selectedWorkflow, tasks, workflowMode]);
|
||||
|
||||
const applyOptimisticTaskWorkflow = useCallback((taskId: string, workflowId: string) => {
|
||||
setBoardWorkflowsState((previous) => {
|
||||
if (!previous || previous.projectId !== projectId) return previous;
|
||||
if (previous.payload.taskWorkflowIds[taskId]) return previous;
|
||||
|
||||
const payload: BoardWorkflowsPayload = {
|
||||
...previous.payload,
|
||||
taskWorkflowIds: {
|
||||
...previous.payload.taskWorkflowIds,
|
||||
[taskId]: workflowId,
|
||||
},
|
||||
};
|
||||
writeBoardWorkflowsCache(projectId, payload);
|
||||
return { projectId, payload };
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowBoard 2026-06-21-21:34:
|
||||
* A task created on a selected non-default workflow lane must render in that lane immediately. The task list updates before board-workflows taskWorkflowIds, so without this optimistic project-scoped assignment the filter falls back to the default workflow and hides the new card until the next metadata refetch (FN-6903).
|
||||
*/
|
||||
const handleWorkflowQuickCreate = useCallback(async (input: TaskCreateInput) => {
|
||||
if (!onQuickCreate || !selectedWorkflow) return undefined;
|
||||
const created = await onQuickCreate(input);
|
||||
if (created?.id) {
|
||||
const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? selectedWorkflow.id;
|
||||
applyOptimisticTaskWorkflow(created.id, createdWorkflowId);
|
||||
refreshBoardWorkflows();
|
||||
}
|
||||
return created;
|
||||
}, [applyOptimisticTaskWorkflow, onQuickCreate, refreshBoardWorkflows, selectedWorkflow]);
|
||||
|
||||
const selectedWorkflowArchivedColumn = useMemo(() => {
|
||||
if (!selectedWorkflow) return null;
|
||||
return selectedWorkflow.columns.find((column) => column.flags.archived) ?? null;
|
||||
@@ -658,7 +690,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMerge={autoMerge}
|
||||
{...(isCreateColumn ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(isCreateColumn ? { onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
||||
/>
|
||||
|
||||
@@ -687,17 +687,45 @@ export function ListView({
|
||||
return target?.id;
|
||||
}, [listColumns]);
|
||||
|
||||
const handleListQuickCreate = useCallback((input: TaskCreateInput) => {
|
||||
/**
|
||||
* FNXC:WorkflowList 2026-06-21-21:37:
|
||||
* List quick-create shares Board's workflow filtering invariant: when taskWorkflowIds lags task creation, optimistically recording the selected workflow keeps the newly-created row visible in the active workflow lane until the authoritative refetch reconciles it (FN-6903).
|
||||
*/
|
||||
const applyOptimisticTaskWorkflow = useCallback((taskId: string, workflowId: string) => {
|
||||
setBoardWorkflowsState((previous) => {
|
||||
if (!previous || previous.projectId !== projectId) return previous;
|
||||
if (previous.payload.taskWorkflowIds[taskId]) return previous;
|
||||
|
||||
const payload: BoardWorkflowsPayload = {
|
||||
...previous.payload,
|
||||
taskWorkflowIds: {
|
||||
...previous.payload.taskWorkflowIds,
|
||||
[taskId]: workflowId,
|
||||
},
|
||||
};
|
||||
writeBoardWorkflowsCache(projectId, payload);
|
||||
return { projectId, payload };
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const handleListQuickCreate = useCallback(async (input: TaskCreateInput) => {
|
||||
const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"));
|
||||
if (workflowMode && selectedWorkflow && createTargetColumn) {
|
||||
return create({
|
||||
const workflowId = input.workflowId ?? selectedWorkflow.id;
|
||||
const created = await create({
|
||||
...input,
|
||||
column: input.column ?? createTargetColumn,
|
||||
workflowId: input.workflowId ?? selectedWorkflow.id,
|
||||
workflowId,
|
||||
});
|
||||
if (created?.id) {
|
||||
const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? workflowId;
|
||||
applyOptimisticTaskWorkflow(created.id, createdWorkflowId);
|
||||
refreshBoardWorkflows();
|
||||
}
|
||||
return created;
|
||||
}
|
||||
return create(input);
|
||||
}, [addToast, createTargetColumn, onQuickCreate, selectedWorkflow, t, workflowMode]);
|
||||
}, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, onQuickCreate, refreshBoardWorkflows, selectedWorkflow, t, workflowMode]);
|
||||
|
||||
|
||||
// Column display labels
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import React, { useState } from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { Task, TaskCreateInput } from "@fusion/core";
|
||||
import { Board } from "../Board";
|
||||
import { ListView } from "../ListView";
|
||||
import type { BoardWorkflowsPayload } from "../../api";
|
||||
|
||||
const fetchBoardWorkflowsMock = vi.fn();
|
||||
const fetchTaskDetailMock = vi.fn();
|
||||
const batchUpdateTaskModelsMock = vi.fn();
|
||||
const fetchNodesMock = vi.fn(() => new Promise(() => {}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkflowSteps: vi.fn(() => new Promise(() => {})),
|
||||
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
|
||||
promoteTask: vi.fn().mockResolvedValue({}),
|
||||
fetchTaskDetail: (...args: unknown[]) => fetchTaskDetailMock(...args),
|
||||
batchUpdateTaskModels: (...args: unknown[]) => batchUpdateTaskModelsMock(...args),
|
||||
fetchNodes: (...args: unknown[]) => fetchNodesMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../Column", () => ({
|
||||
Column: ({ column, tasks, onQuickCreate, workflowId, workflowMode }: {
|
||||
column: string;
|
||||
tasks: Task[];
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
|
||||
workflowId?: string;
|
||||
workflowMode?: boolean;
|
||||
}) => (
|
||||
<section data-testid={`column-${column}`} data-task-ids={JSON.stringify(tasks.map((task) => task.id))}>
|
||||
{tasks.map((task) => <article key={task.id}>{task.title}</article>)}
|
||||
{onQuickCreate ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`quick-create-${column}`}
|
||||
onClick={() => void onQuickCreate({
|
||||
title: `Created ${workflowId ?? "legacy"}`,
|
||||
description: `Created ${workflowId ?? "legacy"}`,
|
||||
column,
|
||||
...(workflowMode && workflowId ? { workflowId } : {}),
|
||||
})}
|
||||
>
|
||||
Create in {column}
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../QuickEntryBox", () => ({
|
||||
QuickEntryBox: ({ onCreate }: { onCreate?: (input: TaskCreateInput) => Promise<Task | void> }) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="list-quick-create"
|
||||
onClick={() => void onCreate?.({ title: "Created from list", description: "Created from list" })}
|
||||
>
|
||||
Create list task
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../TaskDetailModal", () => ({
|
||||
TaskDetailContent: () => <div data-testid="task-detail-content" />,
|
||||
}));
|
||||
|
||||
vi.mock("../CustomModelDropdown", () => ({
|
||||
CustomModelDropdown: () => <div data-testid="custom-model-dropdown" />,
|
||||
}));
|
||||
|
||||
const PROJECT_ID = "project-fn-6903";
|
||||
|
||||
const DEFAULT_WORKFLOW = {
|
||||
id: "builtin:coding",
|
||||
name: "Coding",
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", flags: { intake: true } },
|
||||
{ id: "todo", name: "Todo", flags: { hold: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
{ id: "archived", name: "Archived", flags: { archived: true } },
|
||||
],
|
||||
};
|
||||
|
||||
const CUSTOM_WORKFLOW = {
|
||||
id: "wf-custom",
|
||||
name: "Custom Flow",
|
||||
columns: [
|
||||
{ id: "intake", name: "Intake", flags: { intake: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
],
|
||||
};
|
||||
|
||||
function mkTask(overrides: Partial<Task> & { id: string }): Task {
|
||||
return {
|
||||
title: overrides.id,
|
||||
description: "Task",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-06-21T00:00:00.000Z",
|
||||
updatedAt: "2026-06-21T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workflowPayload(taskWorkflowIds: Record<string, string>, flagEnabled = true): BoardWorkflowsPayload {
|
||||
return {
|
||||
flagEnabled,
|
||||
defaultWorkflowId: DEFAULT_WORKFLOW.id,
|
||||
workflows: flagEnabled ? [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW] : [],
|
||||
taskWorkflowIds,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function readWorkflowCache(): BoardWorkflowsPayload | null {
|
||||
const raw = window.sessionStorage.getItem(`fusion:board-workflows:${PROJECT_ID}`);
|
||||
return raw ? JSON.parse(raw) as BoardWorkflowsPayload : null;
|
||||
}
|
||||
|
||||
function selectWorkflow(workflowId: string) {
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher"));
|
||||
fireEvent.click(screen.getByTestId(`workflow-switcher-option-${workflowId}`));
|
||||
}
|
||||
|
||||
function BoardHarness({ createdTaskId = "FN-new", createReturnsTask = true, onCreateInput }: {
|
||||
createdTaskId?: string;
|
||||
createReturnsTask?: boolean;
|
||||
onCreateInput?: (input: TaskCreateInput) => void;
|
||||
}) {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const onQuickCreate = vi.fn(async (input: TaskCreateInput) => {
|
||||
onCreateInput?.(input);
|
||||
if (!createReturnsTask) return undefined;
|
||||
const task = mkTask({
|
||||
id: createdTaskId,
|
||||
title: input.title ?? input.description ?? createdTaskId,
|
||||
description: input.description ?? "Task",
|
||||
column: input.column ?? "triage",
|
||||
});
|
||||
setTasks((current) => [...current, task]);
|
||||
return task;
|
||||
});
|
||||
|
||||
return (
|
||||
<Board
|
||||
tasks={tasks}
|
||||
projectId={PROJECT_ID}
|
||||
maxConcurrent={2}
|
||||
onMoveTask={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
onQuickCreate={onQuickCreate}
|
||||
onNewTask={vi.fn()}
|
||||
autoMerge
|
||||
onToggleAutoMerge={vi.fn()}
|
||||
workflowColumnsEnabled
|
||||
settingsLoaded
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ListHarness({ createdTaskId = "FN-new", createReturnsTask = true, onCreateInput }: {
|
||||
createdTaskId?: string;
|
||||
createReturnsTask?: boolean;
|
||||
onCreateInput?: (input: TaskCreateInput) => void;
|
||||
}) {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const onQuickCreate = vi.fn(async (input: TaskCreateInput) => {
|
||||
onCreateInput?.(input);
|
||||
if (!createReturnsTask) return undefined;
|
||||
const task = mkTask({
|
||||
id: createdTaskId,
|
||||
title: input.title ?? input.description ?? createdTaskId,
|
||||
description: input.description ?? "Task",
|
||||
column: input.column ?? "triage",
|
||||
});
|
||||
setTasks((current) => [...current, task]);
|
||||
return task;
|
||||
});
|
||||
|
||||
return (
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
projectId={PROJECT_ID}
|
||||
onMoveTask={vi.fn()}
|
||||
onDeleteTask={vi.fn()}
|
||||
onMergeTask={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
onQuickCreate={onQuickCreate}
|
||||
workflowColumnsEnabled
|
||||
settingsLoaded
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchBoardWorkflowsMock.mockReset();
|
||||
fetchTaskDetailMock.mockReset();
|
||||
batchUpdateTaskModelsMock.mockReset();
|
||||
fetchNodesMock.mockClear();
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe("workflow lane quick-create visibility", () => {
|
||||
it.each([
|
||||
["Board", BoardHarness, () => fireEvent.click(screen.getByTestId("quick-create-intake")), "Created wf-custom"],
|
||||
["ListView", ListHarness, () => fireEvent.click(screen.getByTestId("list-quick-create")), "Created from list"],
|
||||
] as const)("%s shows a task created in a non-default workflow lane before the board-workflows refetch resolves", async (_surface, Harness, create, title) => {
|
||||
const refetch = deferred<BoardWorkflowsPayload>();
|
||||
fetchBoardWorkflowsMock
|
||||
.mockResolvedValueOnce(workflowPayload({}))
|
||||
.mockResolvedValueOnce(workflowPayload({}))
|
||||
.mockReturnValueOnce(refetch.promise);
|
||||
|
||||
render(<Harness />);
|
||||
await screen.findByTestId("workflow-switcher");
|
||||
selectWorkflow(CUSTOM_WORKFLOW.id);
|
||||
|
||||
await act(async () => {
|
||||
create();
|
||||
});
|
||||
|
||||
expect(screen.getByText(title)).toBeTruthy();
|
||||
expect(readWorkflowCache()?.taskWorkflowIds["FN-new"]).toBe(CUSTOM_WORKFLOW.id);
|
||||
expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
await act(async () => {
|
||||
refetch.resolve(workflowPayload({ "FN-new": CUSTOM_WORKFLOW.id }));
|
||||
await refetch.promise;
|
||||
});
|
||||
|
||||
expect(screen.getByText(title)).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Board", BoardHarness, () => fireEvent.click(screen.getByTestId("quick-create-triage")), "Created builtin:coding"],
|
||||
["ListView", ListHarness, () => fireEvent.click(screen.getByTestId("list-quick-create")), "Created from list"],
|
||||
] as const)("%s keeps default workflow quick-create visible immediately", async (_surface, Harness, create, title) => {
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({}));
|
||||
|
||||
render(<Harness />);
|
||||
await screen.findByTestId("workflow-switcher");
|
||||
|
||||
await act(async () => {
|
||||
create();
|
||||
});
|
||||
|
||||
expect(screen.getByText(title)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves the legacy flag-off Board quick-create path unchanged", async () => {
|
||||
const inputs: TaskCreateInput[] = [];
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({}, false));
|
||||
|
||||
render(<BoardHarness onCreateInput={(input) => inputs.push(input)} />);
|
||||
await waitFor(() => expect(screen.getByTestId("quick-create-triage")).toBeTruthy());
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("quick-create-triage"));
|
||||
});
|
||||
|
||||
expect(screen.getByText("Created legacy")).toBeTruthy();
|
||||
expect(inputs[0]?.workflowId).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Board", BoardHarness, () => fireEvent.click(screen.getByTestId("quick-create-intake"))],
|
||||
["ListView", ListHarness, () => fireEvent.click(screen.getByTestId("list-quick-create"))],
|
||||
] as const)("%s does not crash or merge when quick-create resolves void", async (_surface, Harness, create) => {
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({}));
|
||||
|
||||
render(<Harness createReturnsTask={false} />);
|
||||
await screen.findByTestId("workflow-switcher");
|
||||
selectWorkflow(CUSTOM_WORKFLOW.id);
|
||||
|
||||
await act(async () => {
|
||||
create();
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Created/)).toBeNull();
|
||||
expect(readWorkflowCache()?.taskWorkflowIds["FN-new"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not overwrite a server-raced taskWorkflowIds entry in the Board cache", async () => {
|
||||
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ "FN-new": DEFAULT_WORKFLOW.id }));
|
||||
|
||||
render(<BoardHarness />);
|
||||
await screen.findByTestId("workflow-switcher");
|
||||
selectWorkflow(CUSTOM_WORKFLOW.id);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("quick-create-intake"));
|
||||
});
|
||||
|
||||
expect(within(screen.getByTestId("column-intake")).queryByText("Created wf-custom")).toBeNull();
|
||||
expect(readWorkflowCache()?.taskWorkflowIds["FN-new"]).toBe(DEFAULT_WORKFLOW.id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user