@nocobase/flow-engine 2.2.0-beta.15 → 2.2.0-beta.17
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/acl/Acl.d.ts +2 -1
- package/lib/acl/Acl.js +28 -0
- package/lib/components/FlowContextSelector.js +7 -1
- package/lib/components/MobilePopup.js +14 -3
- package/lib/components/subModel/LazyDropdown.js +41 -26
- package/lib/flowContext.d.ts +5 -1
- package/lib/flowContext.js +18 -6
- package/lib/locale/en-US.json +2 -0
- package/lib/locale/index.d.ts +4 -0
- package/lib/locale/zh-CN.json +2 -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/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/acl/Acl.tsx +36 -1
- package/src/acl/__tests__/Acl.test.tsx +70 -0
- package/src/components/FlowContextSelector.tsx +7 -1
- package/src/components/MobilePopup.tsx +16 -4
- package/src/components/__tests__/MobilePopup.test.tsx +42 -1
- package/src/components/subModel/LazyDropdown.tsx +44 -26
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +35 -0
- package/src/flowContext.ts +31 -5
- package/src/locale/__tests__/index.test.ts +21 -0
- package/src/locale/en-US.json +2 -0
- package/src/locale/zh-CN.json +2 -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/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
|
@@ -16,6 +16,7 @@ import { RunJSContextRegistry } from '../runjs-context/registry';
|
|
|
16
16
|
import { setupRunJSContexts } from '../runjs-context/setup';
|
|
17
17
|
import { createViewScopedEngine } from '../ViewScopedFlowEngine';
|
|
18
18
|
import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
|
|
19
|
+
import { serializeCtxDateExpressionConfig } from '../utils/dateVariable';
|
|
19
20
|
|
|
20
21
|
describe('FlowContext properties and methods', () => {
|
|
21
22
|
it('should return static property value', () => {
|
|
@@ -2068,10 +2069,16 @@ describe('getPropertyMetaTree with deep delegate meta', () => {
|
|
|
2068
2069
|
describe('FlowContext resolveOnServer selective server resolution', () => {
|
|
2069
2070
|
it('resolves ctx.date expressions on client context', async () => {
|
|
2070
2071
|
const engine = new FlowEngine();
|
|
2072
|
+
const formattedToday = serializeCtxDateExpressionConfig({
|
|
2073
|
+
kind: 'preset',
|
|
2074
|
+
preset: 'today',
|
|
2075
|
+
format: 'YYYY/MM/DD',
|
|
2076
|
+
});
|
|
2071
2077
|
const out = await (engine.context as any).resolveJsonTemplate({
|
|
2072
2078
|
today: '{{ ctx.date.preset.today }}',
|
|
2073
2079
|
next12: '{{ ctx.date.relative.next.day.n12 }}',
|
|
2074
2080
|
now: '{{ ctx.date.preset.now }}',
|
|
2081
|
+
formattedToday,
|
|
2075
2082
|
});
|
|
2076
2083
|
|
|
2077
2084
|
expect(typeof out.today).toBe('string');
|
|
@@ -2080,6 +2087,7 @@ describe('FlowContext resolveOnServer selective server resolution', () => {
|
|
|
2080
2087
|
expect(out.next12).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
2081
2088
|
expect(typeof out.now).toBe('string');
|
|
2082
2089
|
expect(out.now.length).toBeGreaterThan(0);
|
|
2090
|
+
expect(out.formattedToday).toMatch(/^\d{4}\/\d{2}\/\d{2}$/);
|
|
2083
2091
|
});
|
|
2084
2092
|
|
|
2085
2093
|
it('does not call server by default (no resolveOnServer set)', async () => {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { generateFlowModelRdFromToken } from '@nocobase/utils/client';
|
|
11
12
|
import { FlowContext } from '../flowContext';
|
|
12
13
|
import { FlowEngine } from '../flowEngine';
|
|
13
14
|
import {
|
|
@@ -122,7 +123,10 @@ describe('objectVariable utilities', () => {
|
|
|
122
123
|
|
|
123
124
|
// Provide API stub to intercept variables:resolve
|
|
124
125
|
const calls: any[] = [];
|
|
126
|
+
const payload = Buffer.from(JSON.stringify({ userId: 1, signInTime: 'contract-owner-test' })).toString('base64url');
|
|
127
|
+
const token = `test.${payload}.sig`;
|
|
125
128
|
(ctx as any).api = {
|
|
129
|
+
auth: { token },
|
|
126
130
|
request: vi.fn(async ({ url, data, method }) => {
|
|
127
131
|
calls.push({ url, data, method });
|
|
128
132
|
const batch = (data?.values?.batch as any[]) || [];
|
|
@@ -146,13 +150,14 @@ describe('objectVariable utilities', () => {
|
|
|
146
150
|
});
|
|
147
151
|
|
|
148
152
|
const template = { x: '{{ ctx.obj.author.name }}' } as any;
|
|
149
|
-
await (ctx as any).resolveJsonTemplate(template);
|
|
153
|
+
await (ctx as any).resolveJsonTemplate(template, { contractModelUid: 'form-grid' });
|
|
150
154
|
|
|
151
155
|
// Assert variables:resolve was called with proper flattened contextParams
|
|
152
156
|
expect((ctx as any).api.request).toHaveBeenCalled();
|
|
153
157
|
const call = calls.find((c) => c.url === 'variables:resolve');
|
|
154
158
|
expect(call).toBeTruthy();
|
|
155
159
|
const batch0 = call.data?.values?.batch?.[0];
|
|
160
|
+
expect(batch0?.contractRd).toBe(generateFlowModelRdFromToken('form-grid', token));
|
|
156
161
|
expect(batch0?.contextParams).toBeTruthy();
|
|
157
162
|
// Flattened key should be 'obj.author'
|
|
158
163
|
const cp = batch0.contextParams as Record<string, any>;
|
package/src/acl/Acl.tsx
CHANGED
|
@@ -16,7 +16,7 @@ interface CheckOptions {
|
|
|
16
16
|
actionName: string;
|
|
17
17
|
fields?: string[];
|
|
18
18
|
recordPkValue?: string | number;
|
|
19
|
-
allowedActions
|
|
19
|
+
allowedActions?: Record<string, Array<string | number>>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export class ACL {
|
|
@@ -149,6 +149,41 @@ export class ACL {
|
|
|
149
149
|
return allowed;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
can(options: CheckOptions): boolean {
|
|
153
|
+
const { allowAll } = this.data;
|
|
154
|
+
if (allowAll) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { actionName, allowedActions, recordPkValue } = options;
|
|
159
|
+
const hasRecordPkValue = recordPkValue !== undefined && recordPkValue !== null;
|
|
160
|
+
const recordPermission =
|
|
161
|
+
hasRecordPkValue && allowedActions ? this.verifyScope(actionName, recordPkValue, allowedActions) : null;
|
|
162
|
+
if (hasRecordPkValue && allowedActions && recordPermission !== true) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const params = this.parseAction(options);
|
|
167
|
+
if (!params) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (!_.isEmpty(params.filter) && recordPermission !== true) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (!options.fields?.length) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const allowedFields: string[] = []
|
|
178
|
+
.concat(params.whitelist || [])
|
|
179
|
+
.concat(params.fields || [])
|
|
180
|
+
.concat(params.appends || []);
|
|
181
|
+
if (!allowedFields.length) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return options.fields.every((field) => allowedFields.includes(field));
|
|
185
|
+
}
|
|
186
|
+
|
|
152
187
|
async aclCheck(options: CheckOptions): Promise<boolean> {
|
|
153
188
|
// await this.load();
|
|
154
189
|
const { allowAll } = this.data;
|
|
@@ -71,6 +71,76 @@ describe('ACL', () => {
|
|
|
71
71
|
expect(notOk).toBe(false);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
it('checks record update scope before field permission', () => {
|
|
75
|
+
const payload = {
|
|
76
|
+
data: {
|
|
77
|
+
allowAll: false,
|
|
78
|
+
actionAlias: {},
|
|
79
|
+
resources: ['posts'],
|
|
80
|
+
actions: { 'posts:update': { whitelist: ['title'] } },
|
|
81
|
+
strategy: { actions: [] },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const engine = makeEngine(payload);
|
|
85
|
+
const acl = new ACL(engine);
|
|
86
|
+
acl.setData(payload.data);
|
|
87
|
+
|
|
88
|
+
const options = {
|
|
89
|
+
dataSourceKey: 'main',
|
|
90
|
+
resourceName: 'posts',
|
|
91
|
+
actionName: 'update',
|
|
92
|
+
allowedActions: {
|
|
93
|
+
update: [1],
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
expect(acl.can({ ...options, recordPkValue: 1, fields: ['title'] })).toBe(true);
|
|
98
|
+
expect(acl.can({ ...options, recordPkValue: 2, fields: ['title'] })).toBe(false);
|
|
99
|
+
expect(acl.can({ ...options, recordPkValue: 1, fields: ['body'] })).toBe(false);
|
|
100
|
+
expect(acl.can({ ...options, recordPkValue: 0, fields: ['title'] })).toBe(false);
|
|
101
|
+
expect(
|
|
102
|
+
acl.can({
|
|
103
|
+
dataSourceKey: 'main',
|
|
104
|
+
resourceName: 'posts',
|
|
105
|
+
actionName: 'update',
|
|
106
|
+
fields: ['title'],
|
|
107
|
+
}),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('allows every field when a scoped action has no field restriction', () => {
|
|
112
|
+
const payload = {
|
|
113
|
+
data: {
|
|
114
|
+
allowAll: false,
|
|
115
|
+
actionAlias: {},
|
|
116
|
+
resources: ['posts'],
|
|
117
|
+
actions: {
|
|
118
|
+
'posts:update': {
|
|
119
|
+
filter: { createdById: '{{ ctx.state.currentUser.id }}' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
strategy: { actions: [] },
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const engine = makeEngine(payload);
|
|
126
|
+
const acl = new ACL(engine);
|
|
127
|
+
acl.setData(payload.data);
|
|
128
|
+
|
|
129
|
+
const options = {
|
|
130
|
+
dataSourceKey: 'main',
|
|
131
|
+
resourceName: 'posts',
|
|
132
|
+
actionName: 'update',
|
|
133
|
+
allowedActions: {
|
|
134
|
+
update: [1],
|
|
135
|
+
},
|
|
136
|
+
fields: ['title'],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
expect(acl.can({ ...options, recordPkValue: 1 })).toBe(true);
|
|
140
|
+
expect(acl.can({ ...options, recordPkValue: 2 })).toBe(false);
|
|
141
|
+
expect(acl.can({ ...options, allowedActions: undefined, recordPkValue: undefined })).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
74
144
|
it('reloads permissions when auth token changes', async () => {
|
|
75
145
|
const payload1 = {
|
|
76
146
|
data: {
|
|
@@ -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
|
// 双击:选中非叶子节点
|
|
@@ -26,26 +26,38 @@ interface MobilePopupProps {
|
|
|
26
26
|
footer?: ReactNode;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const getMobilePopupMaxHeight = () => {
|
|
30
|
+
if (typeof CSS !== 'undefined' && CSS.supports?.('height', '100dvh')) {
|
|
31
|
+
return 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return 'calc(100vh - var(--nb-mobile-page-header-height, 46px))';
|
|
35
|
+
};
|
|
36
|
+
|
|
29
37
|
export const MobilePopup: FC<MobilePopupProps> = (props) => {
|
|
30
38
|
const { title, visible, onClose: closePopup, children, minHeight, className, footer } = props;
|
|
31
39
|
const { t } = useTranslation();
|
|
32
40
|
const { componentCls, hashId } = useMobileActionDrawerStyle();
|
|
33
41
|
|
|
34
42
|
const bodyStyles = (props as MobilePopupProps & { styles?: { body?: React.CSSProperties } }).styles?.body;
|
|
43
|
+
const defaultMaxHeight = getMobilePopupMaxHeight();
|
|
35
44
|
const popupStyle = useMemo(() => {
|
|
36
45
|
return {
|
|
37
46
|
minHeight: bodyStyles?.minHeight ?? minHeight,
|
|
38
47
|
height: bodyStyles?.height,
|
|
39
|
-
maxHeight: bodyStyles?.maxHeight,
|
|
48
|
+
maxHeight: bodyStyles?.maxHeight ?? defaultMaxHeight,
|
|
40
49
|
};
|
|
41
|
-
}, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, minHeight]);
|
|
50
|
+
}, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, defaultMaxHeight, minHeight]);
|
|
42
51
|
|
|
43
|
-
const bodyStyle = useMemo(() => {
|
|
52
|
+
const bodyStyle = useMemo<React.CSSProperties>(() => {
|
|
44
53
|
return {
|
|
45
54
|
padding: 0,
|
|
55
|
+
maxHeight: defaultMaxHeight,
|
|
56
|
+
overflowY: 'auto',
|
|
57
|
+
overflowX: 'hidden',
|
|
46
58
|
...bodyStyles,
|
|
47
59
|
};
|
|
48
|
-
}, [bodyStyles]);
|
|
60
|
+
}, [bodyStyles, defaultMaxHeight]);
|
|
49
61
|
|
|
50
62
|
const handleCloseKeyDown = useCallback(
|
|
51
63
|
(event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
12
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
13
13
|
import { MobilePopup } from '../MobilePopup';
|
|
14
14
|
|
|
15
15
|
vi.mock('react-i18next', () => ({
|
|
@@ -61,6 +61,47 @@ describe('MobilePopup', () => {
|
|
|
61
61
|
React.ComponentProps<typeof MobilePopup> & { styles?: { body?: React.CSSProperties } }
|
|
62
62
|
>;
|
|
63
63
|
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.unstubAllGlobals();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('constrains the default popup to the dynamic viewport and keeps the body scrollable', () => {
|
|
69
|
+
vi.stubGlobal('CSS', {
|
|
70
|
+
supports: vi.fn((property: string, value: string) => property === 'height' && value === '100dvh'),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
render(
|
|
74
|
+
<MobilePopup visible title="Title" onClose={vi.fn()}>
|
|
75
|
+
body
|
|
76
|
+
</MobilePopup>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const maxHeight = 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))';
|
|
80
|
+
|
|
81
|
+
expect(screen.getByTestId('mobile-popup')).toHaveStyle({ maxHeight });
|
|
82
|
+
expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
|
|
83
|
+
maxHeight,
|
|
84
|
+
overflowY: 'auto',
|
|
85
|
+
overflowX: 'hidden',
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('falls back to the layout viewport when dynamic viewport units are unavailable', () => {
|
|
90
|
+
vi.stubGlobal('CSS', {
|
|
91
|
+
supports: vi.fn(() => false),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
render(
|
|
95
|
+
<MobilePopup visible title="Title" onClose={vi.fn()}>
|
|
96
|
+
body
|
|
97
|
+
</MobilePopup>,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
|
|
101
|
+
maxHeight: 'calc(100vh - var(--nb-mobile-page-header-height, 46px))',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
64
105
|
it('applies drawer body styles as max bounds without forcing fixed half-window height', () => {
|
|
65
106
|
render(
|
|
66
107
|
<MobilePopupWithDrawerStyles visible title="Title" styles={{ body: { maxHeight: '50vh' } }} onClose={vi.fn()}>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { css } from '@emotion/css';
|
|
11
|
-
import { Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
|
|
11
|
+
import { ConfigProvider, Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
|
|
12
12
|
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
13
|
import { useFlowEngine } from '../../provider';
|
|
14
14
|
|
|
@@ -484,13 +484,10 @@ const createSearchItem = (
|
|
|
484
484
|
if (shouldActivateSearchSubmenu) {
|
|
485
485
|
activateSearchSubmenu(searchKey);
|
|
486
486
|
}
|
|
487
|
-
if ((e.nativeEvent as
|
|
487
|
+
if ((e.nativeEvent as InputEvent).isComposing || searchHandlers.isComposing(searchKey)) {
|
|
488
488
|
searchHandlers.updateInputValue(searchKey, value);
|
|
489
489
|
return;
|
|
490
490
|
}
|
|
491
|
-
if (!value && shouldActivateSearchSubmenu) {
|
|
492
|
-
deactivateSearchSubmenu(searchKey);
|
|
493
|
-
}
|
|
494
491
|
searchHandlers.updateSearchValue(searchKey, value);
|
|
495
492
|
}}
|
|
496
493
|
onCompositionStart={(e) => {
|
|
@@ -504,11 +501,7 @@ const createSearchItem = (
|
|
|
504
501
|
e.stopPropagation();
|
|
505
502
|
const value = e.currentTarget.value;
|
|
506
503
|
if (shouldActivateSearchSubmenu) {
|
|
507
|
-
|
|
508
|
-
activateSearchSubmenu(searchKey);
|
|
509
|
-
} else {
|
|
510
|
-
deactivateSearchSubmenu(searchKey);
|
|
511
|
-
}
|
|
504
|
+
activateSearchSubmenu(searchKey);
|
|
512
505
|
}
|
|
513
506
|
searchHandlers.endComposition(searchKey, value);
|
|
514
507
|
}}
|
|
@@ -516,11 +509,21 @@ const createSearchItem = (
|
|
|
516
509
|
e.stopPropagation();
|
|
517
510
|
}}
|
|
518
511
|
onKeyDown={(e) => {
|
|
512
|
+
if (e.key === 'Escape' || e.key === 'Tab') {
|
|
513
|
+
deactivateSearchSubmenu(searchKey);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (shouldActivateSearchSubmenu) {
|
|
517
|
+
activateSearchSubmenu(searchKey);
|
|
518
|
+
}
|
|
519
519
|
e.stopPropagation();
|
|
520
520
|
}}
|
|
521
521
|
onMouseDown={(e) => {
|
|
522
522
|
// 防止菜单聚焦丢失或页面滚动
|
|
523
523
|
e.stopPropagation();
|
|
524
|
+
if (shouldActivateSearchSubmenu) {
|
|
525
|
+
activateSearchSubmenu(searchKey);
|
|
526
|
+
}
|
|
524
527
|
}}
|
|
525
528
|
size="small"
|
|
526
529
|
style={{
|
|
@@ -552,7 +555,7 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
|
|
|
552
555
|
|
|
553
556
|
// 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
|
|
554
557
|
const DROPDOWN_PERSIST_TTL_MS = 350;
|
|
555
|
-
const
|
|
558
|
+
const MENU_CLOSE_DELAY = 0.3;
|
|
556
559
|
const SUBMENU_MOTION_DISABLED = {
|
|
557
560
|
motionEnter: false,
|
|
558
561
|
motionLeave: false,
|
|
@@ -561,13 +564,20 @@ const dropdownPersistRegistry: Map<string, number> = new Map();
|
|
|
561
564
|
|
|
562
565
|
const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
|
|
563
566
|
const engine = useFlowEngine();
|
|
567
|
+
const { getPrefixCls } = React.useContext(ConfigProvider.ConfigContext);
|
|
568
|
+
const triggerId = React.useId();
|
|
564
569
|
const [menuVisible, setMenuVisible] = useState(false);
|
|
565
570
|
const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
|
|
566
|
-
const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
|
|
567
571
|
const [rootItems, setRootItems] = useState<Item[]>([]);
|
|
568
572
|
const [rootLoading, setRootLoading] = useState(false);
|
|
573
|
+
const activeSearchKeyRef = useRef<string | null>(null);
|
|
569
574
|
const closeByOutsideClickRef = useRef(false);
|
|
570
575
|
const skipPreserveActiveSearchRef = useRef(false);
|
|
576
|
+
const triggerOpenClassName = `nb-lazy-dropdown-trigger-${triggerId.replace(/[^a-zA-Z0-9_-]/g, '')}`;
|
|
577
|
+
const defaultOpenClassName = `${getPrefixCls('dropdown', props.prefixCls)}-open`;
|
|
578
|
+
const mergedOpenClassName = [props.openClassName ?? defaultOpenClassName, triggerOpenClassName]
|
|
579
|
+
.filter(Boolean)
|
|
580
|
+
.join(' ');
|
|
571
581
|
const dropdownMaxHeight = useNiceDropdownMaxHeight();
|
|
572
582
|
const t = engine.translate.bind(engine);
|
|
573
583
|
|
|
@@ -589,13 +599,13 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
589
599
|
|
|
590
600
|
const closeMenu = useCallback(() => {
|
|
591
601
|
setMenuVisible(false);
|
|
592
|
-
|
|
602
|
+
activeSearchKeyRef.current = null;
|
|
593
603
|
setOpenKeys(new Set());
|
|
594
604
|
clearAllSearchValues();
|
|
595
605
|
}, [clearAllSearchValues]);
|
|
596
606
|
|
|
597
607
|
const activateSearchSubmenu = useCallback((key: string) => {
|
|
598
|
-
|
|
608
|
+
activeSearchKeyRef.current = key;
|
|
599
609
|
setOpenKeys((prev) => {
|
|
600
610
|
if (prev.has(key)) return prev;
|
|
601
611
|
const next = new Set(prev);
|
|
@@ -605,11 +615,14 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
605
615
|
}, []);
|
|
606
616
|
|
|
607
617
|
const deactivateSearchSubmenu = useCallback((key: string) => {
|
|
608
|
-
|
|
618
|
+
if (activeSearchKeyRef.current === key) {
|
|
619
|
+
activeSearchKeyRef.current = null;
|
|
620
|
+
}
|
|
609
621
|
}, []);
|
|
610
622
|
|
|
611
623
|
const closeActiveSearchForPath = useCallback(
|
|
612
624
|
(keyPath: string) => {
|
|
625
|
+
const activeSearchKey = activeSearchKeyRef.current;
|
|
613
626
|
if (
|
|
614
627
|
!activeSearchKey ||
|
|
615
628
|
keyPath === activeSearchKey ||
|
|
@@ -621,25 +634,26 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
621
634
|
|
|
622
635
|
skipPreserveActiveSearchRef.current = true;
|
|
623
636
|
clearSearchValue(activeSearchKey);
|
|
624
|
-
|
|
637
|
+
activeSearchKeyRef.current = null;
|
|
625
638
|
setOpenKeys((prev) => {
|
|
626
639
|
const next = new Set(prev);
|
|
627
640
|
next.delete(activeSearchKey);
|
|
628
641
|
return next;
|
|
629
642
|
});
|
|
630
643
|
},
|
|
631
|
-
[
|
|
644
|
+
[clearSearchValue],
|
|
632
645
|
);
|
|
633
646
|
|
|
634
647
|
const handleMenuOpenChange = useCallback(
|
|
635
648
|
(nextOpenKeys: string[]) => {
|
|
636
649
|
let normalized = normalizeOpenKeys(nextOpenKeys);
|
|
637
|
-
|
|
638
|
-
|
|
650
|
+
const activeSearchKey = activeSearchKeyRef.current;
|
|
651
|
+
if (activeSearchKey && !normalized.includes(activeSearchKey)) {
|
|
652
|
+
if (skipPreserveActiveSearchRef.current) {
|
|
639
653
|
clearSearchValue(activeSearchKey);
|
|
640
|
-
|
|
654
|
+
activeSearchKeyRef.current = null;
|
|
641
655
|
} else {
|
|
642
|
-
normalized =
|
|
656
|
+
normalized = Array.from(openKeys);
|
|
643
657
|
}
|
|
644
658
|
}
|
|
645
659
|
|
|
@@ -658,7 +672,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
658
672
|
dropdownMenuProps.onOpenChange?.(normalized);
|
|
659
673
|
skipPreserveActiveSearchRef.current = false;
|
|
660
674
|
},
|
|
661
|
-
[
|
|
675
|
+
[clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
|
|
662
676
|
);
|
|
663
677
|
|
|
664
678
|
useEffect(() => {
|
|
@@ -666,7 +680,9 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
666
680
|
|
|
667
681
|
const markOutsideClick = (event: MouseEvent | PointerEvent) => {
|
|
668
682
|
const target = event.target as HTMLElement | null;
|
|
669
|
-
const
|
|
683
|
+
const isInsidePopup = target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
|
|
684
|
+
const isInsideCurrentTrigger = target?.closest(`.${triggerOpenClassName}`);
|
|
685
|
+
const isOutside = !isInsidePopup && !isInsideCurrentTrigger;
|
|
670
686
|
closeByOutsideClickRef.current = isOutside;
|
|
671
687
|
if (isOutside) {
|
|
672
688
|
closeMenu();
|
|
@@ -679,7 +695,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
679
695
|
document.removeEventListener('pointerdown', markOutsideClick, true);
|
|
680
696
|
document.removeEventListener('mousedown', markOutsideClick, true);
|
|
681
697
|
};
|
|
682
|
-
}, [closeMenu, menuVisible]);
|
|
698
|
+
}, [closeMenu, menuVisible, triggerOpenClassName]);
|
|
683
699
|
|
|
684
700
|
// 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
|
|
685
701
|
useEffect(() => {
|
|
@@ -957,13 +973,15 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
957
973
|
{...props}
|
|
958
974
|
open={menuVisible}
|
|
959
975
|
destroyPopupOnHide
|
|
976
|
+
mouseLeaveDelay={props.mouseLeaveDelay ?? MENU_CLOSE_DELAY}
|
|
977
|
+
openClassName={mergedOpenClassName}
|
|
960
978
|
overlayClassName={overlayClassName}
|
|
961
979
|
placement="bottomLeft"
|
|
962
980
|
menu={{
|
|
963
981
|
...dropdownMenuProps,
|
|
964
982
|
openKeys: Array.from(openKeys),
|
|
965
983
|
items: items,
|
|
966
|
-
subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ??
|
|
984
|
+
subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? MENU_CLOSE_DELAY,
|
|
967
985
|
motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
|
|
968
986
|
onClick: () => {},
|
|
969
987
|
onOpenChange: handleMenuOpenChange,
|
|
@@ -974,7 +992,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
974
992
|
},
|
|
975
993
|
}}
|
|
976
994
|
onOpenChange={(visible, info) => {
|
|
977
|
-
if (!visible &&
|
|
995
|
+
if (!visible && activeSearchKeyRef.current && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
|
|
978
996
|
return;
|
|
979
997
|
}
|
|
980
998
|
|
|
@@ -411,7 +411,7 @@ describe('transformItems - searchable flags', () => {
|
|
|
411
411
|
await waitFor(() => expect(screen.getAllByText('No data').length).toBeGreaterThan(0));
|
|
412
412
|
});
|
|
413
413
|
|
|
414
|
-
it('
|
|
414
|
+
it('keeps searchable submenu open while interacting with its input', async () => {
|
|
415
415
|
const engine = new FlowEngine();
|
|
416
416
|
await engine.flowSettings.forceEnable();
|
|
417
417
|
class Parent extends FlowModel {}
|
|
@@ -451,7 +451,11 @@ describe('transformItems - searchable flags', () => {
|
|
|
451
451
|
await user.click(screen.getByRole('textbox'));
|
|
452
452
|
fireEvent.mouseLeave(screen.getByText('Fields'));
|
|
453
453
|
|
|
454
|
-
await
|
|
454
|
+
await act(async () => {
|
|
455
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(screen.getByText('Field 1')).toBeInTheDocument();
|
|
455
459
|
});
|
|
456
460
|
|
|
457
461
|
it('closes active searchable submenu after outside click', async () => {
|
|
@@ -499,6 +503,85 @@ describe('transformItems - searchable flags', () => {
|
|
|
499
503
|
await waitFor(() => expect(screen.queryByText('Fields')).not.toBeInTheDocument());
|
|
500
504
|
});
|
|
501
505
|
|
|
506
|
+
it('keeps the dropdown open when clicking its already-hovered trigger', async () => {
|
|
507
|
+
const engine = new FlowEngine();
|
|
508
|
+
await engine.flowSettings.forceEnable();
|
|
509
|
+
class Parent extends FlowModel {}
|
|
510
|
+
engine.registerModels({ Parent });
|
|
511
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
512
|
+
const user = userEvent.setup();
|
|
513
|
+
|
|
514
|
+
render(
|
|
515
|
+
<FlowEngineProvider engine={engine}>
|
|
516
|
+
<ConfigProvider>
|
|
517
|
+
<App>
|
|
518
|
+
<AddSubModelButton
|
|
519
|
+
model={parent}
|
|
520
|
+
subModelKey="items"
|
|
521
|
+
items={[
|
|
522
|
+
{
|
|
523
|
+
key: 'child',
|
|
524
|
+
label: 'Child',
|
|
525
|
+
createModelOptions: { use: 'Parent' },
|
|
526
|
+
},
|
|
527
|
+
]}
|
|
528
|
+
>
|
|
529
|
+
Open
|
|
530
|
+
</AddSubModelButton>
|
|
531
|
+
</App>
|
|
532
|
+
</ConfigProvider>
|
|
533
|
+
</FlowEngineProvider>,
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const trigger = screen.getByText('Open');
|
|
537
|
+
await user.hover(trigger);
|
|
538
|
+
await waitFor(() => expect(screen.getByText('Child')).toBeInTheDocument());
|
|
539
|
+
expect(trigger).toHaveClass('ant-dropdown-open');
|
|
540
|
+
|
|
541
|
+
await user.click(trigger);
|
|
542
|
+
|
|
543
|
+
expect(screen.getByText('Child')).toBeInTheDocument();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('closes the dropdown when clicking another dropdown trigger', async () => {
|
|
547
|
+
const engine = new FlowEngine();
|
|
548
|
+
await engine.flowSettings.forceEnable();
|
|
549
|
+
class Parent extends FlowModel {}
|
|
550
|
+
engine.registerModels({ Parent });
|
|
551
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
552
|
+
const user = userEvent.setup();
|
|
553
|
+
|
|
554
|
+
render(
|
|
555
|
+
<FlowEngineProvider engine={engine}>
|
|
556
|
+
<ConfigProvider>
|
|
557
|
+
<App>
|
|
558
|
+
<AddSubModelButton
|
|
559
|
+
model={parent}
|
|
560
|
+
subModelKey="firstItems"
|
|
561
|
+
items={[{ key: 'first-child', label: 'First child', createModelOptions: { use: 'Parent' } }]}
|
|
562
|
+
>
|
|
563
|
+
Open first
|
|
564
|
+
</AddSubModelButton>
|
|
565
|
+
<AddSubModelButton
|
|
566
|
+
model={parent}
|
|
567
|
+
subModelKey="secondItems"
|
|
568
|
+
items={[{ key: 'second-child', label: 'Second child', createModelOptions: { use: 'Parent' } }]}
|
|
569
|
+
>
|
|
570
|
+
Open second
|
|
571
|
+
</AddSubModelButton>
|
|
572
|
+
</App>
|
|
573
|
+
</ConfigProvider>
|
|
574
|
+
</FlowEngineProvider>,
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
await user.hover(screen.getByText('Open first'));
|
|
578
|
+
await waitFor(() => expect(screen.getByText('First child')).toBeInTheDocument());
|
|
579
|
+
|
|
580
|
+
fireEvent.pointerDown(screen.getByText('Open second'));
|
|
581
|
+
|
|
582
|
+
await waitFor(() => expect(screen.queryByText('First child')).not.toBeInTheDocument());
|
|
583
|
+
});
|
|
584
|
+
|
|
502
585
|
it('switches away from active searchable submenu and resets its input', async () => {
|
|
503
586
|
const engine = new FlowEngine();
|
|
504
587
|
await engine.flowSettings.forceEnable();
|
|
@@ -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
|
});
|