@nocobase/flow-engine 2.1.0-beta.42 → 2.1.0-beta.44
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/components/subModel/LazyDropdown.js +17 -9
- package/lib/executor/FlowExecutor.js +0 -3
- package/lib/flowContext.d.ts +6 -1
- package/lib/flowContext.js +35 -6
- package/lib/flowEngine.d.ts +4 -3
- package/lib/flowEngine.js +69 -37
- 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 +24 -0
- package/lib/utils/loadedPageCache.js +139 -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 +150 -3
- package/src/components/subModel/LazyDropdown.tsx +16 -7
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +51 -0
- package/src/executor/FlowExecutor.ts +0 -3
- package/src/executor/__tests__/flowExecutor.test.ts +2 -4
- package/src/flowContext.ts +40 -6
- package/src/flowEngine.ts +71 -35
- package/src/models/__tests__/flowModel.test.ts +13 -28
- 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 +147 -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
|
});
|
|
@@ -263,8 +263,18 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
263
263
|
// 当模型发生子模型替换/增删等变化时,强制刷新菜单数据
|
|
264
264
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
265
265
|
const [extraMenuItems, setExtraMenuItems] = useState<FlowModelExtraMenuItem[]>([]);
|
|
266
|
+
const [extraMenuItemsLoaded, setExtraMenuItemsLoaded] = useState(false);
|
|
266
267
|
const [configurableFlowsAndSteps, setConfigurableFlowsAndSteps] = useState<FlowInfo[]>([]);
|
|
267
268
|
const [isLoading, setIsLoading] = useState(true);
|
|
269
|
+
const commonExtras = useMemo(
|
|
270
|
+
() => extraMenuItems.filter((it) => it.group === 'common-actions').sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)),
|
|
271
|
+
[extraMenuItems],
|
|
272
|
+
);
|
|
273
|
+
const hasCommonActions = showCopyUidButton || showDeleteButton || commonExtras.length > 0;
|
|
274
|
+
const shouldDeferConfigLoading = flattenSubMenus && menuLevels > 1 && hasCommonActions;
|
|
275
|
+
const shouldWaitForCommonActionProbe =
|
|
276
|
+
flattenSubMenus && menuLevels > 1 && !showCopyUidButton && !showDeleteButton && !extraMenuItemsLoaded;
|
|
277
|
+
const canRenderIcon = hasCommonActions || (!isLoading && configurableFlowsAndSteps.length > 0);
|
|
268
278
|
const closeDropdown = useCallback(() => {
|
|
269
279
|
setVisible(false);
|
|
270
280
|
onDropdownVisibleChange?.(false);
|
|
@@ -303,26 +313,30 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
303
313
|
useEffect(() => {
|
|
304
314
|
let mounted = true;
|
|
305
315
|
const loadExtras = async () => {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
modelsToProcess
|
|
310
|
-
|
|
316
|
+
setExtraMenuItemsLoaded(false);
|
|
317
|
+
try {
|
|
318
|
+
const allExtras: FlowModelExtraMenuItem[] = [];
|
|
319
|
+
const modelsToProcess: Array<{ model: FlowModel; modelKey?: string }> = [];
|
|
320
|
+
walkSubModels(model, { maxDepth: menuLevels, arrayLimit: 50, mode: 'stack' }, (targetModel, { modelKey }) => {
|
|
321
|
+
modelsToProcess.push({ model: targetModel, modelKey });
|
|
322
|
+
});
|
|
311
323
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
324
|
+
for (const { model: targetModel, modelKey } of modelsToProcess) {
|
|
325
|
+
const Cls = targetModel.constructor as typeof FlowModel;
|
|
326
|
+
const extras = await Cls.getExtraMenuItems?.(targetModel, t);
|
|
327
|
+
if (extras?.length) {
|
|
328
|
+
allExtras.push(
|
|
329
|
+
...extras.map((item) => ({
|
|
330
|
+
...item,
|
|
331
|
+
key: modelKey ? `${modelKey}:${item.key}` : item.key,
|
|
332
|
+
})),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
322
335
|
}
|
|
323
|
-
}
|
|
324
336
|
|
|
325
|
-
|
|
337
|
+
if (!mounted) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
326
340
|
const seen = new Set<string>();
|
|
327
341
|
const dedupedExtras = allExtras.filter((item) => {
|
|
328
342
|
if (seen.has(`${item.key}`)) {
|
|
@@ -332,16 +346,22 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
332
346
|
return true;
|
|
333
347
|
});
|
|
334
348
|
setExtraMenuItems(dedupedExtras);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
console.error('Failed to load extra menu items:', error);
|
|
351
|
+
if (mounted) {
|
|
352
|
+
setExtraMenuItems([]);
|
|
353
|
+
}
|
|
354
|
+
} finally {
|
|
355
|
+
if (mounted) {
|
|
356
|
+
setExtraMenuItemsLoaded(true);
|
|
357
|
+
}
|
|
335
358
|
}
|
|
336
359
|
};
|
|
337
|
-
|
|
338
|
-
if (visible) {
|
|
339
|
-
loadExtras();
|
|
340
|
-
}
|
|
360
|
+
loadExtras();
|
|
341
361
|
return () => {
|
|
342
362
|
mounted = false;
|
|
343
363
|
};
|
|
344
|
-
}, [model, menuLevels, t, refreshTick
|
|
364
|
+
}, [model, menuLevels, t, refreshTick]);
|
|
345
365
|
|
|
346
366
|
// 统一的复制 UID 方法
|
|
347
367
|
const copyUidToClipboard = useCallback(
|
|
@@ -632,7 +652,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
632
652
|
return [];
|
|
633
653
|
}
|
|
634
654
|
},
|
|
635
|
-
[],
|
|
655
|
+
[t],
|
|
636
656
|
);
|
|
637
657
|
|
|
638
658
|
// 获取可配置的flows和steps
|
|
@@ -675,21 +695,50 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
675
695
|
}, [model, menuLevels, refreshTick]);
|
|
676
696
|
|
|
677
697
|
useEffect(() => {
|
|
698
|
+
let mounted = true;
|
|
678
699
|
const loadConfigurableFlowsAndSteps = async () => {
|
|
679
700
|
setIsLoading(true);
|
|
701
|
+
if (shouldDeferConfigLoading) {
|
|
702
|
+
setConfigurableFlowsAndSteps([]);
|
|
703
|
+
}
|
|
680
704
|
try {
|
|
681
705
|
const flows = await getConfigurableFlowsAndSteps();
|
|
682
|
-
|
|
706
|
+
if (mounted) {
|
|
707
|
+
setConfigurableFlowsAndSteps(flows);
|
|
708
|
+
}
|
|
683
709
|
} catch (error) {
|
|
684
710
|
console.error('Failed to load configurable flows and steps:', error);
|
|
685
|
-
|
|
711
|
+
if (mounted) {
|
|
712
|
+
setConfigurableFlowsAndSteps([]);
|
|
713
|
+
}
|
|
686
714
|
} finally {
|
|
687
|
-
|
|
715
|
+
if (mounted) {
|
|
716
|
+
setIsLoading(false);
|
|
717
|
+
}
|
|
688
718
|
}
|
|
689
719
|
};
|
|
690
720
|
|
|
721
|
+
if (shouldWaitForCommonActionProbe) {
|
|
722
|
+
setConfigurableFlowsAndSteps([]);
|
|
723
|
+
setIsLoading(false);
|
|
724
|
+
return () => {
|
|
725
|
+
mounted = false;
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (!visible && shouldDeferConfigLoading) {
|
|
730
|
+
setConfigurableFlowsAndSteps([]);
|
|
731
|
+
setIsLoading(false);
|
|
732
|
+
return () => {
|
|
733
|
+
mounted = false;
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
691
737
|
loadConfigurableFlowsAndSteps();
|
|
692
|
-
|
|
738
|
+
return () => {
|
|
739
|
+
mounted = false;
|
|
740
|
+
};
|
|
741
|
+
}, [getConfigurableFlowsAndSteps, refreshTick, shouldDeferConfigLoading, shouldWaitForCommonActionProbe, visible]);
|
|
693
742
|
|
|
694
743
|
// 构建菜单项,包含错误处理和记忆化
|
|
695
744
|
const menuItems = useMemo((): NonNullable<MenuProps['items']> => {
|
|
@@ -856,16 +905,12 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
856
905
|
}
|
|
857
906
|
|
|
858
907
|
return items;
|
|
859
|
-
}, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, t]);
|
|
908
|
+
}, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, message, model, t]);
|
|
860
909
|
|
|
861
910
|
// 向菜单项添加额外按钮
|
|
862
911
|
const finalMenuItems = useMemo((): NonNullable<MenuProps['items']> => {
|
|
863
912
|
const items = [...menuItems];
|
|
864
913
|
|
|
865
|
-
const commonExtras = extraMenuItems
|
|
866
|
-
.filter((it) => it.group === 'common-actions')
|
|
867
|
-
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
|
868
|
-
|
|
869
914
|
if (showCopyUidButton || showDeleteButton || commonExtras.length > 0) {
|
|
870
915
|
items.push({
|
|
871
916
|
type: 'divider',
|
|
@@ -901,12 +946,9 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
901
946
|
}
|
|
902
947
|
|
|
903
948
|
return items;
|
|
904
|
-
}, [menuItems, showCopyUidButton, showDeleteButton, model.uid, model.destroy, t
|
|
905
|
-
|
|
906
|
-
// 如果正在加载或没有可配置的flows且不显示删除按钮和复制UID按钮,不显示菜单
|
|
907
|
-
const hasExtras = extraMenuItems.some((it) => it.group === 'common-actions');
|
|
949
|
+
}, [menuItems, showCopyUidButton, showDeleteButton, commonExtras, model.uid, model.destroy, t]);
|
|
908
950
|
|
|
909
|
-
if (
|
|
951
|
+
if (!canRenderIcon) {
|
|
910
952
|
return null;
|
|
911
953
|
}
|
|
912
954
|
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import
|
|
11
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
-
import { render, cleanup, waitFor, act } from '@testing-library/react';
|
|
10
|
+
import { act, cleanup, render, waitFor } from '@testing-library/react';
|
|
13
11
|
import { App, ConfigProvider } from 'antd';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
14
14
|
|
|
15
15
|
import { FlowEngine } from '../../../../../flowEngine';
|
|
16
16
|
import { FlowModel } from '../../../../../models/flowModel';
|
|
@@ -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
|
|
|
@@ -720,6 +800,10 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
|
720
800
|
),
|
|
721
801
|
);
|
|
722
802
|
|
|
803
|
+
await act(async () => {
|
|
804
|
+
(globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
|
|
805
|
+
});
|
|
806
|
+
|
|
723
807
|
await waitFor(() => {
|
|
724
808
|
const menu = (globalThis as any).__lastDropdownMenu;
|
|
725
809
|
expect(menu).toBeTruthy();
|
|
@@ -903,4 +987,67 @@ describe('DefaultSettingsIcon - extra menu items', () => {
|
|
|
903
987
|
dispose?.();
|
|
904
988
|
}
|
|
905
989
|
});
|
|
990
|
+
|
|
991
|
+
it('uses common extra actions to defer nested configurable step resolution', async () => {
|
|
992
|
+
const onClick = vi.fn();
|
|
993
|
+
|
|
994
|
+
class TestFlowModel extends FlowModel {}
|
|
995
|
+
const dispose = TestFlowModel.registerExtraMenuItems({
|
|
996
|
+
group: 'common-actions',
|
|
997
|
+
sort: 10,
|
|
998
|
+
items: [{ key: 'extra-action', label: 'Extra Action', onClick }],
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
const engine = new FlowEngine();
|
|
1002
|
+
const model = new TestFlowModel({ uid: 'm-extra-lazy', flowEngine: engine });
|
|
1003
|
+
const uiSchema = vi.fn(() => ({
|
|
1004
|
+
f: { type: 'string', 'x-component': 'Input' },
|
|
1005
|
+
}));
|
|
1006
|
+
|
|
1007
|
+
TestFlowModel.registerFlow({
|
|
1008
|
+
key: 'flow',
|
|
1009
|
+
title: 'Flow',
|
|
1010
|
+
steps: { s: { title: 'S', uiSchema } },
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
try {
|
|
1014
|
+
const { getByLabelText } = render(
|
|
1015
|
+
React.createElement(
|
|
1016
|
+
ConfigProvider as any,
|
|
1017
|
+
null,
|
|
1018
|
+
React.createElement(
|
|
1019
|
+
App as any,
|
|
1020
|
+
null,
|
|
1021
|
+
React.createElement(DefaultSettingsIcon as any, {
|
|
1022
|
+
model,
|
|
1023
|
+
menuLevels: 2,
|
|
1024
|
+
showCopyUidButton: false,
|
|
1025
|
+
showDeleteButton: false,
|
|
1026
|
+
}),
|
|
1027
|
+
),
|
|
1028
|
+
),
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
await waitFor(() => {
|
|
1032
|
+
expect(getByLabelText('flows-settings')).toBeTruthy();
|
|
1033
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
1034
|
+
const items = (menu?.items || []) as any[];
|
|
1035
|
+
expect(items.some((it) => String(it.key || '') === 'extra-action')).toBe(true);
|
|
1036
|
+
});
|
|
1037
|
+
expect(uiSchema).not.toHaveBeenCalled();
|
|
1038
|
+
|
|
1039
|
+
await act(async () => {
|
|
1040
|
+
(globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
await waitFor(() => {
|
|
1044
|
+
expect(uiSchema).toHaveBeenCalledTimes(1);
|
|
1045
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
1046
|
+
const items = (menu?.items || []) as any[];
|
|
1047
|
+
expect(items.some((it) => String(it.key || '') === 'flow:s')).toBe(true);
|
|
1048
|
+
});
|
|
1049
|
+
} finally {
|
|
1050
|
+
dispose?.();
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
906
1053
|
});
|
|
@@ -463,6 +463,7 @@ const createSearchItem = (
|
|
|
463
463
|
},
|
|
464
464
|
activateSearchSubmenu: (key: string) => void,
|
|
465
465
|
deactivateSearchSubmenu: (key: string) => void,
|
|
466
|
+
shouldActivateSearchSubmenu: boolean,
|
|
466
467
|
) => ({
|
|
467
468
|
key: `${searchKey}-search`,
|
|
468
469
|
type: 'group' as const,
|
|
@@ -480,28 +481,34 @@ const createSearchItem = (
|
|
|
480
481
|
onChange={(e) => {
|
|
481
482
|
e.stopPropagation();
|
|
482
483
|
const value = e.target.value;
|
|
483
|
-
|
|
484
|
+
if (shouldActivateSearchSubmenu) {
|
|
485
|
+
activateSearchSubmenu(searchKey);
|
|
486
|
+
}
|
|
484
487
|
if ((e.nativeEvent as any)?.isComposing || searchHandlers.isComposing(searchKey)) {
|
|
485
488
|
searchHandlers.updateInputValue(searchKey, value);
|
|
486
489
|
return;
|
|
487
490
|
}
|
|
488
|
-
if (!value) {
|
|
491
|
+
if (!value && shouldActivateSearchSubmenu) {
|
|
489
492
|
deactivateSearchSubmenu(searchKey);
|
|
490
493
|
}
|
|
491
494
|
searchHandlers.updateSearchValue(searchKey, value);
|
|
492
495
|
}}
|
|
493
496
|
onCompositionStart={(e) => {
|
|
494
497
|
e.stopPropagation();
|
|
495
|
-
|
|
498
|
+
if (shouldActivateSearchSubmenu) {
|
|
499
|
+
activateSearchSubmenu(searchKey);
|
|
500
|
+
}
|
|
496
501
|
searchHandlers.startComposition(searchKey);
|
|
497
502
|
}}
|
|
498
503
|
onCompositionEnd={(e) => {
|
|
499
504
|
e.stopPropagation();
|
|
500
505
|
const value = e.currentTarget.value;
|
|
501
|
-
if (
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
506
|
+
if (shouldActivateSearchSubmenu) {
|
|
507
|
+
if (value) {
|
|
508
|
+
activateSearchSubmenu(searchKey);
|
|
509
|
+
} else {
|
|
510
|
+
deactivateSearchSubmenu(searchKey);
|
|
511
|
+
}
|
|
505
512
|
}
|
|
506
513
|
searchHandlers.endComposition(searchKey, value);
|
|
507
514
|
}}
|
|
@@ -737,6 +744,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
737
744
|
const searchKey = keyPath;
|
|
738
745
|
const currentSearchValue = searchValues[searchKey] || '';
|
|
739
746
|
const currentInputValue = inputValues[searchKey] ?? currentSearchValue;
|
|
747
|
+
const shouldActivateSearchSubmenu = !(item.type === 'group' && path.length === 0);
|
|
740
748
|
|
|
741
749
|
// 递归过滤:当 child 为分组时,会继续向下过滤其 children;
|
|
742
750
|
// 仅保留自身匹配或存在匹配子项的分组。
|
|
@@ -770,6 +778,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
770
778
|
searchHandlers,
|
|
771
779
|
activateSearchSubmenu,
|
|
772
780
|
deactivateSearchSubmenu,
|
|
781
|
+
shouldActivateSearchSubmenu,
|
|
773
782
|
);
|
|
774
783
|
const dividerItem = { key: `${keyPath}-search-divider`, type: 'divider' as const };
|
|
775
784
|
|
|
@@ -556,6 +556,57 @@ describe('transformItems - searchable flags', () => {
|
|
|
556
556
|
await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
|
|
557
557
|
expect(screen.getByRole('textbox')).toHaveValue('');
|
|
558
558
|
});
|
|
559
|
+
|
|
560
|
+
it('keeps root group search value when hovering a sibling submenu', async () => {
|
|
561
|
+
const engine = new FlowEngine();
|
|
562
|
+
await engine.flowSettings.forceEnable();
|
|
563
|
+
class Parent extends FlowModel {}
|
|
564
|
+
engine.registerModels({ Parent });
|
|
565
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
566
|
+
|
|
567
|
+
const items = [
|
|
568
|
+
{
|
|
569
|
+
key: 'fields',
|
|
570
|
+
label: '',
|
|
571
|
+
type: 'group' as const,
|
|
572
|
+
searchable: true,
|
|
573
|
+
searchPlaceholder: 'Search fields',
|
|
574
|
+
children: [
|
|
575
|
+
{ key: 'nickname', label: 'Nickname', createModelOptions: { use: 'Parent' } },
|
|
576
|
+
{ key: 'email', label: 'Email', createModelOptions: { use: 'Parent' } },
|
|
577
|
+
],
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
key: 'association-fields',
|
|
581
|
+
label: 'Display association fields',
|
|
582
|
+
children: [{ key: 'author', label: 'Author', createModelOptions: { use: 'Parent' } }],
|
|
583
|
+
},
|
|
584
|
+
];
|
|
585
|
+
|
|
586
|
+
const user = userEvent.setup();
|
|
587
|
+
render(
|
|
588
|
+
<FlowEngineProvider engine={engine}>
|
|
589
|
+
<ConfigProvider>
|
|
590
|
+
<App>
|
|
591
|
+
<AddSubModelButton model={parent} subModelKey="items" items={items as any}>
|
|
592
|
+
Open
|
|
593
|
+
</AddSubModelButton>
|
|
594
|
+
</App>
|
|
595
|
+
</ConfigProvider>
|
|
596
|
+
</FlowEngineProvider>,
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
await user.click(screen.getByText('Open'));
|
|
600
|
+
const searchInput = await screen.findByPlaceholderText('Search fields');
|
|
601
|
+
await user.type(searchInput, 'nick');
|
|
602
|
+
await waitFor(() => expect(screen.queryByText('Email')).not.toBeInTheDocument());
|
|
603
|
+
|
|
604
|
+
await user.hover(screen.getByText('Display association fields'));
|
|
605
|
+
|
|
606
|
+
await waitFor(() => expect(screen.getByText('Author')).toBeInTheDocument());
|
|
607
|
+
expect(searchInput).toHaveValue('nick');
|
|
608
|
+
expect(screen.getByText('Nickname')).toBeInTheDocument();
|
|
609
|
+
});
|
|
559
610
|
});
|
|
560
611
|
|
|
561
612
|
describe('transformItems - hide', () => {
|
|
@@ -158,9 +158,6 @@ export class FlowExecutor {
|
|
|
158
158
|
const stepDefaultParams = await resolveDefaultParams(step.defaultParams, runtimeCtx);
|
|
159
159
|
combinedParams = { ...stepDefaultParams };
|
|
160
160
|
} else {
|
|
161
|
-
flowContext.logger.warn(
|
|
162
|
-
`BaseModel.applyFlow: Step '${stepKey}' in flow '${flowKey}' has neither 'use' nor 'handler'. Skipping.`,
|
|
163
|
-
);
|
|
164
161
|
continue;
|
|
165
162
|
}
|
|
166
163
|
|
|
@@ -81,7 +81,7 @@ describe('FlowExecutor', () => {
|
|
|
81
81
|
expect(result.step2).toBe('step2-ok');
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
it('runFlow
|
|
84
|
+
it('runFlow silently skips steps without use or handler', async () => {
|
|
85
85
|
const flows = {
|
|
86
86
|
referenceSettings: {
|
|
87
87
|
steps: {
|
|
@@ -98,9 +98,7 @@ describe('FlowExecutor', () => {
|
|
|
98
98
|
const result = await engine.executor.runFlow(model, 'referenceSettings');
|
|
99
99
|
|
|
100
100
|
expect(result).toEqual({});
|
|
101
|
-
expect(loggerWarnSpy).
|
|
102
|
-
"BaseModel.applyFlow: Step 'target' in flow 'referenceSettings' has neither 'use' nor 'handler'. Skipping.",
|
|
103
|
-
);
|
|
101
|
+
expect(loggerWarnSpy).not.toHaveBeenCalled();
|
|
104
102
|
expect(loggerErrorSpy).not.toHaveBeenCalled();
|
|
105
103
|
} finally {
|
|
106
104
|
loggerChildSpy.mockRestore();
|