From b0a2c03f123828a4cc3c629e60c1469f0e70198a Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Thu, 28 May 2026 11:03:47 +0200 Subject: [PATCH 01/10] feat(sdk): extract theme source-of-truth module --- packages/sdk/src/hooks/theme.spec.ts | 63 ++++++++++++++++++++++++++++ packages/sdk/src/hooks/theme.ts | 32 ++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 packages/sdk/src/hooks/theme.spec.ts create mode 100644 packages/sdk/src/hooks/theme.ts diff --git a/packages/sdk/src/hooks/theme.spec.ts b/packages/sdk/src/hooks/theme.spec.ts new file mode 100644 index 000000000..e0bcf6b6d --- /dev/null +++ b/packages/sdk/src/hooks/theme.spec.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getTheme, 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('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..99020087a --- /dev/null +++ b/packages/sdk/src/hooks/theme.ts @@ -0,0 +1,32 @@ +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); + }; +} From 17967b7dcab4807515e4ec268c7d6d28098772ab Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Thu, 28 May 2026 11:05:47 +0200 Subject: [PATCH 02/10] refactor(sdk): make useTheme subscribe to shared theme store --- packages/sdk/src/hooks/use-theme.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/sdk/src/hooks/use-theme.ts b/packages/sdk/src/hooks/use-theme.ts index 69e4c0269..3b2e66cbe 100644 --- a/packages/sdk/src/hooks/use-theme.ts +++ b/packages/sdk/src/hooks/use-theme.ts @@ -1,21 +1,17 @@ -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'; \ No newline at end of file From 225be2828102a7e816951a2192b2a0e0a85f3cee Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Thu, 28 May 2026 11:10:29 +0200 Subject: [PATCH 03/10] feat(sdk): add useWorkflowBuilderActions hook (WB-217) --- .../use-workflow-builder-actions.spec.tsx | 206 ++++++++++++++++++ .../src/hooks/use-workflow-builder-actions.ts | 93 ++++++++ 2 files changed, 299 insertions(+) create mode 100644 packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx create mode 100644 packages/sdk/src/hooks/use-workflow-builder-actions.ts 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..fe604b61d --- /dev/null +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx @@ -0,0 +1,206 @@ +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' }); + }); + + 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'); + }); + }); + + 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..a9359d231 --- /dev/null +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.ts @@ -0,0 +1,93 @@ +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 { useStore } from '../store/store'; +import type { DidSaveStatus } from '../types/integration'; +import { type Theme, getTheme, setTheme } from './theme'; + +/** + * 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 when the active integration is stable. + * + * @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'`). */ + setLayoutDirection: (direction: LayoutDirection) => void; + /** Flip the diagram layout direction. */ + toggleLayoutDirection: () => 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); + + 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: () => + setStoreLayoutDirection(useStore.getState().layoutDirection === 'RIGHT' ? 'DOWN' : 'RIGHT'), + }), + [onSave, setToggleReadOnlyMode, setStoreLayoutDirection], + ); +} From 6bdd9b487d31605dae1ba0b8f1c156a6bfdf74b5 Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Thu, 28 May 2026 11:12:08 +0200 Subject: [PATCH 04/10] feat(sdk): export useWorkflowBuilderActions from barrel (WB-217) --- packages/sdk/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 8aa54f376..2efd2cab6 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 { 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'; From d3e1313be9875733ab2d0575b3cfbe0b39f6ef08 Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Thu, 28 May 2026 11:13:03 +0200 Subject: [PATCH 05/10] docs: WB-217 no-app-bar layout guide + SDK README link + changeset --- .changeset/wb-217-actions-hook.md | 5 ++ .../content/docs/guides/no-app-bar-layout.md | 82 +++++++++++++++++++ packages/sdk/README.md | 2 + 3 files changed, 89 insertions(+) create mode 100644 .changeset/wb-217-actions-hook.md create mode 100644 apps/docs/src/content/docs/guides/no-app-bar-layout.md diff --git a/.changeset/wb-217-actions-hook.md b/.changeset/wb-217-actions-hook.md new file mode 100644 index 000000000..3a4cfe96f --- /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. See the [Layout without the app bar guide](https://www.workflowbuilder.io/docs/guides/no-app-bar-layout/). 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..1b54c78b8 --- /dev/null +++ b/apps/docs/src/content/docs/guides/no-app-bar-layout.md @@ -0,0 +1,82 @@ +--- +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. | +| `toggleLayoutDirection` | `() => void` | Flips `'RIGHT'` ↔ `'DOWN'`. | + +## 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 | From e3c72f294f3bf3ead9d8ea6a318550b1a15e7528 Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Thu, 28 May 2026 11:32:31 +0200 Subject: [PATCH 06/10] feat(no-app-bar): reference app demonstrating useWorkflowBuilderActions (WB-217) --- apps/no-app-bar/README.md | 24 +++++++ apps/no-app-bar/index.html | 20 ++++++ apps/no-app-bar/lint-staged.config.mjs | 1 + apps/no-app-bar/package.json | 35 ++++++++++ apps/no-app-bar/src/app/app.tsx | 30 +++++++++ apps/no-app-bar/src/app/layout.module.css | 38 +++++++++++ .../app/nodes/step/default-properties-data.ts | 9 +++ apps/no-app-bar/src/app/nodes/step/schema.ts | 15 +++++ apps/no-app-bar/src/app/nodes/step/step.ts | 15 +++++ .../no-app-bar/src/app/nodes/step/uischema.ts | 19 ++++++ apps/no-app-bar/src/app/toolbar.module.css | 60 +++++++++++++++++ apps/no-app-bar/src/app/toolbar.tsx | 58 +++++++++++++++++ apps/no-app-bar/src/main.tsx | 12 ++++ apps/no-app-bar/tsconfig.json | 13 ++++ apps/no-app-bar/vite.config.mts | 64 +++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 52 +++++++++++++++ 17 files changed, 466 insertions(+) create mode 100644 apps/no-app-bar/README.md create mode 100644 apps/no-app-bar/index.html create mode 100644 apps/no-app-bar/lint-staged.config.mjs create mode 100644 apps/no-app-bar/package.json create mode 100644 apps/no-app-bar/src/app/app.tsx create mode 100644 apps/no-app-bar/src/app/layout.module.css create mode 100644 apps/no-app-bar/src/app/nodes/step/default-properties-data.ts create mode 100644 apps/no-app-bar/src/app/nodes/step/schema.ts create mode 100644 apps/no-app-bar/src/app/nodes/step/step.ts create mode 100644 apps/no-app-bar/src/app/nodes/step/uischema.ts create mode 100644 apps/no-app-bar/src/app/toolbar.module.css create mode 100644 apps/no-app-bar/src/app/toolbar.tsx create mode 100644 apps/no-app-bar/src/main.tsx create mode 100644 apps/no-app-bar/tsconfig.json create mode 100644 apps/no-app-bar/vite.config.mts diff --git a/apps/no-app-bar/README.md b/apps/no-app-bar/README.md new file mode 100644 index 000000000..838587a82 --- /dev/null +++ b/apps/no-app-bar/README.md @@ -0,0 +1,24 @@ +# No App Bar (reference) + +Reference app for [WB-217](https://app.clickup.com/t/86c9yn7p4) — demonstrates `useWorkflowBuilderActions()`. + +Mounts `` with a custom layout that **omits** ``. A hand-rolled `` (in `src/app/toolbar.tsx`) wires every SDK command (save / import / export / settings / read-only / theme / layout-direction) to its own button through the new hook. + +## Run + +```bash +pnpm --filter @workflow-builder/no-app-bar dev +# → http://127.0.0.1:4202 +``` + +Persistence is the SDK default (`localStorage`) — no backend needed. + +## What to look at + +- [`src/app/toolbar.tsx`](./src/app/toolbar.tsx) — the only file consuming `useWorkflowBuilderActions()`. Every button maps directly to one returned action. +- [`src/app/app.tsx`](./src/app/app.tsx) — composes a custom layout instead of using `` or ``. + +## Related docs + +- Guide: [Layout without the app bar](../../apps/docs/src/content/docs/guides/no-app-bar-layout.md) +- Hook source: [`packages/sdk/src/hooks/use-workflow-builder-actions.ts`](../../packages/sdk/src/hooks/use-workflow-builder-actions.ts) diff --git a/apps/no-app-bar/index.html b/apps/no-app-bar/index.html new file mode 100644 index 000000000..6f2447d85 --- /dev/null +++ b/apps/no-app-bar/index.html @@ -0,0 +1,20 @@ + + + + + WB · No App Bar + + + + + + + + +
+ + + diff --git a/apps/no-app-bar/lint-staged.config.mjs b/apps/no-app-bar/lint-staged.config.mjs new file mode 100644 index 000000000..63809e0a3 --- /dev/null +++ b/apps/no-app-bar/lint-staged.config.mjs @@ -0,0 +1 @@ +export { default } from '../../lint-staged.config.mjs'; diff --git a/apps/no-app-bar/package.json b/apps/no-app-bar/package.json new file mode 100644 index 000000000..05a6f9d45 --- /dev/null +++ b/apps/no-app-bar/package.json @@ -0,0 +1,35 @@ +{ + "name": "@workflow-builder/no-app-bar", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "concurrently 'pnpm typecheck:watch' 'vite'", + "build": "vite build", + "typecheck": "tsc --noEmit", + "typecheck:watch": "tsc --noEmit --watch --pretty", + "lint": "eslint", + "lint:fix": "eslint --fix", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest --passWithNoTests" + }, + "dependencies": { + "@jsonforms/core": "^3.4.1", + "@jsonforms/react": "^3.4.1", + "@phosphor-icons/react": "^2.1.7", + "@synergycodes/overflow-ui": "1.0.0-beta.26", + "@xyflow/react": "catalog:", + "clsx": "^2.1.1", + "immer": "^10.1.1", + "react": "^19.1.0", + "react-dom": "catalog:", + "zustand": "^5.0.1" + }, + "devDependencies": { + "@types/react": "catalog:", + "@vitejs/plugin-react": "^4.3.4", + "@workflowbuilder/sdk": "workspace:*", + "vite": "^6.0.7", + "vite-plugin-svgr": "^4.3.0", + "vitest": "^3.0.4" + } +} diff --git a/apps/no-app-bar/src/app/app.tsx b/apps/no-app-bar/src/app/app.tsx new file mode 100644 index 000000000..57356164f --- /dev/null +++ b/apps/no-app-bar/src/app/app.tsx @@ -0,0 +1,30 @@ +import { WorkflowBuilder } from '@workflowbuilder/sdk'; + +import styles from './layout.module.css'; +import '@workflowbuilder/sdk/style.css'; + +import { step } from './nodes/step/step'; +import { Toolbar } from './toolbar'; + +const nodeTypes = [step]; + +export function App() { + return ( + +
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/apps/no-app-bar/src/app/layout.module.css b/apps/no-app-bar/src/app/layout.module.css new file mode 100644 index 000000000..ccc0fa020 --- /dev/null +++ b/apps/no-app-bar/src/app/layout.module.css @@ -0,0 +1,38 @@ +html, +body, +#root { + height: 100%; + margin: 0; + font-family: 'Poppins', system-ui, sans-serif; +} + +.shell { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.body { + flex: 1; + display: grid; + grid-template-columns: 16rem 1fr 22rem; + min-height: 0; +} + +.body > * { + min-height: 0; + overflow: hidden; +} + +.palette { + border-right: 1px solid var(--wb-app-bar-border-color, #e5e7eb); +} + +.canvas { + position: relative; +} + +.properties { + border-left: 1px solid var(--wb-app-bar-border-color, #e5e7eb); +} diff --git a/apps/no-app-bar/src/app/nodes/step/default-properties-data.ts b/apps/no-app-bar/src/app/nodes/step/default-properties-data.ts new file mode 100644 index 000000000..c2aea0c99 --- /dev/null +++ b/apps/no-app-bar/src/app/nodes/step/default-properties-data.ts @@ -0,0 +1,9 @@ +import type { NodeDataProperties } from '@workflowbuilder/sdk'; + +import { type StepNodeSchema } from './schema'; + +export const defaultPropertiesData: Required> = { + label: 'Step', + description: 'A workflow step', + status: 'active', +}; diff --git a/apps/no-app-bar/src/app/nodes/step/schema.ts b/apps/no-app-bar/src/app/nodes/step/schema.ts new file mode 100644 index 000000000..108944f7c --- /dev/null +++ b/apps/no-app-bar/src/app/nodes/step/schema.ts @@ -0,0 +1,15 @@ +import { sharedProperties, statusOptions } from '@workflowbuilder/sdk'; +import type { NodeSchema } from '@workflowbuilder/sdk'; + +export const schema = { + type: 'object', + properties: { + ...sharedProperties, + status: { + type: 'string', + options: Object.values(statusOptions), + }, + }, +} satisfies NodeSchema; + +export type StepNodeSchema = typeof schema; diff --git a/apps/no-app-bar/src/app/nodes/step/step.ts b/apps/no-app-bar/src/app/nodes/step/step.ts new file mode 100644 index 000000000..a63f83751 --- /dev/null +++ b/apps/no-app-bar/src/app/nodes/step/step.ts @@ -0,0 +1,15 @@ +import type { PaletteItem } from '@workflowbuilder/sdk'; + +import { defaultPropertiesData } from './default-properties-data'; +import { type StepNodeSchema, schema } from './schema'; +import { uischema } from './uischema'; + +export const step: PaletteItem = { + type: 'step', + icon: 'Circle', + label: 'Step', + description: 'A workflow step', + defaultPropertiesData, + schema, + uischema, +}; diff --git a/apps/no-app-bar/src/app/nodes/step/uischema.ts b/apps/no-app-bar/src/app/nodes/step/uischema.ts new file mode 100644 index 000000000..d09fb1ec9 --- /dev/null +++ b/apps/no-app-bar/src/app/nodes/step/uischema.ts @@ -0,0 +1,19 @@ +import { generalInformation, getScope, globalControls } from '@workflowbuilder/sdk'; +import type { UISchema } from '@workflowbuilder/sdk'; + +import { type StepNodeSchema } from './schema'; + +const scope = getScope; + +export const uischema: UISchema = { + type: 'VerticalLayout', + elements: [ + ...globalControls, + ...(generalInformation ? [generalInformation] : []), + { + label: 'Status', + type: 'Select', + scope: scope('properties.status'), + }, + ], +}; diff --git a/apps/no-app-bar/src/app/toolbar.module.css b/apps/no-app-bar/src/app/toolbar.module.css new file mode 100644 index 000000000..67be251c0 --- /dev/null +++ b/apps/no-app-bar/src/app/toolbar.module.css @@ -0,0 +1,60 @@ +.toolbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + background: var(--wb-app-bar-background, #ffffff); + border-bottom: 1px solid var(--wb-app-bar-border-color, #e5e7eb); + font-family: 'Poppins', system-ui, sans-serif; + font-size: 0.875rem; +} + +.brand { + font-weight: 600; + letter-spacing: -0.01em; +} + +.name-input { + flex: 0 1 16rem; + padding: 0.375rem 0.625rem; + border: 1px solid var(--wb-app-bar-border-color, #e5e7eb); + border-radius: 0.375rem; + font-family: inherit; + font-size: inherit; + background: transparent; + color: inherit; +} + +.name-input:disabled { + opacity: 0.6; +} + +.group { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +.group + .group { + margin-left: 0.5rem; +} + +.group button { + padding: 0.375rem 0.75rem; + border: 1px solid var(--wb-app-bar-border-color, #d1d5db); + border-radius: 0.375rem; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.group button:hover { + background-color: color-mix(in srgb, currentcolor, transparent 92%); +} + +.group button:active { + background-color: color-mix(in srgb, currentcolor, transparent 85%); +} diff --git a/apps/no-app-bar/src/app/toolbar.tsx b/apps/no-app-bar/src/app/toolbar.tsx new file mode 100644 index 000000000..8ad75d69e --- /dev/null +++ b/apps/no-app-bar/src/app/toolbar.tsx @@ -0,0 +1,58 @@ +import { useStore, useWorkflowBuilderActions } from '@workflowbuilder/sdk'; + +import styles from './toolbar.module.css'; + +/** + * Custom toolbar — demonstrates `useWorkflowBuilderActions()`. + * + * Replaces ``. Every button below drives an SDK + * command through the hook, so the editor is fully usable without mounting + * the built-in app bar. + */ +export function Toolbar() { + const actions = useWorkflowBuilderActions(); + const documentName = useStore((s) => s.documentName ?? 'Untitled'); + const setDocumentName = useStore((s) => s.setDocumentName); + const isReadOnly = useStore((s) => s.isReadOnlyMode); + + return ( +
+
WB · No App Bar
+ + setDocumentName(event.target.value)} + disabled={isReadOnly} + aria-label="Workflow name" + /> + +
+ + + + +
+ +
+ + + +
+
+ ); +} diff --git a/apps/no-app-bar/src/main.tsx b/apps/no-app-bar/src/main.tsx new file mode 100644 index 000000000..76491a0a0 --- /dev/null +++ b/apps/no-app-bar/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react'; +import * as ReactDOM from 'react-dom/client'; + +import { App } from './app/app'; + +const root = ReactDOM.createRoot(document.querySelector('#root') as HTMLElement); + +root.render( + + + , +); diff --git a/apps/no-app-bar/tsconfig.json b/apps/no-app-bar/tsconfig.json new file mode 100644 index 000000000..2fa5614b6 --- /dev/null +++ b/apps/no-app-bar/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["DOM", "ES2022"], + "baseUrl": ".", + "paths": { + "@workflowbuilder/sdk": ["../../packages/sdk/src/index.ts"] + }, + "verbatimModuleSyntax": true, + "types": ["node", "vite/client", "vite-plugin-svgr/client"] + }, + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.spec.tsx", "src/**/*.test.tsx", "dist"] +} diff --git a/apps/no-app-bar/vite.config.mts b/apps/no-app-bar/vite.config.mts new file mode 100644 index 000000000..2909c22ff --- /dev/null +++ b/apps/no-app-bar/vite.config.mts @@ -0,0 +1,64 @@ +/// +import react from '@vitejs/plugin-react'; +import path from 'node:path'; +import { defineConfig } from 'vite'; +import svgr from 'vite-plugin-svgr'; + +export default defineConfig(() => { + const shouldUseLocalOverflowUI = process.env.LOCAL_OVERFLOW_UI === 'true'; + + const sdkDirectory = path.resolve(import.meta.dirname, '../../packages/sdk'); + + return { + plugins: [svgr(), react()], + resolve: { + alias: [ + ...(shouldUseLocalOverflowUI + ? Object.entries(getLocalOverflowUIAliases()).map(([find, replacement]) => ({ + find, + replacement, + })) + : []), + { + find: /^@workflowbuilder\/sdk\/style\.css$/, + replacement: path.resolve(sdkDirectory, 'src/index.css'), + }, + { + find: /^@workflowbuilder\/sdk$/, + replacement: path.resolve(sdkDirectory, 'src/index.ts'), + }, + // overflow-ui doesn't expose ./dist/index.css via package.json exports + { + find: 'overflow-ui-css', + replacement: path.resolve(sdkDirectory, 'node_modules/@synergycodes/overflow-ui/dist/index.css'), + }, + // SDK source files use @/ to refer to their own root (packages/sdk/src/...). + // App files do not use @/ (they import via @workflowbuilder/sdk subpaths), + // so it's safe to point @/ at the SDK root for cross-package alias parity. + { find: '@/assets', replacement: path.resolve(sdkDirectory, 'src/assets') }, + { find: '@', replacement: path.resolve(sdkDirectory, 'src') }, + ], + }, + server: { + host: '127.0.0.1', + port: 4202, + }, + build: { + rollupOptions: {}, + outDir: '../../dist/apps/no-app-bar', + }, + test: { + globals: true, + environment: 'jsdom', + }, + }; +}); + +function getLocalOverflowUIAliases(): Record { + const distribution = path.resolve(import.meta.dirname, '../../../overflow-ui/packages/ui/dist'); + + return { + '@synergycodes/overflow-ui/tokens.css': path.join(distribution, 'tokens.css'), + '@synergycodes/overflow-ui': path.join(distribution, 'overflow-ui.js'), + }; +} diff --git a/package.json b/package.json index 5e0f48423..d54e0367f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "preflight": "node tools/preflight.mjs", "setup:env": "node tools/setup-env.mjs", "dev:demo": "pnpm --filter @workflow-builder/demo dev", + "dev:no-app-bar": "pnpm --filter @workflow-builder/no-app-bar dev", "dev:ai-studio": "pnpm preflight && pnpm infra:up && pnpm infra:wait && concurrently --kill-others-on-fail -n backend,worker,ai-studio -c blue,magenta,green \"pnpm dev:backend\" \"pnpm dev:worker\" \"pnpm --filter @workflow-builder/ai-studio dev\"", "dev:backend": "pnpm --filter backend dev", "dev:worker": "pnpm --filter execution-worker dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e42921cf..ba10c49d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,6 +396,58 @@ importers: specifier: 'catalog:' version: 19.1.0 + apps/no-app-bar: + dependencies: + '@jsonforms/core': + specifier: ^3.4.1 + version: 3.5.1 + '@jsonforms/react': + specifier: ^3.4.1 + version: 3.5.1(@jsonforms/core@3.5.1)(react@19.1.0) + '@phosphor-icons/react': + specifier: ^2.1.7 + version: 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@synergycodes/overflow-ui': + specifier: 1.0.0-beta.26 + version: 1.0.0-beta.26(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(dayjs@1.11.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@xyflow/react': + specifier: 'catalog:' + version: 12.10.0(@types/react@19.1.8)(immer@10.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + immer: + specifier: ^10.1.1 + version: 10.1.1 + react: + specifier: ^19.1.0 + version: 19.1.0 + react-dom: + specifier: 'catalog:' + version: 19.1.0(react@19.1.0) + zustand: + specifier: ^5.0.1 + version: 5.0.3(@types/react@19.1.8)(immer@10.1.1)(react@19.1.0)(use-sync-external-store@1.4.0(react@19.1.0)) + devDependencies: + '@types/react': + specifier: 'catalog:' + version: 19.1.8 + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.3.4(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)) + '@workflowbuilder/sdk': + specifier: workspace:* + version: link:../../packages/sdk + 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-svgr: + specifier: ^4.3.0 + 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) + apps/tools: devDependencies: '@types/node': From 061f606fe031b16bea7e4ced069a27235fafe9a9 Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Mon, 1 Jun 2026 15:18:07 +0200 Subject: [PATCH 07/10] feat(sdk): add LayoutChangeOptions to layout-direction actions (WB-217) --- .../wb-217-flip-layout-stale-handles.md | 5 ++ .changeset/wb-217-layout-flip-positions.md | 5 ++ .../content/docs/guides/no-app-bar-layout.md | 35 ++++++---- apps/no-app-bar/src/app/toolbar.tsx | 2 +- packages/sdk/src/features/diagram/diagram.tsx | 15 +++- .../use-workflow-builder-actions.spec.tsx | 53 +++++++++++++- .../src/hooks/use-workflow-builder-actions.ts | 69 +++++++++++++++---- packages/sdk/src/index.ts | 2 +- 8 files changed, 157 insertions(+), 29 deletions(-) create mode 100644 .changeset/wb-217-flip-layout-stale-handles.md create mode 100644 .changeset/wb-217-layout-flip-positions.md 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-layout-flip-positions.md b/.changeset/wb-217-layout-flip-positions.md new file mode 100644 index 000000000..6ba69da42 --- /dev/null +++ b/.changeset/wb-217-layout-flip-positions.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +feat: `setLayoutDirection` and `toggleLayoutDirection` from `useWorkflowBuilderActions()` now accept an optional `LayoutChangeOptions` (`{ flipPositions?, fitView? }`). Set `flipPositions: true` to also reflow node coordinates (swaps each node's `x`/`y`) so the diagram visually re-lays-out along the new axis, and `fitView: true` to re-fit the view afterwards. Both default to `false`, so existing callers are unaffected — a bare direction change still only re-orients handles and re-routes edges. The new `LayoutChangeOptions` type is exported from the package barrel. 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 index 1b54c78b8..056eaf102 100644 --- a/apps/docs/src/content/docs/guides/no-app-bar-layout.md +++ b/apps/docs/src/content/docs/guides/no-app-bar-layout.md @@ -23,7 +23,7 @@ function MyToolbar() { - + ); } @@ -48,18 +48,27 @@ export function App() { ## 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. | -| `toggleLayoutDirection` | `() => void` | Flips `'RIGHT'` ↔ `'DOWN'`. | +| 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', options?: LayoutChangeOptions) => void` | Sets the diagram layout direction. | +| `toggleLayoutDirection` | `(options?: LayoutChangeOptions) => void` | Flips `'RIGHT'` ↔ `'DOWN'`. | + +Both layout actions accept an optional `LayoutChangeOptions`: + +| Field | Type | Default | What it does | +| --------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------- | +| `flipPositions` | `boolean` | `false` | Also reflow node positions (swaps each node's `x`/`y`) so the diagram visually re-lays-out along the new axis. | +| `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. ## Renaming the workflow diff --git a/apps/no-app-bar/src/app/toolbar.tsx b/apps/no-app-bar/src/app/toolbar.tsx index 8ad75d69e..851e1fd72 100644 --- a/apps/no-app-bar/src/app/toolbar.tsx +++ b/apps/no-app-bar/src/app/toolbar.tsx @@ -49,7 +49,7 @@ export function Toolbar() { - 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/use-workflow-builder-actions.spec.tsx b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx index fe604b61d..19515979c 100644 --- a/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx @@ -55,7 +55,7 @@ describe('useWorkflowBuilderActions', () => { vi.clearAllMocks(); localStorage.clear(); delete document.documentElement.dataset.theme; - useStore.setState({ isReadOnlyMode: false, layoutDirection: 'RIGHT' }); + useStore.setState({ isReadOnlyMode: false, layoutDirection: 'RIGHT', nodes: [], reactFlowInstance: undefined }); }); afterEach(() => { @@ -172,6 +172,57 @@ describe('useWorkflowBuilderActions', () => { 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 swaps positions when flipPositions is set', () => { + useStore.setState({ nodes: [{ id: 'n1', position: { x: 1, y: 2 } }] as never }); + const { actions } = renderHook(useWorkflowBuilderActions); + + actions.current!.setLayoutDirection('DOWN', { flipPositions: true }); + + expect(useStore.getState().layoutDirection).toBe('DOWN'); + expect(useStore.getState().nodes[0].position).toEqual({ x: 2, y: 1 }); + }); + + 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', () => { diff --git a/packages/sdk/src/hooks/use-workflow-builder-actions.ts b/packages/sdk/src/hooks/use-workflow-builder-actions.ts index a9359d231..bfa7ea7f3 100644 --- a/packages/sdk/src/hooks/use-workflow-builder-actions.ts +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.ts @@ -8,6 +8,24 @@ import type { LayoutDirection } from '../node/common'; 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 behavior for a layout-direction change. + * + * @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 direction. Defaults to + * `false` — the direction change alone only re-orients handles and + * re-routes edges, leaving node coordinates untouched. + */ + 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 @@ -36,10 +54,18 @@ export type WorkflowBuilderActions = { toggleDarkMode: () => void; /** Set the editor theme explicitly. */ setTheme: (theme: Theme) => void; - /** Set the diagram layout direction (`'RIGHT'` ↔ `'DOWN'`). */ - setLayoutDirection: (direction: LayoutDirection) => void; - /** Flip the diagram layout direction. */ - toggleLayoutDirection: () => void; + /** + * Set the diagram layout direction (`'RIGHT'` ↔ `'DOWN'`). Pass + * `options.flipPositions` to also reflow node coordinates and/or + * `options.fitView` to re-fit the view afterwards. + */ + setLayoutDirection: (direction: LayoutDirection, options?: LayoutChangeOptions) => 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; }; /** @@ -69,9 +95,29 @@ 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(() => { + // Apply a direction change plus its optional side effects. Position + // flipping is a naive x/y swap — enough to read as a real layout reflow + // for orthogonal directions; positions are written directly (no schema + // re-validation, since coordinates can't affect node errors). + const applyLayoutChange = ( + direction: LayoutDirection, + { flipPositions, fitView: doFitView }: LayoutChangeOptions = {}, + ) => { + setStoreLayoutDirection(direction); + + if (flipPositions) { + useStore.setState((state) => ({ + nodes: state.nodes.map((node) => ({ ...node, position: { x: node.position.y, y: node.position.x } })), + })); + } + + if (doFitView) fitView(); + }; - return useMemo( - () => ({ + return { save: () => onSave({ isAutoSave: false }), openSettings: openModalWorkflowSettings, @@ -84,10 +130,9 @@ export function useWorkflowBuilderActions(): WorkflowBuilderActions { toggleDarkMode: () => setTheme(getTheme() === 'light' ? 'dark' : 'light'), setTheme: (theme) => setTheme(theme), - setLayoutDirection: (direction) => setStoreLayoutDirection(direction), - toggleLayoutDirection: () => - setStoreLayoutDirection(useStore.getState().layoutDirection === 'RIGHT' ? 'DOWN' : 'RIGHT'), - }), - [onSave, setToggleReadOnlyMode, setStoreLayoutDirection], - ); + setLayoutDirection: (direction, options) => applyLayoutChange(direction, options), + toggleLayoutDirection: (options) => + applyLayoutChange(useStore.getState().layoutDirection === 'RIGHT' ? 'DOWN' : 'RIGHT', options), + }; + }, [onSave, setToggleReadOnlyMode, setStoreLayoutDirection, fitView]); } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 2efd2cab6..7c10d337f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -141,7 +141,7 @@ 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 { WorkflowBuilderActions } 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'; From 1e13c4fd87110a38962cbf5891d95d76f0aab48b Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Tue, 2 Jun 2026 07:48:38 +0200 Subject: [PATCH 08/10] chore: remove no-app-bar reference app (WB-217) --- apps/no-app-bar/README.md | 24 ------ apps/no-app-bar/index.html | 20 ----- apps/no-app-bar/lint-staged.config.mjs | 1 - apps/no-app-bar/package.json | 35 -------- apps/no-app-bar/src/app/app.tsx | 30 ------- apps/no-app-bar/src/app/layout.module.css | 38 --------- .../app/nodes/step/default-properties-data.ts | 9 -- apps/no-app-bar/src/app/nodes/step/schema.ts | 15 ---- apps/no-app-bar/src/app/nodes/step/step.ts | 15 ---- .../no-app-bar/src/app/nodes/step/uischema.ts | 19 ----- apps/no-app-bar/src/app/toolbar.module.css | 60 ------------- apps/no-app-bar/src/app/toolbar.tsx | 58 ------------- apps/no-app-bar/src/main.tsx | 12 --- apps/no-app-bar/tsconfig.json | 13 --- apps/no-app-bar/vite.config.mts | 64 -------------- package.json | 1 - pnpm-lock.yaml | 85 ++++++------------- 17 files changed, 24 insertions(+), 475 deletions(-) delete mode 100644 apps/no-app-bar/README.md delete mode 100644 apps/no-app-bar/index.html delete mode 100644 apps/no-app-bar/lint-staged.config.mjs delete mode 100644 apps/no-app-bar/package.json delete mode 100644 apps/no-app-bar/src/app/app.tsx delete mode 100644 apps/no-app-bar/src/app/layout.module.css delete mode 100644 apps/no-app-bar/src/app/nodes/step/default-properties-data.ts delete mode 100644 apps/no-app-bar/src/app/nodes/step/schema.ts delete mode 100644 apps/no-app-bar/src/app/nodes/step/step.ts delete mode 100644 apps/no-app-bar/src/app/nodes/step/uischema.ts delete mode 100644 apps/no-app-bar/src/app/toolbar.module.css delete mode 100644 apps/no-app-bar/src/app/toolbar.tsx delete mode 100644 apps/no-app-bar/src/main.tsx delete mode 100644 apps/no-app-bar/tsconfig.json delete mode 100644 apps/no-app-bar/vite.config.mts diff --git a/apps/no-app-bar/README.md b/apps/no-app-bar/README.md deleted file mode 100644 index 838587a82..000000000 --- a/apps/no-app-bar/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# No App Bar (reference) - -Reference app for [WB-217](https://app.clickup.com/t/86c9yn7p4) — demonstrates `useWorkflowBuilderActions()`. - -Mounts `` with a custom layout that **omits** ``. A hand-rolled `` (in `src/app/toolbar.tsx`) wires every SDK command (save / import / export / settings / read-only / theme / layout-direction) to its own button through the new hook. - -## Run - -```bash -pnpm --filter @workflow-builder/no-app-bar dev -# → http://127.0.0.1:4202 -``` - -Persistence is the SDK default (`localStorage`) — no backend needed. - -## What to look at - -- [`src/app/toolbar.tsx`](./src/app/toolbar.tsx) — the only file consuming `useWorkflowBuilderActions()`. Every button maps directly to one returned action. -- [`src/app/app.tsx`](./src/app/app.tsx) — composes a custom layout instead of using `` or ``. - -## Related docs - -- Guide: [Layout without the app bar](../../apps/docs/src/content/docs/guides/no-app-bar-layout.md) -- Hook source: [`packages/sdk/src/hooks/use-workflow-builder-actions.ts`](../../packages/sdk/src/hooks/use-workflow-builder-actions.ts) diff --git a/apps/no-app-bar/index.html b/apps/no-app-bar/index.html deleted file mode 100644 index 6f2447d85..000000000 --- a/apps/no-app-bar/index.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - WB · No App Bar - - - - - - - - -
- - - diff --git a/apps/no-app-bar/lint-staged.config.mjs b/apps/no-app-bar/lint-staged.config.mjs deleted file mode 100644 index 63809e0a3..000000000 --- a/apps/no-app-bar/lint-staged.config.mjs +++ /dev/null @@ -1 +0,0 @@ -export { default } from '../../lint-staged.config.mjs'; diff --git a/apps/no-app-bar/package.json b/apps/no-app-bar/package.json deleted file mode 100644 index 05a6f9d45..000000000 --- a/apps/no-app-bar/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@workflow-builder/no-app-bar", - "version": "0.0.0", - "private": true, - "scripts": { - "dev": "concurrently 'pnpm typecheck:watch' 'vite'", - "build": "vite build", - "typecheck": "tsc --noEmit", - "typecheck:watch": "tsc --noEmit --watch --pretty", - "lint": "eslint", - "lint:fix": "eslint --fix", - "test": "vitest run --passWithNoTests", - "test:watch": "vitest --passWithNoTests" - }, - "dependencies": { - "@jsonforms/core": "^3.4.1", - "@jsonforms/react": "^3.4.1", - "@phosphor-icons/react": "^2.1.7", - "@synergycodes/overflow-ui": "1.0.0-beta.26", - "@xyflow/react": "catalog:", - "clsx": "^2.1.1", - "immer": "^10.1.1", - "react": "^19.1.0", - "react-dom": "catalog:", - "zustand": "^5.0.1" - }, - "devDependencies": { - "@types/react": "catalog:", - "@vitejs/plugin-react": "^4.3.4", - "@workflowbuilder/sdk": "workspace:*", - "vite": "^6.0.7", - "vite-plugin-svgr": "^4.3.0", - "vitest": "^3.0.4" - } -} diff --git a/apps/no-app-bar/src/app/app.tsx b/apps/no-app-bar/src/app/app.tsx deleted file mode 100644 index 57356164f..000000000 --- a/apps/no-app-bar/src/app/app.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { WorkflowBuilder } from '@workflowbuilder/sdk'; - -import styles from './layout.module.css'; -import '@workflowbuilder/sdk/style.css'; - -import { step } from './nodes/step/step'; -import { Toolbar } from './toolbar'; - -const nodeTypes = [step]; - -export function App() { - return ( - -
- -
-
- -
-
- -
-
- -
-
-
-
- ); -} diff --git a/apps/no-app-bar/src/app/layout.module.css b/apps/no-app-bar/src/app/layout.module.css deleted file mode 100644 index ccc0fa020..000000000 --- a/apps/no-app-bar/src/app/layout.module.css +++ /dev/null @@ -1,38 +0,0 @@ -html, -body, -#root { - height: 100%; - margin: 0; - font-family: 'Poppins', system-ui, sans-serif; -} - -.shell { - display: flex; - flex-direction: column; - height: 100vh; - overflow: hidden; -} - -.body { - flex: 1; - display: grid; - grid-template-columns: 16rem 1fr 22rem; - min-height: 0; -} - -.body > * { - min-height: 0; - overflow: hidden; -} - -.palette { - border-right: 1px solid var(--wb-app-bar-border-color, #e5e7eb); -} - -.canvas { - position: relative; -} - -.properties { - border-left: 1px solid var(--wb-app-bar-border-color, #e5e7eb); -} diff --git a/apps/no-app-bar/src/app/nodes/step/default-properties-data.ts b/apps/no-app-bar/src/app/nodes/step/default-properties-data.ts deleted file mode 100644 index c2aea0c99..000000000 --- a/apps/no-app-bar/src/app/nodes/step/default-properties-data.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { NodeDataProperties } from '@workflowbuilder/sdk'; - -import { type StepNodeSchema } from './schema'; - -export const defaultPropertiesData: Required> = { - label: 'Step', - description: 'A workflow step', - status: 'active', -}; diff --git a/apps/no-app-bar/src/app/nodes/step/schema.ts b/apps/no-app-bar/src/app/nodes/step/schema.ts deleted file mode 100644 index 108944f7c..000000000 --- a/apps/no-app-bar/src/app/nodes/step/schema.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { sharedProperties, statusOptions } from '@workflowbuilder/sdk'; -import type { NodeSchema } from '@workflowbuilder/sdk'; - -export const schema = { - type: 'object', - properties: { - ...sharedProperties, - status: { - type: 'string', - options: Object.values(statusOptions), - }, - }, -} satisfies NodeSchema; - -export type StepNodeSchema = typeof schema; diff --git a/apps/no-app-bar/src/app/nodes/step/step.ts b/apps/no-app-bar/src/app/nodes/step/step.ts deleted file mode 100644 index a63f83751..000000000 --- a/apps/no-app-bar/src/app/nodes/step/step.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { PaletteItem } from '@workflowbuilder/sdk'; - -import { defaultPropertiesData } from './default-properties-data'; -import { type StepNodeSchema, schema } from './schema'; -import { uischema } from './uischema'; - -export const step: PaletteItem = { - type: 'step', - icon: 'Circle', - label: 'Step', - description: 'A workflow step', - defaultPropertiesData, - schema, - uischema, -}; diff --git a/apps/no-app-bar/src/app/nodes/step/uischema.ts b/apps/no-app-bar/src/app/nodes/step/uischema.ts deleted file mode 100644 index d09fb1ec9..000000000 --- a/apps/no-app-bar/src/app/nodes/step/uischema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { generalInformation, getScope, globalControls } from '@workflowbuilder/sdk'; -import type { UISchema } from '@workflowbuilder/sdk'; - -import { type StepNodeSchema } from './schema'; - -const scope = getScope; - -export const uischema: UISchema = { - type: 'VerticalLayout', - elements: [ - ...globalControls, - ...(generalInformation ? [generalInformation] : []), - { - label: 'Status', - type: 'Select', - scope: scope('properties.status'), - }, - ], -}; diff --git a/apps/no-app-bar/src/app/toolbar.module.css b/apps/no-app-bar/src/app/toolbar.module.css deleted file mode 100644 index 67be251c0..000000000 --- a/apps/no-app-bar/src/app/toolbar.module.css +++ /dev/null @@ -1,60 +0,0 @@ -.toolbar { - display: flex; - align-items: center; - gap: 1rem; - padding: 0.5rem 1rem; - background: var(--wb-app-bar-background, #ffffff); - border-bottom: 1px solid var(--wb-app-bar-border-color, #e5e7eb); - font-family: 'Poppins', system-ui, sans-serif; - font-size: 0.875rem; -} - -.brand { - font-weight: 600; - letter-spacing: -0.01em; -} - -.name-input { - flex: 0 1 16rem; - padding: 0.375rem 0.625rem; - border: 1px solid var(--wb-app-bar-border-color, #e5e7eb); - border-radius: 0.375rem; - font-family: inherit; - font-size: inherit; - background: transparent; - color: inherit; -} - -.name-input:disabled { - opacity: 0.6; -} - -.group { - display: flex; - align-items: center; - gap: 0.5rem; - margin-left: auto; -} - -.group + .group { - margin-left: 0.5rem; -} - -.group button { - padding: 0.375rem 0.75rem; - border: 1px solid var(--wb-app-bar-border-color, #d1d5db); - border-radius: 0.375rem; - background: transparent; - color: inherit; - font: inherit; - cursor: pointer; - transition: background-color 0.15s ease; -} - -.group button:hover { - background-color: color-mix(in srgb, currentcolor, transparent 92%); -} - -.group button:active { - background-color: color-mix(in srgb, currentcolor, transparent 85%); -} diff --git a/apps/no-app-bar/src/app/toolbar.tsx b/apps/no-app-bar/src/app/toolbar.tsx deleted file mode 100644 index 851e1fd72..000000000 --- a/apps/no-app-bar/src/app/toolbar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useStore, useWorkflowBuilderActions } from '@workflowbuilder/sdk'; - -import styles from './toolbar.module.css'; - -/** - * Custom toolbar — demonstrates `useWorkflowBuilderActions()`. - * - * Replaces ``. Every button below drives an SDK - * command through the hook, so the editor is fully usable without mounting - * the built-in app bar. - */ -export function Toolbar() { - const actions = useWorkflowBuilderActions(); - const documentName = useStore((s) => s.documentName ?? 'Untitled'); - const setDocumentName = useStore((s) => s.setDocumentName); - const isReadOnly = useStore((s) => s.isReadOnlyMode); - - return ( -
-
WB · No App Bar
- - setDocumentName(event.target.value)} - disabled={isReadOnly} - aria-label="Workflow name" - /> - -
- - - - -
- -
- - - -
-
- ); -} diff --git a/apps/no-app-bar/src/main.tsx b/apps/no-app-bar/src/main.tsx deleted file mode 100644 index 76491a0a0..000000000 --- a/apps/no-app-bar/src/main.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { StrictMode } from 'react'; -import * as ReactDOM from 'react-dom/client'; - -import { App } from './app/app'; - -const root = ReactDOM.createRoot(document.querySelector('#root') as HTMLElement); - -root.render( - - - , -); diff --git a/apps/no-app-bar/tsconfig.json b/apps/no-app-bar/tsconfig.json deleted file mode 100644 index 2fa5614b6..000000000 --- a/apps/no-app-bar/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["DOM", "ES2022"], - "baseUrl": ".", - "paths": { - "@workflowbuilder/sdk": ["../../packages/sdk/src/index.ts"] - }, - "verbatimModuleSyntax": true, - "types": ["node", "vite/client", "vite-plugin-svgr/client"] - }, - "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.spec.tsx", "src/**/*.test.tsx", "dist"] -} diff --git a/apps/no-app-bar/vite.config.mts b/apps/no-app-bar/vite.config.mts deleted file mode 100644 index 2909c22ff..000000000 --- a/apps/no-app-bar/vite.config.mts +++ /dev/null @@ -1,64 +0,0 @@ -/// -import react from '@vitejs/plugin-react'; -import path from 'node:path'; -import { defineConfig } from 'vite'; -import svgr from 'vite-plugin-svgr'; - -export default defineConfig(() => { - const shouldUseLocalOverflowUI = process.env.LOCAL_OVERFLOW_UI === 'true'; - - const sdkDirectory = path.resolve(import.meta.dirname, '../../packages/sdk'); - - return { - plugins: [svgr(), react()], - resolve: { - alias: [ - ...(shouldUseLocalOverflowUI - ? Object.entries(getLocalOverflowUIAliases()).map(([find, replacement]) => ({ - find, - replacement, - })) - : []), - { - find: /^@workflowbuilder\/sdk\/style\.css$/, - replacement: path.resolve(sdkDirectory, 'src/index.css'), - }, - { - find: /^@workflowbuilder\/sdk$/, - replacement: path.resolve(sdkDirectory, 'src/index.ts'), - }, - // overflow-ui doesn't expose ./dist/index.css via package.json exports - { - find: 'overflow-ui-css', - replacement: path.resolve(sdkDirectory, 'node_modules/@synergycodes/overflow-ui/dist/index.css'), - }, - // SDK source files use @/ to refer to their own root (packages/sdk/src/...). - // App files do not use @/ (they import via @workflowbuilder/sdk subpaths), - // so it's safe to point @/ at the SDK root for cross-package alias parity. - { find: '@/assets', replacement: path.resolve(sdkDirectory, 'src/assets') }, - { find: '@', replacement: path.resolve(sdkDirectory, 'src') }, - ], - }, - server: { - host: '127.0.0.1', - port: 4202, - }, - build: { - rollupOptions: {}, - outDir: '../../dist/apps/no-app-bar', - }, - test: { - globals: true, - environment: 'jsdom', - }, - }; -}); - -function getLocalOverflowUIAliases(): Record { - const distribution = path.resolve(import.meta.dirname, '../../../overflow-ui/packages/ui/dist'); - - return { - '@synergycodes/overflow-ui/tokens.css': path.join(distribution, 'tokens.css'), - '@synergycodes/overflow-ui': path.join(distribution, 'overflow-ui.js'), - }; -} diff --git a/package.json b/package.json index d54e0367f..5e0f48423 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "preflight": "node tools/preflight.mjs", "setup:env": "node tools/setup-env.mjs", "dev:demo": "pnpm --filter @workflow-builder/demo dev", - "dev:no-app-bar": "pnpm --filter @workflow-builder/no-app-bar dev", "dev:ai-studio": "pnpm preflight && pnpm infra:up && pnpm infra:wait && concurrently --kill-others-on-fail -n backend,worker,ai-studio -c blue,magenta,green \"pnpm dev:backend\" \"pnpm dev:worker\" \"pnpm --filter @workflow-builder/ai-studio dev\"", "dev:backend": "pnpm --filter backend dev", "dev:worker": "pnpm --filter execution-worker dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba10c49d9..33ae35c68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,58 +396,6 @@ importers: specifier: 'catalog:' version: 19.1.0 - apps/no-app-bar: - dependencies: - '@jsonforms/core': - specifier: ^3.4.1 - version: 3.5.1 - '@jsonforms/react': - specifier: ^3.4.1 - version: 3.5.1(@jsonforms/core@3.5.1)(react@19.1.0) - '@phosphor-icons/react': - specifier: ^2.1.7 - version: 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@synergycodes/overflow-ui': - specifier: 1.0.0-beta.26 - version: 1.0.0-beta.26(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(dayjs@1.11.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@xyflow/react': - specifier: 'catalog:' - version: 12.10.0(@types/react@19.1.8)(immer@10.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - clsx: - specifier: ^2.1.1 - version: 2.1.1 - immer: - specifier: ^10.1.1 - version: 10.1.1 - react: - specifier: ^19.1.0 - version: 19.1.0 - react-dom: - specifier: 'catalog:' - version: 19.1.0(react@19.1.0) - zustand: - specifier: ^5.0.1 - version: 5.0.3(@types/react@19.1.8)(immer@10.1.1)(react@19.1.0)(use-sync-external-store@1.4.0(react@19.1.0)) - devDependencies: - '@types/react': - specifier: 'catalog:' - version: 19.1.8 - '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.3.4(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)) - '@workflowbuilder/sdk': - specifier: workspace:* - version: link:../../packages/sdk - 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-svgr: - specifier: ^4.3.0 - 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) - apps/tools: devDependencies: '@types/node': @@ -553,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 @@ -562,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) @@ -10063,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 @@ -10074,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': {} @@ -12244,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 @@ -13796,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 @@ -15009,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: From fe448d83a7cebd357beacfda2f2228cd376ff04c Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Tue, 2 Jun 2026 08:50:00 +0200 Subject: [PATCH 09/10] fix(sdk): apply persisted theme to DOM on load (WB-217) --- .changeset/wb-217-layout-flip-positions.md | 5 ----- .changeset/wb-217-theme-shared-store.md | 5 +++++ packages/sdk/src/hooks/theme.spec.ts | 11 ++++++++++- packages/sdk/src/hooks/theme.ts | 14 ++++++++++++++ packages/sdk/src/hooks/use-theme.ts | 6 ++---- 5 files changed, 31 insertions(+), 10 deletions(-) delete mode 100644 .changeset/wb-217-layout-flip-positions.md create mode 100644 .changeset/wb-217-theme-shared-store.md diff --git a/.changeset/wb-217-layout-flip-positions.md b/.changeset/wb-217-layout-flip-positions.md deleted file mode 100644 index 6ba69da42..000000000 --- a/.changeset/wb-217-layout-flip-positions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@workflowbuilder/sdk': minor ---- - -feat: `setLayoutDirection` and `toggleLayoutDirection` from `useWorkflowBuilderActions()` now accept an optional `LayoutChangeOptions` (`{ flipPositions?, fitView? }`). Set `flipPositions: true` to also reflow node coordinates (swaps each node's `x`/`y`) so the diagram visually re-lays-out along the new axis, and `fitView: true` to re-fit the view afterwards. Both default to `false`, so existing callers are unaffected — a bare direction change still only re-orients handles and re-routes edges. The new `LayoutChangeOptions` type is exported from the package barrel. 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/packages/sdk/src/hooks/theme.spec.ts b/packages/sdk/src/hooks/theme.spec.ts index e0bcf6b6d..643889801 100644 --- a/packages/sdk/src/hooks/theme.spec.ts +++ b/packages/sdk/src/hooks/theme.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getTheme, setTheme, subscribeTheme } from './theme'; +import { getTheme, initTheme, setTheme, subscribeTheme } from './theme'; describe('theme module', () => { beforeEach(() => { @@ -51,6 +51,15 @@ describe('theme module', () => { 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); diff --git a/packages/sdk/src/hooks/theme.ts b/packages/sdk/src/hooks/theme.ts index 99020087a..8d64c8423 100644 --- a/packages/sdk/src/hooks/theme.ts +++ b/packages/sdk/src/hooks/theme.ts @@ -30,3 +30,17 @@ export function subscribeTheme(listener: Listener): () => void { 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 3b2e66cbe..9b3a82f4d 100644 --- a/packages/sdk/src/hooks/use-theme.ts +++ b/packages/sdk/src/hooks/use-theme.ts @@ -1,6 +1,6 @@ import { useCallback, useSyncExternalStore } from 'react'; -import { getTheme, setTheme, subscribeTheme } from './theme'; +import { getTheme, setTheme, subscribeTheme } from './theme'; export function useTheme() { const theme = useSyncExternalStore(subscribeTheme, getTheme, getTheme); @@ -12,6 +12,4 @@ export function useTheme() { return { theme, toggleTheme }; } - - -export {type Theme} from './theme'; \ No newline at end of file +export { type Theme } from './theme'; From b6839aaae890247f9126326b1a2e0572f362c54b Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Tue, 2 Jun 2026 08:50:23 +0200 Subject: [PATCH 10/10] refactor(sdk): tighten layout-direction actions API (WB-217) --- .changeset/wb-217-actions-hook.md | 2 +- .../content/docs/guides/no-app-bar-layout.md | 42 +++++------ .../use-workflow-builder-actions.spec.tsx | 17 ++++- .../src/hooks/use-workflow-builder-actions.ts | 72 +++++++++---------- 4 files changed, 72 insertions(+), 61 deletions(-) diff --git a/.changeset/wb-217-actions-hook.md b/.changeset/wb-217-actions-hook.md index 3a4cfe96f..342a57603 100644 --- a/.changeset/wb-217-actions-hook.md +++ b/.changeset/wb-217-actions-hook.md @@ -2,4 +2,4 @@ '@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. See the [Layout without the app bar guide](https://www.workflowbuilder.io/docs/guides/no-app-bar-layout/). +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/apps/docs/src/content/docs/guides/no-app-bar-layout.md b/apps/docs/src/content/docs/guides/no-app-bar-layout.md index 056eaf102..66c85995b 100644 --- a/apps/docs/src/content/docs/guides/no-app-bar-layout.md +++ b/apps/docs/src/content/docs/guides/no-app-bar-layout.md @@ -48,27 +48,27 @@ export function App() { ## 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', options?: LayoutChangeOptions) => void` | Sets the diagram layout direction. | -| `toggleLayoutDirection` | `(options?: LayoutChangeOptions) => void` | Flips `'RIGHT'` ↔ `'DOWN'`. | - -Both layout actions accept an optional `LayoutChangeOptions`: - -| Field | Type | Default | What it does | -| --------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------- | -| `flipPositions` | `boolean` | `false` | Also reflow node positions (swaps each node's `x`/`y`) so the diagram visually re-lays-out along the new axis. | -| `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. +| 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 diff --git a/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx index 19515979c..aebe968c4 100644 --- a/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx @@ -199,14 +199,25 @@ describe('useWorkflowBuilderActions', () => { ]); }); - it('setLayoutDirection swaps positions when flipPositions is set', () => { + 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', { flipPositions: true }); + actions.current!.setLayoutDirection('DOWN'); + actions.current!.setLayoutDirection('DOWN'); expect(useStore.getState().layoutDirection).toBe('DOWN'); - expect(useStore.getState().nodes[0].position).toEqual({ x: 2, y: 1 }); + 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', () => { diff --git a/packages/sdk/src/hooks/use-workflow-builder-actions.ts b/packages/sdk/src/hooks/use-workflow-builder-actions.ts index bfa7ea7f3..e30490ead 100644 --- a/packages/sdk/src/hooks/use-workflow-builder-actions.ts +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.ts @@ -5,22 +5,25 @@ import { openImportModal } from '../features/integration/components/import-expor 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 behavior for a layout-direction change. + * 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 direction. Defaults to - * `false` — the direction change alone only re-orients handles and - * re-routes edges, leaving node coordinates untouched. + * 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`. */ @@ -33,7 +36,9 @@ export type LayoutChangeOptions = { * toggles. Use this when omitting `` from a * custom layout so your own UI can trigger the same actions. * - * Stable across renders when the active integration is stable. + * 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 */ @@ -55,11 +60,11 @@ export type WorkflowBuilderActions = { /** Set the editor theme explicitly. */ setTheme: (theme: Theme) => void; /** - * Set the diagram layout direction (`'RIGHT'` ↔ `'DOWN'`). Pass - * `options.flipPositions` to also reflow node coordinates and/or - * `options.fitView` to re-fit the view afterwards. + * 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, options?: LayoutChangeOptions) => void; + 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 @@ -97,27 +102,8 @@ export function useWorkflowBuilderActions(): WorkflowBuilderActions { const setStoreLayoutDirection = useStore((s) => s.setLayoutDirection); const fitView = useFitView(); - return useMemo(() => { - // Apply a direction change plus its optional side effects. Position - // flipping is a naive x/y swap — enough to read as a real layout reflow - // for orthogonal directions; positions are written directly (no schema - // re-validation, since coordinates can't affect node errors). - const applyLayoutChange = ( - direction: LayoutDirection, - { flipPositions, fitView: doFitView }: LayoutChangeOptions = {}, - ) => { - setStoreLayoutDirection(direction); - - if (flipPositions) { - useStore.setState((state) => ({ - nodes: state.nodes.map((node) => ({ ...node, position: { x: node.position.y, y: node.position.x } })), - })); - } - - if (doFitView) fitView(); - }; - - return { + return useMemo( + () => ({ save: () => onSave({ isAutoSave: false }), openSettings: openModalWorkflowSettings, @@ -130,9 +116,23 @@ export function useWorkflowBuilderActions(): WorkflowBuilderActions { toggleDarkMode: () => setTheme(getTheme() === 'light' ? 'dark' : 'light'), setTheme: (theme) => setTheme(theme), - setLayoutDirection: (direction, options) => applyLayoutChange(direction, options), - toggleLayoutDirection: (options) => - applyLayoutChange(useStore.getState().layoutDirection === 'RIGHT' ? 'DOWN' : 'RIGHT', options), - }; - }, [onSave, setToggleReadOnlyMode, setStoreLayoutDirection, fitView]); + 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], + ); }