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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user