FN-6143: add fullscreen prompt editor toggle
Add a fullscreen expand/collapse control for workflow prompt editing. - add inline maximize/minimize controls for workflow prompt and gate editors - style the fullscreen prompt overlay with responsive spacing and textarea sizing - cover expand/collapse behavior with prompt-editor tests and add the published package changeset - keep the mobile send-back promote action visible in task cards Files changed: .changeset/fn-6143-workflow-prompt-fullscreen.md | 5 + packages/dashboard/app/components/TaskCard.css | 1 + packages/dashboard/app/components/WorkflowNodeEditor.css | 77 ++++++++++++++++ packages/dashboard/app/components/WorkflowNodeEditor.tsx | 58 ++++++++++-- packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx | 102 +++++++++++++++++++++ 5 files changed, 234 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-6143 Fusion-Task-Lineage: 787a33e3-a6fe-44c4-9cb8-c24b3e86e935
This commit is contained in:
5
.changeset/fn-6143-workflow-prompt-fullscreen.md
Normal file
5
.changeset/fn-6143-workflow-prompt-fullscreen.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add an expand/collapse control for the workflow prompt editor so long prompts can be edited in a fullscreen overlay.
|
||||
@@ -1437,6 +1437,7 @@
|
||||
}
|
||||
|
||||
.card-promote-action.card-send-back-btn {
|
||||
opacity: 1;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -665,6 +665,79 @@
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* ── Prompt fullscreen editor ─────────────────────────────────── */
|
||||
.wf-prompt-editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wf-prompt-editor--fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10000;
|
||||
background: var(--surface);
|
||||
padding: max(var(--space-lg), env(safe-area-inset-top, 0))
|
||||
max(var(--space-lg), env(safe-area-inset-right, 0))
|
||||
max(var(--space-lg), env(safe-area-inset-bottom, 0))
|
||||
max(var(--space-lg), env(safe-area-inset-left, 0));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wf-prompt-editor--fullscreen .wf-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wf-prompt-editor--fullscreen textarea {
|
||||
flex: 1;
|
||||
min-height: unset;
|
||||
resize: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.wf-prompt-fullscreen-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-md);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-prompt-expand-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.wf-prompt-expand-btn--inline {
|
||||
position: absolute;
|
||||
top: calc(var(--space-xs) + 1.2em + var(--space-xs));
|
||||
right: var(--space-xs);
|
||||
z-index: 2;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: calc(var(--space-xs) / 2) var(--space-xs);
|
||||
}
|
||||
|
||||
.wf-prompt-expand-btn--inline:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wf-prompt-editor:not(.wf-prompt-editor--fullscreen) textarea {
|
||||
padding-right: calc(var(--space-xl) + var(--space-md));
|
||||
}
|
||||
|
||||
.wf-inspector-note {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
@@ -1242,6 +1315,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wf-prompt-editor--fullscreen {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.modal-overlay:has(.wf-editor-modal),
|
||||
.modal-overlay:has(.wf-create-modal) {
|
||||
padding-top: 0;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type Edge as FlowEdge,
|
||||
} from "@xyflow/react";
|
||||
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 } from "lucide-react";
|
||||
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";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -1879,6 +1879,16 @@ function InnerEditor({
|
||||
|
||||
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
|
||||
const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null;
|
||||
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
|
||||
const handleTogglePromptExpand = useCallback(() => {
|
||||
setIsPromptExpanded((prev) => !prev);
|
||||
}, []);
|
||||
const handlePromptFullscreenKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isPromptExpanded || e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsPromptExpanded(false);
|
||||
}, [isPromptExpanded]);
|
||||
const selectedNodePromptValue =
|
||||
selectedNode && (selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate")
|
||||
? String(
|
||||
@@ -3134,14 +3144,44 @@ function InnerEditor({
|
||||
</label>
|
||||
|
||||
{selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate" ? (
|
||||
<label className="wf-field">
|
||||
<span>Prompt</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={selectedNodePromptValue}
|
||||
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
|
||||
/>
|
||||
</label>
|
||||
<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>
|
||||
)}
|
||||
<label className="wf-field">
|
||||
<span>Prompt</span>
|
||||
<textarea
|
||||
rows={isPromptExpanded ? undefined : 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>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedNode.data.kind === "prompt" ? (
|
||||
|
||||
@@ -241,6 +241,35 @@ function def(): WorkflowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function scriptDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-SCRIPT",
|
||||
kind: "workflow",
|
||||
name: "Script workflow",
|
||||
description: "",
|
||||
ir: {
|
||||
version: "v2",
|
||||
name: "Script workflow",
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "triage" },
|
||||
{ id: "run", kind: "script", column: "triage", config: { scriptName: "test" } },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "run", condition: "success" },
|
||||
{ from: "run", to: "end", condition: "success" },
|
||||
],
|
||||
},
|
||||
layout: { start: { x: 0, y: 20 }, run: { x: 120, y: 60 }, end: { x: 360, y: 240 } },
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("workflow-flow-mapping", () => {
|
||||
it("round-trips IR through flow and back, preserving structure and layout", () => {
|
||||
const original = def();
|
||||
@@ -488,6 +517,79 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(screen.getByTestId("wf-inspector-toggle")).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("shows a prompt expand button and toggles fullscreen for prompt nodes", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
fireEvent.click(await screen.findByTestId("wf-node-prompt"));
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Collapse prompt editor" }));
|
||||
|
||||
expect(promptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
|
||||
expect(screen.getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses the fullscreen prompt editor on Escape", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
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");
|
||||
|
||||
fireEvent.keyDown(promptEditor!, { key: "Escape" });
|
||||
|
||||
expect(promptEditor).not.toHaveClass("wf-prompt-editor--fullscreen");
|
||||
expect(screen.getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the prompt expand button for gate nodes with an empty prompt", 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")).toHaveValue("");
|
||||
expect(within(inspector).getByRole("button", { name: "Expand prompt editor" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the prompt expand button for non-prompt nodes", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([scriptDef()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
fireEvent.click(await screen.findByTestId("wf-node-script"));
|
||||
|
||||
const inspector = await screen.findByTestId("wf-node-inspector");
|
||||
expect(within(inspector).getByLabelText("Script name")).toBeInTheDocument();
|
||||
expect(within(inspector).queryByRole("button", { name: "Expand prompt editor" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
const { container } = render(<WorkflowNodeEditor isOpen={false} onClose={() => {}} addToast={() => {}} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
Reference in New Issue
Block a user