@nocobase/flow-engine 2.0.60 → 2.0.61

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.
Files changed (39) hide show
  1. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +76 -31
  2. package/lib/flowContext.d.ts +6 -1
  3. package/lib/flowContext.js +35 -6
  4. package/lib/flowEngine.d.ts +4 -3
  5. package/lib/flowEngine.js +67 -36
  6. package/lib/models/flowModel.js +45 -13
  7. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
  8. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
  9. package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
  10. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
  11. package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
  12. package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
  13. package/lib/runjs-context/contexts/base.js +464 -29
  14. package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
  15. package/lib/runjs-context/contexts/elementDoc.js +152 -0
  16. package/lib/utils/loadedPageCache.d.ts +21 -0
  17. package/lib/utils/loadedPageCache.js +125 -0
  18. package/package.json +4 -4
  19. package/src/__tests__/flowContext.test.ts +23 -0
  20. package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
  21. package/src/__tests__/runjsContext.test.ts +18 -0
  22. package/src/__tests__/runjsContextImplementations.test.ts +9 -2
  23. package/src/__tests__/runjsLocales.test.ts +6 -5
  24. package/src/__tests__/viewScopedFlowEngine.test.ts +133 -0
  25. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +79 -37
  26. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +148 -1
  27. package/src/flowContext.ts +40 -6
  28. package/src/flowEngine.ts +69 -34
  29. package/src/models/__tests__/flowModel.test.ts +13 -0
  30. package/src/models/flowModel.tsx +62 -29
  31. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
  32. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
  33. package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
  34. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
  35. package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
  36. package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
  37. package/src/runjs-context/contexts/base.ts +467 -31
  38. package/src/runjs-context/contexts/elementDoc.ts +130 -0
  39. package/src/utils/loadedPageCache.ts +117 -0
@@ -18,6 +18,33 @@ import { APIClient as SDKApiClient } from '@nocobase/sdk';
18
18
  import { FlowEngine } from '../flowEngine';
19
19
  import { createViewScopedEngine } from '../ViewScopedFlowEngine';
20
20
  import { FlowModel } from '../models';
21
+ import type { IFlowModelRepository } from '../types';
22
+
23
+ const clone = <T>(value: T): T => (value == null ? value : JSON.parse(JSON.stringify(value)));
24
+
25
+ class DirtyPageRepository implements IFlowModelRepository<FlowModel> {
26
+ public findOneCalls = 0;
27
+ public data: Record<string, any> | null = null;
28
+
29
+ async findOne(): Promise<Record<string, any> | null> {
30
+ this.findOneCalls += 1;
31
+ return clone(this.data);
32
+ }
33
+
34
+ async save(model: FlowModel): Promise<Record<string, any>> {
35
+ return model.serialize();
36
+ }
37
+
38
+ async destroy(): Promise<boolean> {
39
+ return true;
40
+ }
41
+
42
+ async move(): Promise<void> {}
43
+
44
+ async duplicate(): Promise<Record<string, any> | null> {
45
+ return null;
46
+ }
47
+ }
21
48
 
22
49
  describe('ViewScopedFlowEngine', () => {
23
50
  it('shares global actions/events and model classes with parent', async () => {
@@ -307,4 +334,110 @@ describe('ViewScopedFlowEngine', () => {
307
334
  expect(result).not.toBeNull();
308
335
  expect(result?.uid).toBe('child-normal');
309
336
  });
337
+
338
+ it('reloads a dirty loaded page from repository and replaces stale parent reference', async () => {
339
+ const root = new FlowEngine();
340
+ const repository = new DirtyPageRepository();
341
+ root.setModelRepository(repository);
342
+
343
+ class ParentModel extends FlowModel {}
344
+ class PageModel extends FlowModel {}
345
+ class BlockModel extends FlowModel {}
346
+ root.registerModels({ ParentModel, PageModel, BlockModel });
347
+
348
+ const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'popup-action' });
349
+ const oldScoped = createViewScopedEngine(root);
350
+ const stalePage = oldScoped.createModel<PageModel>({
351
+ use: 'PageModel',
352
+ uid: 'popup-page',
353
+ parentId: parent.uid,
354
+ subKey: 'page',
355
+ subType: 'object',
356
+ subModels: {
357
+ items: [{ use: 'BlockModel', uid: 'stale-block' }],
358
+ },
359
+ });
360
+ const staleBlock = stalePage.findSubModel('items' as any, (item) => item.uid === 'stale-block') as FlowModel;
361
+ parent.setSubModel('page', stalePage);
362
+ oldScoped.unlinkFromStack();
363
+
364
+ repository.data = {
365
+ use: 'PageModel',
366
+ uid: 'popup-page',
367
+ parentId: parent.uid,
368
+ subKey: 'page',
369
+ subType: 'object',
370
+ subModels: {
371
+ items: [{ use: 'BlockModel', uid: 'fresh-block' }],
372
+ },
373
+ };
374
+
375
+ root.flowSettings.enable();
376
+ await staleBlock.saveStepParams();
377
+ root.flowSettings.disable();
378
+ repository.findOneCalls = 0;
379
+
380
+ const runtimeScoped = createViewScopedEngine(root);
381
+ const loaded = await runtimeScoped.loadOrCreateModel<PageModel>({
382
+ async: true,
383
+ parentId: parent.uid,
384
+ subKey: 'page',
385
+ subType: 'object',
386
+ use: 'PageModel',
387
+ });
388
+
389
+ expect(repository.findOneCalls).toBe(1);
390
+ expect(loaded).not.toBe(stalePage);
391
+ expect((parent.subModels as any).page).toBe(loaded);
392
+ expect(loaded?.mapSubModels('items' as any, (item) => item.uid)).toEqual(['fresh-block']);
393
+
394
+ repository.findOneCalls = 0;
395
+ const nextRuntimeScoped = createViewScopedEngine(root);
396
+ const loadedAgain = await nextRuntimeScoped.loadOrCreateModel<PageModel>({
397
+ async: true,
398
+ parentId: parent.uid,
399
+ subKey: 'page',
400
+ subType: 'object',
401
+ use: 'PageModel',
402
+ });
403
+
404
+ expect(repository.findOneCalls).toBe(0);
405
+ expect(loadedAgain?.uid).toBe('popup-page');
406
+ });
407
+
408
+ it('does not bypass loaded page cache after a non-config save', async () => {
409
+ const root = new FlowEngine();
410
+ const repository = new DirtyPageRepository();
411
+ root.setModelRepository(repository);
412
+
413
+ class ParentModel extends FlowModel {}
414
+ class PageModel extends FlowModel {}
415
+ root.registerModels({ ParentModel, PageModel });
416
+
417
+ const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'normal-parent' });
418
+ const stalePage = root.createModel<PageModel>({
419
+ use: 'PageModel',
420
+ uid: 'normal-page',
421
+ parentId: parent.uid,
422
+ subKey: 'page',
423
+ subType: 'object',
424
+ });
425
+ parent.setSubModel('page', stalePage);
426
+
427
+ root.flowSettings.disable();
428
+ await root.saveModel(stalePage);
429
+ repository.findOneCalls = 0;
430
+
431
+ const runtimeScoped = createViewScopedEngine(root);
432
+ const loaded = await runtimeScoped.loadOrCreateModel<PageModel>({
433
+ async: true,
434
+ parentId: parent.uid,
435
+ subKey: 'page',
436
+ subType: 'object',
437
+ use: 'PageModel',
438
+ });
439
+
440
+ expect(repository.findOneCalls).toBe(0);
441
+ expect(loaded?.uid).toBe('normal-page');
442
+ });
310
443
  });
@@ -234,8 +234,18 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
234
234
  // 当模型发生子模型替换/增删等变化时,强制刷新菜单数据
235
235
  const [refreshTick, setRefreshTick] = useState(0);
236
236
  const [extraMenuItems, setExtraMenuItems] = useState<FlowModelExtraMenuItem[]>([]);
237
+ const [extraMenuItemsLoaded, setExtraMenuItemsLoaded] = useState(false);
237
238
  const [configurableFlowsAndSteps, setConfigurableFlowsAndSteps] = useState<FlowInfo[]>([]);
238
239
  const [isLoading, setIsLoading] = useState(true);
240
+ const commonExtras = useMemo(
241
+ () => extraMenuItems.filter((it) => it.group === 'common-actions').sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)),
242
+ [extraMenuItems],
243
+ );
244
+ const hasCommonActions = showCopyUidButton || showDeleteButton || commonExtras.length > 0;
245
+ const shouldDeferConfigLoading = flattenSubMenus && menuLevels > 1 && hasCommonActions;
246
+ const shouldWaitForCommonActionProbe =
247
+ flattenSubMenus && menuLevels > 1 && !showCopyUidButton && !showDeleteButton && !extraMenuItemsLoaded;
248
+ const canRenderIcon = hasCommonActions || (!isLoading && configurableFlowsAndSteps.length > 0);
239
249
  const closeDropdown = useCallback(() => {
240
250
  setVisible(false);
241
251
  onDropdownVisibleChange?.(false);
@@ -274,26 +284,30 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
274
284
  useEffect(() => {
275
285
  let mounted = true;
276
286
  const loadExtras = async () => {
277
- const allExtras: FlowModelExtraMenuItem[] = [];
278
- const modelsToProcess: Array<{ model: FlowModel; modelKey?: string }> = [];
279
- walkSubModels(model, { maxDepth: menuLevels, arrayLimit: 50, mode: 'stack' }, (targetModel, { modelKey }) => {
280
- modelsToProcess.push({ model: targetModel, modelKey });
281
- });
287
+ setExtraMenuItemsLoaded(false);
288
+ try {
289
+ const allExtras: FlowModelExtraMenuItem[] = [];
290
+ const modelsToProcess: Array<{ model: FlowModel; modelKey?: string }> = [];
291
+ walkSubModels(model, { maxDepth: menuLevels, arrayLimit: 50, mode: 'stack' }, (targetModel, { modelKey }) => {
292
+ modelsToProcess.push({ model: targetModel, modelKey });
293
+ });
282
294
 
283
- for (const { model: targetModel, modelKey } of modelsToProcess) {
284
- const Cls = targetModel.constructor as typeof FlowModel;
285
- const extras = await Cls.getExtraMenuItems?.(targetModel, t);
286
- if (extras?.length) {
287
- allExtras.push(
288
- ...extras.map((item) => ({
289
- ...item,
290
- key: modelKey ? `${modelKey}:${item.key}` : item.key,
291
- })),
292
- );
295
+ for (const { model: targetModel, modelKey } of modelsToProcess) {
296
+ const Cls = targetModel.constructor as typeof FlowModel;
297
+ const extras = await Cls.getExtraMenuItems?.(targetModel, t);
298
+ if (extras?.length) {
299
+ allExtras.push(
300
+ ...extras.map((item) => ({
301
+ ...item,
302
+ key: modelKey ? `${modelKey}:${item.key}` : item.key,
303
+ })),
304
+ );
305
+ }
293
306
  }
294
- }
295
307
 
296
- if (mounted) {
308
+ if (!mounted) {
309
+ return;
310
+ }
297
311
  const seen = new Set<string>();
298
312
  const dedupedExtras = allExtras.filter((item) => {
299
313
  if (seen.has(`${item.key}`)) {
@@ -303,16 +317,22 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
303
317
  return true;
304
318
  });
305
319
  setExtraMenuItems(dedupedExtras);
320
+ } catch (error) {
321
+ console.error('Failed to load extra menu items:', error);
322
+ if (mounted) {
323
+ setExtraMenuItems([]);
324
+ }
325
+ } finally {
326
+ if (mounted) {
327
+ setExtraMenuItemsLoaded(true);
328
+ }
306
329
  }
307
330
  };
308
- // 避免 effect 触发 setState 导致循环:仅在 visible 打开时加载一次,关闭后仍保留结果
309
- if (visible) {
310
- loadExtras();
311
- }
331
+ loadExtras();
312
332
  return () => {
313
333
  mounted = false;
314
334
  };
315
- }, [model, menuLevels, t, refreshTick, visible]);
335
+ }, [model, menuLevels, t, refreshTick]);
316
336
 
317
337
  // 统一的复制 UID 方法
318
338
  const copyUidToClipboard = useCallback(
@@ -599,7 +619,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
599
619
  return [];
600
620
  }
601
621
  },
602
- [],
622
+ [t],
603
623
  );
604
624
 
605
625
  // 获取可配置的flows和steps
@@ -642,21 +662,50 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
642
662
  }, [model, menuLevels, refreshTick]);
643
663
 
644
664
  useEffect(() => {
665
+ let mounted = true;
645
666
  const loadConfigurableFlowsAndSteps = async () => {
646
667
  setIsLoading(true);
668
+ if (shouldDeferConfigLoading) {
669
+ setConfigurableFlowsAndSteps([]);
670
+ }
647
671
  try {
648
672
  const flows = await getConfigurableFlowsAndSteps();
649
- setConfigurableFlowsAndSteps(flows);
673
+ if (mounted) {
674
+ setConfigurableFlowsAndSteps(flows);
675
+ }
650
676
  } catch (error) {
651
677
  console.error('Failed to load configurable flows and steps:', error);
652
- setConfigurableFlowsAndSteps([]);
678
+ if (mounted) {
679
+ setConfigurableFlowsAndSteps([]);
680
+ }
653
681
  } finally {
654
- setIsLoading(false);
682
+ if (mounted) {
683
+ setIsLoading(false);
684
+ }
655
685
  }
656
686
  };
657
687
 
688
+ if (shouldWaitForCommonActionProbe) {
689
+ setConfigurableFlowsAndSteps([]);
690
+ setIsLoading(false);
691
+ return () => {
692
+ mounted = false;
693
+ };
694
+ }
695
+
696
+ if (!visible && shouldDeferConfigLoading) {
697
+ setConfigurableFlowsAndSteps([]);
698
+ setIsLoading(false);
699
+ return () => {
700
+ mounted = false;
701
+ };
702
+ }
703
+
658
704
  loadConfigurableFlowsAndSteps();
659
- }, [getConfigurableFlowsAndSteps, refreshTick]);
705
+ return () => {
706
+ mounted = false;
707
+ };
708
+ }, [getConfigurableFlowsAndSteps, refreshTick, shouldDeferConfigLoading, shouldWaitForCommonActionProbe, visible]);
660
709
 
661
710
  // 构建菜单项,包含错误处理和记忆化
662
711
  const menuItems = useMemo((): NonNullable<MenuProps['items']> => {
@@ -823,16 +872,12 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
823
872
  }
824
873
 
825
874
  return items;
826
- }, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, t]);
875
+ }, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, message, model, t]);
827
876
 
828
877
  // 向菜单项添加额外按钮
829
878
  const finalMenuItems = useMemo((): NonNullable<MenuProps['items']> => {
830
879
  const items = [...menuItems];
831
880
 
832
- const commonExtras = extraMenuItems
833
- .filter((it) => it.group === 'common-actions')
834
- .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
835
-
836
881
  if (showCopyUidButton || showDeleteButton || commonExtras.length > 0) {
837
882
  items.push({
838
883
  type: 'divider',
@@ -867,12 +912,9 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
867
912
  }
868
913
 
869
914
  return items;
870
- }, [menuItems, showCopyUidButton, showDeleteButton, model.uid, model.destroy, t, extraMenuItems]);
871
-
872
- // 如果正在加载或没有可配置的flows且不显示删除按钮和复制UID按钮,不显示菜单
873
- const hasExtras = extraMenuItems.some((it) => it.group === 'common-actions');
915
+ }, [menuItems, showCopyUidButton, showDeleteButton, commonExtras, model.uid, model.destroy, t]);
874
916
 
875
- if (isLoading || (configurableFlowsAndSteps.length === 0 && !showDeleteButton && !showCopyUidButton && !hasExtras)) {
917
+ if (!canRenderIcon) {
876
918
  return null;
877
919
  }
878
920
 
@@ -141,6 +141,86 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
141
141
  vi.clearAllMocks();
142
142
  });
143
143
 
144
+ it('defers nested configurable step resolution and clears stale config while closed', async () => {
145
+ class TestFlowModel extends FlowModel {}
146
+
147
+ const engine = new FlowEngine();
148
+ const model = new TestFlowModel({ uid: 'model-lazy-settings', flowEngine: engine });
149
+ const hideInSettings = vi.fn((ctx) => !!ctx.getStepParams('general')?.hidden);
150
+ const uiSchema = vi.fn(() => ({
151
+ field: { type: 'string', 'x-component': 'Input' },
152
+ }));
153
+
154
+ TestFlowModel.registerFlow({
155
+ key: 'lazyFlow',
156
+ title: 'Lazy Flow',
157
+ steps: {
158
+ general: {
159
+ title: 'General',
160
+ hideInSettings,
161
+ uiSchema,
162
+ },
163
+ },
164
+ });
165
+
166
+ const { getByLabelText } = render(
167
+ React.createElement(
168
+ ConfigProvider as any,
169
+ null,
170
+ React.createElement(
171
+ App as any,
172
+ null,
173
+ React.createElement(DefaultSettingsIcon as any, { model, menuLevels: 2 }),
174
+ ),
175
+ ),
176
+ );
177
+
178
+ expect(getByLabelText('flows-settings')).toBeTruthy();
179
+ expect(hideInSettings).not.toHaveBeenCalled();
180
+ expect(uiSchema).not.toHaveBeenCalled();
181
+
182
+ await act(async () => {
183
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
184
+ });
185
+
186
+ await waitFor(() => {
187
+ expect(hideInSettings).toHaveBeenCalledTimes(1);
188
+ expect(uiSchema).toHaveBeenCalledTimes(1);
189
+ const menu = (globalThis as any).__lastDropdownMenu;
190
+ const items = (menu?.items || []) as any[];
191
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(true);
192
+ });
193
+
194
+ await act(async () => {
195
+ (globalThis as any).__lastDropdownOnOpenChange?.(false, { source: 'trigger' });
196
+ });
197
+
198
+ await waitFor(() => {
199
+ const menu = (globalThis as any).__lastDropdownMenu;
200
+ const items = (menu?.items || []) as any[];
201
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(false);
202
+ });
203
+
204
+ await act(async () => {
205
+ model.setStepParams('lazyFlow', 'general', { hidden: true });
206
+ });
207
+
208
+ expect(hideInSettings).toHaveBeenCalledTimes(1);
209
+ expect(uiSchema).toHaveBeenCalledTimes(1);
210
+
211
+ await act(async () => {
212
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
213
+ });
214
+
215
+ await waitFor(() => {
216
+ expect(hideInSettings).toHaveBeenCalledTimes(2);
217
+ const menu = (globalThis as any).__lastDropdownMenu;
218
+ const items = (menu?.items || []) as any[];
219
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(false);
220
+ });
221
+ expect(uiSchema).toHaveBeenCalledTimes(1);
222
+ });
223
+
144
224
  it('excludes instance (dynamic) flows from the settings menu', async () => {
145
225
  class TestFlowModel extends FlowModel {}
146
226
 
@@ -612,7 +692,7 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
612
692
  const items = (menu?.items || []) as any[];
613
693
  const subMenu = items.find((it) => Array.isArray(it?.children));
614
694
  expect(subMenu).toBeTruthy();
615
- expect(subMenu!.children.some((it: any) => String(it.key).startsWith('items[0]:childFlow:cstep'))).toBe(true);
695
+ expect(subMenu?.children.some((it: any) => String(it.key).startsWith('items[0]:childFlow:cstep'))).toBe(true);
616
696
  });
617
697
  });
618
698
 
@@ -716,6 +796,10 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
716
796
  ),
717
797
  );
718
798
 
799
+ await act(async () => {
800
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
801
+ });
802
+
719
803
  await waitFor(() => {
720
804
  const menu = (globalThis as any).__lastDropdownMenu;
721
805
  expect(menu).toBeTruthy();
@@ -809,4 +893,67 @@ describe('DefaultSettingsIcon - extra menu items', () => {
809
893
  dispose?.();
810
894
  }
811
895
  });
896
+
897
+ it('uses common extra actions to defer nested configurable step resolution', async () => {
898
+ const onClick = vi.fn();
899
+
900
+ class TestFlowModel extends FlowModel {}
901
+ const dispose = TestFlowModel.registerExtraMenuItems({
902
+ group: 'common-actions',
903
+ sort: 10,
904
+ items: [{ key: 'extra-action', label: 'Extra Action', onClick }],
905
+ });
906
+
907
+ const engine = new FlowEngine();
908
+ const model = new TestFlowModel({ uid: 'm-extra-lazy', flowEngine: engine });
909
+ const uiSchema = vi.fn(() => ({
910
+ f: { type: 'string', 'x-component': 'Input' },
911
+ }));
912
+
913
+ TestFlowModel.registerFlow({
914
+ key: 'flow',
915
+ title: 'Flow',
916
+ steps: { s: { title: 'S', uiSchema } },
917
+ });
918
+
919
+ try {
920
+ const { getByLabelText } = render(
921
+ React.createElement(
922
+ ConfigProvider as any,
923
+ null,
924
+ React.createElement(
925
+ App as any,
926
+ null,
927
+ React.createElement(DefaultSettingsIcon as any, {
928
+ model,
929
+ menuLevels: 2,
930
+ showCopyUidButton: false,
931
+ showDeleteButton: false,
932
+ }),
933
+ ),
934
+ ),
935
+ );
936
+
937
+ await waitFor(() => {
938
+ expect(getByLabelText('flows-settings')).toBeTruthy();
939
+ const menu = (globalThis as any).__lastDropdownMenu;
940
+ const items = (menu?.items || []) as any[];
941
+ expect(items.some((it) => String(it.key || '') === 'extra-action')).toBe(true);
942
+ });
943
+ expect(uiSchema).not.toHaveBeenCalled();
944
+
945
+ await act(async () => {
946
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
947
+ });
948
+
949
+ await waitFor(() => {
950
+ expect(uiSchema).toHaveBeenCalledTimes(1);
951
+ const menu = (globalThis as any).__lastDropdownMenu;
952
+ const items = (menu?.items || []) as any[];
953
+ expect(items.some((it) => String(it.key || '') === 'flow:s')).toBe(true);
954
+ });
955
+ } finally {
956
+ dispose?.();
957
+ }
958
+ });
812
959
  });
@@ -400,6 +400,10 @@ export type FlowContextGetApiInfosOptions = {
400
400
  * RunJS 文档版本(默认 v1)。
401
401
  */
402
402
  version?: RunJSVersion;
403
+ /**
404
+ * Include editor completion metadata. Defaults to false so API-doc callers keep the compact public shape.
405
+ */
406
+ includeCompletion?: boolean;
403
407
  };
404
408
 
405
409
  export type FlowContextGetVarInfosOptions = {
@@ -704,10 +708,11 @@ export class FlowContext {
704
708
  * - 输出仅来自 RunJS doc 与 defineProperty/defineMethod 的 info
705
709
  * - 不读取/展开 PropertyMeta(变量结构)
706
710
  * - 不自动展开深层 properties
707
- * - 不返回自动补全字段(例如 completion
711
+ * - 默认不返回自动补全字段(例如 completion),传入 includeCompletion=true 时返回
708
712
  */
709
713
  async getApiInfos(options: FlowContextGetApiInfosOptions = {}): Promise<Record<string, FlowContextApiInfo>> {
710
714
  const version = (options.version as RunJSVersion) || ('v1' as RunJSVersion);
715
+ const includeCompletion = !!options.includeCompletion;
711
716
  const evalCtx = this.createProxy();
712
717
 
713
718
  const isPrivateKey = (key: string) => typeof key === 'string' && key.startsWith('_');
@@ -759,7 +764,14 @@ export class FlowContext {
759
764
  const src = toDocObject(obj);
760
765
  if (!src) return {};
761
766
  const out: any = {};
762
- for (const k of ['description', 'examples', 'ref', 'params', 'returns']) {
767
+ for (const k of [
768
+ 'description',
769
+ 'examples',
770
+ ...(includeCompletion ? ['completion'] : []),
771
+ 'ref',
772
+ 'params',
773
+ 'returns',
774
+ ]) {
763
775
  const v = (src as any)[k];
764
776
  if (typeof v !== 'undefined') out[k] = v;
765
777
  }
@@ -773,7 +785,17 @@ export class FlowContext {
773
785
  const src = toDocObject(obj);
774
786
  if (!src) return {};
775
787
  const out: any = {};
776
- for (const k of ['title', 'type', 'interface', 'description', 'examples', 'ref', 'params', 'returns']) {
788
+ for (const k of [
789
+ 'title',
790
+ 'type',
791
+ 'interface',
792
+ 'description',
793
+ 'examples',
794
+ ...(includeCompletion ? ['completion'] : []),
795
+ 'ref',
796
+ 'params',
797
+ 'returns',
798
+ ]) {
777
799
  const v = (src as any)[k];
778
800
  if (typeof v !== 'undefined') out[k] = v;
779
801
  }
@@ -872,7 +894,7 @@ export class FlowContext {
872
894
  node = { ...node, ...pickPropertyInfo(docObj) };
873
895
  node = { ...node, ...pickPropertyInfo(infoObj) };
874
896
  delete (node as any).properties;
875
- delete (node as any).completion;
897
+ if (!includeCompletion) delete (node as any).completion;
876
898
  if (!Object.keys(node).length) continue;
877
899
  const outKey = mapDocKeyToApiKey(key, docNode);
878
900
  // Avoid exposing ctx.React/ctx.ReactDOM/ctx.antd in api docs when mapping to ctx.libs.*.
@@ -890,7 +912,7 @@ export class FlowContext {
890
912
  node = { ...node, ...pickMethodInfo(docObj) };
891
913
  node = { ...node, ...pickMethodInfo(info) };
892
914
  delete (node as any).properties;
893
- delete (node as any).completion;
915
+ if (!includeCompletion) delete (node as any).completion;
894
916
  if (!Object.keys(node).length) continue;
895
917
  node.type = 'function';
896
918
 
@@ -913,7 +935,7 @@ export class FlowContext {
913
935
  let node: FlowContextApiInfo = {};
914
936
  node = { ...node, ...pickPropertyInfo(childObj) };
915
937
  delete (node as any).properties;
916
- delete (node as any).completion;
938
+ if (!includeCompletion) delete (node as any).completion;
917
939
  if (!node.description || !String(node.description).trim()) continue;
918
940
  out[outKey] = node;
919
941
  }
@@ -3073,6 +3095,17 @@ class BaseFlowEngineContext extends FlowContext {
3073
3095
  const jsCode = await prepareRunJsCode(String(code ?? ''), { preprocessTemplates: shouldPreprocessTemplates });
3074
3096
  return runner.run(jsCode);
3075
3097
  },
3098
+ {
3099
+ description: 'Execute a RunJS code string in the current Flow context.',
3100
+ detail: '(code: string, variables?: Record<string, any>, options?: JSRunnerOptions) => Promise<RunJSResult>',
3101
+ params: [
3102
+ { name: 'code', type: 'string', description: 'RunJS code to execute.' },
3103
+ { name: 'variables', type: 'Record<string, any>', optional: true, description: 'Additional globals.' },
3104
+ { name: 'options', type: 'JSRunnerOptions', optional: true, description: 'Runner options.' },
3105
+ ],
3106
+ returns: { type: 'Promise<{ success: boolean; value?: any; error?: any; timeout?: boolean }>' },
3107
+ completion: { insertText: `await ctx.runjs('return 1')` },
3108
+ },
3076
3109
  );
3077
3110
  }
3078
3111
  }
@@ -3922,6 +3955,7 @@ export type FlowSettingsContext<TModel extends FlowModel = FlowModel> = FlowRunt
3922
3955
 
3923
3956
  export type RunJSDocCompletionDoc = {
3924
3957
  insertText?: string;
3958
+ requires?: Array<'element'>;
3925
3959
  };
3926
3960
 
3927
3961
  export type RunJSDocHiddenDoc = boolean | ((ctx: any) => boolean | Promise<boolean>);