@nocobase/flow-engine 2.1.0-beta.30 → 2.1.0-beta.33
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/subModel/LazyDropdown.js +79 -33
- package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
- package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
- package/lib/flow-registry/index.d.ts +1 -0
- package/lib/flow-registry/index.js +3 -1
- package/lib/index.d.ts +2 -0
- package/lib/index.js +7 -0
- package/lib/views/FlowView.js +11 -1
- package/lib/views/PageComponent.js +8 -6
- package/lib/views/usePage.d.ts +4 -11
- package/lib/views/usePage.js +301 -150
- package/package.json +4 -4
- package/src/components/subModel/LazyDropdown.tsx +89 -36
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +133 -4
- package/src/flow-registry/DetachedFlowRegistry.ts +46 -0
- package/src/flow-registry/__tests__/detachedFlowRegistry.test.ts +47 -0
- package/src/flow-registry/index.ts +1 -0
- package/src/index.ts +2 -0
- package/src/views/FlowView.tsx +11 -1
- package/src/views/PageComponent.tsx +7 -4
- package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
- package/src/views/usePage.tsx +364 -187
|
@@ -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:
|
|
595
|
-
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:
|
|
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 (
|
|
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
|
|
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
|
+
});
|
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';
|
package/src/views/FlowView.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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,
|
|
117
|
+
}, [header, _title, flowEngine.context.themeToken, styles.header, onClose]);
|
|
115
118
|
|
|
116
119
|
// Footer 组件
|
|
117
120
|
const FooterComponent = useMemo(() => {
|