Skip to content

Commit 1285cc4

Browse files
authored
refactor(editor): Refactor workflow document composables from handleAction to set / apply (n8n-io#26178)
1 parent 84966aa commit 1285cc4

9 files changed

Lines changed: 344 additions & 383 deletions

packages/frontend/editor-ui/src/app/stores/workflowDocument.store.ts

Lines changed: 9 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,9 @@ import { defineStore, getActivePinia, type StoreGeneric } from 'pinia';
22
import { STORES } from '@n8n/stores';
33
import { inject } from 'vue';
44
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
5-
import {
6-
useWorkflowDocumentActive,
7-
isActiveAction,
8-
type ActiveAction,
9-
} from './workflowDocument/useWorkflowDocumentActive';
10-
import {
11-
useWorkflowDocumentPinData,
12-
isPinDataAction,
13-
type PinDataAction,
14-
} from './workflowDocument/useWorkflowDocumentPinData';
15-
import {
16-
useWorkflowDocumentTags,
17-
isTagAction,
18-
type TagAction,
19-
} from './workflowDocument/useWorkflowDocumentTags';
5+
import { useWorkflowDocumentActive } from './workflowDocument/useWorkflowDocumentActive';
6+
import { useWorkflowDocumentPinData } from './workflowDocument/useWorkflowDocumentPinData';
7+
import { useWorkflowDocumentTags } from './workflowDocument/useWorkflowDocumentTags';
208

219
export {
2210
getPinDataSize,
@@ -37,8 +25,6 @@ export function createWorkflowDocumentId(
3725
return `${workflowId}@${version}`;
3826
}
3927

40-
type WorkflowDocumentAction = ActiveAction | TagAction | PinDataAction;
41-
4228
/**
4329
* Gets the store ID for a workflow document store.
4430
*/
@@ -60,65 +46,16 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
6046
return defineStore(getWorkflowDocumentStoreId(id), () => {
6147
const [workflowId, workflowVersion] = id.split('@');
6248

63-
/**
64-
* Handle all document actions in a CRDT-like manner.
65-
* Single entry point for all mutations, enabling future CRDT sync integration.
66-
*/
67-
function onChange(action: WorkflowDocumentAction) {
68-
if (isActiveAction(action)) {
69-
handleActiveAction(action);
70-
} else if (isTagAction(action)) {
71-
handleTagAction(action);
72-
} else if (isPinDataAction(action)) {
73-
handlePinDataAction(action);
74-
}
75-
}
76-
77-
const {
78-
active,
79-
activeVersionId,
80-
activeVersion,
81-
setActiveState,
82-
handleAction: handleActiveAction,
83-
} = useWorkflowDocumentActive(onChange);
84-
85-
const {
86-
tags,
87-
setTags,
88-
addTags,
89-
removeTag,
90-
handleAction: handleTagAction,
91-
} = useWorkflowDocumentTags(onChange);
92-
93-
const {
94-
pinData,
95-
setPinData,
96-
pinNodeData,
97-
unpinNodeData,
98-
renamePinDataNode,
99-
getPinDataSnapshot,
100-
getNodePinData,
101-
handleAction: handlePinDataAction,
102-
} = useWorkflowDocumentPinData(onChange);
49+
const workflowDocumentActive = useWorkflowDocumentActive();
50+
const workflowDocumentTags = useWorkflowDocumentTags();
51+
const workflowDocumentPinData = useWorkflowDocumentPinData();
10352

10453
return {
10554
workflowId,
10655
workflowVersion,
107-
active,
108-
activeVersionId,
109-
activeVersion,
110-
setActiveState,
111-
tags,
112-
setTags,
113-
addTags,
114-
removeTag,
115-
pinData,
116-
setPinData,
117-
pinNodeData,
118-
unpinNodeData,
119-
renamePinDataNode,
120-
getPinDataSnapshot,
121-
getNodePinData,
56+
...workflowDocumentActive,
57+
...workflowDocumentTags,
58+
...workflowDocumentPinData,
12259
};
12360
})();
12461
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# workflowDocument store — Agent Guidelines
2+
3+
## Core pattern: apply/public method split
4+
5+
Every composable in this folder follows a two-layer pattern:
6+
7+
**Public methods** (exposed) — represent user intent. Handle normalization,
8+
deduplication, and preparation. Call apply methods internally.
9+
10+
**Apply methods** (private) — the **only** functions that mutate refs. Each
11+
apply method writes to the ref and fires an event hook. Never expose apply
12+
methods from the composable.
13+
14+
```
15+
Component → publicMethod() → normalize → applyXxx() → ref + event hook
16+
```
17+
18+
This split exists to support CRDT in the future: local user actions,
19+
remote CRDT sync, and undo/redo all converge on the same private apply
20+
methods inside the composable.
21+
22+
## Event hooks
23+
24+
Every composable exposes change notifications via `createEventHook` from
25+
`@vueuse/core`. Event payloads must extend `ChangeEvent` from `./types.ts`:
26+
27+
```typescript
28+
import { createEventHook } from '@vueuse/core';
29+
import type { ChangeEvent, ChangeAction } from './types';
30+
31+
type MyChangeEvent = ChangeEvent<{ /* domain-specific fields */ }>;
32+
const onMyChange = createEventHook<MyChangeEvent>();
33+
```
34+
35+
- Fire `void onMyChange.trigger(...)` inside every apply method
36+
- Expose only the `.on` subscriber: `onMyChange: onMyChange.on`
37+
- Use `CHANGE_ACTION.ADD | UPDATE | DELETE` for the `action` field
38+
39+
## Adding a new composable — checklist
40+
41+
1. Create event hook with typed payload extending `ChangeEvent`
42+
2. Write private `apply*()` methods — each mutates the ref and fires the hook
43+
3. Write public methods — normalize input, then call apply
44+
4. Return: readonly refs, public methods, `onXxxChange: hook.on`
45+
5. Never return apply methods
46+
47+
## Anti-patterns
48+
49+
| Don't | Do instead |
50+
|-------|------------|
51+
| Mutate refs outside apply methods | All ref writes go through apply |
52+
| Expose apply methods from composable | Keep them private (not in return) |
53+
| Use action objects / `onChange` router | Public methods call apply directly |
54+
| Use `dataPinningEventBus` or global event bus | Use scoped `createEventHook` |
55+
| Import global stores inside composable | Inject dependencies via function params |
56+
57+
## Dependency injection
58+
59+
Composables receive external dependencies as constructor params, not via
60+
global store imports. This keeps them testable and makes coupling explicit:
61+
62+
```typescript
63+
export function useWorkflowDocumentFoo(deps: {
64+
getNodeByName: (name: string) => INodeUi | undefined;
65+
}) { ... }
66+
```
67+
68+
## Reference implementation
69+
70+
See `useWorkflowDocumentActive.ts` — the simplest complete example of the
71+
apply/public pattern with event hooks.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const CHANGE_ACTION = {
2+
ADD: 'add',
3+
UPDATE: 'update',
4+
DELETE: 'delete',
5+
} as const;
6+
7+
export type ChangeAction = (typeof CHANGE_ACTION)[keyof typeof CHANGE_ACTION];
8+
9+
/** Base type all composable change events must extend */
10+
export interface ChangeEvent<T = unknown> {
11+
action: ChangeAction;
12+
payload: T;
13+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { useWorkflowDocumentActive } from './useWorkflowDocumentActive';
3+
4+
function createActive() {
5+
return useWorkflowDocumentActive();
6+
}
7+
8+
describe('useWorkflowDocumentActive', () => {
9+
describe('initial state', () => {
10+
it('should start inactive with null values', () => {
11+
const { active, activeVersionId, activeVersion } = createActive();
12+
expect(active.value).toBe(false);
13+
expect(activeVersionId.value).toBeNull();
14+
expect(activeVersion.value).toBeNull();
15+
});
16+
});
17+
18+
describe('setActiveState', () => {
19+
it('should set active state and fire event hook', () => {
20+
const { active, activeVersionId, activeVersion, setActiveState, onActiveChange } =
21+
createActive();
22+
const hookSpy = vi.fn();
23+
onActiveChange(hookSpy);
24+
25+
setActiveState({ activeVersionId: 'v1', activeVersion: null });
26+
27+
expect(active.value).toBe(true);
28+
expect(activeVersionId.value).toBe('v1');
29+
expect(activeVersion.value).toBeNull();
30+
expect(hookSpy).toHaveBeenCalledWith({
31+
action: 'update',
32+
payload: { activeVersionId: 'v1', activeVersion: null },
33+
});
34+
});
35+
36+
it('should clear active state', () => {
37+
const { active, activeVersionId, setActiveState } = createActive();
38+
setActiveState({ activeVersionId: 'v1', activeVersion: null });
39+
40+
setActiveState({ activeVersionId: null, activeVersion: null });
41+
42+
expect(active.value).toBe(false);
43+
expect(activeVersionId.value).toBeNull();
44+
});
45+
46+
it('should replace existing active state', () => {
47+
const { activeVersionId, setActiveState } = createActive();
48+
setActiveState({ activeVersionId: 'v1', activeVersion: null });
49+
50+
setActiveState({ activeVersionId: 'v2', activeVersion: null });
51+
52+
expect(activeVersionId.value).toBe('v2');
53+
});
54+
55+
it('should fire event hook on every call', () => {
56+
const { setActiveState, onActiveChange } = createActive();
57+
const hookSpy = vi.fn();
58+
onActiveChange(hookSpy);
59+
60+
setActiveState({ activeVersionId: 'v1', activeVersion: null });
61+
setActiveState({ activeVersionId: null, activeVersion: null });
62+
63+
expect(hookSpy).toHaveBeenCalledTimes(2);
64+
});
65+
});
66+
});
Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,41 @@
11
import { ref, readonly, computed } from 'vue';
2+
import { createEventHook } from '@vueuse/core';
23
import type { WorkflowHistory } from '@n8n/rest-api-client';
4+
import { CHANGE_ACTION } from './types';
5+
import type { ChangeAction, ChangeEvent } from './types';
36

4-
type Action<N, P> = { name: N; payload: P };
7+
export type ActiveStatePayload = {
8+
activeVersionId: string | null;
9+
activeVersion: WorkflowHistory | null;
10+
};
511

6-
type SetActiveStateAction = Action<
7-
'setActiveState',
8-
{ activeVersionId: string | null; activeVersion: WorkflowHistory | null }
9-
>;
12+
export type ActiveChangeEvent = ChangeEvent<ActiveStatePayload>;
1013

11-
export type ActiveAction = SetActiveStateAction;
12-
13-
const ACTIVE_ACTION_NAMES = new Set<string>(['setActiveState']);
14-
15-
export function isActiveAction(action: { name: string }): action is ActiveAction {
16-
return ACTIVE_ACTION_NAMES.has(action.name);
17-
}
18-
19-
/**
20-
* Composable that encapsulates active state, public API, and mutation logic
21-
* for a workflow document store.
22-
*
23-
* Accepts an `onChange` callback that routes actions through the store's
24-
* unified dispatcher for CRDT migration readiness.
25-
*/
26-
export function useWorkflowDocumentActive(onChange: (action: ActiveAction) => void) {
14+
export function useWorkflowDocumentActive() {
2715
const activeVersionId = ref<string | null>(null);
2816
const activeVersion = ref<WorkflowHistory | null>(null);
2917
const active = computed(() => activeVersionId.value !== null);
3018

31-
function setActiveState(state: {
32-
activeVersionId: string | null;
33-
activeVersion: WorkflowHistory | null;
34-
}) {
35-
onChange({ name: 'setActiveState', payload: state });
19+
const onActiveChange = createEventHook<ActiveChangeEvent>();
20+
21+
function applyActiveState(
22+
state: ActiveStatePayload,
23+
action: ChangeAction = CHANGE_ACTION.UPDATE,
24+
) {
25+
activeVersionId.value = state.activeVersionId;
26+
activeVersion.value = state.activeVersion;
27+
void onActiveChange.trigger({ action, payload: state });
3628
}
3729

38-
/**
39-
* Apply an active state action to the state.
40-
* Called by the store's unified onChange dispatcher.
41-
*/
42-
function handleAction(action: ActiveAction) {
43-
if (action.name === 'setActiveState') {
44-
activeVersionId.value = action.payload.activeVersionId;
45-
activeVersion.value = action.payload.activeVersion;
46-
}
30+
function setActiveState(state: ActiveStatePayload) {
31+
applyActiveState(state);
4732
}
4833

4934
return {
5035
active,
5136
activeVersionId: readonly(activeVersionId),
5237
activeVersion: readonly(activeVersion),
5338
setActiveState,
54-
handleAction,
39+
onActiveChange: onActiveChange.on,
5540
};
5641
}

0 commit comments

Comments
 (0)