FN-6058: improve mobile workflow node editor layout
Make the mobile workflow editor easier to use by collapsing competing panels and updating coverage. - add a mobile inspector toggle that collapses node details and re-expands on node selection - default the templates palette to collapsed on mobile and tighten mobile toolbar/layout styling to avoid overlap - remove the stale workflow-id preselection path from the editor and align workflow edge/mobile regression coverage - drop the unused modal prop wiring so the editor API matches the merged implementation Files changed: packages/dashboard/app/components/AppModals.tsx | 1 - .../app/components/WorkflowNodeEditor.css | 61 +++++++++ .../app/components/WorkflowNodeEditor.tsx | 63 +++++++--- .../__tests__/WorkflowNodeEditor.css.test.ts | 21 ++++ .../__tests__/WorkflowNodeEditor.test.tsx | 139 +++++++++++---------- 5 files changed, 200 insertions(+), 85 deletions(-) Fusion-Task-Id: FN-6058 Fusion-Task-Lineage: d037832f-8bd8-4e3d-b31b-e30cd9752838
This commit is contained in:
@@ -387,7 +387,6 @@ export function AppModals({
|
||||
projectId={projectId}
|
||||
initialPanel={modalManager.workflowEditorInitialPanel}
|
||||
initialAction={modalManager.workflowEditorInitialAction}
|
||||
initialWorkflowId={modalManager.workflowEditorInitialWorkflowId}
|
||||
/>
|
||||
</Suspense>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
@@ -557,6 +557,38 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wf-inspector-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.wf-inspector-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wf-inspector-toggle:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.wf-inspector-toggle--collapsed {
|
||||
position: absolute;
|
||||
right: var(--space-sm);
|
||||
bottom: var(--space-sm);
|
||||
z-index: 5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.wf-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1225,6 +1257,11 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wf-editor-body--editor-stage .wf-editor-canvas {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wf-editor-body--editor-stage .wf-editor-inspector {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -1318,9 +1355,33 @@
|
||||
}
|
||||
|
||||
.wf-editor-toolbar {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.wf-editor-palette,
|
||||
.wf-editor-actions {
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.wf-templates {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wf-templates-header {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.wf-templates-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wf-inspector-toggle {
|
||||
min-height: var(--wf-editor-touch-target);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.wf-ai-panel {
|
||||
position: fixed;
|
||||
inset: var(--space-sm);
|
||||
|
||||
@@ -173,8 +173,6 @@ 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;
|
||||
@@ -659,7 +657,6 @@ function InnerEditor({
|
||||
projectId,
|
||||
initialPanel,
|
||||
initialAction,
|
||||
initialWorkflowId,
|
||||
modalRef,
|
||||
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
@@ -681,6 +678,7 @@ function InnerEditor({
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<FlowEdge>([]);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [inspectorCollapsed, setInspectorCollapsed] = useState(false);
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
// Create-workflow dialog (KTD-7) open state + focus-return ref to the
|
||||
@@ -728,7 +726,11 @@ function InnerEditor({
|
||||
const templatesCollapsedStorageKey = "fusion:wf-templates-collapsed";
|
||||
const [templatesCollapsed, setTemplatesCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(templatesCollapsedStorageKey) === "1";
|
||||
const stored = localStorage.getItem(templatesCollapsedStorageKey);
|
||||
if (stored != null) return stored === "1";
|
||||
return typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia(MOBILE_MEDIA_QUERY).matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -915,6 +917,10 @@ function InnerEditor({
|
||||
}
|
||||
}, [templatesCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNodeId) setInspectorCollapsed(false);
|
||||
}, [selectedNodeId]);
|
||||
|
||||
// U9/R8: fragment definitions surface from the loaded workflow list (kind ===
|
||||
// "fragment"); they are excluded from the sidebar workflow list elsewhere.
|
||||
const fragments = useMemo(
|
||||
@@ -975,22 +981,16 @@ 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 requestedWorkflow?.id ?? (isMobileViewport ? null : data[0]?.id ?? null);
|
||||
return 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, initialWorkflowId]);
|
||||
}, [projectId, addToast, isMobileViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWorkflows();
|
||||
@@ -2677,6 +2677,22 @@ function InnerEditor({
|
||||
)}
|
||||
|
||||
<div className="wf-editor-canvas" ref={canvasRef} tabIndex={-1}>
|
||||
{isMobileViewport &&
|
||||
inspectorCollapsed &&
|
||||
selectedNode &&
|
||||
selectedNode.data.kind !== "start" &&
|
||||
selectedNode.data.kind !== "end" && (
|
||||
<button
|
||||
type="button"
|
||||
className="wf-inspector-toggle wf-inspector-toggle--collapsed"
|
||||
data-testid="wf-inspector-toggle"
|
||||
aria-expanded="false"
|
||||
onClick={() => setInspectorCollapsed(false)}
|
||||
>
|
||||
<ChevronRight size={13} />
|
||||
<span>{t("workflowNodes.showInspector", "Show node details")}</span>
|
||||
</button>
|
||||
)}
|
||||
{isTrivialUserGraph && (
|
||||
<div className="wf-trivial-hint" role="status" data-testid="wf-trivial-hint">
|
||||
{t(
|
||||
@@ -2743,9 +2759,26 @@ function InnerEditor({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && (
|
||||
<aside className="wf-editor-inspector">
|
||||
<h3>Node</h3>
|
||||
{selectedNode &&
|
||||
selectedNode.data.kind !== "start" &&
|
||||
selectedNode.data.kind !== "end" &&
|
||||
!(isMobileViewport && inspectorCollapsed) && (
|
||||
<aside className="wf-editor-inspector" data-testid="wf-node-inspector">
|
||||
<div className="wf-inspector-heading">
|
||||
<h3>Node</h3>
|
||||
{isMobileViewport && (
|
||||
<button
|
||||
type="button"
|
||||
className="wf-inspector-toggle wf-inspector-toggle--expanded"
|
||||
data-testid="wf-inspector-toggle"
|
||||
aria-expanded="true"
|
||||
onClick={() => setInspectorCollapsed(true)}
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
<span>{t("workflowNodes.collapseInspector", "Collapse")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isBuiltin && (
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
Read-only built-in — duplicate the workflow to edit nodes.
|
||||
|
||||
@@ -82,6 +82,27 @@ describe("WorkflowNodeEditor mobile CSS contract", () => {
|
||||
expect(canvasWrapRule).toMatch(/min-height\s*:\s*0\s*;/);
|
||||
});
|
||||
|
||||
it("FN-6058 keeps mobile workflow editor controls from crowding the canvas", () => {
|
||||
const editorCss = readComponentCss("WorkflowNodeEditor.css");
|
||||
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
|
||||
|
||||
const toolbarRule = findRule(mobileBlocks, /\.wf-editor-toolbar\s*\{[^}]*\}/);
|
||||
expect(toolbarRule).toMatch(/flex-wrap\s*:\s*nowrap\s*;/);
|
||||
expect(toolbarRule).toMatch(/overflow-x\s*:\s*auto\s*;/);
|
||||
|
||||
const editorStageCanvasRule = findRule(mobileBlocks, /\.wf-editor-body--editor-stage \.wf-editor-canvas\s*\{[^}]*\}/);
|
||||
expect(editorStageCanvasRule).toMatch(/flex\s*:\s*1 1 auto\s*;/);
|
||||
expect(editorStageCanvasRule).toMatch(/min-height\s*:\s*0\s*;/);
|
||||
|
||||
const inspectorRule = findRule(mobileBlocks, /\.wf-editor-body--editor-stage \.wf-editor-inspector\s*\{[^}]*\}/);
|
||||
expect(inspectorRule).toMatch(/width\s*:\s*100%\s*;/);
|
||||
expect(inspectorRule).toMatch(/max-height\s*:\s*45vh\s*;/);
|
||||
|
||||
const collapsedToggleRule = findRule([editorCss], /\.wf-inspector-toggle--collapsed\s*\{[^}]*\}/);
|
||||
expect(collapsedToggleRule).toMatch(/position\s*:\s*absolute\s*;/);
|
||||
expect(collapsedToggleRule).toMatch(/bottom\s*:\s*var\(--space-sm\)\s*;/);
|
||||
});
|
||||
|
||||
it("FN-6033 keeps workflow editor touch target increases mobile-scoped", () => {
|
||||
const baseCss = loadAllAppCssBaseOnly();
|
||||
const editorCss = readComponentCss("WorkflowNodeEditor.css");
|
||||
|
||||
@@ -293,7 +293,13 @@ describe("workflow-flow-mapping", () => {
|
||||
it("preserves duplicate and parallel built-in edges with valid endpoints and hit targets", () => {
|
||||
const { edges } = edgeRenderableAssertion(builtinDef());
|
||||
const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure");
|
||||
expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual(["execute", "merge", "review"]);
|
||||
expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual([
|
||||
"execute",
|
||||
"merge",
|
||||
"planning",
|
||||
"review",
|
||||
"workflow-step",
|
||||
]);
|
||||
expect(new Set(failuresToEnd.map((edge) => edge.id)).size).toBe(failuresToEnd.length);
|
||||
expect(failuresToEnd.every((edge) => edge.interactionWidth === WF_EDGE_INTERACTION_WIDTH)).toBe(true);
|
||||
});
|
||||
@@ -304,10 +310,12 @@ describe("WorkflowNodeEditor", () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem("fusion:wf-sidebar-settings-collapsed");
|
||||
localStorage.removeItem("fusion:wf-templates-collapsed");
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -331,28 +339,6 @@ 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()]);
|
||||
@@ -366,52 +352,6 @@ 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([
|
||||
@@ -435,6 +375,50 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
|
||||
});
|
||||
|
||||
it("collapses and expands the selected node inspector on mobile", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
|
||||
fireEvent.click(await screen.findByTestId("wf-node-gate"));
|
||||
|
||||
const inspector = await screen.findByTestId("wf-node-inspector");
|
||||
expect(within(inspector).getByLabelText("Prompt")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-inspector-toggle"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-node-inspector")).not.toBeInTheDocument());
|
||||
expect(screen.queryByLabelText("Prompt")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-inspector-toggle")).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-inspector-toggle"));
|
||||
|
||||
expect(await screen.findByTestId("wf-node-inspector")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-inspector-toggle")).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("auto-expands the mobile inspector when selecting another node", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
|
||||
fireEvent.click(await screen.findByTestId("wf-node-gate"));
|
||||
expect(await screen.findByTestId("wf-node-inspector")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-inspector-toggle"));
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-node-inspector")).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(await screen.findByTestId("wf-node-merge"));
|
||||
|
||||
const inspector = await screen.findByTestId("wf-node-inspector");
|
||||
expect(within(inspector).getByLabelText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-inspector-toggle")).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
const { container } = render(<WorkflowNodeEditor isOpen={false} onClose={() => {}} addToast={() => {}} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
@@ -1879,6 +1863,23 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
|
||||
expect(pluginEntry).toHaveTextContent("acme-plugin");
|
||||
});
|
||||
|
||||
it("starts Templates collapsed by default on mobile", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ templates: [stepTpl()] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
|
||||
const toggle = await screen.findByTestId("wf-templates-toggle");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByTestId("wf-tpl-step-qa-check")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(await screen.findByTestId("wf-tpl-step-qa-check")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a step-template entry adds a pre-configured node", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user