FN-6033: fix mobile workflow editor selection and touch targets
Keep the workflow editor mobile-first selection flow usable and improve tap targets on phones. - start the mobile workflow editor on the workflow list with no preselected workflow and show a select-a-workflow prompt - keep mobile list/editor stage transitions in sync after create, duplicate, import, and delete actions - increase workflow editor mobile touch targets and padding without changing desktop sizing - add regression coverage for mobile viewport selection behavior and CSS touch-target scoping - update the dashboard guide to document the mobile workflow editor behavior Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/WorkflowNodeEditor.css | 56 +++++++++++++++++- packages/dashboard/app/components/WorkflowNodeEditor.tsx | 24 ++++++-- packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts | 36 ++++++++++++ packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx | 68 ++++++++++++++++++++++ Fusion-Task-Id: FN-6033 Fusion-Task-Lineage: c9da29ef-c099-4e14-8671-24995106c78c
This commit is contained in:
@@ -111,7 +111,7 @@ Behavior:
|
||||
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. 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 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 and stacks the sidebar, canvas, inspector, and settings/authoring panels vertically so each section remains scrollable and usable on phones
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -252,6 +252,15 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wf-editor-select-note {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: color-mix(in srgb, var(--ws-info) 8%, var(--bg-secondary));
|
||||
border: 1px solid var(--ws-info);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wf-editor-canvas-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1160,6 +1169,8 @@
|
||||
|
||||
.wf-editor-modal,
|
||||
.wf-create-modal {
|
||||
--wf-editor-touch-target: calc(var(--space-xl) + var(--space-lg) + var(--space-xs));
|
||||
|
||||
width: 100vw;
|
||||
min-width: 0;
|
||||
max-width: 100vw;
|
||||
@@ -1228,8 +1239,9 @@
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: var(--space-xs);
|
||||
min-height: var(--wf-editor-touch-target);
|
||||
margin: var(--space-sm) var(--space-sm) 0;
|
||||
padding: 5px 8px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
@@ -1259,6 +1271,48 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wf-editor-list-item,
|
||||
.wf-editor-new,
|
||||
.wf-editor-import,
|
||||
.wf-sidebar-section-toggle,
|
||||
.wf-palette-btn,
|
||||
.wf-editor-action,
|
||||
.wf-editor-delete,
|
||||
.wf-editor-save,
|
||||
.wf-templates-toggle,
|
||||
.wf-templates-entry,
|
||||
.wf-template-option,
|
||||
.wf-ai-toggle,
|
||||
.wf-workflow-name,
|
||||
.wf-workflow-name-input,
|
||||
.wf-column-panel button,
|
||||
.wf-fields-panel button,
|
||||
.wf-settings-panel button {
|
||||
min-height: var(--wf-editor-touch-target);
|
||||
}
|
||||
|
||||
.wf-editor-list-item,
|
||||
.wf-editor-new,
|
||||
.wf-editor-import,
|
||||
.wf-palette-btn,
|
||||
.wf-editor-action,
|
||||
.wf-editor-delete,
|
||||
.wf-editor-save,
|
||||
.wf-templates-toggle,
|
||||
.wf-templates-entry,
|
||||
.wf-ai-toggle {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.wf-field input,
|
||||
.wf-field textarea,
|
||||
.wf-field select,
|
||||
.wf-templates-filter,
|
||||
.wf-ai-prompt {
|
||||
min-height: var(--wf-editor-touch-target);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.wf-template-list {
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useAppSettings } from "../hooks/useAppSettings";
|
||||
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
|
||||
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
||||
import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext";
|
||||
import type { NodeSummaryCatalogs } from "./nodes/node-summary";
|
||||
@@ -660,9 +661,11 @@ function InnerEditor({
|
||||
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobileViewport = viewportMode === "mobile";
|
||||
const [workflowListStageOpen, setWorkflowListStageOpen] = useState(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia("(max-width: 768px)").matches;
|
||||
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -969,13 +972,16 @@ function InnerEditor({
|
||||
try {
|
||||
const data = await fetchWorkflows(projectId);
|
||||
setWorkflows(data);
|
||||
setActiveId((prev) => prev ?? data[0]?.id ?? null);
|
||||
setActiveId((prev) => {
|
||||
if (prev && data.some((workflow) => workflow.id === prev)) return prev;
|
||||
return isMobileViewport ? null : data[0]?.id ?? null;
|
||||
});
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to load workflows", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, addToast]);
|
||||
}, [projectId, addToast, isMobileViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWorkflows();
|
||||
@@ -1061,6 +1067,7 @@ function InnerEditor({
|
||||
const result = await importWorkflow(envelope, projectId);
|
||||
await loadWorkflows();
|
||||
setActiveId(result.workflow.id);
|
||||
setWorkflowListStageOpen(false);
|
||||
addToast(
|
||||
t("workflows.imported", 'Imported workflow "{{name}}"', { name: result.workflow.name }),
|
||||
"success",
|
||||
@@ -1567,6 +1574,7 @@ function InnerEditor({
|
||||
);
|
||||
setWorkflows((ws) => [...ws, created]);
|
||||
setActiveId(created.id);
|
||||
setWorkflowListStageOpen(false);
|
||||
addToast(t("workflows.created", 'Created workflow "{{name}}"', { name: created.name }), "success");
|
||||
closeCreateDialog();
|
||||
},
|
||||
@@ -1599,6 +1607,7 @@ function InnerEditor({
|
||||
pendingInterpreterOnlyRef.current = result.interpreterOnly;
|
||||
setWorkflows((ws) => [...ws, created]);
|
||||
setActiveId(created.id);
|
||||
setWorkflowListStageOpen(false);
|
||||
addToast(t("workflows.created", 'Created workflow "{{name}}"', { name: created.name }), "success");
|
||||
if (result.strippedApprovalFlags) {
|
||||
addToast(
|
||||
@@ -1627,11 +1636,12 @@ function InnerEditor({
|
||||
await deleteWorkflow(activeWorkflow.id, projectId);
|
||||
setWorkflows((ws) => ws.filter((w) => w.id !== activeWorkflow.id));
|
||||
setActiveId(null);
|
||||
if (isMobileViewport) setWorkflowListStageOpen(true);
|
||||
addToast(t("workflows.deleted", "Workflow deleted"), "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("workflows.deleteFailed", "Failed to delete workflow"), "error");
|
||||
}
|
||||
}, [activeWorkflow, projectId, addToast, confirm, t]);
|
||||
}, [activeWorkflow, projectId, addToast, confirm, t, isMobileViewport]);
|
||||
|
||||
const handleDuplicate = useCallback(async () => {
|
||||
if (!activeWorkflow) return;
|
||||
@@ -1647,6 +1657,7 @@ function InnerEditor({
|
||||
);
|
||||
setWorkflows((ws) => [...ws, created]);
|
||||
setActiveId(created.id);
|
||||
setWorkflowListStageOpen(false);
|
||||
addToast(`Duplicated to "${created.name}" — editable`, "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to duplicate workflow", "error");
|
||||
@@ -2146,6 +2157,11 @@ function InnerEditor({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isMobileViewport && workflows.length > 0 && !activeWorkflow ? (
|
||||
<div className="wf-editor-select-note" data-testid="wf-mobile-select-note">
|
||||
{t("workflows.mobileSelectNote", "Select a workflow to edit.")}
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<div className="wf-editor-empty">
|
||||
<Loader2 size={16} className="wf-spin" /> Loading…
|
||||
|
||||
@@ -82,6 +82,42 @@ describe("WorkflowNodeEditor mobile CSS contract", () => {
|
||||
expect(canvasWrapRule).toMatch(/min-height\s*:\s*0\s*;/);
|
||||
});
|
||||
|
||||
it("FN-6033 keeps workflow editor touch target increases mobile-scoped", () => {
|
||||
const baseCss = loadAllAppCssBaseOnly();
|
||||
const editorCss = readComponentCss("WorkflowNodeEditor.css");
|
||||
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
|
||||
|
||||
const modalRule = findRule(mobileBlocks, /\.wf-editor-modal,\s*\.wf-create-modal\s*\{[^}]*\}/);
|
||||
expect(modalRule).toMatch(/--wf-editor-touch-target\s*:\s*calc\(var\(--space-xl\) \+ var\(--space-lg\) \+ var\(--space-xs\)\)\s*;/);
|
||||
|
||||
const listAndActionRule = findRule(
|
||||
mobileBlocks,
|
||||
/\.wf-editor-list-item,\s*\.wf-editor-new,\s*\.wf-editor-import,[^}]*\.wf-settings-panel button\s*\{[^}]*\}/,
|
||||
);
|
||||
expect(listAndActionRule).toMatch(/min-height\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
|
||||
|
||||
const editorButtonsRule = findRule(
|
||||
mobileBlocks,
|
||||
/\.wf-editor-list-item,\s*\.wf-editor-new,\s*\.wf-editor-import,[^}]*\.wf-ai-toggle\s*\{[^}]*\}/,
|
||||
);
|
||||
expect(editorButtonsRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-md\)\s*;/);
|
||||
|
||||
const inlineControlsRule = findRule(
|
||||
mobileBlocks,
|
||||
/\.wf-field input,\s*\.wf-field textarea,\s*\.wf-field select,[^}]*\.wf-ai-prompt\s*\{[^}]*\}/,
|
||||
);
|
||||
expect(inlineControlsRule).toMatch(/min-height\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
|
||||
expect(inlineControlsRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-md\)\s*;/);
|
||||
|
||||
const mobileBackRule = findRule(mobileBlocks, /\.wf-editor-mobile-back\s*\{[^}]*\}/);
|
||||
expect(mobileBackRule).toMatch(/min-height\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
|
||||
expect(mobileBackRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-md\)\s*;/);
|
||||
|
||||
const desktopListRule = findRule([baseCss], /\.wf-editor-list-item\s*\{[^}]*\}/);
|
||||
expect(desktopListRule).not.toMatch(/min-height\s*:/);
|
||||
expect(editorCss).not.toMatch(/@media \(max-width: 768px\)\s*\{[^}]*\.btn\s*\{/s);
|
||||
});
|
||||
|
||||
it("FN-5992 covers create dialog and AI panel mobile overlays", () => {
|
||||
const editorCss = readComponentCss("WorkflowNodeEditor.css");
|
||||
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
|
||||
|
||||
@@ -81,12 +81,33 @@ import type { WorkflowStepTemplate } from "@fusion/core";
|
||||
import { beforeEach as viBeforeEach } from "vitest";
|
||||
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
|
||||
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
|
||||
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
|
||||
|
||||
function mockWorkflowEditorViewport(mode: "desktop" | "mobile" | "tablet" = "desktop") {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches:
|
||||
(mode === "mobile" && (query === MOBILE_MEDIA_QUERY || query === "(max-width: 768px)")) ||
|
||||
(mode === "tablet" && query === "(min-width: 769px) and (max-width: 1024px)"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// useAppSettings (threaded into the editor for the column-agent flag gate)
|
||||
// fetches config + settings on mount via the mocked api module. Default both to
|
||||
// resolved empties for every test so the real hook never rejects; column-agent
|
||||
// tests override fetchSettings to flip the flags on. fetchAgents defaults empty.
|
||||
viBeforeEach(() => {
|
||||
mockWorkflowEditorViewport("desktop");
|
||||
vi.mocked(fetchConfig).mockResolvedValue({ maxConcurrent: 2, rootDir: "." });
|
||||
vi.mocked(fetchSettings).mockResolvedValue({} as never);
|
||||
vi.mocked(fetchAgents).mockResolvedValue([]);
|
||||
@@ -297,6 +318,53 @@ describe("WorkflowNodeEditor", () => {
|
||||
await waitFor(() => expect(screen.getByText(/No workflows yet/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/No workflow selected/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-empty-create")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preselects the first populated workflow on desktop", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
|
||||
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
|
||||
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()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
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: "QA" })).not.toHaveClass("active");
|
||||
expect(screen.getByRole("button", { name: "Custom" })).not.toHaveClass("active");
|
||||
});
|
||||
|
||||
it("selects by 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={() => {}} />);
|
||||
|
||||
await screen.findByTestId("wf-mobile-select-note");
|
||||
const qaButtons = screen.getAllByRole("button", { name: "QA" });
|
||||
expect(qaButtons).toHaveLength(2);
|
||||
expect(qaButtons[0]).not.toHaveClass("active");
|
||||
expect(qaButtons[1]).not.toHaveClass("active");
|
||||
|
||||
fireEvent.click(qaButtons[1]);
|
||||
|
||||
await waitFor(() => expect(qaButtons[1]).toHaveClass("active"));
|
||||
expect(qaButtons[0]).not.toHaveClass("active");
|
||||
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
|
||||
Reference in New Issue
Block a user