Skip to content

Commit 3b9f8fd

Browse files
feat(sdk): expose app bar actions via props (#27)
* feat(sdk): extract theme source-of-truth module * refactor(sdk): make useTheme subscribe to shared theme store * feat(sdk): add useWorkflowBuilderActions hook (WB-217) * feat(sdk): export useWorkflowBuilderActions from barrel (WB-217) * docs: WB-217 no-app-bar layout guide + SDK README link + changeset * feat(no-app-bar): reference app demonstrating useWorkflowBuilderActions (WB-217) * feat(sdk): add LayoutChangeOptions to layout-direction actions (WB-217) * chore: remove no-app-bar reference app (WB-217) * fix(sdk): apply persisted theme to DOM on load (WB-217) * refactor(sdk): tighten layout-direction actions API (WB-217) * fix(sdk): apply theme on Root mount, not module load - export the `Theme` type from the barrel; `WorkflowBuilderActions` already speaks in it - validate the stored theme on read instead of casting, so `''` or an unrecognized value falls back to `light` - correct the hook JSDoc and the no-app-bar guide: the app bar has no layout-direction control, so that action is additive, not a mirror - tests: Root paints persisted theme on mount, read-validation fallback - trim the three WB-217 changesets to release-note length and codify "keep changeset bodies short" in RELEASE.md * docs: fold no-app-bar guide into React Component quick-start * refactor(sdk): use getStoreNodes action in diagram effect --------- Co-authored-by: Piotr Blaszczyk <piotr.blaszczyk@synergycodes.com>
1 parent 5a01ddf commit 3b9f8fd

16 files changed

Lines changed: 657 additions & 22 deletions

.changeset/wb-217-actions-hook.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflowbuilder/sdk': minor
3+
---
4+
5+
feat: add `useWorkflowBuilderActions()`. Gives custom layouts that omit `<WorkflowBuilder.TopBar />` imperative save / import / export / settings / read-only / theme / layout-direction actions. Also exports the `WorkflowBuilderActions`, `LayoutChangeOptions`, and `Theme` types. See [Custom layout without the app bar](https://www.workflowbuilder.io/docs/get-started/quick-start/wb-as-react-component/#custom-layout-without-the-app-bar).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflowbuilder/sdk': patch
3+
---
4+
5+
fix: re-measure node internals when `layoutDirection` changes, so edges re-route to the new handle positions instead of the stale ones React Flow had cached.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflowbuilder/sdk': patch
3+
---
4+
5+
fix: theme now lives in a shared store applied to the DOM on `<WorkflowBuilder.Root>` mount, so a persisted theme paints on first load even without the app bar and multiple consumers stay in sync. Reads of `document` / `localStorage` are guarded, so importing the SDK server-side no longer throws.

apps/docs/src/content/docs/get-started/quick-start/wb-as-react-component.mdx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,35 @@ To add custom overlays alongside the default layout, mount it explicitly:
147147
</WorkflowBuilder.Root>
148148
```
149149

150+
### Custom layout without the app bar
151+
152+
`<WorkflowBuilder.TopBar />` ships the save, import / export, settings, read-only, and theme controls. When you omit it from a custom layout, reach the same commands through the `useWorkflowBuilderActions()` hook. Call it from any descendant of `<WorkflowBuilder.Root>` and wire the returned callbacks to your own buttons:
153+
154+
```tsx
155+
import { useWorkflowBuilderActions } from '@workflowbuilder/sdk';
156+
157+
function MyToolbar() {
158+
const actions = useWorkflowBuilderActions();
159+
160+
return (
161+
<header>
162+
<button onClick={actions.save}>Save</button>
163+
<button onClick={actions.openImport}>Import</button>
164+
<button onClick={actions.openExport}>Export</button>
165+
<button onClick={actions.openSettings}>Settings</button>
166+
<button onClick={actions.toggleReadOnly}>Read-only</button>
167+
<button onClick={actions.toggleDarkMode}>Theme</button>
168+
</header>
169+
);
170+
}
171+
```
172+
173+
The hook returns a stable object, so you can pass any callback straight to an event handler. See [`WorkflowBuilderActions`](/api/hooks/workflowbuilderactions/) for the full action list. A few notes:
174+
175+
- It must be called from a descendant of `<WorkflowBuilder.Root>`. `save` reads the active [integration strategy](#integration-strategies) via context, so calling the hook outside Root resolves `save()` to `'error'` and logs a warning.
176+
- The hook also exposes layout-direction control the bar does not surface: `setLayoutDirection('RIGHT' | 'DOWN')` (idempotent) and `toggleLayoutDirection({ flipPositions?, fitView? })`. `flipPositions` mirrors each node's `x`/`y` as a naive axis swap. It is not auto-layout and ignores node sizes, so pair it with `fitView`. That is why it lives only on the toggle, not on `setLayoutDirection`.
177+
- The top bar also shows and edits the document name. Render your own with `useStore`: read `s.documentName` and write through `s.setDocumentName`.
178+
150179
## Integration strategies
151180

152181
| Strategy | Source / sink | When to use |

packages/sdk/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ To add custom overlays alongside the default layout, mount it explicitly:
8585

8686
Each subcomponent is also exported under a named alias (`WorkflowBuilderTopBar`, `WorkflowBuilderPalette`, `WorkflowBuilderCanvas`, `WorkflowBuilderPropertiesPanel`, `WorkflowBuilderDefaultLayout`) for consumers who prefer the classic style.
8787

88+
If you omit `<WorkflowBuilder.TopBar />`, use [`useWorkflowBuilderActions()`](https://www.workflowbuilder.io/docs/get-started/quick-start/wb-as-react-component/#custom-layout-without-the-app-bar) to trigger save / import / export / settings / read-only / theme / layout-direction from your own controls.
89+
8890
## `<WorkflowBuilder.Root>` props
8991

9092
| Prop | Type | Description |

packages/sdk/RELEASE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ This part Claude (or any contributor) handles per change — not the maintainer.
5959

6060
Skip the changeset only for changes that do not affect the published `dist/` (e.g. internal tests, lint config, comments).
6161

62+
**Keep the body short — it ships verbatim as a release note.** One sentence for a fix, one or two for a feature. State what changed and the consumer-facing effect, name the public symbols touched, and stop. No rationale, no implementation walk-through, no internal file names. Reasoning belongs in the PR description or code comments, not the release notes. Breaking changes are the only exception: add a `Breaking changes:` list with migration steps (see `remove-nodeid-from-handles.md`).
63+
6264
4. Commit code + changeset together. Conventional Commits format is enforced by `.husky/commit-msg`:
6365

6466
```bash

packages/sdk/src/features/diagram/diagram.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import {
99
type OnSelectionChangeParams,
1010
ReactFlow,
1111
SelectionMode,
12+
useUpdateNodeInternals,
1213
} from '@xyflow/react';
13-
import { type DragEventHandler, useCallback, useMemo } from 'react';
14+
import { type DragEventHandler, useCallback, useEffect, useMemo } from 'react';
1415
import type { DragEvent } from 'react';
1516

1617
import styles from './diagram.module.css';
@@ -19,6 +20,7 @@ import '@xyflow/react/dist/style.css';
1920
import { usePaletteDrop } from '../../hooks/use-palette-drop';
2021
import type { WorkflowBuilderOnSelectionChangeParams } from '../../node/common';
2122
import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '../../node/node-data';
23+
import { getStoreNodes } from '../../store/slices/diagram-slice/actions';
2224
import { useStore } from '../../store/store';
2325
import { trackFutureChange } from '../changes-tracker/stores/use-changes-tracker-store';
2426
import { useDeleteConfirmation } from '../modals/delete-confirmation/use-delete-confirmation';
@@ -70,6 +72,18 @@ function DiagramContainerComponent({ edgeTypes = {} }: DiagramContainerProps) {
7072
const setConnectionBeingDragged = useStore((store) => store.setConnectionBeingDragged);
7173
const nodeTypes = useNodeTypes();
7274

75+
// React Flow caches each handle's measured bounds in `nodeInternals`. When
76+
// `layoutDirection` flips, existing nodes re-render their `<Handle>` with a
77+
// new `position` prop, but the cache stays stale and edges keep routing to
78+
// the old port spots. Ask React Flow to remeasure every mounted node when
79+
// the direction changes.
80+
const layoutDirection = useStore((store) => store.layoutDirection);
81+
const updateNodeInternals = useUpdateNodeInternals();
82+
useEffect(() => {
83+
const ids = getStoreNodes().map((node) => node.id);
84+
if (ids.length > 0) updateNodeInternals(ids);
85+
}, [layoutDirection, updateNodeInternals]);
86+
7387
const onDragOver = useCallback((event: DragEvent) => {
7488
event.preventDefault();
7589
event.dataTransfer.dropEffect = 'copy';
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { getTheme, initTheme, setTheme, subscribeTheme } from './theme';
4+
5+
describe('theme module', () => {
6+
beforeEach(() => {
7+
localStorage.clear();
8+
delete document.documentElement.dataset.theme;
9+
});
10+
11+
afterEach(() => {
12+
localStorage.clear();
13+
delete document.documentElement.dataset.theme;
14+
});
15+
16+
it('defaults to "light" when localStorage has no value', () => {
17+
expect(getTheme()).toBe('light');
18+
});
19+
20+
it('reads the persisted value from localStorage', () => {
21+
localStorage.setItem('wb-theme', 'dark');
22+
expect(getTheme()).toBe('dark');
23+
});
24+
25+
it('falls back to "light" for an empty or unrecognized stored value', () => {
26+
localStorage.setItem('wb-theme', '');
27+
expect(getTheme()).toBe('light');
28+
29+
localStorage.setItem('wb-theme', 'Dark');
30+
expect(getTheme()).toBe('light');
31+
});
32+
33+
it('setTheme updates localStorage and the document attribute', () => {
34+
setTheme('dark');
35+
36+
expect(localStorage.getItem('wb-theme')).toBe('dark');
37+
expect(document.documentElement.dataset.theme).toBe('dark');
38+
expect(getTheme()).toBe('dark');
39+
});
40+
41+
it('setTheme notifies subscribers', () => {
42+
const listener = vi.fn();
43+
const unsubscribe = subscribeTheme(listener);
44+
45+
setTheme('dark');
46+
47+
expect(listener).toHaveBeenCalledTimes(1);
48+
unsubscribe();
49+
});
50+
51+
it('setTheme does NOT notify subscribers when the value is unchanged', () => {
52+
setTheme('light');
53+
const listener = vi.fn();
54+
const unsubscribe = subscribeTheme(listener);
55+
56+
setTheme('light');
57+
58+
expect(listener).not.toHaveBeenCalled();
59+
unsubscribe();
60+
});
61+
62+
it('initTheme applies the persisted theme to the DOM without a toggle', () => {
63+
localStorage.setItem('wb-theme', 'dark');
64+
delete document.documentElement.dataset.theme;
65+
66+
initTheme();
67+
68+
expect(document.documentElement.dataset.theme).toBe('dark');
69+
});
70+
71+
it('unsubscribe removes the listener', () => {
72+
const listener = vi.fn();
73+
const unsubscribe = subscribeTheme(listener);
74+
unsubscribe();
75+
76+
setTheme('dark');
77+
78+
expect(listener).not.toHaveBeenCalled();
79+
});
80+
});

packages/sdk/src/hooks/theme.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const THEME_KEY = 'wb-theme';
2+
3+
export type Theme = 'dark' | 'light';
4+
5+
type Listener = () => void;
6+
7+
const listeners = new Set<Listener>();
8+
9+
function applyToDom(theme: Theme): void {
10+
if (typeof document === 'undefined') return;
11+
document.documentElement.dataset.theme = theme;
12+
}
13+
14+
export function getTheme(): Theme {
15+
if (typeof localStorage === 'undefined') return 'light';
16+
const stored = localStorage.getItem(THEME_KEY);
17+
return stored === 'dark' || stored === 'light' ? stored : 'light';
18+
}
19+
20+
export function setTheme(theme: Theme): void {
21+
const current = getTheme();
22+
if (current === theme) return;
23+
24+
localStorage.setItem(THEME_KEY, theme);
25+
applyToDom(theme);
26+
27+
for (const listener of listeners) listener();
28+
}
29+
30+
export function subscribeTheme(listener: Listener): () => void {
31+
listeners.add(listener);
32+
return () => {
33+
listeners.delete(listener);
34+
};
35+
}
36+
37+
/**
38+
* Reflect the persisted theme on the DOM. Idempotent and SSR-safe (no-op when
39+
* `document` is absent). Call once from the editor root's client-side mount
40+
* effect so a saved non-default theme paints on first load without waiting for
41+
* a `setTheme` toggle, including custom layouts that omit the app bar. Kept off
42+
* the module's top level on purpose: a side effect at import would crash any
43+
* server-side import of the SDK and could be dropped by tree-shaking.
44+
*/
45+
export function initTheme(): void {
46+
applyToDom(getTheme());
47+
}
Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import { useCallback, useSyncExternalStore } from 'react';
22

3-
const THEME_KEY = 'wb-theme';
4-
type Theme = 'dark' | 'light';
3+
import { getTheme, setTheme, subscribeTheme } from './theme';
54

65
export function useTheme() {
7-
const [theme, setTheme] = useState<Theme>(() => {
8-
return (localStorage.getItem(THEME_KEY) || 'light') as Theme;
9-
});
10-
11-
useEffect(() => {
12-
document.documentElement.dataset.theme = theme;
13-
localStorage.setItem(THEME_KEY, theme);
14-
}, [theme]);
6+
const theme = useSyncExternalStore(subscribeTheme, getTheme, getTheme);
157

168
const toggleTheme = useCallback(() => {
17-
setTheme((previous) => (previous === 'light' ? 'dark' : 'light'));
9+
setTheme(getTheme() === 'light' ? 'dark' : 'light');
1810
}, []);
1911

2012
return { theme, toggleTheme };
2113
}
14+
15+
export { type Theme } from './theme';

0 commit comments

Comments
 (0)