FN-6155: render prompt fullscreen editor in a portal
Keep the workflow prompt expander usable in the fullscreen editor across desktop and mobile. - render the expanded prompt and gate editor in a document-body portal instead of reusing the inline inspector container - keep the inline prompt textarea compact while wiring fullscreen edits, focus, and collapse controls through the portal overlay - extend workflow node editor coverage for desktop and mobile fullscreen expand, collapse, Escape, empty prompt, and edit persistence flows Files changed: packages/dashboard/app/components/WorkflowNodeEditor.tsx | 81 +++++++------ packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx | 127 +++++++++++++++++++-- 2 files changed, 164 insertions(+), 44 deletions(-) Fusion-Task-Id: FN-6155 Fusion-Task-Lineage: 5d0a6367-932a-4fbc-bded-ebad3073d1a6
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
type Node as FlowNode,
|
||||
type Edge as FlowEdge,
|
||||
} from "@xyflow/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core";
|
||||
@@ -2123,9 +2124,42 @@ function InnerEditor({
|
||||
}, [overrideColumnBinding, agents.length, projectId, addToast]);
|
||||
|
||||
const overlayProps = useOverlayDismiss(requestClose);
|
||||
const promptFullscreenOverlay =
|
||||
isPromptExpanded && (selectedNode?.data.kind === "prompt" || selectedNode?.data.kind === "gate")
|
||||
? createPortal(
|
||||
<div
|
||||
className="wf-prompt-editor wf-prompt-editor--fullscreen"
|
||||
onKeyDown={handlePromptFullscreenKeyDown}
|
||||
>
|
||||
<div className="wf-prompt-fullscreen-header">
|
||||
<span>{t("workflowEditor.editingPrompt", "Editing Prompt")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm wf-prompt-expand-btn"
|
||||
onClick={handleTogglePromptExpand}
|
||||
aria-label={t("workflowEditor.collapsePrompt", "Collapse prompt editor")}
|
||||
title={t("workflowEditor.collapsePrompt", "Collapse prompt editor")}
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<label className="wf-field">
|
||||
<span>Prompt</span>
|
||||
<textarea
|
||||
rows={undefined}
|
||||
value={selectedNodePromptValue}
|
||||
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open wf-editor-overlay" {...overlayProps}>
|
||||
<>
|
||||
<div className="modal-overlay open wf-editor-overlay" {...overlayProps}>
|
||||
<div
|
||||
className="modal wf-editor-modal"
|
||||
ref={modalRef}
|
||||
@@ -3144,43 +3178,24 @@ function InnerEditor({
|
||||
</label>
|
||||
|
||||
{selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate" ? (
|
||||
<div
|
||||
className={`wf-prompt-editor${isPromptExpanded ? " wf-prompt-editor--fullscreen" : ""}`}
|
||||
onKeyDown={handlePromptFullscreenKeyDown}
|
||||
>
|
||||
{isPromptExpanded && (
|
||||
<div className="wf-prompt-fullscreen-header">
|
||||
<span>{t("workflowEditor.editingPrompt", "Editing Prompt")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm wf-prompt-expand-btn"
|
||||
onClick={handleTogglePromptExpand}
|
||||
aria-label={t("workflowEditor.collapsePrompt", "Collapse prompt editor")}
|
||||
title={t("workflowEditor.collapsePrompt", "Collapse prompt editor")}
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="wf-prompt-editor">
|
||||
<label className="wf-field">
|
||||
<span>Prompt</span>
|
||||
<textarea
|
||||
rows={isPromptExpanded ? undefined : 5}
|
||||
rows={5}
|
||||
value={selectedNodePromptValue}
|
||||
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
|
||||
/>
|
||||
</label>
|
||||
{!isPromptExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm wf-prompt-expand-btn wf-prompt-expand-btn--inline"
|
||||
onClick={handleTogglePromptExpand}
|
||||
aria-label={t("workflowEditor.expandPrompt", "Expand prompt editor")}
|
||||
title={t("workflowEditor.expandPrompt", "Expand prompt editor")}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm wf-prompt-expand-btn wf-prompt-expand-btn--inline"
|
||||
onClick={handleTogglePromptExpand}
|
||||
aria-label={t("workflowEditor.expandPrompt", "Expand prompt editor")}
|
||||
title={t("workflowEditor.expandPrompt", "Expand prompt editor")}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -4177,7 +4192,9 @@ function InnerEditor({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{promptFullscreenOverlay}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,16 @@ import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
|
||||
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
|
||||
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
|
||||
|
||||
function getPromptFullscreenOverlay() {
|
||||
return document.body.querySelector(".wf-prompt-editor--fullscreen") as HTMLElement | null;
|
||||
}
|
||||
|
||||
function getPromptFullscreenTextarea() {
|
||||
const overlay = getPromptFullscreenOverlay();
|
||||
expect(overlay).not.toBeNull();
|
||||
return within(overlay!).getByLabelText("Prompt") as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
function mockWorkflowEditorViewport(mode: "desktop" | "mobile" | "tablet" = "desktop") {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
@@ -528,19 +538,22 @@ describe("WorkflowNodeEditor", () => {
|
||||
const expand = await screen.findByRole("button", { name: "Expand prompt editor" });
|
||||
expect(expand).toBeInTheDocument();
|
||||
|
||||
const promptEditor = expand.closest(".wf-prompt-editor");
|
||||
expect(promptEditor).not.toBeNull();
|
||||
expect(promptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
|
||||
const inlinePromptEditor = expand.closest(".wf-prompt-editor");
|
||||
expect(inlinePromptEditor).not.toBeNull();
|
||||
expect(inlinePromptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
|
||||
expect(getPromptFullscreenOverlay()).toBeNull();
|
||||
|
||||
fireEvent.click(expand);
|
||||
|
||||
expect(promptEditor).toHaveClass("wf-prompt-editor--fullscreen");
|
||||
expect(screen.getByRole("button", { name: "Collapse prompt editor" })).toBeVisible();
|
||||
expect(screen.getByLabelText("Prompt")).not.toHaveAttribute("rows");
|
||||
const fullscreenPromptEditor = getPromptFullscreenOverlay();
|
||||
expect(fullscreenPromptEditor).toBeInTheDocument();
|
||||
expect(inlinePromptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
|
||||
expect(within(fullscreenPromptEditor!).getByRole("button", { name: "Collapse prompt editor" })).toBeVisible();
|
||||
expect(getPromptFullscreenTextarea()).not.toHaveAttribute("rows");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Collapse prompt editor" }));
|
||||
fireEvent.click(within(fullscreenPromptEditor!).getByRole("button", { name: "Collapse prompt editor" }));
|
||||
|
||||
expect(promptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
|
||||
expect(getPromptFullscreenOverlay()).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -553,13 +566,12 @@ describe("WorkflowNodeEditor", () => {
|
||||
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Expand prompt editor" }));
|
||||
|
||||
const promptEditor = screen.getByRole("button", { name: "Collapse prompt editor" }).closest(".wf-prompt-editor");
|
||||
expect(promptEditor).not.toBeNull();
|
||||
expect(promptEditor).toHaveClass("wf-prompt-editor--fullscreen");
|
||||
const promptEditor = getPromptFullscreenOverlay();
|
||||
expect(promptEditor).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(promptEditor!, { key: "Escape" });
|
||||
|
||||
expect(promptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
|
||||
expect(getPromptFullscreenOverlay()).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -577,6 +589,97 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(within(inspector).getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the fullscreen prompt editor from the mobile prompt inspector", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
|
||||
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
|
||||
|
||||
const inspector = await screen.findByTestId("wf-node-inspector");
|
||||
fireEvent.click(within(inspector).getByRole("button", { name: "Expand prompt editor" }));
|
||||
|
||||
const fullscreenPromptEditor = getPromptFullscreenOverlay();
|
||||
expect(fullscreenPromptEditor).toBeInTheDocument();
|
||||
expect(within(fullscreenPromptEditor!).getByRole("button", { name: "Collapse prompt editor" })).toBeVisible();
|
||||
expect(getPromptFullscreenTextarea()).toHaveFocus();
|
||||
});
|
||||
|
||||
it("closes the fullscreen prompt editor with the mobile collapse button", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
|
||||
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Expand prompt editor" }));
|
||||
|
||||
const fullscreenPromptEditor = getPromptFullscreenOverlay();
|
||||
expect(fullscreenPromptEditor).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(fullscreenPromptEditor!).getByRole("button", { name: "Collapse prompt editor" }));
|
||||
|
||||
expect(getPromptFullscreenOverlay()).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the fullscreen prompt editor with Escape on mobile", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
|
||||
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Expand prompt editor" }));
|
||||
|
||||
const fullscreenPromptEditor = getPromptFullscreenOverlay();
|
||||
expect(fullscreenPromptEditor).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(fullscreenPromptEditor!, { key: "Escape" });
|
||||
|
||||
expect(getPromptFullscreenOverlay()).toBeNull();
|
||||
});
|
||||
|
||||
it("persists mobile fullscreen prompt edits back to the inline textarea", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Custom" }));
|
||||
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Expand prompt editor" }));
|
||||
|
||||
const fullscreenPromptEditor = getPromptFullscreenOverlay();
|
||||
expect(fullscreenPromptEditor).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(getPromptFullscreenTextarea(), { target: { value: "mobile edit" } });
|
||||
fireEvent.click(within(fullscreenPromptEditor!).getByRole("button", { name: "Collapse prompt editor" }));
|
||||
|
||||
const inspector = await screen.findByTestId("wf-node-inspector");
|
||||
expect(within(inspector).getByLabelText("Prompt")).toHaveValue("mobile edit");
|
||||
});
|
||||
|
||||
it("opens the fullscreen prompt editor for empty mobile gate prompts", 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");
|
||||
fireEvent.click(within(inspector).getByRole("button", { name: "Expand prompt editor" }));
|
||||
|
||||
const fullscreenPromptEditor = getPromptFullscreenOverlay();
|
||||
expect(fullscreenPromptEditor).toBeInTheDocument();
|
||||
expect(within(fullscreenPromptEditor!).getByLabelText("Prompt")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("does not show the prompt expand button for non-prompt nodes", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([scriptDef()]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user