@nocobase/flow-engine 2.0.0-alpha.63 → 2.0.0-alpha.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, test, expect } from 'vitest';
11
+ import { FlowEngine } from '../../flowEngine';
12
+ import { FlowModel } from '../flowModel';
13
+
14
+ describe('dispatchEvent dynamic event flow phase (scheduleModelOperation integration)', () => {
15
+ test('default (phase undefined): instance flows run before static flows', async () => {
16
+ const engine = new FlowEngine();
17
+ class M extends FlowModel {}
18
+ engine.registerModels({ M });
19
+
20
+ const calls: string[] = [];
21
+
22
+ M.registerFlow({
23
+ key: 'S',
24
+ on: { eventName: 'go' },
25
+ steps: {
26
+ a: { handler: async () => void calls.push('static-a') } as any,
27
+ },
28
+ });
29
+
30
+ const model = engine.createModel({ use: 'M' });
31
+ model.registerFlow('D', {
32
+ on: { eventName: 'go' },
33
+ steps: {
34
+ d: { handler: async () => void calls.push('dynamic') } as any,
35
+ },
36
+ });
37
+
38
+ await model.dispatchEvent('go');
39
+ expect(calls).toEqual(['dynamic', 'static-a']);
40
+ });
41
+
42
+ test("phase='afterAllFlows': instance flow runs after static flows", async () => {
43
+ const engine = new FlowEngine();
44
+ class M extends FlowModel {}
45
+ engine.registerModels({ M });
46
+
47
+ const calls: string[] = [];
48
+
49
+ M.registerFlow({
50
+ key: 'S',
51
+ on: { eventName: 'go' },
52
+ steps: {
53
+ a: { handler: async () => void calls.push('static-a') } as any,
54
+ },
55
+ });
56
+
57
+ const model = engine.createModel({ use: 'M' });
58
+ model.registerFlow('D', {
59
+ on: { eventName: 'go', phase: 'afterAllFlows' },
60
+ steps: {
61
+ d: { handler: async () => void calls.push('dynamic') } as any,
62
+ },
63
+ });
64
+
65
+ await model.dispatchEvent('go');
66
+ expect(calls).toEqual(['static-a', 'dynamic']);
67
+ });
68
+
69
+ test("phase='beforeFlow': instance flow runs before the target static flow", async () => {
70
+ const engine = new FlowEngine();
71
+ class M extends FlowModel {}
72
+ engine.registerModels({ M });
73
+
74
+ const calls: string[] = [];
75
+
76
+ M.registerFlow({
77
+ key: 'S',
78
+ on: { eventName: 'go' },
79
+ steps: {
80
+ a: { handler: async () => void calls.push('static-a') } as any,
81
+ b: { handler: async () => void calls.push('static-b') } as any,
82
+ },
83
+ });
84
+
85
+ const model = engine.createModel({ use: 'M' });
86
+ model.registerFlow('D', {
87
+ on: { eventName: 'go', phase: 'beforeFlow', flowKey: 'S' },
88
+ steps: {
89
+ d: { handler: async () => void calls.push('dynamic') } as any,
90
+ },
91
+ });
92
+
93
+ await model.dispatchEvent('go');
94
+ expect(calls).toEqual(['dynamic', 'static-a', 'static-b']);
95
+ });
96
+
97
+ test("phase='afterFlow': instance flow runs after the target static flow", async () => {
98
+ const engine = new FlowEngine();
99
+ class M extends FlowModel {}
100
+ engine.registerModels({ M });
101
+
102
+ const calls: string[] = [];
103
+
104
+ M.registerFlow({
105
+ key: 'S',
106
+ on: { eventName: 'go' },
107
+ steps: {
108
+ a: { handler: async () => void calls.push('static-a') } as any,
109
+ b: { handler: async () => void calls.push('static-b') } as any,
110
+ },
111
+ });
112
+
113
+ const model = engine.createModel({ use: 'M' });
114
+ model.registerFlow('D', {
115
+ on: { eventName: 'go', phase: 'afterFlow', flowKey: 'S' },
116
+ steps: {
117
+ d: { handler: async () => void calls.push('dynamic') } as any,
118
+ },
119
+ });
120
+
121
+ await model.dispatchEvent('go');
122
+ expect(calls).toEqual(['static-a', 'static-b', 'dynamic']);
123
+ });
124
+
125
+ test("phase='beforeStep': instance flow runs before the target static step", async () => {
126
+ const engine = new FlowEngine();
127
+ class M extends FlowModel {}
128
+ engine.registerModels({ M });
129
+
130
+ const calls: string[] = [];
131
+
132
+ M.registerFlow({
133
+ key: 'S',
134
+ on: { eventName: 'go' },
135
+ steps: {
136
+ a: { handler: async () => void calls.push('static-a') } as any,
137
+ b: { handler: async () => void calls.push('static-b') } as any,
138
+ },
139
+ });
140
+
141
+ const model = engine.createModel({ use: 'M' });
142
+ model.registerFlow('D', {
143
+ on: { eventName: 'go', phase: 'beforeStep', flowKey: 'S', stepKey: 'a' },
144
+ steps: {
145
+ d: { handler: async () => void calls.push('dynamic') } as any,
146
+ },
147
+ });
148
+
149
+ await model.dispatchEvent('go');
150
+ expect(calls).toEqual(['dynamic', 'static-a', 'static-b']);
151
+ });
152
+
153
+ test("phase='afterStep': instance flow runs after the target static step", async () => {
154
+ const engine = new FlowEngine();
155
+ class M extends FlowModel {}
156
+ engine.registerModels({ M });
157
+
158
+ const calls: string[] = [];
159
+
160
+ M.registerFlow({
161
+ key: 'S',
162
+ on: { eventName: 'go' },
163
+ steps: {
164
+ a: { handler: async () => void calls.push('static-a') } as any,
165
+ b: { handler: async () => void calls.push('static-b') } as any,
166
+ },
167
+ });
168
+
169
+ const model = engine.createModel({ use: 'M' });
170
+ model.registerFlow('D', {
171
+ on: { eventName: 'go', phase: 'afterStep', flowKey: 'S', stepKey: 'a' },
172
+ steps: {
173
+ d: { handler: async () => void calls.push('dynamic') } as any,
174
+ },
175
+ });
176
+
177
+ await model.dispatchEvent('go');
178
+ expect(calls).toEqual(['static-a', 'dynamic', 'static-b']);
179
+ });
180
+
181
+ test("phase='beforeFlow' missing flow: falls back to afterAllFlows", async () => {
182
+ const engine = new FlowEngine();
183
+ class M extends FlowModel {}
184
+ engine.registerModels({ M });
185
+
186
+ const calls: string[] = [];
187
+
188
+ M.registerFlow({
189
+ key: 'S',
190
+ on: { eventName: 'go' },
191
+ steps: {
192
+ a: { handler: async () => void calls.push('static-a') } as any,
193
+ },
194
+ });
195
+
196
+ const model = engine.createModel({ use: 'M' });
197
+ model.registerFlow('D', {
198
+ on: { eventName: 'go', phase: 'beforeFlow', flowKey: 'missing' },
199
+ steps: {
200
+ d: { handler: async () => void calls.push('dynamic') } as any,
201
+ },
202
+ });
203
+
204
+ await model.dispatchEvent('go');
205
+ expect(calls).toEqual(['static-a', 'dynamic']);
206
+ });
207
+
208
+ test("phase='beforeStep' missing step: falls back to afterAllFlows", async () => {
209
+ const engine = new FlowEngine();
210
+ class M extends FlowModel {}
211
+ engine.registerModels({ M });
212
+
213
+ const calls: string[] = [];
214
+
215
+ M.registerFlow({
216
+ key: 'S',
217
+ on: { eventName: 'go' },
218
+ steps: {
219
+ a: { handler: async () => void calls.push('static-a') } as any,
220
+ },
221
+ });
222
+
223
+ const model = engine.createModel({ use: 'M' });
224
+ model.registerFlow('D', {
225
+ on: { eventName: 'go', phase: 'beforeStep', flowKey: 'S', stepKey: 'missing' },
226
+ steps: {
227
+ d: { handler: async () => void calls.push('dynamic') } as any,
228
+ },
229
+ });
230
+
231
+ await model.dispatchEvent('go');
232
+ expect(calls).toEqual(['static-a', 'dynamic']);
233
+ });
234
+
235
+ test('multiple flows on same anchor: executes by flow.sort asc (stable)', async () => {
236
+ const engine = new FlowEngine();
237
+ class M extends FlowModel {}
238
+ engine.registerModels({ M });
239
+
240
+ const calls: string[] = [];
241
+
242
+ M.registerFlow({
243
+ key: 'S',
244
+ on: { eventName: 'go' },
245
+ steps: {
246
+ a: { handler: async () => void calls.push('static-a') } as any,
247
+ },
248
+ });
249
+
250
+ const model = engine.createModel({ use: 'M' });
251
+ model.registerFlow('D5', {
252
+ sort: 5,
253
+ on: { eventName: 'go', phase: 'beforeStep', flowKey: 'S', stepKey: 'a' },
254
+ steps: {
255
+ d: { handler: async () => void calls.push('dynamic-5') } as any,
256
+ },
257
+ });
258
+ model.registerFlow('D0', {
259
+ sort: 0,
260
+ on: { eventName: 'go', phase: 'beforeStep', flowKey: 'S', stepKey: 'a' },
261
+ steps: {
262
+ d: { handler: async () => void calls.push('dynamic-0') } as any,
263
+ },
264
+ });
265
+
266
+ await model.dispatchEvent('go');
267
+ expect(calls).toEqual(['dynamic-0', 'dynamic-5', 'static-a']);
268
+ });
269
+ });
270
+
271
+ describe('dispatchEvent static flow phase (scheduleModelOperation integration)', () => {
272
+ test("phase='beforeFlow': static flow runs before the target static flow", async () => {
273
+ const engine = new FlowEngine();
274
+ class M extends FlowModel {}
275
+ engine.registerModels({ M });
276
+
277
+ const calls: string[] = [];
278
+
279
+ M.registerFlow({
280
+ key: 'S',
281
+ on: { eventName: 'go' },
282
+ steps: {
283
+ a: { handler: async () => void calls.push('static-a') } as any,
284
+ },
285
+ });
286
+
287
+ M.registerFlow({
288
+ key: 'P',
289
+ on: { eventName: 'go', phase: 'beforeFlow', flowKey: 'S' },
290
+ steps: {
291
+ p: { handler: async () => void calls.push('phase') } as any,
292
+ },
293
+ });
294
+
295
+ const model = engine.createModel({ use: 'M' });
296
+ await model.dispatchEvent('go');
297
+ expect(calls).toEqual(['phase', 'static-a']);
298
+ });
299
+
300
+ test("phase='afterStep': static flow runs after the target static step", async () => {
301
+ const engine = new FlowEngine();
302
+ class M extends FlowModel {}
303
+ engine.registerModels({ M });
304
+
305
+ const calls: string[] = [];
306
+
307
+ M.registerFlow({
308
+ key: 'S',
309
+ on: { eventName: 'go' },
310
+ steps: {
311
+ a: { handler: async () => void calls.push('static-a') } as any,
312
+ b: { handler: async () => void calls.push('static-b') } as any,
313
+ },
314
+ });
315
+
316
+ M.registerFlow({
317
+ key: 'P',
318
+ on: { eventName: 'go', phase: 'afterStep', flowKey: 'S', stepKey: 'a' },
319
+ steps: {
320
+ p: { handler: async () => void calls.push('phase') } as any,
321
+ },
322
+ });
323
+
324
+ const model = engine.createModel({ use: 'M' });
325
+ await model.dispatchEvent('go');
326
+ expect(calls).toEqual(['static-a', 'phase', 'static-b']);
327
+ });
328
+
329
+ test("phase='afterAllFlows': static flow runs after static flows", async () => {
330
+ const engine = new FlowEngine();
331
+ class M extends FlowModel {}
332
+ engine.registerModels({ M });
333
+
334
+ const calls: string[] = [];
335
+
336
+ M.registerFlow({
337
+ key: 'S',
338
+ on: { eventName: 'go' },
339
+ steps: {
340
+ a: { handler: async () => void calls.push('static-a') } as any,
341
+ },
342
+ });
343
+
344
+ M.registerFlow({
345
+ key: 'P',
346
+ on: { eventName: 'go', phase: 'afterAllFlows' },
347
+ steps: {
348
+ p: { handler: async () => void calls.push('phase') } as any,
349
+ },
350
+ });
351
+
352
+ const model = engine.createModel({ use: 'M' });
353
+ await model.dispatchEvent('go');
354
+ expect(calls).toEqual(['static-a', 'phase']);
355
+ });
356
+ });
@@ -1558,6 +1558,22 @@ describe('FlowModel', () => {
1558
1558
  expect(model.forks.size).toBe(1);
1559
1559
  });
1560
1560
 
1561
+ test('should recreate cached fork after dispose to avoid state leakage', () => {
1562
+ const fork1 = model.createFork({ foo: 'bar' }, 'cacheKey');
1563
+ fork1.hidden = true;
1564
+ fork1.setProps({ disabled: true });
1565
+
1566
+ fork1.dispose();
1567
+
1568
+ expect(model.getFork('cacheKey')).toBeUndefined();
1569
+
1570
+ const fork2 = model.createFork({}, 'cacheKey');
1571
+
1572
+ expect(fork2).not.toBe(fork1);
1573
+ expect(fork2.hidden).toBe(false);
1574
+ expect(fork2.localProps).toEqual({});
1575
+ });
1576
+
1561
1577
  test('should create different instances for different keys', () => {
1562
1578
  const fork1 = model.createFork({}, 'key1');
1563
1579
  const fork2 = model.createFork({}, 'key2');
package/src/provider.tsx CHANGED
@@ -90,9 +90,10 @@ export const useFlowEngine = ({ throwError = true } = {}): FlowEngine => {
90
90
  if (!context && throwError) {
91
91
  // This error should ideally not be hit if FlowEngineProvider is used correctly at the root
92
92
  // and always supplied with an engine.
93
- throw new Error(
93
+ console.warn(
94
94
  'useFlowEngine must be used within a FlowEngineProvider, and FlowEngineProvider must be supplied with an engine.',
95
95
  );
96
+ return;
96
97
  }
97
98
  return context;
98
99
  };
@@ -36,6 +36,8 @@ export interface LifecycleEvent {
36
36
  error?: any;
37
37
  inputArgs?: Record<string, any>;
38
38
  result?: any;
39
+ flowKey?: string;
40
+ stepKey?: string;
39
41
  }
40
42
 
41
43
  type ScheduledItem = {
@@ -162,37 +164,37 @@ export class ModelOperationScheduler {
162
164
  const emitter = this.engine.emitter;
163
165
  if (!emitter || typeof emitter.on !== 'function') return;
164
166
 
165
- const onCreated = (e: LifecycleEvent) => {
166
- this.processLifecycleEvent(e.uid, { ...e, type: 'created' });
167
+ const onCreated = async (e: LifecycleEvent) => {
168
+ await this.processLifecycleEvent(e.uid, { ...e, type: 'created' });
167
169
  };
168
170
  emitter.on('model:created', onCreated);
169
171
  this.unbindHandlers.push(() => emitter.off('model:created', onCreated));
170
172
 
171
- const onMounted = (e: LifecycleEvent) => {
172
- this.processLifecycleEvent(e.uid, { ...e, type: 'mounted' });
173
+ const onMounted = async (e: LifecycleEvent) => {
174
+ await this.processLifecycleEvent(e.uid, { ...e, type: 'mounted' });
173
175
  };
174
176
  emitter.on('model:mounted', onMounted);
175
177
  this.unbindHandlers.push(() => emitter.off('model:mounted', onMounted));
176
178
 
177
- const onGenericBeforeStart = (e: LifecycleEvent) => {
178
- this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:start' });
179
+ const onGenericBeforeStart = async (e: LifecycleEvent) => {
180
+ await this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:start' });
179
181
  };
180
182
  emitter.on('model:event:beforeRender:start', onGenericBeforeStart);
181
183
  this.unbindHandlers.push(() => emitter.off('model:event:beforeRender:start', onGenericBeforeStart));
182
184
 
183
- const onGenericBeforeEnd = (e: LifecycleEvent) => {
184
- this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:end' });
185
+ const onGenericBeforeEnd = async (e: LifecycleEvent) => {
186
+ await this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:end' });
185
187
  };
186
188
  emitter.on('model:event:beforeRender:end', onGenericBeforeEnd);
187
189
  this.unbindHandlers.push(() => emitter.off('model:event:beforeRender:end', onGenericBeforeEnd));
188
190
 
189
- const onUnmounted = (e: LifecycleEvent) => {
190
- this.processLifecycleEvent(e.uid, { ...e, type: 'unmounted' });
191
+ const onUnmounted = async (e: LifecycleEvent) => {
192
+ await this.processLifecycleEvent(e.uid, { ...e, type: 'unmounted' });
191
193
  };
192
194
  emitter.on('model:unmounted', onUnmounted);
193
195
  this.unbindHandlers.push(() => emitter.off('model:unmounted', onUnmounted));
194
196
 
195
- const onDestroyed = (e: LifecycleEvent) => {
197
+ const onDestroyed = async (e: LifecycleEvent) => {
196
198
  const targetBucket = this.itemsByTargetUid.get(e.uid);
197
199
  const event = { ...e, type: 'destroyed' as const };
198
200
  if (targetBucket && targetBucket.size) {
@@ -201,7 +203,7 @@ export class ModelOperationScheduler {
201
203
  const it = this.itemsById.get(id);
202
204
  if (!it) continue;
203
205
  if (this.shouldTrigger(it.options.when, event)) {
204
- void this.tryExecuteOnce(id, event);
206
+ await this.tryExecuteOnce(id, event);
205
207
  } else {
206
208
  this.internalCancel(id);
207
209
  }
@@ -220,14 +222,14 @@ export class ModelOperationScheduler {
220
222
  if (this.subscribedEventNames.has(name)) return;
221
223
  this.subscribedEventNames.add(name);
222
224
  const emitter = this.engine.emitter;
223
- const onStart = (e: LifecycleEvent) => {
224
- this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:start` as const });
225
+ const onStart = async (e: LifecycleEvent) => {
226
+ await this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:start` as LifecycleType });
225
227
  };
226
- const onEnd = (e: LifecycleEvent) => {
227
- this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:end` as const });
228
+ const onEnd = async (e: LifecycleEvent) => {
229
+ await this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:end` as LifecycleType });
228
230
  };
229
- const onError = (e: LifecycleEvent) => {
230
- this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:error` as const });
231
+ const onError = async (e: LifecycleEvent) => {
232
+ await this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:error` as LifecycleType });
231
233
  };
232
234
  emitter.on(`model:event:${name}:start`, onStart);
233
235
  emitter.on(`model:event:${name}:end`, onEnd);
@@ -239,12 +241,12 @@ export class ModelOperationScheduler {
239
241
 
240
242
  private parseEventWhen(when?: ScheduleWhen): { name: string; phase: 'start' | 'end' | 'error' } | null {
241
243
  if (!when || typeof when !== 'string') return null;
242
- const m = /^event:([^:]+):(start|end|error)$/.exec(when);
244
+ const m = /^event:(.+):(start|end|error)$/.exec(when);
243
245
  if (!m) return null;
244
246
  return { name: m[1], phase: m[2] as 'start' | 'end' | 'error' };
245
247
  }
246
248
 
247
- private processLifecycleEvent(targetUid: string, event: LifecycleEvent) {
249
+ private async processLifecycleEvent(targetUid: string, event: LifecycleEvent) {
248
250
  const targetBucket = this.itemsByTargetUid.get(targetUid);
249
251
  if (!targetBucket || targetBucket.size === 0) return;
250
252
  const ids = Array.from(targetBucket.keys());
@@ -253,7 +255,7 @@ export class ModelOperationScheduler {
253
255
  if (!item) continue;
254
256
  const should = this.shouldTrigger(item.options.when, event);
255
257
  if (!should) continue;
256
- void this.tryExecuteOnce(id, event);
258
+ await this.tryExecuteOnce(id, event);
257
259
  }
258
260
  }
259
261
 
package/src/types.ts CHANGED
@@ -213,12 +213,37 @@ export type FlowEventName =
213
213
  // fallback to any string for extensibility
214
214
  | (string & {});
215
215
 
216
+ /**
217
+ * 事件流的执行时机(phase)。
218
+ *
219
+ * 说明:
220
+ * - 缺省(phase 未配置)表示保持现有行为;
221
+ * - 当配置了 phase 时,运行时会将其映射为 `scheduleModelOperation` 的 `when` 锚点;
222
+ * - phase 同时适用于动态事件流(实例级)与静态流(内置)。
223
+ */
224
+ export type FlowEventPhase =
225
+ | 'beforeAllFlows'
226
+ | 'afterAllFlows'
227
+ | 'beforeFlow'
228
+ | 'afterFlow'
229
+ | 'beforeStep'
230
+ | 'afterStep';
231
+
216
232
  /**
217
233
  * Flow 事件类型(供 FlowDefinitionOptions.on 使用)。
218
234
  */
219
235
  export type FlowEvent<TModel extends FlowModel = FlowModel> =
220
236
  | FlowEventName
221
- | { eventName: FlowEventName; defaultParams?: Record<string, any> };
237
+ | {
238
+ eventName: FlowEventName;
239
+ defaultParams?: Record<string, any>;
240
+ /** 动态事件流的执行时机(默认 beforeAllFlows) */
241
+ phase?: FlowEventPhase;
242
+ /** phase 为 beforeFlow/afterFlow/beforeStep/afterStep 时使用 */
243
+ flowKey?: string;
244
+ /** phase 为 beforeStep/afterStep 时使用 */
245
+ stepKey?: string;
246
+ };
222
247
 
223
248
  /**
224
249
  * 事件分发选项。
@@ -164,9 +164,9 @@ export function useDialog() {
164
164
  className="nb-dialog-overflow-hidden"
165
165
  ref={dialogRef}
166
166
  hidden={config.inputArgs?.hidden?.value}
167
- {...config}
168
167
  footer={currentFooter}
169
168
  header={currentHeader}
169
+ {...config}
170
170
  onCancel={() => {
171
171
  currentDialog.close(config.result);
172
172
  }}