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/add-onChange-callback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openworkflowspec/diagram-editor": minor
---

Add `setOnChangeCallback` to the editor ref API to receive the latest content after changes made inside the editor
5 changes: 5 additions & 0 deletions .changeset/fix-errors-after-undo-redo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openworkflowspec/diagram-editor": patch
---

Fix validation errors staying stale after undo, redo or `setContent`: the errors now always describe the workflow currently shown
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ import { I18nProvider, detectLocale, useI18n } from "@openworkflowspec/i18n";
import { dictionaries } from "../i18n/locales";
import { useDiagramEditorContext } from "../store/DiagramEditorContext";
import { ParsingErrorPage } from "./error-pages/ParsingErrorPage";
import { ColorMode, ResolvedColorMode } from "@openworkflowspec/core";
import { useResolvedColorMode } from "@openworkflowspec/core";
import { ColorMode, ResolvedColorMode, useResolvedColorMode } from "@openworkflowspec/core";
import { SidebarProvider } from "@/components/ui/sidebar";
import { SidePanel } from "@/side-panel/SidePanel";
import { DiagramEditorErrorBoundary } from "./error-pages/DiagramEditorErrorBoundary";
Expand All @@ -43,15 +42,21 @@ import { EditSessionProvider } from "@/side-panel/EditSession";
* Both are no-ops when there is nothing to undo or redo respectively.
*
* **`canUndo` / `canRedo`** — Plain boolean values (not reactive state).
* Copy them into local state after each operation and after the `content`
* prop changes to keep toolbar or menu items in sync.
* To drive toolbar or menu items, use the `onHistoryChange` prop instead of
* polling them.
*
* **`getContent()`** — Returns the current workflow serialised back to a
* string. The format (YAML or JSON) tracks the most recently successfully
* loaded content: it starts as the format of the initial `content` prop and
* updates whenever `setContent()` loads a new string, so undo/redo always
* round-trips in the format of the last successfully loaded content.
* Returns `""` when no valid model has been loaded yet.
*
* **`setContent()`** — Loads a new workflow from a YAML or JSON string, as if
* the `content` prop had changed.
*
* **`setOnChangeCallback()`** — Registers a function called after changes made
* inside the editor, with the latest content.
*/
export type DiagramEditorRef = {
/** Step back one history entry. No-op if there is nothing to undo. */
Expand All @@ -75,13 +80,28 @@ export type DiagramEditorRef = {
* Silently ignored when the string cannot be parsed.
*/
setContent: (content: string) => void;
/**
* Register (or clear with `null` / `undefined`) a function called after every
* change made inside the editor: side-panel edits, undo and redo. It is never
* called for content that arrives through the `content` prop or
* `setContent()`, so it is safe to call `setContent()` from inside it.
* Replaces any previously registered callback. Errors thrown by the callback
* never break the editor; they are rethrown asynchronously.
*/
setOnChangeCallback: (callback: ((content: string) => void) | null | undefined) => void;
};

/** Whether the editor currently has history to step back or forward through. */
export type HistoryAvailability = {
canUndo: boolean;
canRedo: boolean;
};

export type DiagramEditorProps = {
/**
* The workflow definition to visualise, as a YAML or JSON string.
* Updating this prop (e.g. from an addon panel) re-parses the workflow and,
* in edit mode, pushes a new history entry if the model changed structurally.
* Updating this prop re-parses the workflow and, in edit mode, pushes a
* new history entry if the model changed structurally.
* The serialisation format is auto-detected on first load and preserved for
* the lifetime of the component — see `getContent()` on `DiagramEditorRef`.
*/
Expand All @@ -95,8 +115,8 @@ export type DiagramEditorProps = {
*/
isReadOnly: boolean;
/**
* BCP 47 locale tag (e.g. `"en"`). Controls the language used for
* aria-labels and any localised text inside the editor.
* BCP 47 locale tag. Controls the language used for aria-labels and any
* localised text inside the editor.
*/
locale: string;
/**
Expand All @@ -109,6 +129,13 @@ export type DiagramEditorProps = {
* Initial and externally supplied content changes do not trigger the callback.
*/
onContentChange?: (content: string) => void;
/**
* Called whenever `canUndo` or `canRedo` changes, whatever caused it:
* side-panel edits, undo, redo, `setContent()`, `content` prop updates or a
* reset. Not called when the values are unchanged, and not called on mount
* (both start as `false`).
*/
onHistoryChange?: (state: HistoryAvailability) => void;
};

const DiagramEditorContent = ({
Expand All @@ -117,7 +144,7 @@ const DiagramEditorContent = ({
}: {
diagramDivRef: React.RefObject<HTMLDivElement | null>;
colorMode: ResolvedColorMode;
}) => {
}): React.JSX.Element => {
const { model } = useDiagramEditorContext();
return model === null ? (
<ParsingErrorPage />
Expand All @@ -140,7 +167,7 @@ const DiagramEditorBody = ({
resolvedColorMode: ResolvedColorMode;
props: DiagramEditorProps;
editorRef: React.ForwardedRef<DiagramEditorRef>;
}) => {
}): React.JSX.Element => {
const { t } = useI18n();
const errorBoundaryProps = {
title: t("workflowError.title"),
Expand All @@ -156,6 +183,7 @@ const DiagramEditorBody = ({
locale={props.locale}
{...(props.language && { language: props.language })}
{...(props.onContentChange && { onContentChange: props.onContentChange })}
{...(props.onHistoryChange && { onHistoryChange: props.onHistoryChange })}
>
<SidebarProvider defaultOpen={false}>
<EditSessionProvider>
Expand Down
30 changes: 30 additions & 0 deletions packages/open-workflow-diagram-editor/src/lib/invokeSafely.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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.
*/

/**
* Runs a host-provided function so that an exception cannot break the caller.
* The error is rethrown asynchronously so the host's global error handling
* still sees it.
*/
export function invokeSafely(fn: () => void): void {
try {
fn();
} catch (error: unknown) {
queueMicrotask((): void => {
throw error;
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
* limitations under the License.
*/

import * as React from "react";

/**
* Maximum number of past snapshots retained in the history stack.
* Oldest entries are evicted when the cap is reached.
Expand Down Expand Up @@ -52,7 +50,7 @@ export function getFuture<T>(state: HistoryState<T>): T[] {
return state.presentIndex === -1 ? [] : state.history.slice(state.presentIndex + 1);
}

type HistoryAction<T> =
export type HistoryAction<T> =
| { type: "PUSH"; payload: T }
| { type: "SET_PRESENT"; payload: T }
| { type: "UNDO" }
Expand Down Expand Up @@ -105,69 +103,10 @@ export function historyReducer<T>(
case "RESET":
// Wipe the entire history stack and return to the uninitialised state.
return initialHistoryState<T>();

default:
return state;
}
}

const initialHistoryState = <T>(): HistoryState<T> => ({
export const initialHistoryState = <T>(): HistoryState<T> => ({
history: [],
presentIndex: -1,
});

export type UseHistoryReturn<T> = {
state: HistoryState<T>;
push: (payload: T) => void;
setPresent: (payload: T) => void;
reset: () => void;
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
};

/**
* Generic history hook backed by useReducer.
* Uses a single flat array + a cursor index instead of three separate arrays.
* Starts uninitialised (presentIndex = -1). The first push sets the
* initial present without adding a past entry.
*/
export function useHistory<T>(): UseHistoryReturn<T> {
const [state, dispatch] = React.useReducer(
historyReducer as React.Reducer<HistoryState<T>, HistoryAction<T>>,
undefined,
initialHistoryState<T>,
);

const push = React.useCallback((payload: T) => {
dispatch({ type: "PUSH", payload });
}, []);

const setPresent = React.useCallback((payload: T) => {
dispatch({ type: "SET_PRESENT", payload });
}, []);

const reset = React.useCallback(() => {
dispatch({ type: "RESET" });
}, []);

const undo = React.useCallback(() => {
dispatch({ type: "UNDO" });
}, []);

const redo = React.useCallback(() => {
dispatch({ type: "REDO" });
}, []);

return {
state,
push,
setPresent,
reset,
undo,
redo,
canUndo: state.presentIndex > 0,
canRedo: state.presentIndex < state.history.length - 1,
};
}
Loading
Loading