diff --git a/.changeset/add-onChange-callback.md b/.changeset/add-onChange-callback.md new file mode 100644 index 00000000..91461335 --- /dev/null +++ b/.changeset/add-onChange-callback.md @@ -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 diff --git a/.changeset/fix-errors-after-undo-redo.md b/.changeset/fix-errors-after-undo-redo.md new file mode 100644 index 00000000..dcc10762 --- /dev/null +++ b/.changeset/fix-errors-after-undo-redo.md @@ -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 diff --git a/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx b/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx index 69a41dd0..b44b6c1d 100644 --- a/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx +++ b/packages/open-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx @@ -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"; @@ -43,8 +42,8 @@ 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 @@ -52,6 +51,12 @@ import { EditSessionProvider } from "@/side-panel/EditSession"; * 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. */ @@ -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`. */ @@ -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; /** @@ -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 = ({ @@ -117,7 +144,7 @@ const DiagramEditorContent = ({ }: { diagramDivRef: React.RefObject; colorMode: ResolvedColorMode; -}) => { +}): React.JSX.Element => { const { model } = useDiagramEditorContext(); return model === null ? ( @@ -140,7 +167,7 @@ const DiagramEditorBody = ({ resolvedColorMode: ResolvedColorMode; props: DiagramEditorProps; editorRef: React.ForwardedRef; -}) => { +}): React.JSX.Element => { const { t } = useI18n(); const errorBoundaryProps = { title: t("workflowError.title"), @@ -156,6 +183,7 @@ const DiagramEditorBody = ({ locale={props.locale} {...(props.language && { language: props.language })} {...(props.onContentChange && { onContentChange: props.onContentChange })} + {...(props.onHistoryChange && { onHistoryChange: props.onHistoryChange })} > diff --git a/packages/open-workflow-diagram-editor/src/lib/invokeSafely.ts b/packages/open-workflow-diagram-editor/src/lib/invokeSafely.ts new file mode 100644 index 00000000..9483a2a3 --- /dev/null +++ b/packages/open-workflow-diagram-editor/src/lib/invokeSafely.ts @@ -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; + }); + } +} diff --git a/packages/open-workflow-diagram-editor/src/react-flow/hooks/useHistory.ts b/packages/open-workflow-diagram-editor/src/react-flow/hooks/historyReducer.ts similarity index 71% rename from packages/open-workflow-diagram-editor/src/react-flow/hooks/useHistory.ts rename to packages/open-workflow-diagram-editor/src/react-flow/hooks/historyReducer.ts index 1a3ad013..ae80cc92 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/hooks/useHistory.ts +++ b/packages/open-workflow-diagram-editor/src/react-flow/hooks/historyReducer.ts @@ -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. @@ -52,7 +50,7 @@ export function getFuture(state: HistoryState): T[] { return state.presentIndex === -1 ? [] : state.history.slice(state.presentIndex + 1); } -type HistoryAction = +export type HistoryAction = | { type: "PUSH"; payload: T } | { type: "SET_PRESENT"; payload: T } | { type: "UNDO" } @@ -105,69 +103,10 @@ export function historyReducer( case "RESET": // Wipe the entire history stack and return to the uninitialised state. return initialHistoryState(); - - default: - return state; } } -const initialHistoryState = (): HistoryState => ({ +export const initialHistoryState = (): HistoryState => ({ history: [], presentIndex: -1, }); - -export type UseHistoryReturn = { - state: HistoryState; - 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(): UseHistoryReturn { - const [state, dispatch] = React.useReducer( - historyReducer as React.Reducer, HistoryAction>, - undefined, - initialHistoryState, - ); - - 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, - }; -} diff --git a/packages/open-workflow-diagram-editor/src/react-flow/hooks/useWorkflowHistory.ts b/packages/open-workflow-diagram-editor/src/react-flow/hooks/useWorkflowHistory.ts index ab428215..41f794d0 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/hooks/useWorkflowHistory.ts +++ b/packages/open-workflow-diagram-editor/src/react-flow/hooks/useWorkflowHistory.ts @@ -18,7 +18,14 @@ import * as React from "react"; import type * as RF from "@xyflow/react"; import type { Specification } from "@openworkflowspec/sdk"; import { structuralEqual } from "../../core/structuralEqual"; -import { useHistory, getPresent, getPast, getFuture } from "./useHistory"; +import { + historyReducer, + initialHistoryState, + getPresent, + getPast, + getFuture, +} from "./historyReducer"; +import type { HistoryAction, HistoryState } from "./historyReducer"; /** * One atomic history entry capturing the full editor state at a point in time. @@ -31,18 +38,26 @@ export type HistorySnapshot = { selectedNodeId: string | null; }; +/** + * Who caused the transition that produced the current present: the host + * (content prop, `setContent`) or the editor (edits, undo/redo). + */ +export type ChangeOrigin = "external" | "internal"; + export type UseWorkflowHistoryReturn = { model: Specification.Workflow | null; selectedNodeId: string | null; + /** Origin of the transition that produced the current model. */ + changeOrigin: ChangeOrigin; /** - * Seeds the model from external props.content. - * Respects the isReadOnly guard — no history entry is created in read-only mode, - * but the present snapshot is still set so the diagram can render. + * Replaces the model wholesale (host content loads, side-panel commits). + * In read-only mode the present is set without creating an undoable entry. */ seedModel: ( newModel: Specification.Workflow, viewport: RF.Viewport, selectedNodeId: string | null, + origin?: ChangeOrigin, ) => void; submitModel: ( newModel: Specification.Workflow, @@ -63,31 +78,107 @@ export type UseWorkflowHistoryReturn = { clearPendingViewportRestore: () => void; }; +export type WorkflowHistoryState = { + history: HistoryState; + changeOrigin: ChangeOrigin; +}; + +export type WorkflowHistoryAction = + | { type: "SEED"; snapshot: HistorySnapshot; origin: ChangeOrigin; isReadOnly: boolean } + | { type: "SUBMIT"; snapshot: HistorySnapshot; isReadOnly: boolean } + | { type: "UNDO" } + | { type: "REDO" } + | { type: "RESET" }; + +export const INITIAL_WORKFLOW_HISTORY_STATE: WorkflowHistoryState = { + history: initialHistoryState(), + changeOrigin: "external", +}; + /** - * Workflow-specific history hook wrapping the generic useHistory. + * Equality checks run here, against the latest queued state, rather than in the + * callbacks: several updates dispatched in one event must each see the result of + * the previous one, not the last committed render. + */ +export function workflowHistoryReducer( + state: WorkflowHistoryState, + action: WorkflowHistoryAction, +): WorkflowHistoryState { + const present: HistorySnapshot | null = getPresent(state.history); + + const withHistory = ( + historyAction: HistoryAction, + changeOrigin: ChangeOrigin, + ): WorkflowHistoryState => ({ + history: historyReducer(state.history, historyAction), + changeOrigin, + }); + + switch (action.type) { + case "SEED": { + const { snapshot, origin } = action; + // Read-only editors never report: only the host can change their content. + if (action.isReadOnly) { + return withHistory({ type: "SET_PRESENT", payload: snapshot }, "external"); + } + if (present === null) return withHistory({ type: "PUSH", payload: snapshot }, origin); + if (structuralEqual(present.model, snapshot.model)) return state; + // Keep the current viewport so undo restores where the user was looking; the + // caller passes a placeholder and submitModel records the real one after layout. + return withHistory( + { type: "PUSH", payload: { ...snapshot, viewport: present.viewport } }, + origin, + ); + } + + case "SUBMIT": { + if (action.isReadOnly) return state; + const { snapshot } = action; + if (present !== null && structuralEqual(present.model, snapshot.model)) { + const viewportChanged: boolean = + present.viewport.x !== snapshot.viewport.x || + present.viewport.y !== snapshot.viewport.y || + present.viewport.zoom !== snapshot.viewport.zoom; + const selectionChanged: boolean = present.selectedNodeId !== snapshot.selectedNodeId; + if (!viewportChanged && !selectionChanged) return state; + // Viewport/selection only: the model did not change, so neither does its origin. + return withHistory({ type: "SET_PRESENT", payload: snapshot }, state.changeOrigin); + } + return withHistory({ type: "PUSH", payload: snapshot }, "internal"); + } + + case "UNDO": + case "REDO": { + const history: HistoryState = historyReducer(state.history, { + type: action.type, + }); + return history === state.history ? state : { history, changeOrigin: "internal" }; + } + + case "RESET": + return withHistory({ type: "RESET" }, "external"); + } +} + +/** + * Workflow-specific history hook. * Starts uninitialised (present = null). History is only recorded when * isReadOnly is false and the incoming model differs from the current present. */ export function useWorkflowHistory(isReadOnly: boolean): UseWorkflowHistoryReturn { - const { - state, - push, - setPresent, - reset, - undo: dispatchUndo, - redo: dispatchRedo, - canUndo, - canRedo, - } = useHistory(); + const [state, dispatch] = React.useReducer( + workflowHistoryReducer, + INITIAL_WORKFLOW_HISTORY_STATE, + ); const [pendingViewportRestore, setPendingViewportRestore] = React.useState( null, ); - // Keep a ref to the latest state so callbacks can read it at call time without - // closing over it — this prevents them from being recreated on every state change, - // which would retrigger layout effects and cause infinite re-render loops. - const stateRef = React.useRef(state); + // Keep a ref to the latest history so undo/redo can read the target snapshot at call + // time without closing over it — this prevents them from being recreated on every + // state change, which would retrigger layout effects and cause infinite re-render loops. + const stateRef = React.useRef(state.history); // Keep a ref to the latest isReadOnly so callbacks don't go stale when the prop // changes (e.g. Storybook controls toggling the isReadOnly arg). @@ -95,85 +186,46 @@ export function useWorkflowHistory(isReadOnly: boolean): UseWorkflowHistoryRetur // Assigned after commit rather than during render (a render must not have side effects) React.useLayoutEffect(() => { - stateRef.current = state; + stateRef.current = state.history; isReadOnlyRef.current = isReadOnly; - }, [state, isReadOnly]); + }, [state.history, isReadOnly]); - /** - * Seeds the model from external props.content. - * In read-only mode the present snapshot is updated so the diagram renders, - * but no past entry is created (history is edit-mode only). - */ const seedModel = React.useCallback( - (newModel: Specification.Workflow, viewport: RF.Viewport, selectedNodeId: string | null) => { + ( + newModel: Specification.Workflow, + viewport: RF.Viewport, + selectedNodeId: string | null, + origin: ChangeOrigin = "external", + ) => { // Null model is never stored in history. if (newModel == null) return; - - const present = getPresent(stateRef.current); - - // In read-only mode, update present so the diagram renders the new - // content, but leave past and future untouched (no undoable history entry). - if (isReadOnlyRef.current) { - setPresent({ model: newModel, viewport, selectedNodeId }); - return; - } - - // First load — no present yet. Set without creating a past entry. - if (present === null) { - push({ model: newModel, viewport, selectedNodeId }); - return; - } - - // No-op if model content is unchanged. - if (structuralEqual(present.model, newModel)) { - return; - } - - // Content changed externally (e.g. props.content updated by host or addon panel). - // Preserve the current viewport so undo restores to where the user was looking, - // rather than the placeholder {x:0,y:0,zoom:1} passed by the caller. - // The real viewport will be updated by submitModel after layout settles. - push({ model: newModel, viewport: present.viewport, selectedNodeId }); + dispatch({ + type: "SEED", + snapshot: { model: newModel, viewport, selectedNodeId }, + origin, + isReadOnly: isReadOnlyRef.current, + }); }, - [push, setPresent], + [], ); const submitModel = React.useCallback( (newModel: Specification.Workflow, viewport: RF.Viewport, selectedNodeId: string | null) => { - // No-op in read-only mode. - if (isReadOnlyRef.current) return; - // Null model is never stored in history. if (newModel == null) return; - - const present = getPresent(stateRef.current); - - // First load — no present yet. Set it without creating a past entry. - if (present === null) { - push({ model: newModel, viewport, selectedNodeId }); - return; - } - - // Model content unchanged — update viewport/selection on the present snapshot - // without creating a new history entry. This keeps the saved viewport - // current as the user pans/zooms between edits and after undo/redo restores. - if (structuralEqual(present.model, newModel)) { - const vp = present.viewport; - const viewportChanged = - vp.x !== viewport.x || vp.y !== viewport.y || vp.zoom !== viewport.zoom; - const selectionChanged = present.selectedNodeId !== selectedNodeId; - if (viewportChanged || selectionChanged) { - setPresent({ model: newModel, viewport, selectedNodeId }); - } - return; - } - - // New distinct model — push snapshot (future is discarded inside reducer). - push({ model: newModel, viewport, selectedNodeId }); + dispatch({ + type: "SUBMIT", + snapshot: { model: newModel, viewport, selectedNodeId }, + isReadOnly: isReadOnlyRef.current, + }); }, - [push, setPresent], + [], ); + const resetHistory = React.useCallback(() => { + dispatch({ type: "RESET" }); + }, []); + const undo = React.useCallback( (setSelectedNodeId: React.Dispatch>) => { const past = getPast(stateRef.current); @@ -183,9 +235,9 @@ export function useWorkflowHistory(isReadOnly: boolean): UseWorkflowHistoryRetur // Restore selectedNodeId via direct callback, not via useEffect. setSelectedNodeId(target.selectedNodeId); setPendingViewportRestore(target.viewport); - dispatchUndo(); + dispatch({ type: "UNDO" }); }, - [dispatchUndo], + [], ); const redo = React.useCallback( @@ -196,25 +248,28 @@ export function useWorkflowHistory(isReadOnly: boolean): UseWorkflowHistoryRetur const target = future[0]!; setSelectedNodeId(target.selectedNodeId); setPendingViewportRestore(target.viewport); - dispatchRedo(); + dispatch({ type: "REDO" }); }, - [dispatchRedo], + [], ); const clearPendingViewportRestore = React.useCallback(() => { setPendingViewportRestore(null); }, []); + const present: HistorySnapshot | null = getPresent(state.history); + return { - model: getPresent(state)?.model ?? null, - selectedNodeId: getPresent(state)?.selectedNodeId ?? null, + model: present?.model ?? null, + selectedNodeId: present?.selectedNodeId ?? null, + changeOrigin: state.changeOrigin, seedModel, submitModel, - resetHistory: reset, + resetHistory, undo, redo, - canUndo, - canRedo, + canUndo: state.history.presentIndex > 0, + canRedo: state.history.presentIndex < state.history.history.length - 1, pendingViewportRestore, clearPendingViewportRestore, }; diff --git a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx index db30702b..5019c0a1 100644 --- a/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx +++ b/packages/open-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx @@ -20,11 +20,17 @@ import { ContentFormat, getTaskReferences, parseWorkflow, + SdkError, serializeWorkflow, validateWorkflow, } from "../core"; import type { Specification } from "@openworkflowspec/sdk"; -import { DiagramEditorProps, DiagramEditorRef } from "../diagram-editor/DiagramEditor"; +import { + DiagramEditorProps, + DiagramEditorRef, + HistoryAvailability, +} from "../diagram-editor/DiagramEditor"; +import { invokeSafely } from "../lib/invokeSafely"; import { DiagramEditorContext, DiagramEditorContextType } from "./DiagramEditorContext"; import type * as RF from "@xyflow/react"; import { useWorkflowHistory } from "../react-flow/hooks/useWorkflowHistory"; @@ -47,7 +53,8 @@ export const DiagramEditorContextProvider = React.forwardRef< DiagramEditorRef, React.PropsWithChildren >((props, ref) => { - const onContentChange = props.onContentChange; + const onContentChange: ((content: string) => void) | undefined = props.onContentChange; + const onHistoryChange: DiagramEditorProps["onHistoryChange"] = props.onHistoryChange; // Detect the serialization format once from the initial content prop. // JSON content starts with `{` (after trimming); everything else is YAML. // useState keeps the format in sync with React's render cycle, so consumers @@ -55,7 +62,7 @@ export const DiagramEditorContextProvider = React.forwardRef< const [detectedContentFormat, setDetectedContentFormat] = React.useState( props.content.trimStart().startsWith("{") ? "json" : "yaml", ); - const contentFormat = props.language ?? detectedContentFormat; + const contentFormat: ContentFormat = props.language ?? detectedContentFormat; // Config state (non-history) const [locale, setLocale] = React.useState(props.locale); @@ -66,20 +73,21 @@ export const DiagramEditorContextProvider = React.forwardRef< setLocale(props.locale); } - const [nodes, setNodes] = React.useState([] as RF.Node[]); - const [edges, setEdges] = React.useState([] as RF.Edge[]); + const [nodes, setNodes] = React.useState([]); + const [edges, setEdges] = React.useState([]); const [selectedNodeId, setSelectedNodeId] = React.useState(null); - const [isExporting, setIsExporting] = React.useState(false); + const [isExporting, setIsExporting] = React.useState(false); // Read isReadOnly directly from props — no local state copy. // This ensures useWorkflowHistory always receives the current value without // a one-render lag from useState + useEffect synchronisation. - const isReadOnly = props.isReadOnly; + const isReadOnly: boolean = props.isReadOnly; // History — starts uninitialised (present = null). // Diagram.tsx is the sole caller of submitModel (after layout, with real viewport). const { model, + changeOrigin, seedModel, submitModel, resetHistory, @@ -91,45 +99,51 @@ export const DiagramEditorContextProvider = React.forwardRef< clearPendingViewportRestore, } = useWorkflowHistory(isReadOnly); - // errors are shared state written by both the props.content path and setContent. - // They are never part of a history snapshot — always reflect the last parse result. - const [errors, setErrors] = React.useState["errors"]>( + // Parse errors are only used while there is no model (unparseable content). + const [parseErrors, setParseErrors] = React.useState( () => parseWorkflow(props.content).errors, ); + // Derived from the model, so the errors always describe the model currently shown. + const validationErrors = React.useMemo( + () => (model ? validateWorkflow(model) : null), + [model], + ); + const errors: SdkError[] = validationErrors ?? parseErrors; + // Keep a ref to the latest selectedNodeId so the effect below can read it // synchronously without taking it as a dependency (avoids re-seeding on every click). const selectedNodeIdRef = React.useRef(selectedNodeId); - const externallySeededModelRef = React.useRef(null); const reportedModelRef = React.useRef(null); + const onChangeCallbackRef = React.useRef<((content: string) => void) | null>(null); + // Both flags start as false, so the first report only happens on a real change. + const reportedHistoryRef = React.useRef({ canUndo: false, canRedo: false }); React.useEffect(() => { selectedNodeIdRef.current = selectedNodeId; }, [selectedNodeId]); - // Seed history from the external content prop using seedModel (bypasses isReadOnly guard). + // Seed history from the external content prop using seedModel. // The real viewport is set by Diagram.tsx once layout completes in edit mode. // In read-only mode the placeholder viewport is acceptable since fitView always runs. React.useEffect(() => { const { model: parsedModel, errors: parsedErrors } = parseWorkflow(props.content); - // 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); + setParseErrors(parsedErrors); if (parsedModel === null) { // Content is unparseable — reset history to null so downstream consumers - // (e.g. DiagramEditorContent) see model === null and render the error page - // instead of displaying the last successfully-parsed (now stale) model. + // see model === null and render the error page instead of displaying + // the last successfully-parsed (now stale) model. resetHistory(); setSelectedNodeId(null); return; } - // Preserve selection across content reloads (e.g. addon-panel edits): only clear - // selectedNodeId when the previously-selected node/edge no longer exists in the - // new model. We read the ref synchronously so we can pass the same resolved value + // Preserve selection across content reloads: only clear selectedNodeId + // when the previously-selected node/edge no longer exists in the new model. + // We read the ref synchronously so we can pass the same resolved value // to both setSelectedNodeId and seedModel in one shot. const resolvedId = resolveSelectedId(parsedModel, selectedNodeIdRef.current); setSelectedNodeId(resolvedId); - externallySeededModelRef.current = parsedModel; seedModel(parsedModel, { x: 0, y: 0, zoom: 1 }, resolvedId); // selectedNodeIdRef is a ref (stable, mutated inline — not a dep by convention). }, [props.content, resetHistory, seedModel]); @@ -145,17 +159,14 @@ export const DiagramEditorContextProvider = React.forwardRef< */ const setContent = React.useCallback( (content: string) => { - const { model: newModel, errors: newErrors } = parseWorkflow(content); + const { model: newModel } = parseWorkflow(content); if (newModel === null) return; - setErrors(newErrors); - const newFormat: ContentFormat = content.trimStart().startsWith("{") ? "json" : "yaml"; setDetectedContentFormat(newFormat); const resolvedId = resolveSelectedId(newModel, selectedNodeIdRef.current); setSelectedNodeId(resolvedId); - externallySeededModelRef.current = newModel; seedModel(newModel, { x: 0, y: 0, zoom: 1 }, resolvedId); }, [seedModel], @@ -163,11 +174,9 @@ export const DiagramEditorContextProvider = React.forwardRef< const commitWorkflow = React.useCallback( (workflow: Specification.Workflow) => { - // TODO: Temporary - Errors still computed here by validating full workflow, Open question on how to tackle this - a form knows about one task, but these areas are for entire workflow - setErrors(validateWorkflow(workflow)); const resolvedId = resolveSelectedId(workflow, selectedNodeIdRef.current); setSelectedNodeId(resolvedId); - seedModel(workflow, { x: 0, y: 0, zoom: 1 }, resolvedId); + seedModel(workflow, { x: 0, y: 0, zoom: 1 }, resolvedId, "internal"); }, [seedModel], ); @@ -189,20 +198,50 @@ export const DiagramEditorContextProvider = React.forwardRef< React.useEffect(() => { if (!model) return; - if (model === externallySeededModelRef.current) { - externallySeededModelRef.current = null; - reportedModelRef.current = model; - return; - } if (model === reportedModelRef.current) return; reportedModelRef.current = model; - onContentChange?.(serializeWorkflow(model, contentFormat)); - }, [model, contentFormat, onContentChange]); + // The host already knows about content it loaded itself. + if (changeOrigin === "external") return; + + let content: string | null = null; + try { + content = serializeWorkflow(model, contentFormat); + } catch { + // Ignore serialisation failure + } + + if (content !== null) { + const reportedContent: string = content; + if (onContentChange) { + invokeSafely(() => onContentChange(reportedContent)); + } + const onChange: ((content: string) => void) | null = onChangeCallbackRef.current; + if (onChange) { + invokeSafely(() => onChange(reportedContent)); + } + } + }, [model, changeOrigin, contentFormat, onContentChange]); + + React.useEffect(() => { + const reported: HistoryAvailability = reportedHistoryRef.current; + if (reported.canUndo === canUndo && reported.canRedo === canRedo) return; + reportedHistoryRef.current = { canUndo, canRedo }; + if (onHistoryChange) { + invokeSafely(() => onHistoryChange({ canUndo, canRedo })); + } + }, [canUndo, canRedo, onHistoryChange]); + + const setOnChangeCallback = React.useCallback( + (callback: ((content: string) => void) | null | undefined) => { + onChangeCallbackRef.current = callback ?? null; + }, + [], + ); React.useImperativeHandle( ref as React.Ref, - () => ({ undo, redo, canUndo, canRedo, getContent, setContent }), - [undo, redo, canUndo, canRedo, getContent, setContent], + () => ({ undo, redo, canUndo, canRedo, getContent, setContent, setOnChangeCallback }), + [undo, redo, canUndo, canRedo, getContent, setContent, setOnChangeCallback], ); // Memoize context value to prevent unnecessary re-renders of consumers. diff --git a/packages/open-workflow-diagram-editor/stories/features/OnChangeCallback.stories.tsx b/packages/open-workflow-diagram-editor/stories/features/OnChangeCallback.stories.tsx new file mode 100644 index 00000000..839df105 --- /dev/null +++ b/packages/open-workflow-diagram-editor/stories/features/OnChangeCallback.stories.tsx @@ -0,0 +1,240 @@ +/* + * 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 React, { useEffect, useRef, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { action } from "storybook/actions"; +import { + DiagramEditor, + DiagramEditorRef, + HistoryAvailability, +} from "../../src/diagram-editor/DiagramEditor"; +import { authenticationReusable } from "../examples"; +import { EditorToolbarLayout } from "./shared/EditorToolbarLayout"; + +// --------------------------------------------------------------------------- +// Story render function +// --------------------------------------------------------------------------- + +// Logs every call, with the received content as its argument, in the Storybook Actions panel. +const logOnChange = action("onChange"); + +function OnChangeCallbackStory({ + content, + colorMode, + isReadOnly, + locale, +}: { + content: string; + colorMode?: string; + isReadOnly?: boolean; + locale?: string; +}): React.JSX.Element { + const editorRef = useRef(null); + const [history, setHistory] = useState({ canUndo: false, canRedo: false }); + + // Register the callback for the lifetime of the story. + useEffect(() => { + const editor: DiagramEditorRef | null = editorRef.current; + editor?.setOnChangeCallback(logOnChange); + return () => editor?.setOnChangeCallback(null); + }, []); + + return ( + +
+ +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Storybook meta +// --------------------------------------------------------------------------- + +const meta = { + title: "Features/OnChange Callback", + component: DiagramEditor, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + docs: { + description: { + component: ` +## OnChange Callback + +The \`DiagramEditor\` ref API lets a host application register a callback with +\`setOnChangeCallback()\`. The editor calls it with the **latest content** (YAML or JSON) +every time the workflow is changed **from inside the editor**. + +### How it works + +The callback runs only when the origin of the change is the editor itself: + +| What changed the workflow | Callback called? | +|---|---| +| The user edits a task in the side panel | Yes | +| \`undo()\` / \`redo()\` (button, shortcut or API) | Yes | +| \`setContent()\` called by the host | **No** | +| The \`content\` prop is updated by the host | **No** | +| Initial load | **No** | + +Because external changes never trigger the callback, a host can safely forward the +received content somewhere and write it back without creating a loop: + +\`\`\` +host calls setContent(x) -> editor reloads -> callback is NOT called +user edits in the editor -> callback(content) -> host stores / forwards content +\`\`\` + +The callback is optional. When none is registered the editor does nothing extra. +Registering or clearing a callback never triggers a render or a call. + +### API + +\`\`\`tsx +import { useEffect, useRef } from "react"; +import { + DiagramEditor, + DiagramEditorRef, +} from "@openworkflowspec/diagram-editor"; + +const editorRef = useRef(null); + +// The ref is set after the first render, so register from an effect. +useEffect(() => { + const editor = editorRef.current; + editor?.setOnChangeCallback((content: string) => { + save(content); + }); + // Unregister: + return () => editor?.setOnChangeCallback(null); +}, []); + + +\`\`\` + +| Member | Type | Description | +|---|---|---| +| \`setOnChangeCallback(callback)\` | \`(callback: ((content: string) => void) \\| null \\| undefined) => void\` | Registers the function called after each in-editor change. \`null\` / \`undefined\` clears it. The last registration wins. | + +### Good to know + +- The callback runs **after** the change has been applied to the model, not + synchronously inside the user action. +- Undo and redo are in-editor changes, so they call the callback, including when they + revert an earlier \`setContent()\`. +- In read-only mode nothing can be edited and undo/redo are no-ops, so the callback + is not called. +- An exception thrown by your callback never breaks the editor. It is rethrown + asynchronously, so your global error handling (\`window.onerror\`, error reporting) + still sees it. The editor itself writes nothing to the console. +- The \`onContentChange\` prop remains available. It reports the same events but only + receives the content string. +- The callback reports **content**, not history state. To enable or disable Undo / Redo + controls use the \`onHistoryChange\` prop instead, which also covers \`setContent()\` + and \`content\` prop updates. See the **Undo Redo** story. + +### Story toolbar and Actions panel + +The toolbar above the diagram (**Undo**, **Redo**, **Set Content**, **Get Content**) is +implemented in the story, it is **not** part of the \`DiagramEditor\` component. The toolbar +is the same as in the **Undo Redo** story and exercises the ref API; its Undo / Redo buttons +are enabled from the \`onHistoryChange\` prop. The story also registers an on-change callback +that is a Storybook action named \`onChange\`. Open the **Actions** panel at the bottom of +Storybook to see each call with the full content it received. + +Try it: + +1. Select a task, edit it in the side panel and apply the change. A new \`onChange\` entry appears in the Actions panel. +2. Click **Undo**, then **Redo**. Each one adds an entry, because they are in-editor changes. +3. Click **Set Content**, edit the text and **Apply**. **No** entry appears, because the + change came from the host. Click **Undo** afterwards: that reverts the loaded content + and **does** add an entry. +4. Click **Get Content** to compare the current model with the last entry in the Actions panel. +5. Change the \`content\` control in the Storybook controls panel. **No** entry appears. + +### Calling the API from the browser console + +This story publishes the editor ref on \`window.diagramEditorRef\`: + +1. Open the browser DevTools (**F12** or **⌘ ⌥ I**) and go to the **Console** tab. +2. If Storybook runs the story in an \`