@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.
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +76 -31
- package/lib/flowContext.d.ts +6 -1
- package/lib/flowContext.js +35 -6
- package/lib/flowEngine.d.ts +4 -3
- package/lib/flowEngine.js +67 -36
- package/lib/models/flowModel.js +45 -13
- package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
- package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
- package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
- package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/base.js +464 -29
- package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
- package/lib/runjs-context/contexts/elementDoc.js +152 -0
- package/lib/utils/loadedPageCache.d.ts +21 -0
- package/lib/utils/loadedPageCache.js +125 -0
- package/package.json +4 -4
- package/src/__tests__/flowContext.test.ts +23 -0
- package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
- package/src/__tests__/runjsContext.test.ts +18 -0
- package/src/__tests__/runjsContextImplementations.test.ts +9 -2
- package/src/__tests__/runjsLocales.test.ts +6 -5
- package/src/__tests__/viewScopedFlowEngine.test.ts +133 -0
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +79 -37
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +148 -1
- package/src/flowContext.ts +40 -6
- package/src/flowEngine.ts +69 -34
- package/src/models/__tests__/flowModel.test.ts +13 -0
- package/src/models/flowModel.tsx +62 -29
- package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
- package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
- package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
- package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/base.ts +467 -31
- package/src/runjs-context/contexts/elementDoc.ts +130 -0
- 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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
modelsToProcess
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
if (visible) {
|
|
310
|
-
loadExtras();
|
|
311
|
-
}
|
|
331
|
+
loadExtras();
|
|
312
332
|
return () => {
|
|
313
333
|
mounted = false;
|
|
314
334
|
};
|
|
315
|
-
}, [model, menuLevels, t, refreshTick
|
|
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
|
-
|
|
673
|
+
if (mounted) {
|
|
674
|
+
setConfigurableFlowsAndSteps(flows);
|
|
675
|
+
}
|
|
650
676
|
} catch (error) {
|
|
651
677
|
console.error('Failed to load configurable flows and steps:', error);
|
|
652
|
-
|
|
678
|
+
if (mounted) {
|
|
679
|
+
setConfigurableFlowsAndSteps([]);
|
|
680
|
+
}
|
|
653
681
|
} finally {
|
|
654
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
});
|
package/src/flowContext.ts
CHANGED
|
@@ -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
|
-
* -
|
|
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 [
|
|
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 [
|
|
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>);
|