@nocobase/flow-engine 3.0.0-alpha.1 → 3.0.0-alpha.11
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/FlowContextSelector.js +7 -1
- package/lib/components/subModel/LazyDropdown.js +21 -7
- package/lib/flowContext.d.ts +12 -1
- package/lib/flowContext.js +47 -6
- package/lib/resources/flowResource.js +1 -0
- package/lib/utils/associationObjectVariable.d.ts +10 -0
- package/lib/utils/associationObjectVariable.js +10 -7
- package/lib/utils/dateVariable.d.ts +22 -0
- package/lib/utils/dateVariable.js +123 -16
- package/lib/utils/dirtyAwareApiClient.d.ts +1 -0
- package/lib/utils/dirtyAwareApiClient.js +15 -2
- package/lib/utils/index.d.ts +3 -3
- package/lib/utils/index.js +8 -0
- package/lib/utils/params-resolvers.d.ts +3 -0
- package/lib/utils/params-resolvers.js +10 -0
- package/lib/utils/variablesParams.js +5 -0
- package/lib/views/createViewMeta.d.ts +1 -0
- package/lib/views/createViewMeta.js +53 -22
- package/package.json +4 -4
- package/src/__tests__/createViewMeta.popup.test.ts +84 -1
- package/src/__tests__/flowContext.test.ts +8 -0
- package/src/__tests__/objectVariable.test.ts +6 -1
- package/src/__tests__/runjsFormSubmit.test.ts +138 -0
- package/src/components/FlowContextSelector.tsx +7 -1
- package/src/components/subModel/LazyDropdown.tsx +28 -13
- package/src/components/subModel/__tests__/LazyDropdown.test.tsx +202 -0
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +35 -0
- package/src/flowContext.ts +79 -6
- package/src/resources/__tests__/flowResource.test.ts +3 -0
- package/src/resources/flowResource.ts +1 -0
- package/src/utils/__tests__/dateVariable.test.ts +57 -4
- package/src/utils/__tests__/variablesParams.test.ts +28 -1
- package/src/utils/associationObjectVariable.ts +9 -6
- package/src/utils/dateVariable.ts +145 -18
- package/src/utils/dirtyAwareApiClient.ts +25 -2
- package/src/utils/index.ts +17 -2
- package/src/utils/params-resolvers.ts +12 -0
- package/src/utils/variablesParams.ts +10 -0
- package/src/views/createViewMeta.ts +52 -18
|
@@ -311,6 +311,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
311
311
|
const path = selectedValues.map(String);
|
|
312
312
|
const pathString = path.join('.');
|
|
313
313
|
const isLeaf = lastOption?.isLeaf;
|
|
314
|
+
const isSelectable = lastOption?.meta?.selectable !== false;
|
|
314
315
|
const now = Date.now();
|
|
315
316
|
|
|
316
317
|
// 使用自定义格式化函数或默认函数
|
|
@@ -325,6 +326,10 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
325
326
|
}
|
|
326
327
|
|
|
327
328
|
if (isLeaf) {
|
|
329
|
+
if (!isSelectable) {
|
|
330
|
+
setTempSelectedPath(path);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
328
333
|
onChange?.(formattedValue, lastOption?.meta);
|
|
329
334
|
// 选中叶子节点后,可清空内部临时路径(外部 value 将驱动级联)
|
|
330
335
|
setTempSelectedPath([]);
|
|
@@ -333,7 +338,8 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
333
338
|
|
|
334
339
|
// 非叶子节点:检查双击
|
|
335
340
|
const lastSelected = lastSelectedRef.current;
|
|
336
|
-
const isDoubleClick =
|
|
341
|
+
const isDoubleClick =
|
|
342
|
+
isSelectable && !onlyLeafSelectable && lastSelected?.path === pathString && now - lastSelected.time < 300;
|
|
337
343
|
|
|
338
344
|
if (isDoubleClick) {
|
|
339
345
|
// 双击:选中非叶子节点
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { css } from '@emotion/css';
|
|
11
|
-
import { ConfigProvider, Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
|
|
12
|
-
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
+
import { ConfigProvider, Dropdown, DropdownProps, Empty, Input, InputProps, Spin, theme } from 'antd';
|
|
12
|
+
import React, { FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
13
13
|
import { useFlowEngine } from '../../provider';
|
|
14
14
|
|
|
15
15
|
// ==================== Types ====================
|
|
@@ -69,16 +69,6 @@ interface ExtendedMenuInfo {
|
|
|
69
69
|
|
|
70
70
|
// ==================== Custom Hooks ====================
|
|
71
71
|
|
|
72
|
-
/**
|
|
73
|
-
* 计算合适的下拉菜单最大高度
|
|
74
|
-
*/
|
|
75
|
-
const useNiceDropdownMaxHeight = () => {
|
|
76
|
-
return useMemo(() => {
|
|
77
|
-
const maxHeight = Math.min(window.innerHeight * 0.6, 400);
|
|
78
|
-
return maxHeight;
|
|
79
|
-
}, []);
|
|
80
|
-
};
|
|
81
|
-
|
|
82
72
|
/**
|
|
83
73
|
* 处理异步菜单项加载的逻辑
|
|
84
74
|
*/
|
|
@@ -555,6 +545,7 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
|
|
|
555
545
|
|
|
556
546
|
// 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
|
|
557
547
|
const DROPDOWN_PERSIST_TTL_MS = 350;
|
|
548
|
+
const DEFAULT_DROPDOWN_MAX_HEIGHT = 400;
|
|
558
549
|
const MENU_CLOSE_DELAY = 0.3;
|
|
559
550
|
const SUBMENU_MOTION_DISABLED = {
|
|
560
551
|
motionEnter: false,
|
|
@@ -565,8 +556,11 @@ const dropdownPersistRegistry: Map<string, number> = new Map();
|
|
|
565
556
|
const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
|
|
566
557
|
const engine = useFlowEngine();
|
|
567
558
|
const { getPrefixCls } = React.useContext(ConfigProvider.ConfigContext);
|
|
559
|
+
const { token } = theme.useToken();
|
|
568
560
|
const triggerId = React.useId();
|
|
561
|
+
const showArrow = Boolean(props.arrow);
|
|
569
562
|
const [menuVisible, setMenuVisible] = useState(false);
|
|
563
|
+
const [dropdownMaxHeight, setDropdownMaxHeight] = useState(DEFAULT_DROPDOWN_MAX_HEIGHT);
|
|
570
564
|
const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
|
|
571
565
|
const [rootItems, setRootItems] = useState<Item[]>([]);
|
|
572
566
|
const [rootLoading, setRootLoading] = useState(false);
|
|
@@ -578,7 +572,6 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
578
572
|
const mergedOpenClassName = [props.openClassName ?? defaultOpenClassName, triggerOpenClassName]
|
|
579
573
|
.filter(Boolean)
|
|
580
574
|
.join(' ');
|
|
581
|
-
const dropdownMaxHeight = useNiceDropdownMaxHeight();
|
|
582
575
|
const t = engine.translate.bind(engine);
|
|
583
576
|
|
|
584
577
|
// 解构 menu,避免在 effect 中直接依赖整个对象,减少不必要的重跑并满足 exhaustive-deps
|
|
@@ -597,6 +590,28 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
597
590
|
const { requestKeepOpen, shouldPreventClose } = useKeepDropdownOpen();
|
|
598
591
|
useSubmenuStyles(menuVisible, dropdownMaxHeight);
|
|
599
592
|
|
|
593
|
+
useLayoutEffect(() => {
|
|
594
|
+
if (!menuVisible) return;
|
|
595
|
+
|
|
596
|
+
const updateDropdownMaxHeight = () => {
|
|
597
|
+
const trigger = document.querySelector<HTMLElement>(`.${triggerOpenClassName}`);
|
|
598
|
+
if (!trigger) return;
|
|
599
|
+
|
|
600
|
+
const triggerRect = trigger.getBoundingClientRect();
|
|
601
|
+
const placementOffset = token.marginXXS + (showArrow ? token.sizePopupArrow / 2 : 0);
|
|
602
|
+
const reservedSpace = placementOffset + token.marginXXS;
|
|
603
|
+
const availableAbove = triggerRect.top - reservedSpace;
|
|
604
|
+
const availableBelow = window.innerHeight - triggerRect.bottom - reservedSpace;
|
|
605
|
+
const nextMaxHeight = Math.min(DEFAULT_DROPDOWN_MAX_HEIGHT, Math.max(0, availableAbove, availableBelow));
|
|
606
|
+
|
|
607
|
+
setDropdownMaxHeight(nextMaxHeight);
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
updateDropdownMaxHeight();
|
|
611
|
+
window.addEventListener('resize', updateDropdownMaxHeight);
|
|
612
|
+
return () => window.removeEventListener('resize', updateDropdownMaxHeight);
|
|
613
|
+
}, [menuVisible, showArrow, token.marginXXS, token.sizePopupArrow, triggerOpenClassName]);
|
|
614
|
+
|
|
600
615
|
const closeMenu = useCallback(() => {
|
|
601
616
|
setMenuVisible(false);
|
|
602
617
|
activeSearchKeyRef.current = null;
|
|
@@ -0,0 +1,202 @@
|
|
|
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 { act, render, screen, userEvent, waitFor } from '@nocobase/test/client';
|
|
11
|
+
import { ConfigProvider } from 'antd';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { FlowEngineProvider } from '../../../provider';
|
|
15
|
+
import { FlowEngine } from '../../../flowEngine';
|
|
16
|
+
import LazyDropdown from '../LazyDropdown';
|
|
17
|
+
|
|
18
|
+
const setViewportHeight = (height: number) => {
|
|
19
|
+
Object.defineProperty(window, 'innerHeight', {
|
|
20
|
+
configurable: true,
|
|
21
|
+
value: height,
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('LazyDropdown', () => {
|
|
26
|
+
const originalInnerHeight = window.innerHeight;
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
setViewportHeight(originalInnerHeight);
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('uses the current viewport space when opening after the viewport height changes', async () => {
|
|
34
|
+
setViewportHeight(720);
|
|
35
|
+
const engine = new FlowEngine();
|
|
36
|
+
const user = userEvent.setup();
|
|
37
|
+
|
|
38
|
+
render(
|
|
39
|
+
<FlowEngineProvider engine={engine}>
|
|
40
|
+
<ConfigProvider>
|
|
41
|
+
<LazyDropdown
|
|
42
|
+
trigger={['click']}
|
|
43
|
+
menu={{
|
|
44
|
+
items: [{ key: 'field', label: 'Field' }],
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<button type="button">Open fields</button>
|
|
48
|
+
</LazyDropdown>
|
|
49
|
+
</ConfigProvider>
|
|
50
|
+
</FlowEngineProvider>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const trigger = screen.getByRole('button', { name: 'Open fields' });
|
|
54
|
+
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
|
55
|
+
bottom: 196,
|
|
56
|
+
height: 32,
|
|
57
|
+
left: 49,
|
|
58
|
+
right: 141,
|
|
59
|
+
top: 164,
|
|
60
|
+
width: 92,
|
|
61
|
+
x: 49,
|
|
62
|
+
y: 164,
|
|
63
|
+
toJSON: () => ({}),
|
|
64
|
+
});
|
|
65
|
+
setViewportHeight(460);
|
|
66
|
+
|
|
67
|
+
await user.click(trigger);
|
|
68
|
+
|
|
69
|
+
const menu = await screen.findByRole('menu');
|
|
70
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '256px', overflowY: 'auto' }));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('updates the available height while the dropdown is open', async () => {
|
|
74
|
+
setViewportHeight(720);
|
|
75
|
+
const engine = new FlowEngine();
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
|
|
78
|
+
render(
|
|
79
|
+
<FlowEngineProvider engine={engine}>
|
|
80
|
+
<ConfigProvider>
|
|
81
|
+
<LazyDropdown
|
|
82
|
+
trigger={['click']}
|
|
83
|
+
menu={{
|
|
84
|
+
items: [{ key: 'field', label: 'Field' }],
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<button type="button">Open fields</button>
|
|
88
|
+
</LazyDropdown>
|
|
89
|
+
</ConfigProvider>
|
|
90
|
+
</FlowEngineProvider>,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const trigger = screen.getByRole('button', { name: 'Open fields' });
|
|
94
|
+
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
|
95
|
+
bottom: 196,
|
|
96
|
+
height: 32,
|
|
97
|
+
left: 49,
|
|
98
|
+
right: 141,
|
|
99
|
+
top: 164,
|
|
100
|
+
width: 92,
|
|
101
|
+
x: 49,
|
|
102
|
+
y: 164,
|
|
103
|
+
toJSON: () => ({}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await user.click(trigger);
|
|
107
|
+
|
|
108
|
+
const menu = await screen.findByRole('menu');
|
|
109
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '400px', overflowY: 'auto' }));
|
|
110
|
+
|
|
111
|
+
act(() => {
|
|
112
|
+
setViewportHeight(460);
|
|
113
|
+
window.dispatchEvent(new Event('resize'));
|
|
114
|
+
});
|
|
115
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '256px', overflowY: 'auto' }));
|
|
116
|
+
|
|
117
|
+
act(() => {
|
|
118
|
+
setViewportHeight(720);
|
|
119
|
+
window.dispatchEvent(new Event('resize'));
|
|
120
|
+
});
|
|
121
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '400px', overflowY: 'auto' }));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('reserves the placement offset when the dropdown has an arrow', async () => {
|
|
125
|
+
setViewportHeight(460);
|
|
126
|
+
const engine = new FlowEngine();
|
|
127
|
+
const user = userEvent.setup();
|
|
128
|
+
|
|
129
|
+
render(
|
|
130
|
+
<FlowEngineProvider engine={engine}>
|
|
131
|
+
<ConfigProvider>
|
|
132
|
+
<LazyDropdown
|
|
133
|
+
arrow
|
|
134
|
+
trigger={['click']}
|
|
135
|
+
menu={{
|
|
136
|
+
items: [{ key: 'field', label: 'Field' }],
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<button type="button">Open fields</button>
|
|
140
|
+
</LazyDropdown>
|
|
141
|
+
</ConfigProvider>
|
|
142
|
+
</FlowEngineProvider>,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const trigger = screen.getByRole('button', { name: 'Open fields' });
|
|
146
|
+
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
|
147
|
+
bottom: 196,
|
|
148
|
+
height: 32,
|
|
149
|
+
left: 49,
|
|
150
|
+
right: 141,
|
|
151
|
+
top: 164,
|
|
152
|
+
width: 92,
|
|
153
|
+
x: 49,
|
|
154
|
+
y: 164,
|
|
155
|
+
toJSON: () => ({}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await user.click(trigger);
|
|
159
|
+
|
|
160
|
+
const menu = await screen.findByRole('menu');
|
|
161
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '248px', overflowY: 'auto' }));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('uses the space above when it is larger than the space below', async () => {
|
|
165
|
+
setViewportHeight(460);
|
|
166
|
+
const engine = new FlowEngine();
|
|
167
|
+
const user = userEvent.setup();
|
|
168
|
+
|
|
169
|
+
render(
|
|
170
|
+
<FlowEngineProvider engine={engine}>
|
|
171
|
+
<ConfigProvider>
|
|
172
|
+
<LazyDropdown
|
|
173
|
+
trigger={['click']}
|
|
174
|
+
menu={{
|
|
175
|
+
items: [{ key: 'field', label: 'Field' }],
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<button type="button">Open fields</button>
|
|
179
|
+
</LazyDropdown>
|
|
180
|
+
</ConfigProvider>
|
|
181
|
+
</FlowEngineProvider>,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const trigger = screen.getByRole('button', { name: 'Open fields' });
|
|
185
|
+
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
|
186
|
+
bottom: 332,
|
|
187
|
+
height: 32,
|
|
188
|
+
left: 49,
|
|
189
|
+
right: 141,
|
|
190
|
+
top: 300,
|
|
191
|
+
width: 92,
|
|
192
|
+
x: 49,
|
|
193
|
+
y: 300,
|
|
194
|
+
toJSON: () => ({}),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await user.click(trigger);
|
|
198
|
+
|
|
199
|
+
const menu = await screen.findByRole('menu');
|
|
200
|
+
await waitFor(() => expect(menu).toHaveStyle({ maxHeight: '292px', overflowY: 'auto' }));
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -864,4 +864,39 @@ describe('FlowContextSelector', () => {
|
|
|
864
864
|
// It should only expand the node, not select it
|
|
865
865
|
expect(onChange).not.toHaveBeenCalled();
|
|
866
866
|
});
|
|
867
|
+
|
|
868
|
+
it('should expand but never select a node marked selectable=false', async () => {
|
|
869
|
+
const onChange = vi.fn();
|
|
870
|
+
const flowContext = createTestFlowContext();
|
|
871
|
+
const metaTree = [
|
|
872
|
+
{
|
|
873
|
+
name: 'date',
|
|
874
|
+
title: 'Date',
|
|
875
|
+
type: 'date',
|
|
876
|
+
paths: ['date'],
|
|
877
|
+
selectable: false,
|
|
878
|
+
children: [{ name: 'today', title: 'Today', type: 'date', paths: ['date', 'today'] }],
|
|
879
|
+
},
|
|
880
|
+
];
|
|
881
|
+
|
|
882
|
+
render(
|
|
883
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
884
|
+
<FlowContextSelector metaTree={metaTree} onChange={onChange} />
|
|
885
|
+
</TestFlowContextWrapper>,
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
fireEvent.click(screen.getByRole('button'));
|
|
889
|
+
await waitFor(() => expect(screen.getByText('Date')).toBeInTheDocument());
|
|
890
|
+
|
|
891
|
+
fireEvent.click(screen.getByText('Date'));
|
|
892
|
+
fireEvent.click(screen.getByText('Date'));
|
|
893
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
894
|
+
|
|
895
|
+
await waitFor(() => expect(screen.getByText('Today')).toBeInTheDocument());
|
|
896
|
+
fireEvent.click(screen.getByText('Today'));
|
|
897
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
898
|
+
'{{ ctx.date.today }}',
|
|
899
|
+
expect.objectContaining({ paths: ['date', 'today'] }),
|
|
900
|
+
);
|
|
901
|
+
});
|
|
867
902
|
});
|
package/src/flowContext.ts
CHANGED
|
@@ -50,11 +50,11 @@ import {
|
|
|
50
50
|
resolveModuleUrl,
|
|
51
51
|
} from './utils';
|
|
52
52
|
import { FlowExitAllException } from './utils/exceptions';
|
|
53
|
-
import { enqueueVariablesResolve, JSONValue } from './utils/params-resolvers';
|
|
53
|
+
import { buildFlowModelResolveDescriptor, enqueueVariablesResolve, JSONValue } from './utils/params-resolvers';
|
|
54
54
|
import type { RecordRef } from './utils/serverContextParams';
|
|
55
55
|
import { buildServerContextParams as _buildServerContextParams } from './utils/serverContextParams';
|
|
56
|
-
import { getDirtyAwareApiClient } from './utils/dirtyAwareApiClient';
|
|
57
|
-
import { inferRecordRef } from './utils/variablesParams';
|
|
56
|
+
import { getDirtyAwareApiClient, PREPARE_CONTEXT_RESOURCE_ACTION_PARAMS } from './utils/dirtyAwareApiClient';
|
|
57
|
+
import { inferRecordRef, inferViewRecordRef } from './utils/variablesParams';
|
|
58
58
|
import { FlowView, FlowViewer } from './views/FlowView';
|
|
59
59
|
import { RunJSContextRegistry, getModelClassName, type RunJSVersion } from './runjs-context/registry';
|
|
60
60
|
import { createEphemeralContext } from './utils/createEphemeralContext';
|
|
@@ -165,6 +165,10 @@ function inferSelectsFromUsage(paths: string[] = []): { generatedAppends?: strin
|
|
|
165
165
|
|
|
166
166
|
type Getter<T = any> = (ctx: FlowContext) => T | Promise<T>;
|
|
167
167
|
|
|
168
|
+
export type ResolveJsonTemplateOptions = {
|
|
169
|
+
contractModelUid?: string | number | null;
|
|
170
|
+
};
|
|
171
|
+
|
|
168
172
|
export type FlowContextDocRef = string | { url: string; title?: string };
|
|
169
173
|
|
|
170
174
|
export type FlowDeprecationDoc =
|
|
@@ -221,6 +225,8 @@ export interface MetaTreeNode {
|
|
|
221
225
|
// 变量禁用状态与原因(用于变量选择器 UI 展示)
|
|
222
226
|
disabled?: boolean | (() => boolean);
|
|
223
227
|
disabledReason?: string | (() => string | undefined);
|
|
228
|
+
// 允许节点仅用于展开子级,而不能作为变量值被选中
|
|
229
|
+
selectable?: boolean;
|
|
224
230
|
children?: MetaTreeNode[] | (() => Promise<MetaTreeNode[]>);
|
|
225
231
|
}
|
|
226
232
|
|
|
@@ -3044,7 +3050,7 @@ class BaseFlowEngineContext extends FlowContext {
|
|
|
3044
3050
|
* @deprecated use `resolveJsonTemplate` instead
|
|
3045
3051
|
*/
|
|
3046
3052
|
declare renderJson: (template: JSONValue) => Promise<any>;
|
|
3047
|
-
declare resolveJsonTemplate: (template: JSONValue) => Promise<any>;
|
|
3053
|
+
declare resolveJsonTemplate: (template: JSONValue, options?: ResolveJsonTemplateOptions) => Promise<any>;
|
|
3048
3054
|
declare getVar: (path: string) => Promise<any>;
|
|
3049
3055
|
declare request: (options: RequestOptions) => Promise<any>;
|
|
3050
3056
|
declare runjs: (code: string, variables?: Record<string, any>, options?: JSRunnerOptions) => Promise<any>;
|
|
@@ -3227,7 +3233,11 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3227
3233
|
this.defineMethod('renderJson', function (template: any) {
|
|
3228
3234
|
return this.resolveJsonTemplate(template);
|
|
3229
3235
|
});
|
|
3230
|
-
|
|
3236
|
+
const resolveJsonTemplate = async function (
|
|
3237
|
+
this: BaseFlowEngineContext,
|
|
3238
|
+
template: any,
|
|
3239
|
+
options?: ResolveJsonTemplateOptions,
|
|
3240
|
+
) {
|
|
3231
3241
|
// 提取模板使用到的变量及其子路径
|
|
3232
3242
|
const used = extractUsedVariablePaths(template);
|
|
3233
3243
|
const usedVarNames = Object.keys(used || {});
|
|
@@ -3316,6 +3326,15 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3316
3326
|
const inputFromMeta = await collectFromMeta();
|
|
3317
3327
|
const autoInput = { ...inputFromMeta } as Record<string, any>;
|
|
3318
3328
|
|
|
3329
|
+
const viewPaths = serverVarPaths.view || [];
|
|
3330
|
+
if (
|
|
3331
|
+
!autoInput.view &&
|
|
3332
|
+
viewPaths.some((path) => path === 'record' || path.startsWith('record.') || path.startsWith('record['))
|
|
3333
|
+
) {
|
|
3334
|
+
const recordRef = inferViewRecordRef(this);
|
|
3335
|
+
if (recordRef) autoInput.view = { record: recordRef };
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3319
3338
|
// Special-case: formValues
|
|
3320
3339
|
// If server needs to resolve some formValues paths but meta params only cover association anchors
|
|
3321
3340
|
// (e.g. formValues.customer) and some top-level paths are missing (e.g. formValues.status),
|
|
@@ -3387,7 +3406,13 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3387
3406
|
|
|
3388
3407
|
if (this.api) {
|
|
3389
3408
|
try {
|
|
3409
|
+
const contractRd = buildFlowModelResolveDescriptor(
|
|
3410
|
+
this as FlowRuntimeContext<FlowModel>,
|
|
3411
|
+
options?.contractModelUid,
|
|
3412
|
+
);
|
|
3390
3413
|
serverResolved = await enqueueVariablesResolve(this as FlowRuntimeContext<FlowModel>, {
|
|
3414
|
+
...(contractRd ? { contractRd } : {}),
|
|
3415
|
+
rd: buildFlowModelResolveDescriptor(this as FlowRuntimeContext<FlowModel>, this.model?.uid),
|
|
3391
3416
|
template,
|
|
3392
3417
|
contextParams: autoContextParams || {},
|
|
3393
3418
|
});
|
|
@@ -3399,7 +3424,8 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3399
3424
|
}
|
|
3400
3425
|
|
|
3401
3426
|
return resolveExpressions(serverResolved, this);
|
|
3402
|
-
}
|
|
3427
|
+
};
|
|
3428
|
+
this.defineMethod('resolveJsonTemplate', resolveJsonTemplate);
|
|
3403
3429
|
|
|
3404
3430
|
// Helper: resolve a single ctx expression value via resolveJsonTemplate behavior.
|
|
3405
3431
|
// Example: await ctx.getVar('ctx.record.id')
|
|
@@ -4582,9 +4608,56 @@ function __mergeRunJSDocMeta(base: any, patch: any): RunJSDocMeta {
|
|
|
4582
4608
|
return out as RunJSDocMeta;
|
|
4583
4609
|
}
|
|
4584
4610
|
export class FlowRunJSContext extends FlowContext {
|
|
4611
|
+
[PREPARE_CONTEXT_RESOURCE_ACTION_PARAMS](
|
|
4612
|
+
action: { actionName: string; dataSourceKey?: string; resourceName: string; resourceOf?: unknown },
|
|
4613
|
+
params: Record<string, unknown> | undefined,
|
|
4614
|
+
) {
|
|
4615
|
+
if (
|
|
4616
|
+
action.actionName.toLowerCase() !== 'create' ||
|
|
4617
|
+
!params ||
|
|
4618
|
+
Array.isArray(params) ||
|
|
4619
|
+
Object.prototype.hasOwnProperty.call(params, 'updateAssociationValues') ||
|
|
4620
|
+
!this.form ||
|
|
4621
|
+
typeof this.blockModel?.submitFromRunJs !== 'function'
|
|
4622
|
+
) {
|
|
4623
|
+
return params;
|
|
4624
|
+
}
|
|
4625
|
+
|
|
4626
|
+
const resource = this.resource;
|
|
4627
|
+
const currentResourceName = resource?.getResourceName?.();
|
|
4628
|
+
const currentDataSourceKey = resource?.getDataSourceKey?.() || 'main';
|
|
4629
|
+
if (action.resourceName !== currentResourceName || (action.dataSourceKey || 'main') !== currentDataSourceKey) {
|
|
4630
|
+
return params;
|
|
4631
|
+
}
|
|
4632
|
+
|
|
4633
|
+
const currentSourceId = resource?.getSourceId?.();
|
|
4634
|
+
if (
|
|
4635
|
+
currentResourceName?.includes('.') &&
|
|
4636
|
+
currentSourceId !== null &&
|
|
4637
|
+
typeof currentSourceId !== 'undefined' &&
|
|
4638
|
+
String(action.resourceOf ?? '') !== String(currentSourceId)
|
|
4639
|
+
) {
|
|
4640
|
+
return params;
|
|
4641
|
+
}
|
|
4642
|
+
|
|
4643
|
+
const updateAssociationValues = resource?.getUpdateAssociationValues?.();
|
|
4644
|
+
if (!Array.isArray(updateAssociationValues) || updateAssociationValues.length === 0) {
|
|
4645
|
+
return params;
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
return {
|
|
4649
|
+
...params,
|
|
4650
|
+
updateAssociationValues: [...updateAssociationValues],
|
|
4651
|
+
};
|
|
4652
|
+
}
|
|
4653
|
+
|
|
4585
4654
|
constructor(delegate: FlowContext) {
|
|
4586
4655
|
super();
|
|
4587
4656
|
this.addDelegate(delegate);
|
|
4657
|
+
const submit = delegate.blockModel?.submitFromRunJs?.bind(delegate.blockModel);
|
|
4658
|
+
if (delegate.form && submit) {
|
|
4659
|
+
this.defineProperty('form', { value: { ...delegate.form, submit } });
|
|
4660
|
+
}
|
|
4588
4661
|
this.defineProperty('React', { value: React });
|
|
4589
4662
|
this.defineProperty('antd', { value: antd });
|
|
4590
4663
|
this.defineProperty('dayjs', {
|
|
@@ -79,6 +79,9 @@ describe('FlowResource - error handling', () => {
|
|
|
79
79
|
expect(r.getError()).toBeNull();
|
|
80
80
|
|
|
81
81
|
const err = new ResourceError({ response: { data: { error: { message: 'boom', code: 'X' } } } });
|
|
82
|
+
expect(err.data).toEqual({ message: 'boom', code: 'X' });
|
|
83
|
+
expect(err.message).toBe('boom');
|
|
84
|
+
expect(err.code).toBe('X');
|
|
82
85
|
const ret = r.setError(err);
|
|
83
86
|
expect(ret).toBe(r);
|
|
84
87
|
expect(r.error).toBe(err);
|
|
@@ -12,9 +12,12 @@ import {
|
|
|
12
12
|
decodeBase64Url,
|
|
13
13
|
encodeBase64Url,
|
|
14
14
|
isCompleteCtxDatePath,
|
|
15
|
+
isCtxDatePathPrefix,
|
|
15
16
|
isCtxDateExpression,
|
|
16
17
|
parseCtxDateExpression,
|
|
18
|
+
parseCtxDateExpressionConfig,
|
|
17
19
|
resolveCtxDatePath,
|
|
20
|
+
serializeCtxDateExpressionConfig,
|
|
18
21
|
serializeCtxDateValue,
|
|
19
22
|
} from '../dateVariable';
|
|
20
23
|
|
|
@@ -54,13 +57,60 @@ describe('dateVariable utils', () => {
|
|
|
54
57
|
number: 2,
|
|
55
58
|
});
|
|
56
59
|
|
|
57
|
-
const singleExpr = serializeCtxDateValue('2026-02-12')
|
|
60
|
+
const singleExpr = serializeCtxDateValue('2026-02-12');
|
|
61
|
+
if (!singleExpr) throw new Error('Expected exact date expression');
|
|
58
62
|
expect(parseCtxDateExpression(singleExpr)).toBe('2026-02-12');
|
|
59
63
|
|
|
60
|
-
const rangeExpr = serializeCtxDateValue(['2026-02-12', '2026-02-20'])
|
|
64
|
+
const rangeExpr = serializeCtxDateValue(['2026-02-12', '2026-02-20']);
|
|
65
|
+
if (!rangeExpr) throw new Error('Expected exact date range expression');
|
|
61
66
|
expect(parseCtxDateExpression(rangeExpr)).toEqual(['2026-02-12', '2026-02-20']);
|
|
62
67
|
});
|
|
63
68
|
|
|
69
|
+
it('serializes, parses and resolves formatted expressions', () => {
|
|
70
|
+
const expression = serializeCtxDateExpressionConfig({
|
|
71
|
+
kind: 'preset',
|
|
72
|
+
preset: 'today',
|
|
73
|
+
format: 'YYYY/MM/DD',
|
|
74
|
+
});
|
|
75
|
+
if (!expression) throw new Error('Expected formatted date expression');
|
|
76
|
+
|
|
77
|
+
expect(expression).toMatch(/^\{\{ ctx\.date\.format\.v[A-Za-z0-9_-]+\.preset\.today \}\}$/);
|
|
78
|
+
expect(parseCtxDateExpressionConfig(expression)).toEqual({
|
|
79
|
+
kind: 'preset',
|
|
80
|
+
preset: 'today',
|
|
81
|
+
format: 'YYYY/MM/DD',
|
|
82
|
+
});
|
|
83
|
+
// Keep the legacy parser contract for filter-form consumers.
|
|
84
|
+
expect(parseCtxDateExpression(expression)).toEqual({ type: 'today' });
|
|
85
|
+
|
|
86
|
+
const path = expression.replace('{{ ctx.', '').replace(' }}', '').split('.');
|
|
87
|
+
expect(resolveCtxDatePath(path)).toMatch(/^\d{4}\/\d{2}\/\d{2}$/);
|
|
88
|
+
expect(isCompleteCtxDatePath(path)).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('preserves significant whitespace in a custom Format', () => {
|
|
92
|
+
const expression = serializeCtxDateExpressionConfig({
|
|
93
|
+
kind: 'preset',
|
|
94
|
+
preset: 'today',
|
|
95
|
+
format: 'YYYY-MM-DD ',
|
|
96
|
+
});
|
|
97
|
+
if (!expression) throw new Error('Expected formatted date expression');
|
|
98
|
+
|
|
99
|
+
expect(parseCtxDateExpressionConfig(expression)?.format).toBe('YYYY-MM-DD ');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('formats exact ranges element by element', () => {
|
|
103
|
+
const expression = serializeCtxDateExpressionConfig({
|
|
104
|
+
kind: 'exact',
|
|
105
|
+
value: ['2026-02-12', '2026-02-20'],
|
|
106
|
+
format: 'YYYYMMDD',
|
|
107
|
+
});
|
|
108
|
+
if (!expression) throw new Error('Expected formatted date range expression');
|
|
109
|
+
const path = expression.replace('{{ ctx.', '').replace(' }}', '').split('.');
|
|
110
|
+
|
|
111
|
+
expect(resolveCtxDatePath(path)).toEqual(['20260212', '20260220']);
|
|
112
|
+
});
|
|
113
|
+
|
|
64
114
|
it('resolves preset/relative/exact path', () => {
|
|
65
115
|
expect(typeof resolveCtxDatePath(['date', 'preset', 'now'])).toBe('string');
|
|
66
116
|
|
|
@@ -72,11 +122,13 @@ describe('dateVariable utils', () => {
|
|
|
72
122
|
expect(typeof rel).toBe('string');
|
|
73
123
|
expect(rel).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
74
124
|
|
|
75
|
-
const singleExpr = serializeCtxDateValue('2026-02-12')
|
|
125
|
+
const singleExpr = serializeCtxDateValue('2026-02-12');
|
|
126
|
+
if (!singleExpr) throw new Error('Expected exact date expression');
|
|
76
127
|
const token = singleExpr.replace('{{ ctx.date.exact.single.date.', '').replace(' }}', '');
|
|
77
128
|
expect(resolveCtxDatePath(['date', 'exact', 'single', 'date', token])).toBe('2026-02-12');
|
|
78
129
|
|
|
79
|
-
const rangeExpr = serializeCtxDateValue(['2026-02-12', '2026-02-20'])
|
|
130
|
+
const rangeExpr = serializeCtxDateValue(['2026-02-12', '2026-02-20']);
|
|
131
|
+
if (!rangeExpr) throw new Error('Expected exact date range expression');
|
|
80
132
|
const parts = rangeExpr.replace('{{ ctx.date.exact.range.date.', '').replace(' }}', '').split('.');
|
|
81
133
|
expect(resolveCtxDatePath(['date', 'exact', 'range', 'date', parts[0], parts[1]])).toEqual([
|
|
82
134
|
'2026-02-12',
|
|
@@ -90,6 +142,7 @@ describe('dateVariable utils', () => {
|
|
|
90
142
|
expect(isCompleteCtxDatePath(['date', 'exact', 'single', 'date', 'vabc'])).toBe(true);
|
|
91
143
|
expect(isCompleteCtxDatePath(['date', 'exact', 'range', 'date', 'vabc', 'vdef'])).toBe(true);
|
|
92
144
|
expect(isCompleteCtxDatePath(['date', 'relative', 'next', 'day'])).toBe(false);
|
|
145
|
+
expect(isCtxDatePathPrefix(['date', 'format'])).toBe(true);
|
|
93
146
|
expect(isCompleteCtxDatePath(['user', 'name'])).toBe(false);
|
|
94
147
|
});
|
|
95
148
|
|
|
@@ -34,7 +34,8 @@ describe('variablesParams helpers', () => {
|
|
|
34
34
|
|
|
35
35
|
it('inferRecordRef fallback to collection.getFilterByTK when resource has no filterByTk', () => {
|
|
36
36
|
const engine = new FlowEngine();
|
|
37
|
-
const ds = engine.context.dataSourceManager.getDataSource('main')
|
|
37
|
+
const ds = engine.context.dataSourceManager.getDataSource('main');
|
|
38
|
+
if (!ds) throw new Error('main data source is required');
|
|
38
39
|
ds.addCollection({
|
|
39
40
|
name: 'users',
|
|
40
41
|
filterTargetKey: 'id',
|
|
@@ -108,6 +109,32 @@ describe('variablesParams helpers', () => {
|
|
|
108
109
|
});
|
|
109
110
|
});
|
|
110
111
|
|
|
112
|
+
it('collectContextParamsForTemplate infers view.record when its meta has no descriptor', async () => {
|
|
113
|
+
const ctx: any = {
|
|
114
|
+
getPropertyOptions: () => undefined,
|
|
115
|
+
view: {
|
|
116
|
+
inputArgs: {
|
|
117
|
+
collectionName: 'posts',
|
|
118
|
+
dataSourceKey: 'main',
|
|
119
|
+
filterByTk: 3,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const res = await collectContextParamsForTemplate(ctx, {
|
|
125
|
+
recordId: '{{ ctx.view.record.id }}',
|
|
126
|
+
viewType: '{{ ctx.view.type }}',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(res).toEqual({
|
|
130
|
+
'view.record': {
|
|
131
|
+
collection: 'posts',
|
|
132
|
+
dataSourceKey: 'main',
|
|
133
|
+
filterByTk: 3,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
111
138
|
it('createRecordResolveOnServerWithLocal: no local record => always use server', () => {
|
|
112
139
|
const resolver = createRecordResolveOnServerWithLocal(
|
|
113
140
|
() => ({ name: 'posts', dataSourceKey: 'main' }) as any,
|