@nocobase/flow-engine 2.1.0-alpha.34 → 2.1.0-alpha.36

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.
@@ -358,6 +358,16 @@ const SearchInputWithAutoFocus: FC<InputProps & { visible: boolean }> = (props)
358
358
 
359
359
  const getKeyPath = (path: string[], key: string) => [...path, key].join('/');
360
360
 
361
+ const normalizeOpenKeys = (nextOpenKeys: string[]) => {
362
+ const latestKey = nextOpenKeys[nextOpenKeys.length - 1];
363
+
364
+ if (!latestKey) {
365
+ return [];
366
+ }
367
+
368
+ return nextOpenKeys.filter((key) => latestKey === key || latestKey.startsWith(`${key}/`));
369
+ };
370
+
361
371
  const createSearchItem = (
362
372
  item: Item,
363
373
  searchKey: string,
@@ -406,6 +416,11 @@ const createEmptyItem = (itemKey: string, t: (key: string) => string) => ({
406
416
  disabled: true,
407
417
  });
408
418
 
419
+ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
420
+ display: 'block',
421
+ width: '100%',
422
+ };
423
+
409
424
  // ==================== Main Component ====================
410
425
 
411
426
  // 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
@@ -435,6 +450,19 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
435
450
  const { searchValues, isSearching, updateSearchValue } = useMenuSearch();
436
451
  const { requestKeepOpen, shouldPreventClose } = useKeepDropdownOpen();
437
452
  useSubmenuStyles(menuVisible, dropdownMaxHeight);
453
+ const handleMenuOpenChange = useCallback(
454
+ (nextOpenKeys: string[]) => {
455
+ if (!nextOpenKeys.length && shouldPreventClose()) {
456
+ dropdownMenuProps.onOpenChange?.(Array.from(openKeys));
457
+ return;
458
+ }
459
+
460
+ const normalized = normalizeOpenKeys(nextOpenKeys);
461
+ setOpenKeys(new Set(normalized));
462
+ dropdownMenuProps.onOpenChange?.(normalized);
463
+ },
464
+ [dropdownMenuProps, openKeys, shouldPreventClose],
465
+ );
438
466
 
439
467
  // 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
440
468
  useEffect(() => {
@@ -460,6 +488,12 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
460
488
  };
461
489
  }, [persistKey, menuVisible]);
462
490
 
491
+ useEffect(() => {
492
+ if (!menuVisible) {
493
+ setOpenKeys(new Set());
494
+ }
495
+ }, [menuVisible]);
496
+
463
497
  // 加载根 items,支持同步/异步函数
464
498
  useEffect(() => {
465
499
  const loadRootItems = async () => {
@@ -588,56 +622,73 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
588
622
  return { type: 'divider', key: keyPath };
589
623
  }
590
624
 
625
+ const label = typeof item.label === 'string' ? t(item.label) : item.label;
626
+
591
627
  // 非 group 的“子菜单”也支持本层级搜索:当 item.searchable = true 且存在 children 时
592
628
  if (item.searchable && children) {
593
629
  return {
594
- key: item.key,
595
- label: typeof item.label === 'string' ? t(item.label) : item.label,
630
+ key: keyPath,
631
+ label,
596
632
  onClick: (info: any) => {},
597
- onMouseEnter: () => {
598
- setOpenKeys((prev) => {
599
- if (prev.has(keyPath)) return prev;
600
- const next = new Set(prev);
601
- next.add(keyPath);
602
- return next;
603
- });
604
- },
605
633
  children: buildSearchChildren(children, item, keyPath, path, menuVisible, resolveItems),
606
634
  };
607
635
  }
608
636
 
637
+ const itemShouldKeepOpen = !children && (item.keepDropdownOpen ?? keepDropdownOpen ?? false);
638
+ const handleLeafClick = (info: any) => {
639
+ if (children) {
640
+ return;
641
+ }
642
+
643
+ if (itemShouldKeepOpen) {
644
+ requestKeepOpen();
645
+ }
646
+
647
+ const extendedInfo: ExtendedMenuInfo = {
648
+ ...info,
649
+ key: info?.key ?? keyPath,
650
+ keyPath: info?.keyPath ?? [keyPath],
651
+ item: info?.item || item,
652
+ originalItem: item,
653
+ keepDropdownOpen: itemShouldKeepOpen,
654
+ };
655
+
656
+ menu.onClick?.(extendedInfo);
657
+ };
658
+
609
659
  return {
610
660
  key: keyPath,
611
- label: typeof item.label === 'string' ? t(item.label) : item.label,
661
+ label: itemShouldKeepOpen ? (
662
+ <div
663
+ style={KEEP_OPEN_LABEL_STYLE}
664
+ onMouseDown={(event) => {
665
+ event.stopPropagation();
666
+ requestKeepOpen();
667
+ }}
668
+ onClick={(event) => {
669
+ event.stopPropagation();
670
+ handleLeafClick({
671
+ key: keyPath,
672
+ keyPath: [keyPath],
673
+ item,
674
+ domEvent: event,
675
+ });
676
+ }}
677
+ >
678
+ {label}
679
+ </div>
680
+ ) : (
681
+ label
682
+ ),
612
683
  onClick: (info: any) => {
613
- if (children) {
684
+ if (!itemShouldKeepOpen) handleLeafClick(info);
685
+ },
686
+ onMouseDown: () => {
687
+ if (!itemShouldKeepOpen) {
614
688
  return;
615
689
  }
616
690
 
617
- // 检查是否应该保持下拉菜单打开
618
- const itemShouldKeepOpen = item.keepDropdownOpen ?? keepDropdownOpen ?? false;
619
-
620
- // 如果需要保持菜单打开,请求保持打开状态
621
- if (itemShouldKeepOpen) {
622
- requestKeepOpen();
623
- }
624
-
625
- const extendedInfo: ExtendedMenuInfo = {
626
- ...info,
627
- item: info.item || item,
628
- originalItem: item,
629
- keepDropdownOpen: itemShouldKeepOpen,
630
- };
631
-
632
- menu.onClick?.(extendedInfo);
633
- },
634
- onMouseEnter: () => {
635
- setOpenKeys((prev) => {
636
- if (prev.has(keyPath)) return prev;
637
- const next = new Set(prev);
638
- next.add(keyPath);
639
- return next;
640
- });
691
+ requestKeepOpen();
641
692
  },
642
693
  children:
643
694
  children && children.length > 0
@@ -684,8 +735,10 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
684
735
  placement="bottomLeft"
685
736
  menu={{
686
737
  ...dropdownMenuProps,
738
+ openKeys: Array.from(openKeys),
687
739
  items: items,
688
740
  onClick: () => {},
741
+ onOpenChange: handleMenuOpenChange,
689
742
  style: {
690
743
  maxHeight: dropdownMaxHeight,
691
744
  overflowY: 'auto',
@@ -21,6 +21,8 @@ import {
21
21
  import { SubModelItem, mergeSubModelItems, transformItems } from '../AddSubModelButton';
22
22
  import { App, ConfigProvider } from 'antd';
23
23
 
24
+ const getSubmenuTitle = (label: string) => screen.getByText(label).closest('.ant-dropdown-menu-submenu-title');
25
+
24
26
  describe('AddSubModelButton - preset settings open on add', () => {
25
27
  test('calls openFlowSettings with preset=true for subModel with preset steps', async () => {
26
28
  // Arrange: set up engine and models
@@ -215,6 +217,68 @@ describe('AddSubModelButton - async group children (nested)', () => {
215
217
  await waitFor(() => expect(screen.getByText('Nested-Leaf-1')).toBeInTheDocument());
216
218
  await waitFor(() => expect(screen.getByText('Nested-Leaf-2')).toBeInTheDocument());
217
219
  });
220
+
221
+ it('keeps root dropdown open while only the current nested group stays expanded', async () => {
222
+ const engine = new FlowEngine();
223
+ await engine.flowSettings.forceEnable();
224
+ class Parent extends FlowModel {}
225
+ engine.registerModels({ Parent });
226
+ const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-multi-open' });
227
+
228
+ const items = [
229
+ {
230
+ key: 'group-a',
231
+ label: 'Group A',
232
+ children: [
233
+ { key: 'a-1', label: 'A-1', createModelOptions: { use: 'Parent' } },
234
+ { key: 'a-2', label: 'A-2', createModelOptions: { use: 'Parent' } },
235
+ ],
236
+ },
237
+ {
238
+ key: 'group-b',
239
+ label: 'Group B',
240
+ children: [
241
+ { key: 'b-1', label: 'B-1', createModelOptions: { use: 'Parent' } },
242
+ { key: 'b-2', label: 'B-2', createModelOptions: { use: 'Parent' } },
243
+ ],
244
+ },
245
+ ];
246
+
247
+ render(
248
+ <FlowEngineProvider engine={engine}>
249
+ <ConfigProvider>
250
+ <App>
251
+ <AddSubModelButton model={parent} subModelKey="items" items={items as any}>
252
+ Open Menu
253
+ </AddSubModelButton>
254
+ </App>
255
+ </ConfigProvider>
256
+ </FlowEngineProvider>,
257
+ );
258
+
259
+ await act(async () => {
260
+ await userEvent.click(screen.getByText('Open Menu'));
261
+ });
262
+
263
+ await waitFor(() => expect(screen.getByText('Group A')).toBeInTheDocument());
264
+ await waitFor(() => expect(screen.getByText('Group B')).toBeInTheDocument());
265
+
266
+ await act(async () => {
267
+ await userEvent.hover(screen.getByText('Group A'));
268
+ });
269
+ await waitFor(() => expect(screen.getByText('A-1')).toBeInTheDocument());
270
+ await waitFor(() => expect(getSubmenuTitle('Group A')).toHaveAttribute('aria-expanded', 'true'));
271
+ expect(getSubmenuTitle('Group B')).toHaveAttribute('aria-expanded', 'false');
272
+
273
+ await act(async () => {
274
+ await userEvent.hover(screen.getByText('Group B'));
275
+ });
276
+ await waitFor(() => expect(screen.getByText('B-1')).toBeInTheDocument());
277
+ await waitFor(() => expect(getSubmenuTitle('Group B')).toHaveAttribute('aria-expanded', 'true'));
278
+ expect(getSubmenuTitle('Group A')).toHaveAttribute('aria-expanded', 'false');
279
+ expect(screen.getByText('Group A')).toBeInTheDocument();
280
+ expect(screen.getByText('Group B')).toBeInTheDocument();
281
+ });
218
282
  });
219
283
 
220
284
  describe('transformItems - searchable flags', () => {
@@ -1202,6 +1266,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1202
1266
  },
1203
1267
  { timeout: 3000 },
1204
1268
  );
1269
+ await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'));
1205
1270
 
1206
1271
  // dropdown should remain open and children should still be visible (no flicker / reload)
1207
1272
  expect(screen.getByText('Async Group')).toBeInTheDocument();
@@ -1218,6 +1283,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1218
1283
 
1219
1284
  // ensure destroy has been called (avoid flakiness on exact call counts)
1220
1285
  await waitFor(() => {
1286
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false');
1221
1287
  expect(repo.destroy).toHaveBeenCalled();
1222
1288
  });
1223
1289
  });
@@ -1371,15 +1437,78 @@ describe('AddSubModelButton toggleable behavior', () => {
1371
1437
  // click leaf toggle to add
1372
1438
  await user.click(screen.getByText('Leaf Toggle'));
1373
1439
 
1374
- // menu should remain visible; submenu parent still visible
1440
+ // menu and submenu should remain visible after toggling a submenu leaf
1375
1441
  expect(screen.getByText('Fields')).toBeInTheDocument();
1376
-
1377
- // 由于点击叶子项后二级子菜单可能被收起,这里先重新展开再断言开关状态
1378
- await user.hover(screen.getByText('Fields'));
1379
1442
  await waitFor(() => expect(screen.getByText('Leaf Toggle')).toBeInTheDocument());
1380
1443
  await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'));
1381
1444
  });
1382
1445
 
1446
+ test('keepDropdownOpen keeps root menu visible after clicking a nested relation-style leaf', async () => {
1447
+ const engine = new FlowEngine();
1448
+ await engine.flowSettings.forceEnable();
1449
+
1450
+ class Parent extends FlowModel {}
1451
+ class RelationLeafModel extends FlowModel {}
1452
+
1453
+ engine.registerModels({ Parent, RelationLeafModel });
1454
+ engine.setModelRepository(new FakeRepo());
1455
+ vi.spyOn(engine.flowSettings, 'open').mockResolvedValue(false as any);
1456
+
1457
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
1458
+
1459
+ const items = [
1460
+ {
1461
+ key: 'relation-fields',
1462
+ label: 'Display association fields',
1463
+ children: [
1464
+ {
1465
+ key: 'users',
1466
+ label: 'Users',
1467
+ type: 'group' as const,
1468
+ children: [
1469
+ {
1470
+ key: 'user-name',
1471
+ label: 'User name',
1472
+ createModelOptions: { use: 'RelationLeafModel' },
1473
+ },
1474
+ ],
1475
+ },
1476
+ ],
1477
+ },
1478
+ ];
1479
+
1480
+ render(
1481
+ <FlowEngineProvider engine={engine}>
1482
+ <ConfigProvider>
1483
+ <App>
1484
+ <AddSubModelButton
1485
+ model={parent}
1486
+ items={items as any}
1487
+ subModelType="array"
1488
+ subModelKey="subs"
1489
+ keepDropdownOpen
1490
+ >
1491
+ Open
1492
+ </AddSubModelButton>
1493
+ </App>
1494
+ </ConfigProvider>
1495
+ </FlowEngineProvider>,
1496
+ );
1497
+
1498
+ const user = userEvent.setup();
1499
+ await user.click(screen.getByText('Open'));
1500
+
1501
+ await waitFor(() => expect(screen.getByText('Display association fields')).toBeInTheDocument());
1502
+ await user.hover(screen.getByText('Display association fields'));
1503
+ await waitFor(() => expect(screen.getByText('Users')).toBeInTheDocument());
1504
+ await waitFor(() => expect(getSubmenuTitle('Display association fields')).toHaveAttribute('aria-expanded', 'true'));
1505
+
1506
+ await user.click(screen.getByText('User name'));
1507
+
1508
+ await waitFor(() => expect(screen.getByText('Display association fields')).toBeInTheDocument());
1509
+ await waitFor(() => expect(getSubmenuTitle('Display association fields')).toHaveAttribute('aria-expanded', 'true'));
1510
+ });
1511
+
1383
1512
  test('top-level toggle updates after opening a second-level branch', async () => {
1384
1513
  const engine = new FlowEngine();
1385
1514
  await engine.flowSettings.forceEnable();
@@ -0,0 +1,46 @@
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 _ from 'lodash';
11
+ import { FlowDefinition } from '../FlowDefinition';
12
+ import { FlowDefinitionOptions } from '../types';
13
+ import { BaseFlowRegistry, IFlowRepository } from './BaseFlowRegistry';
14
+
15
+ export type FlowRegistryData = Record<string, Omit<FlowDefinitionOptions, 'key'> & { key?: string }>;
16
+
17
+ export class DetachedFlowRegistry extends BaseFlowRegistry {
18
+ constructor(flows: FlowRegistryData = {}) {
19
+ super();
20
+ this.addFlows(_.cloneDeep(flows));
21
+ }
22
+
23
+ saveFlow(_flow: FlowDefinition): void {}
24
+
25
+ destroyFlow(flowKey: string): void {
26
+ this.removeFlow(flowKey);
27
+ }
28
+ }
29
+
30
+ export function serializeFlowRegistry(registry: Pick<IFlowRepository, 'getFlows'>): FlowRegistryData {
31
+ const flows: FlowRegistryData = {};
32
+ for (const [key, flow] of registry.getFlows()) {
33
+ flows[key] = _.cloneDeep(flow.toData());
34
+ }
35
+ return flows;
36
+ }
37
+
38
+ export function replaceFlowRegistry(
39
+ registry: Pick<IFlowRepository, 'getFlows' | 'removeFlow' | 'addFlows'>,
40
+ flows: FlowRegistryData,
41
+ ) {
42
+ for (const key of Array.from(registry.getFlows().keys())) {
43
+ registry.removeFlow(key);
44
+ }
45
+ registry.addFlows(_.cloneDeep(flows));
46
+ }
@@ -0,0 +1,47 @@
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, expect, test } from 'vitest';
11
+ import { DetachedFlowRegistry, replaceFlowRegistry, serializeFlowRegistry } from '../DetachedFlowRegistry';
12
+
13
+ describe('DetachedFlowRegistry', () => {
14
+ test('keeps flow edits detached and can replace another registry', () => {
15
+ const source = {
16
+ flow1: {
17
+ title: 'Flow 1',
18
+ steps: {
19
+ step1: { title: 'Step 1' } as any,
20
+ },
21
+ },
22
+ };
23
+ const registry = new DetachedFlowRegistry(source);
24
+
25
+ source.flow1.title = 'Changed outside';
26
+ expect(registry.getFlow('flow1')?.title).toBe('Flow 1');
27
+
28
+ const flow = registry.getFlow('flow1');
29
+ expect(flow).toBeDefined();
30
+ if (!flow) {
31
+ throw new Error('flow1 should exist');
32
+ }
33
+ flow.title = 'Draft title';
34
+ const serialized = serializeFlowRegistry(registry);
35
+ serialized.flow1.title = 'Changed serialized';
36
+ expect(registry.getFlow('flow1')?.title).toBe('Draft title');
37
+
38
+ const target = new DetachedFlowRegistry({ stale: { title: 'Stale', steps: {} } });
39
+ replaceFlowRegistry(target, serializeFlowRegistry(registry));
40
+
41
+ expect(target.hasFlow('stale')).toBe(false);
42
+ expect(target.getFlow('flow1')?.title).toBe('Draft title');
43
+
44
+ target.destroyFlow('flow1');
45
+ expect(target.hasFlow('flow1')).toBe(false);
46
+ });
47
+ });
@@ -10,3 +10,4 @@
10
10
  export * from './BaseFlowRegistry';
11
11
  export * from './InstanceFlowRegistry';
12
12
  export * from './GlobalFlowRegistry';
13
+ export * from './DetachedFlowRegistry';
package/src/index.ts CHANGED
@@ -57,5 +57,7 @@ export {
57
57
  } from './views/viewEvents';
58
58
 
59
59
  export * from './FlowDefinition';
60
+ export { DetachedFlowRegistry, replaceFlowRegistry, serializeFlowRegistry } from './flow-registry';
61
+ export type { FlowRegistryData } from './flow-registry';
60
62
  export { createViewScopedEngine } from './ViewScopedFlowEngine';
61
63
  export { createBlockScopedEngine } from './BlockScopedFlowEngine';
@@ -84,11 +84,21 @@ export class FlowViewer {
84
84
  if (this.types[type]) {
85
85
  zIndex += 1;
86
86
  const onClose = others.onClose;
87
+ let zIndexReleased = false;
88
+ const releaseZIndex = () => {
89
+ if (!zIndexReleased) {
90
+ zIndexReleased = true;
91
+ zIndex -= 1;
92
+ }
93
+ };
87
94
  const _zIndex = others.zIndex;
88
95
  others.onClose = (...args) => {
89
96
  onClose?.(...args);
90
- zIndex -= 1;
97
+ releaseZIndex();
91
98
  };
99
+ if (type === 'embed') {
100
+ others.onOpenCancelled = releaseZIndex;
101
+ }
92
102
  // embed 不能设置过高的 zIndex,会遮挡菜单的折叠按钮图表
93
103
  if (type !== 'embed') {
94
104
  others.zIndex = _zIndex ?? this.getNextZIndex();
@@ -24,6 +24,7 @@ export const PageComponent = forwardRef((props: any, ref) => {
24
24
  title: _title,
25
25
  styles = {},
26
26
  zIndex = 4, // 这个默认值是为了防止表格的阴影显示到子页面上面
27
+ onClose,
27
28
  } = mergedProps;
28
29
  const closedRef = useRef(false);
29
30
  const flowEngine = useFlowEngine();
@@ -86,10 +87,12 @@ export const PageComponent = forwardRef((props: any, ref) => {
86
87
  type="text"
87
88
  size="small"
88
89
  icon={<CloseOutlined />}
89
- onClick={() => {
90
+ onClick={async () => {
90
91
  if (!closedRef.current) {
91
- closedRef.current = true;
92
- props.onClose?.();
92
+ const closed = await onClose?.();
93
+ if (closed !== false) {
94
+ closedRef.current = true;
95
+ }
93
96
  }
94
97
  }}
95
98
  style={{
@@ -111,7 +114,7 @@ export const PageComponent = forwardRef((props: any, ref) => {
111
114
  {extra && <div>{extra}</div>}
112
115
  </div>
113
116
  );
114
- }, [header, _title, flowEngine.context.themeToken, styles.header, props.onClose]);
117
+ }, [header, _title, flowEngine.context.themeToken, styles.header, onClose]);
115
118
 
116
119
  // Footer 组件
117
120
  const FooterComponent = useMemo(() => {