diff --git a/.changeset/wb-217-actions-hook.md b/.changeset/wb-217-actions-hook.md new file mode 100644 index 000000000..342a57603 --- /dev/null +++ b/.changeset/wb-217-actions-hook.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +feat: add `useWorkflowBuilderActions()` hook exposing save / import / export / settings / read-only / theme / layout-direction actions. Lets custom layouts that omit `` trigger every action the built-in app bar offers. `toggleLayoutDirection` takes an optional `LayoutChangeOptions` (`{ flipPositions?, fitView? }`): `flipPositions` mirrors node `x`/`y` so the diagram re-lays-out along the new axis (a naive mirror, not auto-layout), and `fitView` re-fits the view afterwards. `setLayoutDirection` stays idempotent and takes no options. The `LayoutChangeOptions` and `WorkflowBuilderActions` types are exported from the package barrel. See the [Layout without the app bar guide](https://www.workflowbuilder.io/docs/guides/no-app-bar-layout/). diff --git a/.changeset/wb-217-flip-layout-stale-handles.md b/.changeset/wb-217-flip-layout-stale-handles.md new file mode 100644 index 000000000..b52957b0c --- /dev/null +++ b/.changeset/wb-217-flip-layout-stale-handles.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': patch +--- + +fix: re-measure node internals when `layoutDirection` changes. React Flow caches each handle's measured bounds; when the direction flipped, the cache stayed stale and edges kept routing to the old port positions on every node that had already been mounted. The `DiagramContainer` now calls `updateNodeInternals` for all mounted nodes whenever `layoutDirection` changes, so handle positions, edge routing, and the new `toggleLayoutDirection` action all stay in sync. diff --git a/.changeset/wb-217-theme-shared-store.md b/.changeset/wb-217-theme-shared-store.md new file mode 100644 index 000000000..d7d4ed892 --- /dev/null +++ b/.changeset/wb-217-theme-shared-store.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': patch +--- + +fix: theme is now a shared store, applied to the DOM on load. Previously `useTheme` held per-component `useState`, so multiple consumers could desync and the persisted theme was only applied to the DOM by the app-bar toggle's mount effect. Theme now lives in a single module-level store (`useSyncExternalStore`), and the persisted value is applied to `document` on import, so a saved non-default theme paints correctly on first load even when `` is omitted. diff --git a/apps/docs/src/content/docs/guides/no-app-bar-layout.md b/apps/docs/src/content/docs/guides/no-app-bar-layout.md new file mode 100644 index 000000000..66c85995b --- /dev/null +++ b/apps/docs/src/content/docs/guides/no-app-bar-layout.md @@ -0,0 +1,91 @@ +--- +title: Layout without the app bar +description: Use useWorkflowBuilderActions() to trigger save, import, export, settings, read-only, theme, and layout-direction commands from your own UI when omitting WorkflowBuilder.TopBar. +sidebar: + order: 6 +--- + +`` ships save, import / export, settings, read-only, theme, and layout-direction controls. When you build a custom layout and omit the top bar, those actions are still reachable via the `useWorkflowBuilderActions()` hook — call it from any descendant of `` and wire the returned callbacks to your own buttons. + +## Quick example + +```tsx +import { WorkflowBuilder, useWorkflowBuilderActions } from '@workflowbuilder/sdk'; + +function MyToolbar() { + const actions = useWorkflowBuilderActions(); + + return ( +
+ + + + + + + +
+ ); +} + +export function App() { + return ( + + + + + + + ); +} +``` + +## Action reference + +| Action | Signature | What it does | +| ----------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `save` | `() => Promise` | Triggers a manual save through the active [`integration` strategy](/guides/configuring-the-editor/#integration-strategies). | +| `openSettings` | `() => void` | Opens the built-in workflow settings modal (general settings, global variables). | +| `openImport` | `() => void` | Opens the import-diagram modal. | +| `openExport` | `() => void` | Opens the export-diagram modal. | +| `toggleReadOnly` | `() => void` | Flips read-only mode. | +| `setReadOnly` | `(value: boolean) => void` | Sets read-only mode explicitly. | +| `toggleDarkMode` | `() => void` | Flips the editor theme between `'light'` and `'dark'`. | +| `setTheme` | `(theme: 'light' \| 'dark') => void` | Sets the editor theme explicitly. | +| `setLayoutDirection` | `(direction: 'RIGHT' \| 'DOWN') => void` | Sets the diagram layout direction. Idempotent. | +| `toggleLayoutDirection` | `(options?: LayoutChangeOptions) => void` | Flips `'RIGHT'` ↔ `'DOWN'`. | + +`toggleLayoutDirection` accepts an optional `LayoutChangeOptions`: + +| Field | Type | Default | What it does | +| --------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `flipPositions` | `boolean` | `false` | Mirror node positions (swaps each node's `x`/`y`) so the diagram re-lays-out along the new axis. Naive mirror, not auto-layout: it ignores node sizes, so pair it with `fitView`. | +| `fitView` | `boolean` | `false` | Animate the view to fit all nodes after the change. | + +Without `flipPositions`, a direction change only re-orients handles and re-routes edges, node coordinates stay put. Position flipping is relative, so it lives only on the toggle, not on the idempotent `setLayoutDirection`. + +## Renaming the workflow + +Document name lives in the editor store. Read and write it with the existing `useStore` hook: + +```tsx +import { useStore } from '@workflowbuilder/sdk'; + +function NameField() { + const documentName = useStore((s) => s.documentName ?? ''); + const setDocumentName = useStore((s) => s.setDocumentName); + return setDocumentName(event.target.value)} />; +} +``` + +## Constraints + +- The hook must be called from a descendant of ``. `save` reads the active integration via React context; calling the hook outside Root resolves `save()` to `'error'` and logs a warning. +- The returned object is a stable reference across re-renders, so you can pass any callback straight to an event handler without `useCallback`. +- Modal openers (`openSettings` / `openImport` / `openExport`) render into the modal overlay mounted by `` — no extra setup needed. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 75a4a55f2..43fadae04 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -85,6 +85,8 @@ To add custom overlays alongside the default layout, mount it explicitly: Each subcomponent is also exported under a named alias (`WorkflowBuilderTopBar`, `WorkflowBuilderPalette`, `WorkflowBuilderCanvas`, `WorkflowBuilderPropertiesPanel`, `WorkflowBuilderDefaultLayout`) for consumers who prefer the classic style. +If you omit ``, use [`useWorkflowBuilderActions()`](https://www.workflowbuilder.io/docs/guides/no-app-bar-layout/) to trigger save / import / export / settings / read-only / theme / layout-direction from your own controls. + ## `` props | Prop | Type | Description | diff --git a/packages/sdk/src/features/diagram/diagram.tsx b/packages/sdk/src/features/diagram/diagram.tsx index a90c47fb2..338055151 100644 --- a/packages/sdk/src/features/diagram/diagram.tsx +++ b/packages/sdk/src/features/diagram/diagram.tsx @@ -9,8 +9,9 @@ import { type OnSelectionChangeParams, ReactFlow, SelectionMode, + useUpdateNodeInternals, } from '@xyflow/react'; -import { type DragEventHandler, useCallback, useMemo } from 'react'; +import { type DragEventHandler, useCallback, useEffect, useMemo } from 'react'; import type { DragEvent } from 'react'; import styles from './diagram.module.css'; @@ -70,6 +71,18 @@ function DiagramContainerComponent({ edgeTypes = {} }: DiagramContainerProps) { const setConnectionBeingDragged = useStore((store) => store.setConnectionBeingDragged); const nodeTypes = useNodeTypes(); + // React Flow caches each handle's measured bounds in `nodeInternals`. When + // `layoutDirection` flips, existing nodes re-render their `` with a + // new `position` prop, but the cache stays stale and edges keep routing to + // the old port spots. Ask React Flow to remeasure every mounted node when + // the direction changes. + const layoutDirection = useStore((store) => store.layoutDirection); + const updateNodeInternals = useUpdateNodeInternals(); + useEffect(() => { + const ids = useStore.getState().nodes.map((node) => node.id); + if (ids.length > 0) updateNodeInternals(ids); + }, [layoutDirection, updateNodeInternals]); + const onDragOver = useCallback((event: DragEvent) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; diff --git a/packages/sdk/src/hooks/theme.spec.ts b/packages/sdk/src/hooks/theme.spec.ts new file mode 100644 index 000000000..643889801 --- /dev/null +++ b/packages/sdk/src/hooks/theme.spec.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getTheme, initTheme, setTheme, subscribeTheme } from './theme'; + +describe('theme module', () => { + beforeEach(() => { + localStorage.clear(); + delete document.documentElement.dataset.theme; + }); + + afterEach(() => { + localStorage.clear(); + delete document.documentElement.dataset.theme; + }); + + it('defaults to "light" when localStorage has no value', () => { + expect(getTheme()).toBe('light'); + }); + + it('reads the persisted value from localStorage', () => { + localStorage.setItem('wb-theme', 'dark'); + expect(getTheme()).toBe('dark'); + }); + + it('setTheme updates localStorage and the document attribute', () => { + setTheme('dark'); + + expect(localStorage.getItem('wb-theme')).toBe('dark'); + expect(document.documentElement.dataset.theme).toBe('dark'); + expect(getTheme()).toBe('dark'); + }); + + it('setTheme notifies subscribers', () => { + const listener = vi.fn(); + const unsubscribe = subscribeTheme(listener); + + setTheme('dark'); + + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + }); + + it('setTheme does NOT notify subscribers when the value is unchanged', () => { + setTheme('light'); + const listener = vi.fn(); + const unsubscribe = subscribeTheme(listener); + + setTheme('light'); + + expect(listener).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it('initTheme applies the persisted theme to the DOM without a toggle', () => { + localStorage.setItem('wb-theme', 'dark'); + delete document.documentElement.dataset.theme; + + initTheme(); + + expect(document.documentElement.dataset.theme).toBe('dark'); + }); + + it('unsubscribe removes the listener', () => { + const listener = vi.fn(); + const unsubscribe = subscribeTheme(listener); + unsubscribe(); + + setTheme('dark'); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/src/hooks/theme.ts b/packages/sdk/src/hooks/theme.ts new file mode 100644 index 000000000..8d64c8423 --- /dev/null +++ b/packages/sdk/src/hooks/theme.ts @@ -0,0 +1,46 @@ +const THEME_KEY = 'wb-theme'; + +export type Theme = 'dark' | 'light'; + +type Listener = () => void; + +const listeners = new Set(); + +function applyToDom(theme: Theme): void { + document.documentElement.dataset.theme = theme; +} + +export function getTheme(): Theme { + return (localStorage.getItem(THEME_KEY) as Theme | null) ?? 'light'; +} + +export function setTheme(theme: Theme): void { + const current = getTheme(); + if (current === theme) return; + + localStorage.setItem(THEME_KEY, theme); + applyToDom(theme); + + for (const listener of listeners) listener(); +} + +export function subscribeTheme(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Reflect the persisted theme on the DOM. Idempotent. Run once on import so a + * saved non-default theme paints correctly on first load, without waiting for + * a `setTheme` toggle. Previously this lived in `useTheme`'s mount effect, so + * it only ran when the app-bar's theme toggle was mounted; centralizing it + * here keeps it correct for custom layouts that omit the bar. + */ +export function initTheme(): void { + applyToDom(getTheme()); +} + +// Client-only SDK: apply the persisted theme as soon as this module loads. +initTheme(); diff --git a/packages/sdk/src/hooks/use-theme.ts b/packages/sdk/src/hooks/use-theme.ts index 69e4c0269..9b3a82f4d 100644 --- a/packages/sdk/src/hooks/use-theme.ts +++ b/packages/sdk/src/hooks/use-theme.ts @@ -1,21 +1,15 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; -const THEME_KEY = 'wb-theme'; -type Theme = 'dark' | 'light'; +import { getTheme, setTheme, subscribeTheme } from './theme'; export function useTheme() { - const [theme, setTheme] = useState(() => { - return (localStorage.getItem(THEME_KEY) || 'light') as Theme; - }); - - useEffect(() => { - document.documentElement.dataset.theme = theme; - localStorage.setItem(THEME_KEY, theme); - }, [theme]); + const theme = useSyncExternalStore(subscribeTheme, getTheme, getTheme); const toggleTheme = useCallback(() => { - setTheme((previous) => (previous === 'light' ? 'dark' : 'light')); + setTheme(getTheme() === 'light' ? 'dark' : 'light'); }, []); return { theme, toggleTheme }; } + +export { type Theme } from './theme'; diff --git a/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx new file mode 100644 index 000000000..aebe968c4 --- /dev/null +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx @@ -0,0 +1,268 @@ +import { render } from '@testing-library/react'; +import { useRef } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { openExportModal } from '../features/integration/components/import-export/export-modal/open-export-modal'; +import { openImportModal } from '../features/integration/components/import-export/import-modal/open-import-modal'; +import { IntegrationContext } from '../features/integration/components/integration-variants/context/integration-context-wrapper'; +import { openModalWorkflowSettings } from '../features/variables/modals/modal-settings'; +import { useStore } from '../store/store'; +import { getTheme } from './theme'; +import { type WorkflowBuilderActions, useWorkflowBuilderActions } from './use-workflow-builder-actions'; + +vi.mock('../features/integration/components/import-export/export-modal/open-export-modal', () => ({ + openExportModal: vi.fn(), +})); + +vi.mock('../features/integration/components/import-export/import-modal/open-import-modal', () => ({ + openImportModal: vi.fn(), +})); + +vi.mock('../features/variables/modals/modal-settings', () => ({ + openModalWorkflowSettings: vi.fn(), +})); + +// Short-circuit the use-integration-store chain that drags in +// @synergycodes/overflow-ui (CSS side-effect that vitest's jsdom env can't load). +vi.mock('@/features/integration/stores/use-integration-store', () => ({ + getStoreSavingStatus: vi.fn(), + setStoreSavingStatus: vi.fn(), +})); + +vi.mock('@/features/changes-tracker/stores/use-changes-tracker-store', () => ({ + trackFutureChange: vi.fn(), +})); + +function renderHook(useHookFn: () => T, onSave = vi.fn().mockResolvedValue('success' as const)) { + const captured: { current: T | null } = { current: null }; + + function Consumer() { + captured.current = useHookFn(); + return null; + } + + render( + + + , + ); + + return { actions: captured, onSave }; +} + +describe('useWorkflowBuilderActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + delete document.documentElement.dataset.theme; + useStore.setState({ isReadOnlyMode: false, layoutDirection: 'RIGHT', nodes: [], reactFlowInstance: undefined }); + }); + + afterEach(() => { + localStorage.clear(); + delete document.documentElement.dataset.theme; + }); + + describe('save', () => { + it('calls IntegrationContext.onSave with isAutoSave: false', async () => { + const { actions, onSave } = renderHook(useWorkflowBuilderActions); + + await actions.current!.save(); + + expect(onSave).toHaveBeenCalledWith({ isAutoSave: false }); + }); + + it('returns the resolution from onSave', async () => { + const { actions } = renderHook(useWorkflowBuilderActions, vi.fn().mockResolvedValue('error')); + + await expect(actions.current!.save()).resolves.toBe('error'); + }); + }); + + describe('modal openers', () => { + it('openSettings delegates to openModalWorkflowSettings', () => { + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.openSettings(); + + expect(openModalWorkflowSettings).toHaveBeenCalledTimes(1); + }); + + it('openImport delegates to openImportModal', () => { + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.openImport(); + + expect(openImportModal).toHaveBeenCalledTimes(1); + }); + + it('openExport delegates to openExportModal', () => { + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.openExport(); + + expect(openExportModal).toHaveBeenCalledTimes(1); + }); + }); + + describe('read-only', () => { + it('toggleReadOnly flips isReadOnlyMode', () => { + const { actions } = renderHook(useWorkflowBuilderActions); + expect(useStore.getState().isReadOnlyMode).toBe(false); + + actions.current!.toggleReadOnly(); + expect(useStore.getState().isReadOnlyMode).toBe(true); + + actions.current!.toggleReadOnly(); + expect(useStore.getState().isReadOnlyMode).toBe(false); + }); + + it('setReadOnly sets the value explicitly regardless of current state', () => { + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.setReadOnly(true); + expect(useStore.getState().isReadOnlyMode).toBe(true); + + actions.current!.setReadOnly(true); + expect(useStore.getState().isReadOnlyMode).toBe(true); + + actions.current!.setReadOnly(false); + expect(useStore.getState().isReadOnlyMode).toBe(false); + }); + }); + + describe('theme', () => { + it('setTheme writes through to the theme module', () => { + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.setTheme('dark'); + + expect(getTheme()).toBe('dark'); + expect(document.documentElement.dataset.theme).toBe('dark'); + }); + + it('toggleDarkMode flips theme', () => { + const { actions } = renderHook(useWorkflowBuilderActions); + expect(getTheme()).toBe('light'); + + actions.current!.toggleDarkMode(); + expect(getTheme()).toBe('dark'); + + actions.current!.toggleDarkMode(); + expect(getTheme()).toBe('light'); + }); + }); + + describe('layout direction', () => { + it('setLayoutDirection sets the value explicitly', () => { + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.setLayoutDirection('DOWN'); + + expect(useStore.getState().layoutDirection).toBe('DOWN'); + }); + + it('toggleLayoutDirection flips RIGHT to DOWN and back', () => { + const { actions } = renderHook(useWorkflowBuilderActions); + expect(useStore.getState().layoutDirection).toBe('RIGHT'); + + actions.current!.toggleLayoutDirection(); + expect(useStore.getState().layoutDirection).toBe('DOWN'); + + actions.current!.toggleLayoutDirection(); + expect(useStore.getState().layoutDirection).toBe('RIGHT'); + }); + + it('leaves node positions untouched by default', () => { + useStore.setState({ nodes: [{ id: 'n1', position: { x: 10, y: 20 } }] as never }); + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.toggleLayoutDirection(); + + expect(useStore.getState().nodes[0].position).toEqual({ x: 10, y: 20 }); + }); + + it('swaps every node x/y when flipPositions is set', () => { + useStore.setState({ + nodes: [ + { id: 'n1', position: { x: 10, y: 20 } }, + { id: 'n2', position: { x: 30, y: 40 } }, + ] as never, + }); + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.toggleLayoutDirection({ flipPositions: true }); + + expect(useStore.getState().nodes.map((n) => n.position)).toEqual([ + { x: 20, y: 10 }, + { x: 40, y: 30 }, + ]); + }); + + it('setLayoutDirection is idempotent and never moves nodes', () => { + useStore.setState({ nodes: [{ id: 'n1', position: { x: 1, y: 2 } }] as never }); + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.setLayoutDirection('DOWN'); + actions.current!.setLayoutDirection('DOWN'); + + expect(useStore.getState().layoutDirection).toBe('DOWN'); + expect(useStore.getState().nodes[0].position).toEqual({ x: 1, y: 2 }); + }); + + it('flipPositions is relative: toggling twice restores original positions', () => { + useStore.setState({ nodes: [{ id: 'n1', position: { x: 10, y: 20 } }] as never }); + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.toggleLayoutDirection({ flipPositions: true }); + actions.current!.toggleLayoutDirection({ flipPositions: true }); + + expect(useStore.getState().nodes[0].position).toEqual({ x: 10, y: 20 }); + }); + + it('fits the view when fitView is set', () => { + const fitView = vi.fn(); + useStore.setState({ reactFlowInstance: { getZoom: () => 1, fitView } as never }); + const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 0; + }); + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.toggleLayoutDirection({ fitView: true }); + + expect(fitView).toHaveBeenCalledTimes(1); + rafSpy.mockRestore(); + }); + }); + + describe('identity', () => { + it('returns a stable object reference across re-renders when dependencies are unchanged', () => { + const seen: WorkflowBuilderActions[] = []; + const onSave = vi.fn().mockResolvedValue('success' as const); + + function Consumer() { + const actions = useWorkflowBuilderActions(); + const ref = useRef(0); + ref.current += 1; + seen.push(actions); + return null; + } + + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + + expect(seen.length).toBe(2); + expect(seen[0]).toBe(seen[1]); + }); + }); +}); diff --git a/packages/sdk/src/hooks/use-workflow-builder-actions.ts b/packages/sdk/src/hooks/use-workflow-builder-actions.ts new file mode 100644 index 000000000..e30490ead --- /dev/null +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.ts @@ -0,0 +1,138 @@ +import { useContext, useMemo } from 'react'; + +import { openExportModal } from '../features/integration/components/import-export/export-modal/open-export-modal'; +import { openImportModal } from '../features/integration/components/import-export/import-modal/open-import-modal'; +import { IntegrationContext } from '../features/integration/components/integration-variants/context/integration-context-wrapper'; +import { openModalWorkflowSettings } from '../features/variables/modals/modal-settings'; +import type { LayoutDirection } from '../node/common'; +import { getStoreNodes, setStoreNodes } from '../store/slices/diagram-slice/actions'; +import { useStore } from '../store/store'; +import type { DidSaveStatus } from '../types/integration'; +import { type Theme, getTheme, setTheme } from './theme'; +import { useFitView } from './use-fit-view'; + +/** + * Optional side effects for a layout-direction *toggle*. + * + * @category Hooks + */ +export type LayoutChangeOptions = { + /** + * Also reflow node positions by swapping each node's `x`/`y`, so the diagram + * visually re-lays-out along the new axis. Defaults to `false` (handles and + * edges re-orient, coordinates stay put). This is a naive mirror, not a + * layout algorithm: it ignores node dimensions, so non-square nodes shift + * relative to their neighbours. Pair it with `fitView` and treat it as a + * quick approximation, not production auto-layout. + */ + flipPositions?: boolean; + /** Animate the view to fit all nodes after the change. Defaults to `false`. */ + fitView?: boolean; +}; + +/** + * Imperative action surface for every command the built-in app bar + * exposes — `save`, modal openers, read-only / theme / layout-direction + * toggles. Use this when omitting `` from a + * custom layout so your own UI can trigger the same actions. + * + * Stable across renders while the active integration and the mounted + * React Flow instance are stable (layout actions close over the fit-view + * callback, which is keyed on that instance). + * + * @category Hooks + */ +export type WorkflowBuilderActions = { + /** Persist the current diagram through the active integration strategy. */ + save: () => Promise; + /** Open the built-in workflow settings modal. */ + openSettings: () => void; + /** Open the import-diagram modal. */ + openImport: () => void; + /** Open the export-diagram modal. */ + openExport: () => void; + /** Flip read-only mode. */ + toggleReadOnly: () => void; + /** Set read-only mode explicitly. */ + setReadOnly: (value: boolean) => void; + /** Flip the editor theme between `'light'` and `'dark'`. */ + toggleDarkMode: () => void; + /** Set the editor theme explicitly. */ + setTheme: (theme: Theme) => void; + /** + * Set the diagram layout direction (`'RIGHT'` ↔ `'DOWN'`). Idempotent: + * setting the same direction twice is a no-op. Position reflow is only + * offered on {@link toggleLayoutDirection}, where it is unambiguous. + */ + setLayoutDirection: (direction: LayoutDirection) => void; + /** + * Flip the diagram layout direction. Pass `options.flipPositions` to also + * reflow node coordinates and/or `options.fitView` to re-fit the view + * afterwards. + */ + toggleLayoutDirection: (options?: LayoutChangeOptions) => void; +}; + +/** + * Returns a stable object of action callbacks mirroring every command + * the built-in `` offers. Use it from a custom + * header / toolbar when omitting the bar. + * + * Must be called from a descendant of ``; `save` + * reads the active integration via React context. + * + * @example + * ```tsx + * function MyToolbar() { + * const actions = useWorkflowBuilderActions(); + * return ; + * } + * + * + * + * + * + * ``` + * + * @category Hooks + */ +export function useWorkflowBuilderActions(): WorkflowBuilderActions { + const { onSave } = useContext(IntegrationContext); + const setToggleReadOnlyMode = useStore((s) => s.setToggleReadOnlyMode); + const setStoreLayoutDirection = useStore((s) => s.setLayoutDirection); + const fitView = useFitView(); + + return useMemo( + () => ({ + save: () => onSave({ isAutoSave: false }), + + openSettings: openModalWorkflowSettings, + openImport: openImportModal, + openExport: openExportModal, + + toggleReadOnly: () => setToggleReadOnlyMode(), + setReadOnly: (value) => setToggleReadOnlyMode(value), + + toggleDarkMode: () => setTheme(getTheme() === 'light' ? 'dark' : 'light'), + setTheme: (theme) => setTheme(theme), + + setLayoutDirection: (direction) => setStoreLayoutDirection(direction), + toggleLayoutDirection: ({ flipPositions, fitView: doFitView } = {}) => { + setStoreLayoutDirection(useStore.getState().layoutDirection === 'RIGHT' ? 'DOWN' : 'RIGHT'); + + // Naive x/y mirror. Goes through setStoreNodes so the nodes stay on + // the same mutation path as the rest of the editor (schema re-validation + // included); positions can't change validation, but consistency matters + // more than the redundant pass. + if (flipPositions) { + setStoreNodes( + getStoreNodes().map((node) => ({ ...node, position: { x: node.position.y, y: node.position.x } })), + ); + } + + if (doFitView) fitView(); + }, + }), + [onSave, setToggleReadOnlyMode, setStoreLayoutDirection, fitView], + ); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 8aa54f376..7c10d337f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -140,6 +140,8 @@ export { defineNodeTemplate } from './utils/define-node-template'; export { useEffectChange } from './hooks/use-effect-change'; export { useFitView } from './hooks/use-fit-view'; export { useKeyPress } from './hooks/use-key-press'; +export { useWorkflowBuilderActions } from './hooks/use-workflow-builder-actions'; +export type { LayoutChangeOptions, WorkflowBuilderActions } from './hooks/use-workflow-builder-actions'; export { useLabelEdgeHover } from './features/diagram/edges/label-edge/use-label-edge-hover'; export { useSingleSelectedElement } from './features/properties-bar/use-single-selected-element'; export { useChangesTrackerStore, trackFutureChange } from './features/changes-tracker/stores/use-changes-tracker-store'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e42921cf..33ae35c68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -501,7 +501,7 @@ importers: version: 0.4.0 i18next: specifier: ^24.2.3 - version: 24.2.3(typescript@5.9.3) + version: 24.2.3(typescript@5.6.3) i18next-browser-languagedetector: specifier: ^8.0.5 version: 8.0.5 @@ -510,16 +510,16 @@ importers: version: 10.1.1 react-i18next: specifier: ^15.4.1 - version: 15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) vite: specifier: ^6.0.7 version: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4)) vite-plugin-svgr: specifier: ^4.3.0 - version: 4.3.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.3.0(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4)) vitest: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4) @@ -10011,7 +10011,7 @@ snapshots: de-indent: 1.0.2 he: 1.2.0 - '@vue/language-core@2.2.0(typescript@5.9.3)': + '@vue/language-core@2.2.0(typescript@5.6.3)': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.33 @@ -10022,7 +10022,7 @@ snapshots: muggle-string: 0.4.1 path-browserify: 1.0.1 optionalDependencies: - typescript: 5.9.3 + typescript: 5.6.3 '@vue/shared@3.5.33': {} @@ -12192,6 +12192,12 @@ snapshots: dependencies: '@babel/runtime': 7.27.0 + i18next@24.2.3(typescript@5.6.3): + dependencies: + '@babel/runtime': 7.27.0 + optionalDependencies: + typescript: 5.6.3 + i18next@24.2.3(typescript@5.9.3): dependencies: '@babel/runtime': 7.27.0 @@ -13744,6 +13750,15 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-i18next@15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/runtime': 7.27.0 + html-parse-stringify: 3.0.1 + i18next: 24.2.3(typescript@5.6.3) + react: 19.1.0 + optionalDependencies: + react-dom: 19.1.0(react@19.1.0) + react-i18next@15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.27.0 @@ -14957,18 +14972,18 @@ snapshots: - tsx - yaml - vite-plugin-dts@4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4)): + vite-plugin-dts@4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@microsoft/api-extractor': 7.58.7(@types/node@22.12.0) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) '@volar/typescript': 2.4.28 - '@vue/language-core': 2.2.0(typescript@5.9.3) + '@vue/language-core': 2.2.0(typescript@5.6.3) compare-versions: 6.1.1 debug: 4.4.3 kolorist: 1.8.0 local-pkg: 1.1.2 magic-string: 0.30.21 - typescript: 5.9.3 + typescript: 5.6.3 optionalDependencies: vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.4) transitivePeerDependencies: