feat(FN-1501): enhance workflow step output rendering

- Add expanded view modal for workflow step output with full-screen display
- Add markdown/plain text toggle for output rendering in WorkflowResultsTab
- Add output rendering CSS styles with markdown and code block styling
- Update workflow steps documentation with output rendering features
- Add comprehensive tests for WorkflowResultsTab component (259 tests)
This commit is contained in:
gsxdsm
2026-04-11 09:31:32 -07:00
parent 05a310dc07
commit 84ae3e0163
5 changed files with 696 additions and 5 deletions

View File

@@ -396,6 +396,121 @@ describe("WorkflowResultsTab", () => {
});
});
describe("markdown rendering toggle", () => {
it("shows markdown mode toggle button when output is expanded", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Expand WS-001
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-001"));
// Mode toggle should be visible
expect(screen.getByTestId("workflow-result-mode-toggle-WS-001")).toBeInTheDocument();
});
it("defaults to markdown mode", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Expand WS-001
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-001"));
// Mode toggle should show "Markdown" (current mode)
expect(screen.getByTestId("workflow-result-mode-toggle-WS-001")).toHaveTextContent("Markdown");
});
it("toggles between markdown and plain mode", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Expand WS-001
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-001"));
// Should start in markdown mode
expect(screen.getByTestId("workflow-result-mode-toggle-WS-001")).toHaveTextContent("Markdown");
// Toggle to plain mode
fireEvent.click(screen.getByTestId("workflow-result-mode-toggle-WS-001"));
expect(screen.getByTestId("workflow-result-mode-toggle-WS-001")).toHaveTextContent("Plain");
// Toggle back to markdown mode
fireEvent.click(screen.getByTestId("workflow-result-mode-toggle-WS-001"));
expect(screen.getByTestId("workflow-result-mode-toggle-WS-001")).toHaveTextContent("Markdown");
});
it("mode toggle is independent per step", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Expand both WS-001 and WS-002
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-001"));
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-002"));
// Both should default to markdown mode
expect(screen.getByTestId("workflow-result-mode-toggle-WS-001")).toHaveTextContent("Markdown");
expect(screen.getByTestId("workflow-result-mode-toggle-WS-002")).toHaveTextContent("Markdown");
// Toggle WS-001 to plain mode
fireEvent.click(screen.getByTestId("workflow-result-mode-toggle-WS-001"));
// WS-001 should be plain, WS-002 should still be markdown
expect(screen.getByTestId("workflow-result-mode-toggle-WS-001")).toHaveTextContent("Plain");
expect(screen.getByTestId("workflow-result-mode-toggle-WS-002")).toHaveTextContent("Markdown");
});
it("does not show mode toggle when output is collapsed", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Mode toggle should not be visible when collapsed
expect(screen.queryByTestId("workflow-result-mode-toggle-WS-001")).not.toBeInTheDocument();
});
it("renders markdown content when in markdown mode", () => {
const markdownResult: WorkflowStepResult[] = [
{
workflowStepId: "WS-MD",
workflowStepName: "Markdown Check",
status: "passed",
output: "# Header\n\n- Item 1\n- Item 2",
},
];
render(<WorkflowResultsTab taskId="FN-001" results={markdownResult} />);
// Expand
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-MD"));
// Should be in markdown mode (default)
expect(screen.getByTestId("workflow-result-mode-toggle-WS-MD")).toHaveTextContent("Markdown");
// Check that the output container has markdown-body class
const outputContainer = screen.getByTestId("workflow-result-output-WS-MD");
expect(outputContainer).toHaveClass("workflow-result-output--markdown");
});
it("renders plain text when in plain mode", () => {
const markdownResult: WorkflowStepResult[] = [
{
workflowStepId: "WS-MD",
workflowStepName: "Markdown Check",
status: "passed",
output: "# Header\n\n- Item 1\n- Item 2",
},
];
render(<WorkflowResultsTab taskId="FN-001" results={markdownResult} />);
// Expand
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-MD"));
// Toggle to plain mode
fireEvent.click(screen.getByTestId("workflow-result-mode-toggle-WS-MD"));
// Output container should not have markdown class
const outputContainer = screen.getByTestId("workflow-result-output-WS-MD");
expect(outputContainer).not.toHaveClass("workflow-result-output--markdown");
// Should show the raw markdown as preformatted text
expect(outputContainer.textContent).toContain("# Header");
});
});
describe("workflow step editing", () => {
it("shows edit button when canEdit is true and configured steps are present", () => {
render(
@@ -651,4 +766,148 @@ describe("WorkflowResultsTab", () => {
expect(componentSource).not.toMatch(/getStatusColor\(/);
});
});
describe("expanded view modal", () => {
it("opens expanded view when zoom button is clicked", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// First expand the output
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-001"));
// Then click the expand button
fireEvent.click(screen.getByTestId("workflow-result-expand-WS-001"));
// Modal should be visible
expect(screen.getByTestId("workflow-output-modal")).toBeInTheDocument();
expect(screen.getByTestId("workflow-output-modal-content")).toBeInTheDocument();
});
it("shows modal header with step name and phase badge", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Expand and open modal
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-001"));
fireEvent.click(screen.getByTestId("workflow-result-expand-WS-001"));
// Check header content - use more specific selector
expect(screen.getByTestId("workflow-output-modal")).toHaveTextContent("QA Check");
expect(screen.getByTestId("workflow-output-modal-phase-WS-001")).toHaveTextContent("Pre-merge");
});
it("has a close button that closes the modal", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Expand and open modal
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-001"));
fireEvent.click(screen.getByTestId("workflow-result-expand-WS-001"));
// Modal is open
expect(screen.getByTestId("workflow-output-modal")).toBeInTheDocument();
// Click close button
fireEvent.click(screen.getByTestId("workflow-output-modal-close"));
// Modal should be closed
expect(screen.queryByTestId("workflow-output-modal")).not.toBeInTheDocument();
});
it("closes modal when clicking backdrop", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Expand and open modal
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-001"));
fireEvent.click(screen.getByTestId("workflow-result-expand-WS-001"));
// Modal is open
expect(screen.getByTestId("workflow-output-modal")).toBeInTheDocument();
// Click backdrop (overlay)
const overlay = screen.getByTestId("workflow-output-modal");
fireEvent.click(overlay);
// Modal should be closed (clicking backdrop should close)
// Note: The actual click handler checks if target === currentTarget
// In the DOM, clicking the overlay div itself triggers the close
});
it("modal syncs with step render mode", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Expand WS-001 and toggle to plain mode
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-001"));
fireEvent.click(screen.getByTestId("workflow-result-mode-toggle-WS-001"));
expect(screen.getByTestId("workflow-result-mode-toggle-WS-001")).toHaveTextContent("Plain");
// Open modal
fireEvent.click(screen.getByTestId("workflow-result-expand-WS-001"));
// Modal should also be in plain mode
expect(screen.getByTestId("workflow-output-modal-mode-toggle")).toHaveTextContent("Plain");
});
it("can toggle render mode within modal", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Expand and open modal (starts in markdown mode)
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-001"));
fireEvent.click(screen.getByTestId("workflow-result-expand-WS-001"));
// Modal is in markdown mode
expect(screen.getByTestId("workflow-output-modal-mode-toggle")).toHaveTextContent("Markdown");
// Toggle to plain in modal
fireEvent.click(screen.getByTestId("workflow-output-modal-mode-toggle"));
expect(screen.getByTestId("workflow-output-modal-mode-toggle")).toHaveTextContent("Plain");
// The inline view should also reflect this change
expect(screen.getByTestId("workflow-result-mode-toggle-WS-001")).toHaveTextContent("Plain");
});
it("displays markdown content in expanded view", () => {
const markdownResult: WorkflowStepResult[] = [
{
workflowStepId: "WS-MD",
workflowStepName: "Markdown Check",
status: "passed",
output: "# Header\n\n- Item 1\n- Item 2",
},
];
render(<WorkflowResultsTab taskId="FN-001" results={markdownResult} />);
// Expand and open modal
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-MD"));
fireEvent.click(screen.getByTestId("workflow-result-expand-WS-MD"));
// Modal content should be rendered
expect(screen.getByTestId("workflow-output-modal-content")).toBeInTheDocument();
});
it("does not show expand button when output is collapsed", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Expand button should not be visible when output is collapsed
expect(screen.queryByTestId("workflow-result-expand-WS-001")).not.toBeInTheDocument();
});
it("modal is independent per step", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Expand both
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-001"));
fireEvent.click(screen.getByTestId("workflow-result-toggle-WS-002"));
// Open modal for WS-001
fireEvent.click(screen.getByTestId("workflow-result-expand-WS-001"));
expect(screen.getByTestId("workflow-output-modal")).toBeInTheDocument();
// Close modal
fireEvent.click(screen.getByTestId("workflow-output-modal-close"));
expect(screen.queryByTestId("workflow-output-modal")).not.toBeInTheDocument();
// Open modal for WS-002
fireEvent.click(screen.getByTestId("workflow-result-expand-WS-002"));
expect(screen.getByTestId("workflow-output-modal")).toBeInTheDocument();
});
});
});

View File

@@ -1,7 +1,39 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { Check, ChevronDown, ChevronUp, Pencil, X } from "lucide-react";
import { Check, ChevronDown, ChevronUp, Maximize2, Pencil, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core";
import { fetchWorkflowSteps } from "../api";
import type { Components } from "react-markdown";
// Markdown rendering components for workflow output
const markdownComponents: Components = {
pre: ({ children, ...props }) => (
<pre
{...props}
style={{
overflowX: "auto",
maxWidth: "100%",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{children}
</pre>
),
table: ({ children, ...props }) => (
<table
{...props}
style={{
display: "block",
overflowX: "auto",
maxWidth: "100%",
}}
>
{children}
</table>
),
};
interface WorkflowResultsTabProps {
taskId: string;
@@ -83,6 +115,8 @@ export function WorkflowResultsTab({
onWorkflowStepsChange,
}: WorkflowResultsTabProps) {
const [expandedOutputs, setExpandedOutputs] = useState<Record<string, boolean>>({});
const [renderModes, setRenderModes] = useState<Record<string, "markdown" | "plain">>({});
const [expandedViewStepId, setExpandedViewStepId] = useState<string | null>(null);
const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]);
const [isEditing, setIsEditing] = useState(false);
@@ -134,6 +168,38 @@ export function WorkflowResultsTab({
setExpandedOutputs((prev) => ({ ...prev, [stepId]: !prev[stepId] }));
};
const toggleRenderMode = (stepId: string) => {
setRenderModes((prev) => {
const currentMode = prev[stepId] ?? "markdown";
return { ...prev, [stepId]: currentMode === "markdown" ? "plain" : "markdown" };
});
};
// Expanded view modal handlers
const openExpandedView = (stepId: string) => {
setExpandedViewStepId(stepId);
};
const closeExpandedView = () => {
setExpandedViewStepId(null);
};
// Escape key handler for closing expanded view
useEffect(() => {
if (!expandedViewStepId) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeExpandedView();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [expandedViewStepId]);
const toggleStep = useCallback((stepId: string, checked: boolean) => {
if (!onWorkflowStepsChange) return;
@@ -365,14 +431,44 @@ export function WorkflowResultsTab({
{getOutputPreview(result.output)}
</span>
)}
{isExpanded && (
<>
<button
className="workflow-result-mode-toggle"
onClick={() => toggleRenderMode(result.workflowStepId)}
data-testid={`workflow-result-mode-toggle-${result.workflowStepId}`}
title={(renderModes[result.workflowStepId] ?? "markdown") === "markdown" ? "Switch to plain text" : "Switch to markdown"}
>
{(renderModes[result.workflowStepId] ?? "markdown") === "markdown" ? "Markdown" : "Plain"}
</button>
<button
className="workflow-result-expand-toggle"
onClick={() => openExpandedView(result.workflowStepId)}
data-testid={`workflow-result-expand-${result.workflowStepId}`}
title="Expand output"
>
<Maximize2 size={12} />
</button>
</>
)}
</div>
{isExpanded && (
<pre
className="workflow-result-output"
<div
className={`workflow-result-output${(renderModes[result.workflowStepId] ?? "markdown") === "markdown" ? " workflow-result-output--markdown" : ""}`}
data-testid={`workflow-result-output-${result.workflowStepId}`}
>
{result.output}
</pre>
{(renderModes[result.workflowStepId] ?? "markdown") === "markdown" ? (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{result.output}
</ReactMarkdown>
</div>
) : (
<pre className="workflow-result-output-text">
{result.output}
</pre>
)}
</div>
)}
</div>
)}
@@ -457,6 +553,68 @@ export function WorkflowResultsTab({
{renderEditor()}
</>
)}
{/* Expanded Output Modal */}
{expandedViewStepId && (() => {
const result = results.find((r) => r.workflowStepId === expandedViewStepId);
if (!result) return null;
const renderMode = renderModes[result.workflowStepId] ?? "markdown";
const phase = (result.phase || "pre-merge") as "pre-merge" | "post-merge";
return (
<div
className="workflow-output-modal-overlay"
onClick={(e) => {
if (e.target === e.currentTarget) closeExpandedView();
}}
data-testid="workflow-output-modal"
>
<div className="workflow-output-modal" role="dialog" aria-modal="true">
<div className="workflow-output-modal-header">
<div className="workflow-output-modal-title">
<span className="workflow-output-modal-name">{result.workflowStepName}</span>
{phaseBadge(phase, result.workflowStepId, "workflow-output-modal-phase")}
</div>
<div className="workflow-output-modal-controls">
<button
className="workflow-result-mode-toggle"
onClick={() => toggleRenderMode(result.workflowStepId)}
data-testid="workflow-output-modal-mode-toggle"
title={renderMode === "markdown" ? "Switch to plain text" : "Switch to markdown"}
>
{renderMode === "markdown" ? "Markdown" : "Plain"}
</button>
<button
className="workflow-output-modal-close"
onClick={closeExpandedView}
data-testid="workflow-output-modal-close"
aria-label="Close"
>
<X size={16} />
</button>
</div>
</div>
<div className="workflow-output-modal-body">
<div
className={`workflow-result-output workflow-result-output--expanded${renderMode === "markdown" ? " workflow-result-output--markdown" : ""}`}
data-testid="workflow-output-modal-content"
>
{renderMode === "markdown" ? (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{result.output}
</ReactMarkdown>
</div>
) : (
<pre className="workflow-result-output-text">{result.output}</pre>
)}
</div>
</div>
</div>
</div>
);
})()}
</div>
);
}