FN-6037: open selected workflow editor on mobile
Open the board workflow editor directly to the selected workflow, including on mobile. - pass the selected workflow id from the board toolbar through app modal state into the workflow editor - preselect the requested workflow when loading workflows, while preserving settings/create entry points and mobile fallback behavior - cover the new workflow targeting behavior in board, modal manager, and workflow editor tests and document the mobile editor flow Files changed: docs/dashboard-guide.md | 3 +- packages/dashboard/app/App.tsx | 4 +- packages/dashboard/app/components/AppModals.tsx | 1 + packages/dashboard/app/components/Board.tsx | 6 +- packages/dashboard/app/components/WorkflowNodeEditor.tsx | 13 ++++- packages/dashboard/app/components/__tests__/Board.test.tsx | 4 ++ packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx | 68 ++++++++++++++++++++++ packages/dashboard/app/hooks/__tests__/useModalManager.test.ts | 42 +++++++++++++ packages/dashboard/app/hooks/useModalManager.ts | 16 +++-- Fusion-Task-Id: FN-6037 Fusion-Task-Lineage: e983e699-5376-434f-af03-39ce5a300029
This commit is contained in:
@@ -104,6 +104,7 @@ The workflow editor opens as a full-screen modal editor for authoring custom wor
|
||||
|
||||
Navigation:
|
||||
- Open a task or board surface that shows the workflow selector, then choose **Manage…**
|
||||
- From the board workflow toolbar, use the edit workflow button beside the selector to open the currently selected workflow directly when one is selected.
|
||||
|
||||
Behavior:
|
||||
- Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels
|
||||
@@ -111,7 +112,7 @@ Behavior:
|
||||
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring.
|
||||
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; those dropdown controls write workflow setting values for the active default workflow.
|
||||
- On desktop, the editor uses a multi-panel layout for editing the graph and adjacent workflow metadata
|
||||
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet, opens to the workflow list with no workflow preselected, prompts users to select a workflow to edit, and uses larger workflow-editor touch targets so each section remains scrollable and usable on phones
|
||||
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the board workflow toolbar edit button opens directly to the selected workflow editor when that selected workflow is available.
|
||||
- The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens
|
||||
|
||||
## Planning Mode
|
||||
|
||||
@@ -1239,8 +1239,8 @@ function AppInner() {
|
||||
pushNav({ type: "modal", close: modalManager.closeScripts });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const openWorkflowEditorWithNav = useCallback(() => {
|
||||
modalManager.openWorkflowEditor();
|
||||
const openWorkflowEditorWithNav = useCallback((workflowId?: string) => {
|
||||
modalManager.openWorkflowEditor(undefined, workflowId);
|
||||
pushNav({ type: "modal", close: modalManager.closeWorkflowEditor });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
|
||||
@@ -387,6 +387,7 @@ export function AppModals({
|
||||
projectId={projectId}
|
||||
initialPanel={modalManager.workflowEditorInitialPanel}
|
||||
initialAction={modalManager.workflowEditorInitialAction}
|
||||
initialWorkflowId={modalManager.workflowEditorInitialWorkflowId}
|
||||
/>
|
||||
</Suspense>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
@@ -67,8 +67,8 @@ interface BoardProps {
|
||||
lastFetchTimeMs?: number;
|
||||
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
|
||||
prAuthAvailable?: boolean;
|
||||
/** Opens the workflow editor modal. */
|
||||
onOpenWorkflowEditor?: () => void;
|
||||
/** Opens the workflow editor modal, optionally focused on a workflow id. */
|
||||
onOpenWorkflowEditor?: (workflowId?: string) => void;
|
||||
/** Opens the workflow editor to create a new workflow. */
|
||||
onCreateWorkflow?: () => void;
|
||||
}
|
||||
@@ -460,7 +460,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm board-workflow-edit-btn"
|
||||
onClick={onOpenWorkflowEditor}
|
||||
onClick={() => onOpenWorkflowEditor(selectedWorkflow.id)}
|
||||
title="Edit workflows"
|
||||
aria-label="Edit workflows"
|
||||
>
|
||||
|
||||
@@ -173,6 +173,8 @@ interface WorkflowNodeEditorProps {
|
||||
initialPanel?: "settings";
|
||||
/** When "create" the editor opens with the new-workflow dialog active. */
|
||||
initialAction?: "create";
|
||||
/** Workflow id to pre-select when opening directly into an existing workflow. */
|
||||
initialWorkflowId?: string;
|
||||
}
|
||||
|
||||
let nodeSeq = 0;
|
||||
@@ -657,6 +659,7 @@ function InnerEditor({
|
||||
projectId,
|
||||
initialPanel,
|
||||
initialAction,
|
||||
initialWorkflowId,
|
||||
modalRef,
|
||||
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
@@ -972,16 +975,22 @@ function InnerEditor({
|
||||
try {
|
||||
const data = await fetchWorkflows(projectId);
|
||||
setWorkflows(data);
|
||||
const requestedWorkflow = initialWorkflowId
|
||||
? data.find((workflow) => workflow.id === initialWorkflowId)
|
||||
: undefined;
|
||||
setActiveId((prev) => {
|
||||
if (prev && data.some((workflow) => workflow.id === prev)) return prev;
|
||||
return isMobileViewport ? null : data[0]?.id ?? null;
|
||||
return requestedWorkflow?.id ?? (isMobileViewport ? null : data[0]?.id ?? null);
|
||||
});
|
||||
if (requestedWorkflow && isMobileViewport) {
|
||||
setWorkflowListStageOpen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to load workflows", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, addToast, isMobileViewport]);
|
||||
}, [projectId, addToast, isMobileViewport, initialWorkflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWorkflows();
|
||||
|
||||
@@ -1012,6 +1012,7 @@ describe("Board", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
|
||||
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("builtin:coding");
|
||||
});
|
||||
|
||||
it("preserves workflow toolbar partial action visibility", async () => {
|
||||
@@ -1065,6 +1066,9 @@ describe("Board", () => {
|
||||
await waitFor(() => expect(screen.getByTestId("column-intake")).toBeDefined());
|
||||
expect(JSON.parse(screen.getByTestId("column-intake").getAttribute("data-tasks") || "[]").map((task: Task) => task.id).sort()).toEqual(["FN-2", "FN-3"]);
|
||||
expect(screen.queryByTestId("column-todo")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-custom");
|
||||
});
|
||||
|
||||
it("keeps the default workflow first in the dropdown even when another workflow has cards", async () => {
|
||||
|
||||
@@ -331,6 +331,28 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(screen.getAllByRole("button", { name: "QA" })[0]).toHaveClass("active");
|
||||
});
|
||||
|
||||
it("preselects the requested workflow id on desktop instead of the first workflow", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-002" />);
|
||||
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Custom");
|
||||
const workflowItems = document.querySelectorAll(".wf-editor-list-item");
|
||||
expect(workflowItems[0]).toHaveTextContent("QA");
|
||||
expect(workflowItems[0]).not.toHaveClass("active");
|
||||
expect(workflowItems[1]).toHaveTextContent("Custom");
|
||||
expect(workflowItems[1]).toHaveClass("active");
|
||||
});
|
||||
|
||||
it("falls back to the first desktop workflow when the requested workflow id is missing", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-deleted" />);
|
||||
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
|
||||
expect(screen.getAllByRole("button", { name: "QA" })[0]).toHaveClass("active");
|
||||
});
|
||||
|
||||
it("opens populated mobile workflows on the list with no preselected workflow", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
|
||||
@@ -344,6 +366,52 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(screen.getByRole("button", { name: "Custom" })).not.toHaveClass("active");
|
||||
});
|
||||
|
||||
it("opens the requested workflow id directly on mobile and bypasses the select stage", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-002" />);
|
||||
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Custom");
|
||||
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
|
||||
const workflowItems = document.querySelectorAll(".wf-editor-list-item");
|
||||
expect(workflowItems[0]).toHaveTextContent("QA");
|
||||
expect(workflowItems[0]).not.toHaveClass("active");
|
||||
expect(workflowItems[1]).toHaveTextContent("Custom");
|
||||
expect(workflowItems[1]).toHaveClass("active");
|
||||
});
|
||||
|
||||
it("keeps the mobile list fallback when the requested workflow id is missing", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-deleted" />);
|
||||
|
||||
expect(await screen.findByTestId("wf-mobile-select-note")).toHaveTextContent("Select a workflow to edit.");
|
||||
expect(screen.getByText(/No workflow selected/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("wf-workflow-name")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Custom" })).not.toHaveClass("active");
|
||||
});
|
||||
|
||||
it("selects the requested workflow id on mobile even when workflow names are duplicated", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([
|
||||
{ ...def(), id: "WF-DUP-A", name: "QA" },
|
||||
{ ...v2Def(), id: "WF-DUP-B", name: "QA" },
|
||||
]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-DUP-B" />);
|
||||
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
|
||||
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
|
||||
const qaButtons = document.querySelectorAll(".wf-editor-list-item");
|
||||
expect(qaButtons).toHaveLength(2);
|
||||
expect(qaButtons[0]).toHaveTextContent("QA");
|
||||
expect(qaButtons[0]).not.toHaveClass("active");
|
||||
expect(qaButtons[1]).toHaveTextContent("QA");
|
||||
expect(qaButtons[1]).toHaveClass("active");
|
||||
});
|
||||
|
||||
it("selects by workflow id on mobile even when workflow names are duplicated", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([
|
||||
|
||||
@@ -344,4 +344,46 @@ describe("useModalManager", () => {
|
||||
expect(result.current.detailTask?.id).toBe("FN-B");
|
||||
expect(result.current.detailTask?.title).toBe("renamed");
|
||||
});
|
||||
|
||||
it("tracks a target workflow id for normal workflow editor opens and resets it on close", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openWorkflowEditor(undefined, "WF-selected");
|
||||
});
|
||||
|
||||
expect(result.current.workflowEditorOpen).toBe(true);
|
||||
expect(result.current.workflowEditorInitialPanel).toBeUndefined();
|
||||
expect(result.current.workflowEditorInitialAction).toBeUndefined();
|
||||
expect(result.current.workflowEditorInitialWorkflowId).toBe("WF-selected");
|
||||
|
||||
act(() => {
|
||||
result.current.closeWorkflowEditor();
|
||||
});
|
||||
|
||||
expect(result.current.workflowEditorOpen).toBe(false);
|
||||
expect(result.current.workflowEditorInitialWorkflowId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps workflow editor settings and create modes distinct from target workflow opens", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openWorkflowEditor("settings", "WF-ignored");
|
||||
});
|
||||
expect(result.current.workflowEditorInitialPanel).toBe("settings");
|
||||
expect(result.current.workflowEditorInitialAction).toBeUndefined();
|
||||
expect(result.current.workflowEditorInitialWorkflowId).toBeUndefined();
|
||||
|
||||
act(() => {
|
||||
result.current.openWorkflowEditor("create", "WF-ignored");
|
||||
});
|
||||
expect(result.current.workflowEditorInitialPanel).toBeUndefined();
|
||||
expect(result.current.workflowEditorInitialAction).toBe("create");
|
||||
expect(result.current.workflowEditorInitialWorkflowId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface ModalManager {
|
||||
workflowEditorInitialPanel?: "settings";
|
||||
/** When the workflow editor opens, which modal action to start. */
|
||||
workflowEditorInitialAction?: "create";
|
||||
/** When the workflow editor opens for editing, which workflow id to pre-select. */
|
||||
workflowEditorInitialWorkflowId?: string;
|
||||
agentsOpen: boolean;
|
||||
scriptsOpen: boolean;
|
||||
setupWizardOpen: boolean;
|
||||
@@ -120,7 +122,7 @@ export interface ModalManager {
|
||||
openGitManager: () => void;
|
||||
closeGitManager: () => void;
|
||||
|
||||
openWorkflowEditor: (initialPanelOrAction?: "settings" | "create") => void;
|
||||
openWorkflowEditor: (initialPanelOrAction?: "settings" | "create", initialWorkflowId?: string) => void;
|
||||
closeWorkflowEditor: () => void;
|
||||
|
||||
openAgents: () => void;
|
||||
@@ -181,6 +183,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const [workflowEditorOpen, setWorkflowEditorOpen] = useState(false);
|
||||
const [workflowEditorInitialPanel, setWorkflowEditorInitialPanel] = useState<"settings" | undefined>(undefined);
|
||||
const [workflowEditorInitialAction, setWorkflowEditorInitialAction] = useState<"create" | undefined>(undefined);
|
||||
const [workflowEditorInitialWorkflowId, setWorkflowEditorInitialWorkflowId] = useState<string | undefined>(undefined);
|
||||
const [agentsOpen, setAgentsOpen] = useState(false);
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false);
|
||||
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
|
||||
@@ -346,15 +349,19 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const openGitManager = useCallback(() => setGitManagerOpen(true), []);
|
||||
const closeGitManager = useCallback(() => setGitManagerOpen(false), []);
|
||||
|
||||
const openWorkflowEditor = useCallback((initialPanelOrAction?: "settings" | "create") => {
|
||||
setWorkflowEditorInitialPanel(initialPanelOrAction === "settings" ? "settings" : undefined);
|
||||
setWorkflowEditorInitialAction(initialPanelOrAction === "create" ? "create" : undefined);
|
||||
const openWorkflowEditor = useCallback((initialPanelOrAction?: "settings" | "create", initialWorkflowId?: string) => {
|
||||
const isSettingsOpen = initialPanelOrAction === "settings";
|
||||
const isCreateOpen = initialPanelOrAction === "create";
|
||||
setWorkflowEditorInitialPanel(isSettingsOpen ? "settings" : undefined);
|
||||
setWorkflowEditorInitialAction(isCreateOpen ? "create" : undefined);
|
||||
setWorkflowEditorInitialWorkflowId(!isSettingsOpen && !isCreateOpen ? initialWorkflowId : undefined);
|
||||
setWorkflowEditorOpen(true);
|
||||
}, []);
|
||||
const closeWorkflowEditor = useCallback(() => {
|
||||
setWorkflowEditorOpen(false);
|
||||
setWorkflowEditorInitialPanel(undefined);
|
||||
setWorkflowEditorInitialAction(undefined);
|
||||
setWorkflowEditorInitialWorkflowId(undefined);
|
||||
}, []);
|
||||
|
||||
const openAgents = useCallback(() => setAgentsOpen(true), []);
|
||||
@@ -424,6 +431,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
workflowEditorOpen,
|
||||
workflowEditorInitialPanel,
|
||||
workflowEditorInitialAction,
|
||||
workflowEditorInitialWorkflowId,
|
||||
agentsOpen,
|
||||
scriptsOpen,
|
||||
setupWizardOpen,
|
||||
|
||||
Reference in New Issue
Block a user