feat: redesign mobile workflow editor
This commit is contained in:
138
packages/dashboard/app/components/MobileWorkflowGraphView.css
Normal file
138
packages/dashboard/app/components/MobileWorkflowGraphView.css
Normal file
@@ -0,0 +1,138 @@
|
||||
.mobile-wf-graph {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mobile-wf-node-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.mobile-wf-node-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-xs);
|
||||
padding-left: calc(var(--mobile-wf-depth, 0) * var(--space-md));
|
||||
}
|
||||
|
||||
.mobile-wf-node-main {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
min-height: var(--wf-editor-touch-target, 44px);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-wf-node-row--selected .mobile-wf-node-main {
|
||||
border-color: var(--accent, var(--ws-info));
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.mobile-wf-node-kind {
|
||||
min-width: 4.5rem;
|
||||
max-width: 6rem;
|
||||
padding: 2px var(--space-xs);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mobile-wf-node-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mobile-wf-node-title,
|
||||
.mobile-wf-node-summary,
|
||||
.mobile-wf-edge-target {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mobile-wf-node-title {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-wf-node-summary {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.mobile-wf-node-expand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--wf-editor-touch-target, 44px);
|
||||
min-height: var(--wf-editor-touch-target, 44px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-wf-node-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
padding-left: calc((var(--mobile-wf-depth, 0) * var(--space-md)) + var(--space-xs));
|
||||
}
|
||||
|
||||
.mobile-wf-column-chip,
|
||||
.mobile-wf-edge-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 34px;
|
||||
max-width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.mobile-wf-edge-chip {
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-wf-edge-chip--selected {
|
||||
border-color: var(--accent, var(--ws-info));
|
||||
}
|
||||
|
||||
.mobile-wf-edge-target {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-wf-node-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mobile-wf-graph-empty {
|
||||
padding: var(--space-lg);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
138
packages/dashboard/app/components/MobileWorkflowGraphView.tsx
Normal file
138
packages/dashboard/app/components/MobileWorkflowGraphView.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { ChevronDown, ChevronRight, GitBranch, Pencil } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MobileWorkflowNodeSummary } from "./workflow-mobile-graph";
|
||||
import "./MobileWorkflowGraphView.css";
|
||||
|
||||
interface MobileWorkflowGraphViewProps {
|
||||
rows: MobileWorkflowNodeSummary[];
|
||||
selectedNodeId?: string | null;
|
||||
selectedEdgeId?: string | null;
|
||||
onSelectNode: (id: string) => void;
|
||||
onSelectEdge: (id: string) => void;
|
||||
}
|
||||
|
||||
function NodeRow({
|
||||
row,
|
||||
depth,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
onSelectNode,
|
||||
onSelectEdge,
|
||||
}: {
|
||||
row: MobileWorkflowNodeSummary;
|
||||
depth: number;
|
||||
selectedNodeId?: string | null;
|
||||
selectedEdgeId?: string | null;
|
||||
onSelectNode: (id: string) => void;
|
||||
onSelectEdge: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const hasChildren = row.children.length > 0;
|
||||
const [expanded, setExpanded] = useState(depth === 0);
|
||||
const selected = selectedNodeId === row.id;
|
||||
|
||||
return (
|
||||
<div className="mobile-wf-node-group">
|
||||
<div
|
||||
className={`mobile-wf-node-row${selected ? " mobile-wf-node-row--selected" : ""}`}
|
||||
style={{ ["--mobile-wf-depth" as string]: String(depth) }}
|
||||
data-testid={`mobile-wf-node-${row.id}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-wf-node-main"
|
||||
onClick={() => onSelectNode(row.id)}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
>
|
||||
<span className="mobile-wf-node-kind">{row.kind}</span>
|
||||
<span className="mobile-wf-node-text">
|
||||
<span className="mobile-wf-node-title">{row.label}</span>
|
||||
{row.summary ? <span className="mobile-wf-node-summary">{row.summary}</span> : null}
|
||||
</span>
|
||||
{row.editable ? <Pencil size={14} aria-hidden /> : null}
|
||||
</button>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-wf-node-expand"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? t("common.collapse", "Collapse") : t("common.expand", "Expand")}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{(row.columnName || row.outgoing.length > 0) && (
|
||||
<div
|
||||
className="mobile-wf-node-meta"
|
||||
style={{ ["--mobile-wf-depth" as string]: String(depth) }}
|
||||
>
|
||||
{row.columnName ? <span className="mobile-wf-column-chip">{row.columnName}</span> : null}
|
||||
{row.outgoing.map((edge) => (
|
||||
<button
|
||||
key={edge.id}
|
||||
type="button"
|
||||
className={`mobile-wf-edge-chip${selectedEdgeId === edge.id ? " mobile-wf-edge-chip--selected" : ""}`}
|
||||
data-testid={`mobile-wf-edge-${edge.id}`}
|
||||
onClick={() => onSelectEdge(edge.id)}
|
||||
>
|
||||
<GitBranch size={12} aria-hidden />
|
||||
<span>{edge.label}</span>
|
||||
<span className="mobile-wf-edge-target">{edge.targetLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasChildren && expanded ? (
|
||||
<div className="mobile-wf-node-children">
|
||||
{row.children.map((child) => (
|
||||
<NodeRow
|
||||
key={child.id}
|
||||
row={child}
|
||||
depth={depth + 1}
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={onSelectNode}
|
||||
onSelectEdge={onSelectEdge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileWorkflowGraphView({
|
||||
rows,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
onSelectNode,
|
||||
onSelectEdge,
|
||||
}: MobileWorkflowGraphViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="mobile-wf-graph-empty" data-testid="mobile-wf-graph-empty">
|
||||
{t("workflowNodes.mobileGraphEmpty", "No graph nodes yet.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-wf-graph" data-testid="mobile-wf-graph">
|
||||
{rows.map((row) => (
|
||||
<NodeRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
depth={0}
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={onSelectNode}
|
||||
onSelectEdge={onSelectEdge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -545,6 +545,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wf-mobile-shell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-editor-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1327,6 +1331,122 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wf-editor-body--editor-stage .wf-editor-readonly-banner:not(.wf-mobile-shell),
|
||||
.wf-editor-body--editor-stage .wf-editor-toolbar:not(.wf-mobile-shell),
|
||||
.wf-editor-body--editor-stage .wf-templates:not(.wf-mobile-shell),
|
||||
.wf-editor-body--editor-stage .wf-editor-canvas:not(.wf-mobile-shell) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-mobile-shell {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wf-mobile-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm);
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wf-mobile-tab {
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--wf-editor-touch-target);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wf-mobile-tab--active {
|
||||
border-color: var(--accent, var(--ws-info));
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.wf-mobile-panel {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wf-mobile-add,
|
||||
.wf-mobile-actions,
|
||||
.wf-mobile-destination {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.wf-mobile-add-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.wf-mobile-add-section h3,
|
||||
.wf-mobile-template-group h4 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wf-mobile-add-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.wf-mobile-add-option,
|
||||
.wf-mobile-template-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
min-height: var(--wf-editor-touch-target);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.wf-mobile-template-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wf-mobile-template-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.wf-mobile-actions .wf-editor-action,
|
||||
.wf-mobile-actions .wf-editor-delete,
|
||||
.wf-mobile-actions .wf-editor-save {
|
||||
justify-content: center;
|
||||
min-height: var(--wf-editor-touch-target);
|
||||
}
|
||||
|
||||
.wf-mobile-ai-panel {
|
||||
position: static;
|
||||
inset: auto;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.wf-editor-canvas .react-flow,
|
||||
.wf-editor-canvas .react-flow__renderer,
|
||||
.wf-editor-canvas .react-flow__pane,
|
||||
|
||||
@@ -82,8 +82,11 @@ import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
|
||||
import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
|
||||
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
|
||||
import { buildMobileWorkflowGraph } from "./workflow-mobile-graph";
|
||||
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
|
||||
|
||||
function builtinSeamPrompt(config: Record<string, unknown> | undefined): string {
|
||||
const seam = typeof config?.seam === "string" ? config.seam : "";
|
||||
@@ -690,6 +693,9 @@ function InnerEditor({
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [inspectorCollapsed, setInspectorCollapsed] = useState(false);
|
||||
const [mobilePanel, setMobilePanel] = useState<MobileWorkflowPanel>(() =>
|
||||
initialPanel === "settings" ? "settings" : "graph",
|
||||
);
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
// Create-workflow dialog (KTD-7) open state + focus-return ref to the
|
||||
@@ -932,6 +938,10 @@ function InnerEditor({
|
||||
if (selectedNodeId) setInspectorCollapsed(false);
|
||||
}, [selectedNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPanel === "settings") setMobilePanel("settings");
|
||||
}, [initialPanel]);
|
||||
|
||||
// U9/R8: fragment definitions surface from the loaded workflow list (kind ===
|
||||
// "fragment"); they are excluded from the sidebar workflow list elsewhere.
|
||||
const fragments = useMemo(
|
||||
@@ -1324,11 +1334,11 @@ function InnerEditor({
|
||||
// offset from the canvas origin.
|
||||
const handleInsertFragment = useCallback(
|
||||
(fragment: WorkflowDefinition) => {
|
||||
if (isBuiltin) return;
|
||||
if (isBuiltin) return false;
|
||||
const conflicts = fragmentSeamConflicts(fragment.ir, nodes);
|
||||
if (conflicts.length > 0) {
|
||||
setTemplateConflict(conflicts.join(", "));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
setTemplateConflict(null);
|
||||
const result = insertFragment(
|
||||
@@ -1341,6 +1351,7 @@ function InnerEditor({
|
||||
setNodes(result.nodes);
|
||||
setEdges(result.edges);
|
||||
setSelectedNodeId(result.insertedNodeIds[0] ?? null);
|
||||
return true;
|
||||
},
|
||||
[isBuiltin, nodes, edges, setNodes, setEdges],
|
||||
);
|
||||
@@ -1931,6 +1942,10 @@ function InnerEditor({
|
||||
}),
|
||||
[models, agents, skills],
|
||||
);
|
||||
const mobileGraphRows = useMemo(
|
||||
() => buildMobileWorkflowGraph(nodesForRender, edges, columns, catalogs, t),
|
||||
[nodesForRender, edges, columns, catalogs, t],
|
||||
);
|
||||
|
||||
const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model";
|
||||
|
||||
@@ -2208,7 +2223,7 @@ function InnerEditor({
|
||||
workflow is active (read-only gating preserved via isBuiltin). The
|
||||
disclosure button serves as the section header; the panels' own
|
||||
internal <h3> is suppressed via CSS to avoid a double header. */}
|
||||
{activeWorkflow && (
|
||||
{activeWorkflow && !isMobileViewport && (
|
||||
<div className="wf-sidebar-panels">
|
||||
<section className="wf-sidebar-section" data-testid="wf-sidebar-columns-section">
|
||||
<button
|
||||
@@ -2378,6 +2393,282 @@ function InnerEditor({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isMobileViewport && (
|
||||
<div className="wf-mobile-shell" data-testid="wf-mobile-shell">
|
||||
<nav className="wf-mobile-tabs" aria-label={t("workflows.mobileEditorNav", "Workflow editor sections")}>
|
||||
{([
|
||||
["graph", t("workflowNodes.mobileGraph", "Graph")],
|
||||
["add", t("workflowNodes.mobileAdd", "Add")],
|
||||
["settings", t("workflowSettings.title", "Settings")],
|
||||
["fields", t("workflowFields.title", "Fields")],
|
||||
["columns", t("workflowColumns.title", "Columns")],
|
||||
["actions", t("workflowNodes.mobileActions", "Actions")],
|
||||
] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => (
|
||||
<button
|
||||
key={panel}
|
||||
type="button"
|
||||
className={`wf-mobile-tab${mobilePanel === panel ? " wf-mobile-tab--active" : ""}`}
|
||||
aria-current={mobilePanel === panel ? "page" : undefined}
|
||||
data-testid={`wf-mobile-tab-${panel}`}
|
||||
onClick={() => setMobilePanel(panel)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="wf-mobile-panel" data-testid={`wf-mobile-panel-${mobilePanel}`}>
|
||||
{mobilePanel === "graph" && (
|
||||
<MobileWorkflowGraphView
|
||||
rows={mobileGraphRows}
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={(id) => {
|
||||
setSelectedNodeId(id);
|
||||
setSelectedEdgeId(null);
|
||||
}}
|
||||
onSelectEdge={(id) => {
|
||||
setSelectedEdgeId(id);
|
||||
setSelectedNodeId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mobilePanel === "add" && (
|
||||
<div className="wf-mobile-add">
|
||||
{isBuiltin ? (
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t("workflows.readOnlyBuiltin", "Read-only built-in workflow")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<section className="wf-mobile-add-section">
|
||||
<h3>{t("workflowNodes.mobileNodeKinds", "Node types")}</h3>
|
||||
<div className="wf-mobile-add-grid">
|
||||
{PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className="wf-mobile-add-option"
|
||||
data-testid={`wf-mobile-add-${kind}-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
|
||||
onClick={() => {
|
||||
addNode(kind, label, presetConfig);
|
||||
setMobilePanel("graph");
|
||||
}}
|
||||
>
|
||||
<Icon size={16} aria-hidden />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{hasAnyTemplate && (
|
||||
<section className="wf-mobile-add-section">
|
||||
<h3>{t("workflowNodes.templatesSection", "Templates")}</h3>
|
||||
{templateTotalCount > 8 && (
|
||||
<input
|
||||
type="text"
|
||||
className="wf-templates-filter wf-mobile-template-filter"
|
||||
data-testid="wf-mobile-template-filter"
|
||||
value={templateFilter}
|
||||
onChange={(e) => setTemplateFilter(e.target.value)}
|
||||
placeholder={t("workflowNodes.templateFilterPlaceholder", "Filter templates")}
|
||||
aria-label={t("workflowNodes.templateFilterLabel", "Filter templates")}
|
||||
/>
|
||||
)}
|
||||
{templateConflict && (
|
||||
<div className="wf-templates-conflict" role="alert" data-testid="wf-mobile-tpl-conflict">
|
||||
{t(
|
||||
"workflowNodes.templateSeamConflict",
|
||||
'This fragment duplicates the "{{seam}}" seam already on the canvas, so it can\'t be inserted.',
|
||||
{ seam: templateConflict },
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{templateGroups.fragmentEntries.length > 0 && (
|
||||
<div className="wf-mobile-template-group">
|
||||
<h4>{t("workflowNodes.templatesFragments", "Fragments")}</h4>
|
||||
{templateGroups.fragmentEntries.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
className="wf-mobile-template-option"
|
||||
data-testid={`wf-mobile-tpl-fragment-${f.id}`}
|
||||
onClick={() => {
|
||||
if (handleInsertFragment(f)) setMobilePanel("graph");
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{templateGroups.stepEntries.length > 0 && (
|
||||
<div className="wf-mobile-template-group">
|
||||
<h4>{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}</h4>
|
||||
{templateGroups.stepEntries.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="wf-mobile-template-option"
|
||||
data-testid={`wf-mobile-tpl-step-${s.id}`}
|
||||
onClick={() => {
|
||||
handleInsertStepTemplate(s);
|
||||
setMobilePanel("graph");
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{templateGroups.pluginEntries.length > 0 && (
|
||||
<div className="wf-mobile-template-group">
|
||||
<h4>{t("workflowNodes.templatesPluginSteps", "Plugin steps")}</h4>
|
||||
{templateGroups.pluginEntries.map(({ pluginId, template }) => (
|
||||
<button
|
||||
key={`${pluginId}:${template.id}`}
|
||||
type="button"
|
||||
className="wf-mobile-template-option"
|
||||
data-testid={`wf-mobile-tpl-plugin-${template.id}`}
|
||||
onClick={() => {
|
||||
handleInsertStepTemplate(template);
|
||||
setMobilePanel("graph");
|
||||
}}
|
||||
>
|
||||
<span>{template.name}</span>
|
||||
<span className="wf-templates-badge">{pluginId}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "settings" && activeWorkflow && (
|
||||
<div ref={settingsPanelRef} className="wf-mobile-destination">
|
||||
<WorkflowSettingsPanel
|
||||
workflowId={activeWorkflow.id}
|
||||
settings={settings}
|
||||
onChange={setSettings}
|
||||
readOnly={isBuiltin}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
initialTab="values"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "fields" && (
|
||||
<div className="wf-mobile-destination">
|
||||
<WorkflowFieldsPanel
|
||||
fields={fields}
|
||||
onChange={setFields}
|
||||
readOnly={isBuiltin}
|
||||
addToast={addToast}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "columns" && (
|
||||
<div className="wf-mobile-destination">
|
||||
<WorkflowColumnPanel
|
||||
columns={columns}
|
||||
onChange={setColumns}
|
||||
violations={columnViolations}
|
||||
readOnly={isBuiltin}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
columnAgentsEnabled={columnAgentsEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "actions" && (
|
||||
<div className="wf-mobile-actions">
|
||||
{isBuiltin ? (
|
||||
<>
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t("workflows.readOnlyBuiltin", "Read-only built-in workflow")}
|
||||
</p>
|
||||
<button className="wf-editor-action" data-testid="wf-mobile-export" onClick={handleExport}>
|
||||
<Download size={15} /> {t("workflows.export", "Export")}
|
||||
</button>
|
||||
<button className="wf-editor-save wf-editor-duplicate-primary" data-testid="wf-mobile-duplicate" onClick={handleDuplicate}>
|
||||
<Plus size={15} /> {t("workflows.duplicateToCustomize", "Duplicate to customize")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="wf-editor-save" data-testid="wf-mobile-save" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 size={15} className="wf-spin" /> : <Save size={15} />}
|
||||
{t("common.save", "Save")}
|
||||
</button>
|
||||
<button className="wf-editor-action" data-testid="wf-mobile-ai-edit" onClick={() => setAiPanelOpen((o) => !o)}>
|
||||
<Sparkles size={15} /> {t("workflows.aiEdit", "Design with AI")}
|
||||
</button>
|
||||
{aiPanelOpen && (
|
||||
<div className="wf-ai-panel wf-mobile-ai-panel" data-testid="wf-mobile-ai-panel" role="dialog" aria-busy={aiEditBusy}>
|
||||
<textarea
|
||||
className="wf-ai-prompt"
|
||||
data-testid="wf-mobile-ai-edit-prompt"
|
||||
rows={4}
|
||||
value={aiEditPrompt}
|
||||
disabled={aiEditBusy}
|
||||
placeholder={t(
|
||||
"workflows.aiPromptPlaceholder",
|
||||
"e.g. Run lint and tests before merge, then post a changelog comment after merge",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
setAiEditPrompt(e.target.value);
|
||||
if (aiEditError) setAiEditError(null);
|
||||
}}
|
||||
/>
|
||||
{aiEditError && (
|
||||
<p className="wf-create-error" role="alert" data-testid="wf-mobile-ai-edit-error">
|
||||
{aiEditError}
|
||||
</p>
|
||||
)}
|
||||
<div className="wf-ai-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary wf-ai-submit"
|
||||
data-testid="wf-mobile-ai-edit-submit"
|
||||
disabled={aiEditBusy}
|
||||
onClick={() => void handleAiEditSubmit()}
|
||||
>
|
||||
{aiEditBusy ? <Loader2 size={13} className="wf-spin" /> : <Sparkles size={13} />}
|
||||
{t("workflows.aiSubmit", "Design with AI")}
|
||||
</button>
|
||||
{aiEditBusy && (
|
||||
<button type="button" className="btn wf-ai-cancel" onClick={handleAiEditCancel}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button className="wf-editor-action" data-testid="wf-mobile-auto-layout" onClick={handleAutoLayout}>
|
||||
<LayoutGrid size={15} /> {t("workflowNodes.autoLayout", "Auto-layout")}
|
||||
</button>
|
||||
<button className="wf-editor-action" data-testid="wf-mobile-export" onClick={handleExport} disabled={isDirty}>
|
||||
<Download size={15} /> {t("workflows.export", "Export")}
|
||||
</button>
|
||||
<button className="wf-editor-delete" data-testid="wf-mobile-delete" onClick={handleDeleteWorkflow}>
|
||||
<Trash2 size={15} /> {t("common.delete", "Delete")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isBuiltin ? (
|
||||
// Read-only built-in: a banner *replaces* the save/edit toolbar
|
||||
// (not an overlay); the canvas below stays inspectable.
|
||||
|
||||
@@ -76,6 +76,11 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workflow-selector select,
|
||||
.workflow-selector-manage {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workflow-selector-manage {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MobileWorkflowGraphView } from "../MobileWorkflowGraphView";
|
||||
import type { MobileWorkflowNodeSummary } from "../workflow-mobile-graph";
|
||||
|
||||
const rows: MobileWorkflowNodeSummary[] = [
|
||||
{
|
||||
id: "start",
|
||||
label: "Start",
|
||||
kind: "start",
|
||||
summary: "",
|
||||
editable: false,
|
||||
outgoing: [{ id: "e1", source: "start", target: "prompt", targetLabel: "Prompt", label: "success" }],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: "loop",
|
||||
label: "Review loop",
|
||||
kind: "loop",
|
||||
summary: "3x",
|
||||
editable: true,
|
||||
outgoing: [],
|
||||
children: [
|
||||
{
|
||||
id: "loop::child",
|
||||
label: "Loop step",
|
||||
kind: "prompt",
|
||||
summary: "Not configured",
|
||||
editable: true,
|
||||
parentId: "loop",
|
||||
templateLocalId: "child",
|
||||
outgoing: [],
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe("MobileWorkflowGraphView", () => {
|
||||
it("renders graph rows and selects nodes and edges", () => {
|
||||
const onSelectNode = vi.fn();
|
||||
const onSelectEdge = vi.fn();
|
||||
render(
|
||||
<MobileWorkflowGraphView
|
||||
rows={rows}
|
||||
selectedNodeId={null}
|
||||
selectedEdgeId={null}
|
||||
onSelectNode={onSelectNode}
|
||||
onSelectEdge={onSelectEdge}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(within(screen.getByTestId("mobile-wf-node-start")).getByRole("button", { name: /start/i }));
|
||||
expect(onSelectNode).toHaveBeenCalledWith("start");
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-wf-edge-e1"));
|
||||
expect(onSelectEdge).toHaveBeenCalledWith("e1");
|
||||
});
|
||||
|
||||
it("expands grouped template children", () => {
|
||||
render(
|
||||
<MobileWorkflowGraphView
|
||||
rows={rows}
|
||||
selectedNodeId="loop"
|
||||
selectedEdgeId={null}
|
||||
onSelectNode={() => {}}
|
||||
onSelectEdge={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mobile-wf-node-loop::child")).toBeInTheDocument();
|
||||
fireEvent.click(within(screen.getByTestId("mobile-wf-node-loop")).getByRole("button", { name: /collapse/i }));
|
||||
expect(screen.queryByTestId("mobile-wf-node-loop::child")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react";
|
||||
import { buildMobileWorkflowGraph } from "../workflow-mobile-graph";
|
||||
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
|
||||
import { columnBandNodeId, foreachChildFlowId } from "../workflow-flow-mapping";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
kind: WorkflowFlowNodeData["kind"],
|
||||
x: number,
|
||||
y: number,
|
||||
extra: Partial<FlowNode<WorkflowFlowNodeData>> = {},
|
||||
): FlowNode<WorkflowFlowNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: kind,
|
||||
position: { x, y },
|
||||
data: { kind, label: id, ...(extra.data ?? {}) },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function edge(id: string, source: string, target: string, condition = "success"): FlowEdge {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
label: condition,
|
||||
data: { condition },
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildMobileWorkflowGraph", () => {
|
||||
it("returns ordered linear rows with outgoing edge destinations", () => {
|
||||
const rows = buildMobileWorkflowGraph(
|
||||
[
|
||||
node("end", "end", 300, 0),
|
||||
node("start", "start", 0, 0),
|
||||
node("lint", "gate", 150, 0, { data: { kind: "gate", label: "Lint", config: { gateMode: "gate" } } }),
|
||||
],
|
||||
[edge("e1", "start", "lint"), edge("e2", "lint", "end")],
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(["start", "lint", "end"]);
|
||||
expect(rows[0].outgoing[0]).toMatchObject({ target: "lint", targetLabel: "Lint" });
|
||||
expect(rows[1].summary).toBe("Gate (blocks)");
|
||||
});
|
||||
|
||||
it("preserves branch edges and column labels while ignoring column band nodes", () => {
|
||||
const rows = buildMobileWorkflowGraph(
|
||||
[
|
||||
node(columnBandNodeId("todo"), "start", -40, 0, {
|
||||
type: "group",
|
||||
data: { kind: "start", label: "Todo", column: "todo" },
|
||||
}),
|
||||
node("split", "split", 0, 0, { data: { kind: "split", label: "Split", column: "todo" } }),
|
||||
node("a", "prompt", 160, 20, { data: { kind: "prompt", label: "A", column: "todo" } }),
|
||||
node("b", "script", 160, 90, { data: { kind: "script", label: "B", column: "todo" } }),
|
||||
],
|
||||
[edge("e1", "split", "a", "success"), edge("e2", "split", "b", "failure")],
|
||||
[{ id: "todo", name: "Todo", traits: [] }],
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(["split", "a", "b"]);
|
||||
expect(rows[0].columnName).toBe("Todo");
|
||||
expect(rows[0].outgoing.map((out) => [out.label, out.targetLabel])).toEqual([
|
||||
["success", "A"],
|
||||
["failure", "B"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("nests foreach template children without exposing local ids as top-level rows", () => {
|
||||
const childId = foreachChildFlowId("each", "step");
|
||||
const rows = buildMobileWorkflowGraph(
|
||||
[
|
||||
node("each", "foreach", 0, 0, { data: { kind: "foreach", label: "Each step", config: { mode: "parallel" } } }),
|
||||
node(childId, "prompt", 20, 60, {
|
||||
parentId: "each",
|
||||
data: { kind: "prompt", label: "Run step", config: { seam: "step-execute" } },
|
||||
}),
|
||||
],
|
||||
[edge("e-child", childId, childId, "outcome:revise")],
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe("each");
|
||||
expect(rows[0].children).toHaveLength(1);
|
||||
expect(rows[0].children[0]).toMatchObject({
|
||||
id: childId,
|
||||
templateLocalId: "step",
|
||||
label: "Run step",
|
||||
});
|
||||
});
|
||||
});
|
||||
112
packages/dashboard/app/components/workflow-mobile-graph.ts
Normal file
112
packages/dashboard/app/components/workflow-mobile-graph.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react";
|
||||
import type { WorkflowIrColumn } from "@fusion/core";
|
||||
import type { WorkflowFlowNodeData } from "./nodes/WorkflowNodeTypes";
|
||||
import {
|
||||
columnIdFromBandNode,
|
||||
isColumnBandNode,
|
||||
templateNodeIdFromChild,
|
||||
} from "./workflow-flow-mapping";
|
||||
import { nodeConfigSummary, type NodeSummaryCatalogs, type SummaryTranslate } from "./nodes/node-summary";
|
||||
|
||||
export interface MobileWorkflowEdgeSummary {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
targetLabel: string;
|
||||
label: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface MobileWorkflowNodeSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: WorkflowFlowNodeData["kind"];
|
||||
summary: string;
|
||||
columnName?: string;
|
||||
editable: boolean;
|
||||
parentId?: string;
|
||||
templateLocalId?: string;
|
||||
outgoing: MobileWorkflowEdgeSummary[];
|
||||
children: MobileWorkflowNodeSummary[];
|
||||
}
|
||||
|
||||
function edgeLabel(edge: FlowEdge): string {
|
||||
if (typeof edge.label === "string" && edge.label.trim()) return edge.label;
|
||||
return String(edge.data?.condition ?? "success");
|
||||
}
|
||||
|
||||
function nodeDisplayLabel(node: FlowNode<WorkflowFlowNodeData>): string {
|
||||
return node.data.label || node.id;
|
||||
}
|
||||
|
||||
function nodeSortValue(node: FlowNode<WorkflowFlowNodeData>): number {
|
||||
return Math.round(node.position.y) * 100000 + Math.round(node.position.x);
|
||||
}
|
||||
|
||||
function buildColumnNameMap(columns: WorkflowIrColumn[], nodes: FlowNode<WorkflowFlowNodeData>[]) {
|
||||
const names = new Map(columns.map((column) => [column.id, column.name || column.id]));
|
||||
for (const node of nodes) {
|
||||
if (!isColumnBandNode(node.id)) continue;
|
||||
const id = columnIdFromBandNode(node.id);
|
||||
if (!names.has(id)) names.set(id, node.data.label || id);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export function buildMobileWorkflowGraph(
|
||||
nodes: FlowNode<WorkflowFlowNodeData>[],
|
||||
edges: FlowEdge[],
|
||||
columns: WorkflowIrColumn[] = [],
|
||||
catalogs: NodeSummaryCatalogs = {},
|
||||
t?: SummaryTranslate,
|
||||
): MobileWorkflowNodeSummary[] {
|
||||
const columnNames = buildColumnNameMap(columns, nodes);
|
||||
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const childNodesByParent = new Map<string, FlowNode<WorkflowFlowNodeData>[]>();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!node.parentId) continue;
|
||||
const list = childNodesByParent.get(node.parentId) ?? [];
|
||||
list.push(node);
|
||||
childNodesByParent.set(node.parentId, list);
|
||||
}
|
||||
|
||||
for (const list of childNodesByParent.values()) {
|
||||
list.sort((a, b) => nodeSortValue(a) - nodeSortValue(b));
|
||||
}
|
||||
|
||||
const summarizeEdge = (edge: FlowEdge): MobileWorkflowEdgeSummary => {
|
||||
const target = nodesById.get(edge.target);
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
targetLabel: target ? nodeDisplayLabel(target) : edge.target,
|
||||
label: edgeLabel(edge),
|
||||
kind: typeof edge.data?.kind === "string" ? edge.data.kind : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const summarizeNode = (node: FlowNode<WorkflowFlowNodeData>): MobileWorkflowNodeSummary => {
|
||||
const children = (childNodesByParent.get(node.id) ?? []).map(summarizeNode);
|
||||
const columnId = node.data.column;
|
||||
return {
|
||||
id: node.id,
|
||||
label: nodeDisplayLabel(node),
|
||||
kind: node.data.kind,
|
||||
summary: nodeConfigSummary(node.data, catalogs, t),
|
||||
columnName: columnId ? columnNames.get(columnId) ?? columnId : undefined,
|
||||
editable: node.data.kind !== "start" && node.data.kind !== "end" && !isColumnBandNode(node.id),
|
||||
parentId: node.parentId,
|
||||
templateLocalId: node.parentId ? templateNodeIdFromChild(node.parentId, node.id) : undefined,
|
||||
outgoing: edges.filter((edge) => edge.source === node.id).map(summarizeEdge),
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
const topLevelNodes = nodes
|
||||
.filter((node) => !node.parentId && !isColumnBandNode(node.id))
|
||||
.sort((a, b) => nodeSortValue(a) - nodeSortValue(b));
|
||||
|
||||
return topLevelNodes.map(summarizeNode);
|
||||
}
|
||||
Reference in New Issue
Block a user