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 {
|
.card-promote-action.card-send-back-btn {
|
||||||
|
opacity: 1;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -665,6 +665,79 @@
|
|||||||
box-shadow: var(--focus-ring);
|
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 {
|
.wf-inspector-note {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -1242,6 +1315,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.wf-prompt-editor--fullscreen {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-overlay:has(.wf-editor-modal),
|
.modal-overlay:has(.wf-editor-modal),
|
||||||
.modal-overlay:has(.wf-create-modal) {
|
.modal-overlay:has(.wf-create-modal) {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type Edge as FlowEdge,
|
type Edge as FlowEdge,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
@@ -1879,6 +1879,16 @@ function InnerEditor({
|
|||||||
|
|
||||||
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
|
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
|
||||||
const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? 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 =
|
const selectedNodePromptValue =
|
||||||
selectedNode && (selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate")
|
selectedNode && (selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate")
|
||||||
? String(
|
? String(
|
||||||
@@ -3134,14 +3144,44 @@ function InnerEditor({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate" ? (
|
{selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate" ? (
|
||||||
<label className="wf-field">
|
<div
|
||||||
<span>Prompt</span>
|
className={`wf-prompt-editor${isPromptExpanded ? " wf-prompt-editor--fullscreen" : ""}`}
|
||||||
<textarea
|
onKeyDown={handlePromptFullscreenKeyDown}
|
||||||
rows={5}
|
>
|
||||||
value={selectedNodePromptValue}
|
{isPromptExpanded && (
|
||||||
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
|
<div className="wf-prompt-fullscreen-header">
|
||||||
/>
|
<span>{t("workflowEditor.editingPrompt", "Editing Prompt")}</span>
|
||||||
</label>
|
<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}
|
) : null}
|
||||||
|
|
||||||
{selectedNode.data.kind === "prompt" ? (
|
{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", () => {
|
describe("workflow-flow-mapping", () => {
|
||||||
it("round-trips IR through flow and back, preserving structure and layout", () => {
|
it("round-trips IR through flow and back, preserving structure and layout", () => {
|
||||||
const original = def();
|
const original = def();
|
||||||
@@ -488,6 +517,79 @@ describe("WorkflowNodeEditor", () => {
|
|||||||
expect(screen.getByTestId("wf-inspector-toggle")).toHaveAttribute("aria-expanded", "true");
|
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", () => {
|
it("renders nothing when closed", () => {
|
||||||
const { container } = render(<WorkflowNodeEditor isOpen={false} onClose={() => {}} addToast={() => {}} />);
|
const { container } = render(<WorkflowNodeEditor isOpen={false} onClose={() => {}} addToast={() => {}} />);
|
||||||
expect(container).toBeEmptyDOMElement();
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
|||||||
Reference in New Issue
Block a user