Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wb-217-actions-hook.md
Original file line number Diff line number Diff line change
@@ -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 `<WorkflowBuilder.TopBar />` 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/).
5 changes: 5 additions & 0 deletions .changeset/wb-217-flip-layout-stale-handles.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/wb-217-theme-shared-store.md
Original file line number Diff line number Diff line change
@@ -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 `<WorkflowBuilder.TopBar />` is omitted.
91 changes: 91 additions & 0 deletions apps/docs/src/content/docs/guides/no-app-bar-layout.md
Original file line number Diff line number Diff line change
@@ -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
---

`<WorkflowBuilder.TopBar />` 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 `<WorkflowBuilder.Root>` and wire the returned callbacks to your own buttons.

## Quick example

```tsx
import { WorkflowBuilder, useWorkflowBuilderActions } from '@workflowbuilder/sdk';

function MyToolbar() {
const actions = useWorkflowBuilderActions();

return (
<header style={{ display: 'flex', gap: 8 }}>
<button onClick={actions.save}>Save</button>
<button onClick={actions.openImport}>Import</button>
<button onClick={actions.openExport}>Export</button>
<button onClick={actions.openSettings}>Settings</button>
<button onClick={actions.toggleReadOnly}>Toggle read-only</button>
<button onClick={actions.toggleDarkMode}>Toggle theme</button>
<button onClick={() => actions.toggleLayoutDirection({ flipPositions: true, fitView: true })}>Flip layout</button>
</header>
);
}

export function App() {
return (
<WorkflowBuilder.Root
nodeTypes={
[
/* ... */
]
}
>
<MyToolbar />
<WorkflowBuilder.Palette />
<WorkflowBuilder.Canvas />
<WorkflowBuilder.PropertiesPanel />
</WorkflowBuilder.Root>
);
}
```

## Action reference

| Action | Signature | What it does |
| ----------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `save` | `() => Promise<DidSaveStatus>` | 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 <input value={documentName} onChange={(event) => setDocumentName(event.target.value)} />;
}
```

## Constraints

- The hook must be called from a descendant of `<WorkflowBuilder.Root>`. `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 `<WorkflowBuilder.Root>` — no extra setup needed.
2 changes: 2 additions & 0 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<WorkflowBuilder.TopBar />`, 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.

## `<WorkflowBuilder.Root>` props

| Prop | Type | Description |
Expand Down
15 changes: 14 additions & 1 deletion packages/sdk/src/features/diagram/diagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 `<Handle>` 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';
Expand Down
72 changes: 72 additions & 0 deletions packages/sdk/src/hooks/theme.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
46 changes: 46 additions & 0 deletions packages/sdk/src/hooks/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const THEME_KEY = 'wb-theme';

export type Theme = 'dark' | 'light';

type Listener = () => void;

const listeners = new Set<Listener>();

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();
18 changes: 6 additions & 12 deletions packages/sdk/src/hooks/use-theme.ts
Original file line number Diff line number Diff line change
@@ -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<Theme>(() => {
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';
Loading
Loading