Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/handle-blank-canvas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openworkflowspec/diagram-editor": minor
---

Handle blank canvas in diagram editor
26 changes: 17 additions & 9 deletions packages/dual-editor/tests/DualEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useEffect, useState } from "react";
import { useEffect, useRef } from "react";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DualEditor } from "../src/DualEditor";
Expand All @@ -28,17 +28,21 @@ vi.mock("@openworkflowspec/text-editor", () => ({
content: string;
onContentChange?: (content: string) => void;
}) => {
const [editorContent, setEditorContent] = useState(content);
useEffect(() => setEditorContent(content), [content]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (textareaRef.current && textareaRef.current.value !== content) {
textareaRef.current.value = content;
}
}, [content]);

return (
<textarea
aria-label="Text source"
data-testid="text-source"
value={editorContent}
ref={textareaRef}
defaultValue={content}
onChange={(event) => {
const nextContent = event.currentTarget.value;
setEditorContent(nextContent);
onContentChange?.(nextContent);
}}
/>
Expand All @@ -56,18 +60,22 @@ vi.mock("@openworkflowspec/diagram-editor", () => ({
language?: "json" | "yaml";
onContentChange?: (content: string) => void;
}) => {
const [diagramContent, setDiagramContent] = useState(content);
useEffect(() => setDiagramContent(content), [content]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (textareaRef.current && textareaRef.current.value !== content) {
textareaRef.current.value = content;
}
}, [content]);

return (
<textarea
aria-label="Diagram source"
data-testid="diagram-source"
data-format={language}
value={diagramContent}
ref={textareaRef}
defaultValue={content}
onChange={(event) => {
const nextContent = event.currentTarget.value;
setDiagramContent(nextContent);
onContentChange?.(nextContent);
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,31 @@ export type DiagramEditorProps = {
const DiagramEditorContent = ({
diagramDivRef,
colorMode,
content,
}: {
diagramDivRef: React.RefObject<HTMLDivElement | null>;
colorMode: ResolvedColorMode;
content: string;
}) => {
const { model } = useDiagramEditorContext();
const { model, processedContent } = useDiagramEditorContext();
const { t } = useI18n();
if (content.trim().length === 0 && model === null) {
return (
<div className="dec-empty-state">
<div className="dec-empty-state__content">
<h2 className="dec-empty-state__title">{t("workflowEmpty.title")}</h2>
<p className="dec-empty-state__message">{t("workflowEmpty.message")}</p>
</div>
</div>
);
}
if (model === null && processedContent !== content) {
return (
<div role="status" aria-busy="true" className="dec-loading-state">
{t("workflowLoading")}
</div>
);
}
return model === null ? (
<ParsingErrorPage />
) : (
Expand Down Expand Up @@ -158,7 +178,11 @@ const DiagramEditorBody = ({
>
<SidebarProvider defaultOpen={false}>
<div className="dec-diagram-content">
<DiagramEditorContent diagramDivRef={diagramDivRef} colorMode={resolvedColorMode} />
<DiagramEditorContent
diagramDivRef={diagramDivRef}
colorMode={resolvedColorMode}
content={props.content}
/>
</div>
<SidePanel />
</SidebarProvider>
Expand Down
3 changes: 3 additions & 0 deletions packages/open-workflow-diagram-editor/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const en = {
"workflowError.title": "Workflow Error",
"workflowError.default": "There was an error loading the workflow.",
"workflowError.parsing.title": "Parsing Error",
"workflowEmpty.title": "Your diagram will appear here",
"workflowEmpty.message": "Start by adding YAML or JSON workflow content to see your diagram.",
workflowLoading: "Loading diagram…",
"sidebar.toggle": "Toggle sidebar",
"sidebar.workflow": "Workflow",
"sidebar.document": "Document",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export type DiagramEditorContextType = {
isReadOnly: boolean;
locale: string;
contentFormat: ContentFormat;
/** Content string whose most recent parse attempt has completed. */
processedContent: string | null;
model: Specification.Workflow | null;
errors: SdkError[];
nodes: RF.Node[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const DiagramEditorContextProvider = React.forwardRef<
props.content.trimStart().startsWith("{") ? "json" : "yaml",
);
const contentFormat = props.language ?? detectedContentFormat;
const [processedContent, setProcessedContent] = React.useState<string | null>(null);

// Config state (non-history)
const [locale, setLocale] = React.useState<string>(props.locale);
Expand Down Expand Up @@ -114,6 +115,8 @@ export const DiagramEditorContextProvider = React.forwardRef<
// This will be addressed in the editing feature branch as the implemntation is changing
// oxlint-disable-next-line react/set-state-in-effect
setErrors(parsedErrors);
// oxlint-disable-next-line react/set-state-in-effect
setProcessedContent(props.content);
if (parsedModel === null) {
// Content is unparseable — reset history to null so downstream consumers
// (e.g. DiagramEditorContent) see model === null and render the error page
Expand Down Expand Up @@ -148,6 +151,7 @@ export const DiagramEditorContextProvider = React.forwardRef<
if (newModel === null) return;

setErrors(newErrors);
setProcessedContent(content);

const newFormat: ContentFormat = content.trimStart().startsWith("{") ? "json" : "yaml";
setDetectedContentFormat(newFormat);
Expand Down Expand Up @@ -199,6 +203,7 @@ export const DiagramEditorContextProvider = React.forwardRef<
isReadOnly,
locale,
contentFormat,
processedContent,
model,
errors,
nodes,
Expand All @@ -224,6 +229,7 @@ export const DiagramEditorContextProvider = React.forwardRef<
isReadOnly,
locale,
contentFormat,
processedContent,
model,
errors,
nodes,
Expand Down
35 changes: 35 additions & 0 deletions packages/open-workflow-diagram-editor/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@
--dec-surface-dark-raised: #1f2937;
--dec-surface-dark-elevated: #2d3748;

/* Empty and loading state text */
--dec-state-title-color: #111827;
--dec-state-message-color: #4b5563;

/* edges */
--dec-edge-selected: #aea6a6;
--dec-edge-selected-condition: rgb(var(--dec-selected-rgb));
Expand All @@ -97,6 +101,9 @@
}

.dec-root.dark {
--dec-state-title-color: #f3f4f6;
--dec-state-message-color: #d1d5db;

/* canvas */
--dec-canvas-bg: #1c222c;
--dec-canvas-pattern: #4a5568;
Expand All @@ -123,4 +130,32 @@
.dec-root .dec-diagram-content {
@apply dec:flex-1 dec:min-w-0 dec:h-full;
}

.dec-root .dec-empty-state {
@apply dec:flex dec:h-full dec:min-h-0 dec:items-center dec:justify-center dec:p-6;
}

.dec-root .dec-empty-state,
.dec-root .dec-loading-state {
background-color: var(--dec-canvas-bg);
}

.dec-root .dec-empty-state__content {
@apply dec:max-w-sm dec:text-center;
}

.dec-root .dec-empty-state__title {
@apply dec:text-base dec:font-semibold;
color: var(--dec-state-title-color);
}

.dec-root .dec-empty-state__message {
@apply dec:mt-1 dec:text-sm;
color: var(--dec-state-message-color);
}

.dec-root .dec-loading-state {
@apply dec:flex dec:h-full dec:items-center dec:justify-center dec:text-sm;
color: var(--dec-state-message-color);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,75 @@ describe("DiagramEditor Component", () => {
expect(screen.queryByTestId("diagram-container")).not.toBeInTheDocument();
});

it.each(["", " \n\t "])(
"renders the empty state without an error for blank content %j",
(content) => {
render(<DiagramEditor content={content} locale={locale} isReadOnly={isReadOnly} />);

expect(screen.getByText(t("workflowEmpty.title"))).toBeInTheDocument();
expect(screen.getByText(t("workflowEmpty.message"))).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByTestId("diagram-container")).not.toBeInTheDocument();
},
);

it("transitions from the empty state to the diagram when workflow content is added", async () => {
const { container, rerender } = render(
<DiagramEditor content="" locale={locale} isReadOnly={isReadOnly} />,
);

expect(screen.getByText(t("workflowEmpty.title"))).toBeInTheDocument();

const insertedAlerts: Element[] = [];
const observer = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (!(node instanceof Element)) continue;
if (node.matches('[role="alert"]')) insertedAlerts.push(node);
insertedAlerts.push(...node.querySelectorAll('[role="alert"]'));
}
}
});
observer.observe(container, { childList: true, subtree: true });

try {
rerender(
<DiagramEditor
content={BASIC_VALID_WORKFLOW_YAML}
locale={locale}
isReadOnly={isReadOnly}
/>,
);

// A mounted wrapper can still contain an empty canvas while ELK is laying out nodes.
await waitFor(() => expect(container.querySelector(".dec-leaf-node")).toBeInTheDocument());
await act(async () => {
await Promise.resolve();
});
} finally {
observer.disconnect();
}

expect(insertedAlerts).toHaveLength(0);
expect(screen.queryByText(t("workflowEmpty.title"))).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});

it("returns to the empty state when workflow content is removed", async () => {
const { rerender } = render(
<DiagramEditor content={BASIC_VALID_WORKFLOW_YAML} locale={locale} isReadOnly={isReadOnly} />,
);

await waitFor(() => expect(screen.getByTestId("diagram-container")).toBeInTheDocument());
rerender(<DiagramEditor content={" \n"} locale={locale} isReadOnly={isReadOnly} />);

await waitFor(() => {
expect(screen.getByText(t("workflowEmpty.title"))).toBeInTheDocument();
expect(screen.queryByTestId("diagram-container")).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});

it.each([
{ colorMode: "light" as const, expectedDark: false },
{ colorMode: "dark" as const, expectedDark: true },
Expand Down Expand Up @@ -234,6 +303,19 @@ do:
expect(output).toContain("loaded-via-set-content");
});

it("shows a workflow loaded through the ref when the initial content is blank", async () => {
const ref = React.createRef<DiagramEditorRef>();
render(<DiagramEditor ref={ref} content="" locale="en" isReadOnly={true} />);

expect(screen.getByText(t("workflowEmpty.title"))).toBeInTheDocument();

act(() => ref.current?.setContent(BASIC_VALID_WORKFLOW_YAML));

await waitFor(() => expect(screen.getByTestId("diagram-container")).toBeInTheDocument());
expect(screen.queryByText(t("workflowEmpty.title"))).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});

it("setContent with JSON input switches the format so getContent returns JSON", () => {
const ref = React.createRef<DiagramEditorRef>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const createMockContextValue = (
isReadOnly: true,
locale: "en",
contentFormat: "yaml",
processedContent: null,
model: null,
errors: [],
nodes: [],
Expand Down
Loading