diff --git a/.changeset/dual-editor-vanilla-componet-example.md b/.changeset/dual-editor-vanilla-componet-example.md new file mode 100644 index 00000000..a337cb99 --- /dev/null +++ b/.changeset/dual-editor-vanilla-componet-example.md @@ -0,0 +1,5 @@ +--- +"@openworkflowspec/vanilla-web-component-example": minor +--- + +Integrate the Dual Editor in the Vanilla Web Component example. diff --git a/examples/vanilla-web-component/README.md b/examples/vanilla-web-component/README.md index be295c87..aab4e824 100644 --- a/examples/vanilla-web-component/README.md +++ b/examples/vanilla-web-component/README.md @@ -16,7 +16,7 @@ # @openworkflowspec/vanilla-web-component-example -This example demonstrates how to embed the `@openworkflowspec/diagram-editor` React component in a **non-React** host application using a Web Component. +This example demonstrates how to embed the `@openworkflowspec/dual-editor` React component in a **non-React** host application using a Web Component. It displays the text and diagram editors side by side. ## Quick Start @@ -31,10 +31,10 @@ Browse http://localhost:6007 ## How It Works -The wrapper in `src/diagram-editor-element.ts` defines a custom element `` that: +The wrapper in `src/dual-editor-element.ts` defines a custom element `` that: 1. Creates a React root inside itself -2. Renders the `DiagramEditor` React component +2. Renders the `DualEditor` React component and creates its language-service worker 3. Re-renders when attributes or properties change 4. Unmounts the React root on disconnect @@ -45,17 +45,18 @@ The wrapper in `src/diagram-editor-element.ts` defines a custom element ` + diff --git a/examples/vanilla-web-component/package.json b/examples/vanilla-web-component/package.json index 61fcfeee..392c96b9 100644 --- a/examples/vanilla-web-component/package.json +++ b/examples/vanilla-web-component/package.json @@ -2,7 +2,7 @@ "name": "@openworkflowspec/vanilla-web-component-example", "version": "1.1.0", "private": true, - "description": "non-React example embedding the open workflow diagram component", + "description": "non-React example embedding the Open Workflow dual editor", "keywords": [], "homepage": "https://github.com/open-workflow-specification/editor", "bugs": { @@ -30,7 +30,8 @@ }, "dependencies": { "@openworkflowspec/core": "workspace:*", - "@openworkflowspec/diagram-editor": "workspace:*", + "@openworkflowspec/dual-editor": "workspace:*", + "@openworkflowspec/text-editor": "workspace:*", "react": "catalog:", "react-dom": "catalog:" }, diff --git a/examples/vanilla-web-component/playwright.config.ts b/examples/vanilla-web-component/playwright.config.ts index 768e00bc..e7274047 100644 --- a/examples/vanilla-web-component/playwright.config.ts +++ b/examples/vanilla-web-component/playwright.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ use: { baseURL: "http://localhost:6007", + permissions: ["clipboard-read", "clipboard-write"], }, webServer: { diff --git a/examples/vanilla-web-component/src/diagram-editor-element.ts b/examples/vanilla-web-component/src/dual-editor-element.ts similarity index 63% rename from examples/vanilla-web-component/src/diagram-editor-element.ts rename to examples/vanilla-web-component/src/dual-editor-element.ts index bfa26452..f62aeccd 100644 --- a/examples/vanilla-web-component/src/diagram-editor-element.ts +++ b/examples/vanilla-web-component/src/dual-editor-element.ts @@ -14,20 +14,25 @@ * limitations under the License. */ -// React is required internally by @openworkflowspec/diagram-editor. +// React is required internally by @openworkflowspec/dual-editor. // This wrapper encapsulates it behind a standard Custom Element API, // so host applications don't need to interact with React directly. import { createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { DiagramEditor } from "@openworkflowspec/diagram-editor"; -import type { DiagramEditorProps } from "@openworkflowspec/diagram-editor"; +import { DualEditor } from "@openworkflowspec/dual-editor"; +import type { DualEditorProps } from "@openworkflowspec/dual-editor"; +// Vite transforms this module import into a Worker constructor. +// oxlint-disable-next-line import/default +import LanguageServiceWorker from "@openworkflowspec/text-editor/worker?worker"; import type { ColorMode } from "@openworkflowspec/core"; -export class DiagramEditorElement extends HTMLElement { - static observedAttributes = ["locale", "color-mode", "read-only"]; + +export class DualEditorElement extends HTMLElement { + static observedAttributes = ["locale", "color-mode", "read-only", "language"]; private _root: Root | null = null; private _mountPoint: HTMLDivElement | null = null; private _content = ""; + private _contentResetKey = 0; get content(): string { return this._content; @@ -35,21 +40,23 @@ export class DiagramEditorElement extends HTMLElement { set content(value: string) { this._content = value; + this._contentResetKey += 1; this.render(); } connectedCallback(): void { this._mountPoint = document.createElement("div"); this._mountPoint.style.height = "100%"; - this._mountPoint.dataset.testid = "diagram-editor-mount-point"; + this._mountPoint.dataset.testid = "dual-editor-mount-point"; this.appendChild(this._mountPoint); this._root = createRoot(this._mountPoint); this.render(); } disconnectedCallback(): void { - this._root?.unmount(); + const root = this._root; this._root = null; + root?.unmount(); this._mountPoint?.remove(); this._mountPoint = null; } @@ -61,13 +68,22 @@ export class DiagramEditorElement extends HTMLElement { private render(): void { if (!this._root) return; - const props: DiagramEditorProps = { + const contentResetKey = this._contentResetKey; + const props: DualEditorProps = { content: this._content, + contentResetKey, locale: this.getAttribute("locale") ?? "en", isReadOnly: this.hasAttribute("read-only"), colorMode: (this.getAttribute("color-mode") as ColorMode) ?? "system", + language: this.getAttribute("language") === "yaml" ? "yaml" : "json", + createLanguageServiceWorker: () => new LanguageServiceWorker(), + onContentChange: (content) => { + if (contentResetKey !== this._contentResetKey) return; + this._content = content; + this.render(); + }, }; - this._root.render(createElement(DiagramEditor, props)); + this._root.render(createElement(DualEditor, props)); } } diff --git a/examples/vanilla-web-component/src/index.ts b/examples/vanilla-web-component/src/index.ts index f5930fa6..c8bfa88c 100644 --- a/examples/vanilla-web-component/src/index.ts +++ b/examples/vanilla-web-component/src/index.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import "@openworkflowspec/diagram-editor/styles.css"; -import { DiagramEditorElement } from "./diagram-editor-element"; +import "@openworkflowspec/dual-editor/styles.css"; +import { DualEditorElement } from "./dual-editor-element"; -if (!customElements.get("openworkflowspec-diagram-editor")) { - customElements.define("openworkflowspec-diagram-editor", DiagramEditorElement); +if (!customElements.get("openworkflowspec-dual-editor")) { + customElements.define("openworkflowspec-dual-editor", DualEditorElement); } diff --git a/examples/vanilla-web-component/tests-e2e/diagram-editor.spec.ts b/examples/vanilla-web-component/tests-e2e/diagram-editor.spec.ts index 2983479c..6911fd1c 100644 --- a/examples/vanilla-web-component/tests-e2e/diagram-editor.spec.ts +++ b/examples/vanilla-web-component/tests-e2e/diagram-editor.spec.ts @@ -16,35 +16,85 @@ import { test, expect } from "@playwright/test"; -test("web component renders the diagram editor", async ({ page }) => { +test("web component renders both dual editor panes", async ({ page }) => { await page.goto("/"); + await expect(page.locator(".monaco-editor").first()).toBeVisible(); + await expect(page.locator(".monaco-editor").first()).toContainText("accumulate-room-readings"); + await expect(page.getByRole("region", { name: "Diagram editor" })).toBeVisible(); await expect(page.getByTestId("diagram-container")).toBeVisible(); await expect(page.getByTestId("rf__node-/do/consumeReading")).toContainText("consumeReading"); }); -test("web component re-renders when content is replaced", async ({ page }) => { +test("text edits update the content property and diagram, and external content resets both", async ({ + page, +}) => { await page.goto("/"); - await expect(page.getByTestId("rf__node-/do/consumeReading")).toContainText("consumeReading"); - - const newWorkflow = `document: + const editor = page.locator(".monaco-editor").first(); + const originalWorkflow = `document: + dsl: '1.0.3' + namespace: examples + name: original +do: + - firstTask: + call: http + with: + method: get + endpoint: https://example.com/first`; + const editedWorkflow = `document: dsl: '1.0.3' namespace: examples - name: greet + name: edited do: - - sayHello: + - updatedTask: call: http with: method: get - endpoint: https://example.com/hello`; + endpoint: https://example.com/updated`; + + await editor.click(); + await page.keyboard.press("ControlOrMeta+a"); + await page.evaluate((content) => navigator.clipboard.writeText(content), editedWorkflow); + await page.keyboard.press("ControlOrMeta+v"); + + await expect(page.getByTestId("rf__node-/do/updatedTask")).toContainText("updatedTask"); + await expect + .poll(() => + page + .locator("openworkflowspec-dual-editor") + .evaluate((element) => (element as HTMLElement & { content: string }).content), + ) + .toBe(editedWorkflow); + + await page.evaluate((content) => { + const customEditor = document.querySelector("openworkflowspec-dual-editor") as + | (HTMLElement & { content: string }) + | null; + if (!customEditor) throw new Error("Dual Editor custom element was not found"); + customEditor.content = content; + }, originalWorkflow); + + await expect(page.locator(".monaco-editor").first()).toContainText("firstTask"); + await expect(page.getByTestId("rf__node-/do/firstTask")).toContainText("firstTask"); + await expect(page.getByTestId("rf__node-/do/updatedTask")).not.toBeVisible(); +}); + +test("JSON mode provides Open Workflow schema diagnostics", async ({ page }) => { + await page.goto("/"); - await page.evaluate((yaml) => { - const editor = document.querySelector("openworkflowspec-diagram-editor") as any; - editor.content = yaml; - }, newWorkflow); + const monacoEditor = page.locator(".monaco-editor").first(); + await page.evaluate(() => { + const editor = document.querySelector("openworkflowspec-dual-editor") as + | (HTMLElement & { content: string }) + | null; + if (!editor) throw new Error("Dual Editor custom element was not found"); + editor.setAttribute("language", "json"); + editor.content = JSON.stringify({ + document: { dsl: "1.0.3", namespace: "examples", name: "missing-do" }, + }); + }); - await expect(page.getByTestId("rf__node-/do/sayHello")).toContainText("sayHello"); - await expect(page.getByTestId("rf__node-/do/consumeReading")).not.toBeVisible(); + await expect(monacoEditor.locator(".squiggly-warning").first()).toBeVisible(); }); diff --git a/examples/vanilla-web-component/tests/diagram-editor-element.test.ts b/examples/vanilla-web-component/tests/diagram-editor-element.test.ts deleted file mode 100644 index e1fe2b5c..00000000 --- a/examples/vanilla-web-component/tests/diagram-editor-element.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2021-Present The Open Workflow Specification Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; -import { screen } from "@testing-library/dom"; -import { DiagramEditorElement } from "../src/diagram-editor-element"; - -let lastProps: Record | null = null; - -vi.mock("@openworkflowspec/diagram-editor", () => ({ - DiagramEditor: (props: Record) => { - lastProps = props; - return null; - }, -})); - -vi.mock("@openworkflowspec/diagram-editor/styles.css", () => ({})); - -beforeAll(() => { - if (!customElements.get("openworkflowspec-diagram-editor")) { - customElements.define("openworkflowspec-diagram-editor", DiagramEditorElement); - } -}); - -afterEach(() => { - lastProps = null; - document.body.innerHTML = ""; -}); - -describe("DiagramEditorElement", () => { - function createElement(): DiagramEditorElement { - const el = document.createElement("openworkflowspec-diagram-editor") as DiagramEditorElement; - document.body.appendChild(el); - return el; - } - - it("is registered as a custom element", () => { - const el = createElement(); - expect(el).toBeInstanceOf(DiagramEditorElement); - expect(el).toBeInstanceOf(HTMLElement); - }); - - it("creates a mount point div on connect", () => { - createElement(); - expect(screen.getByTestId("diagram-editor-mount-point")).toBeDefined(); - }); - - it("renders DiagramEditor with default props", async () => { - createElement(); - await vi.waitFor(() => expect(lastProps).not.toBeNull()); - expect(lastProps).toEqual( - expect.objectContaining({ - content: "", - locale: "en", - isReadOnly: false, - colorMode: "system", - }), - ); - }); - - it("passes content via property setter", async () => { - const el = createElement(); - el.content = "document:\n dsl: '1.0.3'"; - await vi.waitFor(() => expect(lastProps?.content).toBe("document:\n dsl: '1.0.3'")); - }); - - it("re-renders when content is replaced with a different workflow", async () => { - const el = createElement(); - el.content = "document:\n dsl: '1.0.3'\n name: workflow-a"; - await vi.waitFor(() => expect(lastProps?.content).toContain("workflow-a")); - - el.content = "document:\n dsl: '1.0.3'\n name: workflow-b"; - await vi.waitFor(() => expect(lastProps?.content).toContain("workflow-b")); - }); -}); diff --git a/examples/vanilla-web-component/tests/dual-editor-element.test.ts b/examples/vanilla-web-component/tests/dual-editor-element.test.ts new file mode 100644 index 00000000..bd95ea09 --- /dev/null +++ b/examples/vanilla-web-component/tests/dual-editor-element.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright 2021-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { screen } from "@testing-library/dom"; +import { DualEditorElement } from "../src/dual-editor-element"; + +let lastProps: Record | null = null; + +vi.mock("@openworkflowspec/dual-editor", () => ({ + DualEditor: (props: Record) => { + lastProps = props; + return null; + }, +})); + +vi.mock("@openworkflowspec/text-editor/worker?worker", () => ({ + default: class LanguageServiceWorker {}, +})); + +beforeAll(() => { + if (!customElements.get("openworkflowspec-dual-editor")) { + customElements.define("openworkflowspec-dual-editor", DualEditorElement); + } +}); + +afterEach(() => { + lastProps = null; + document.body.innerHTML = ""; +}); + +describe("DualEditorElement", () => { + function createElement(): DualEditorElement { + const el = document.createElement("openworkflowspec-dual-editor") as DualEditorElement; + document.body.appendChild(el); + return el; + } + + it("is registered as a custom element", () => { + const el = createElement(); + expect(el).toBeInstanceOf(DualEditorElement); + expect(el).toBeInstanceOf(HTMLElement); + }); + + it("creates a mount point div on connect", () => { + createElement(); + expect(screen.getByTestId("dual-editor-mount-point")).toBeDefined(); + }); + + it("renders DualEditor with default props", async () => { + createElement(); + await vi.waitFor(() => expect(lastProps).not.toBeNull()); + expect(lastProps).toEqual( + expect.objectContaining({ + content: "", + contentResetKey: 0, + locale: "en", + isReadOnly: false, + colorMode: "system", + language: "json", + createLanguageServiceWorker: expect.any(Function), + }), + ); + }); + + it("passes content via property setter", async () => { + const el = createElement(); + el.content = "document:\n dsl: '1.0.3'"; + await vi.waitFor(() => expect(lastProps?.content).toBe("document:\n dsl: '1.0.3'")); + expect(lastProps?.contentResetKey).toBe(1); + }); + + it("re-renders when content is replaced with a different workflow", async () => { + const el = createElement(); + el.content = "document:\n dsl: '1.0.3'\n name: workflow-a"; + await vi.waitFor(() => expect(lastProps?.content).toContain("workflow-a")); + + el.content = "document:\n dsl: '1.0.3'\n name: workflow-b"; + await vi.waitFor(() => expect(lastProps?.content).toContain("workflow-b")); + }); + + it("increments the content reset key when the host reassigns the same content", async () => { + const el = createElement(); + el.content = "workflow-a"; + await vi.waitFor(() => expect(lastProps?.contentResetKey).toBe(1)); + + el.content = "workflow-a"; + await vi.waitFor(() => expect(lastProps?.contentResetKey).toBe(2)); + }); + + it("ignores an edit callback from before a host reset when disconnected", async () => { + const el = createElement(); + el.content = "original workflow"; + await vi.waitFor(() => expect(lastProps?.content).toBe("original workflow")); + const staleOnContentChange = lastProps?.onContentChange as (content: string) => void; + + el.content = "replacement workflow"; + el.remove(); + staleOnContentChange("stale pending edit"); + + expect(el.content).toBe("replacement workflow"); + + document.body.appendChild(el); + await vi.waitFor(() => expect(lastProps?.content).toBe("replacement workflow")); + }); + + it("re-renders after edits so external content resets reach the editor", async () => { + const el = createElement(); + const originalContent = "document:\n dsl: '1.0.3'\n name: workflow-a"; + el.content = originalContent; + await vi.waitFor(() => expect(lastProps?.content).toBe(originalContent)); + + const onContentChange = lastProps?.onContentChange; + expect(onContentChange).toBeTypeOf("function"); + (onContentChange as (content: string) => void)("edited workflow"); + await vi.waitFor(() => expect(lastProps?.content).toBe("edited workflow")); + + el.content = originalContent; + await vi.waitFor(() => expect(lastProps?.content).toBe(originalContent)); + }); + + it("passes YAML mode and updates when the language attribute changes", async () => { + const el = createElement(); + el.setAttribute("language", "yaml"); + await vi.waitFor(() => expect(lastProps?.language).toBe("yaml")); + }); +}); diff --git a/packages/dual-editor/README.md b/packages/dual-editor/README.md index db2862c2..34c33e6a 100644 --- a/packages/dual-editor/README.md +++ b/packages/dual-editor/README.md @@ -45,6 +45,7 @@ The component fills its parent. Give the parent a definite height. Edits in eith | Prop | Type | Required | Default | Description | | ----------------------------- | --------------------------- | -------- | ---------- | ------------------------------------------------------------- | | `content` | `string` | Yes | — | Workflow definition as JSON or YAML. | +| `contentResetKey` | `number` | No | — | Reapplies host content and cancels pending text edits. | | `onContentChange` | `(content: string) => void` | No | — | Called when either editor changes the workflow content. | | `createLanguageServiceWorker` | `() => Worker` | Yes | — | Creates the language service worker for the text editor. | | `language` | `TextEditorLanguage` | No | `"json"` | Sets text syntax and diagram output format; defaults to JSON. | diff --git a/packages/dual-editor/src/DualEditor.tsx b/packages/dual-editor/src/DualEditor.tsx index 4a866e4b..5175b85d 100644 --- a/packages/dual-editor/src/DualEditor.tsx +++ b/packages/dual-editor/src/DualEditor.tsx @@ -26,6 +26,7 @@ import "./DualEditor.css"; export type DualEditorProps = { content: string; + contentResetKey?: number; createLanguageServiceWorker: () => Worker; language?: TextEditorLanguage; isReadOnly?: boolean; @@ -42,6 +43,7 @@ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.ma export const DualEditor = ({ content, + contentResetKey, createLanguageServiceWorker, language = "json", isReadOnly = false, @@ -55,6 +57,7 @@ export const DualEditor = ({ const [editorContent, setEditorContent] = React.useState(content); const [diagramContent, setDiagramContent] = React.useState(content); const editorContentRef = React.useRef(content); + const contentResetKeyRef = React.useRef(contentResetKey); const diagramUpdateTimeoutRef = React.useRef | null>(null); const pendingTextContentRef = React.useRef(null); const onContentChangeRef = React.useRef(onContentChange); @@ -67,13 +70,15 @@ export const DualEditor = ({ }, [onContentChange]); React.useEffect(() => { - if (content === editorContentRef.current) return; + const contentResetRequested = contentResetKey !== contentResetKeyRef.current; + contentResetKeyRef.current = contentResetKey; + if (!contentResetRequested && content === editorContentRef.current) return; if (diagramUpdateTimeoutRef.current) clearTimeout(diagramUpdateTimeoutRef.current); pendingTextContentRef.current = null; editorContentRef.current = content; setEditorContent(content); setDiagramContent(content); - }, [content]); + }, [content, contentResetKey]); React.useEffect( () => () => { diff --git a/packages/dual-editor/tests/DualEditor.test.tsx b/packages/dual-editor/tests/DualEditor.test.tsx index 70c075f4..3ef68f2c 100644 --- a/packages/dual-editor/tests/DualEditor.test.tsx +++ b/packages/dual-editor/tests/DualEditor.test.tsx @@ -179,6 +179,37 @@ describe("DualEditor", () => { vi.useRealTimers(); }); + it("resets to the same host content and cancels a pending text edit when the reset key changes", () => { + const onContentChange = vi.fn(); + const content = "original workflow"; + const { rerender } = renderDualEditor({ content, contentResetKey: 0, onContentChange }); + vi.useFakeTimers(); + + fireEvent.change(screen.getByTestId("text-source"), { + target: { value: "pending edit" }, + }); + expect(screen.getByTestId("text-source")).toHaveValue("pending edit"); + expect(screen.getByTestId("diagram-source")).toHaveValue(content); + + rerender( + , + ); + + expect(screen.getByTestId("text-source")).toHaveValue(content); + expect(screen.getByTestId("diagram-source")).toHaveValue(content); + + act(() => vi.advanceTimersByTime(3000)); + expect(onContentChange).not.toHaveBeenCalled(); + expect(screen.getByTestId("text-source")).toHaveValue(content); + expect(screen.getByTestId("diagram-source")).toHaveValue(content); + vi.useRealTimers(); + }); + it("notifies the host of a pending text edit when unmounted", () => { const onContentChange = vi.fn(); const { unmount } = renderDualEditor({ onContentChange }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7874e0f2..79825df8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,9 +209,12 @@ importers: '@openworkflowspec/core': specifier: workspace:* version: link:../../packages/core - '@openworkflowspec/diagram-editor': + '@openworkflowspec/dual-editor': + specifier: workspace:* + version: link:../../packages/dual-editor + '@openworkflowspec/text-editor': specifier: workspace:* - version: link:../../packages/open-workflow-diagram-editor + version: link:../../packages/text-editor react: specifier: 'catalog:' version: 19.3.1