@nocobase/flow-engine 2.2.0-alpha.1 → 2.2.0-alpha.3
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/JSRunner.d.ts +1 -0
- package/lib/JSRunner.js +110 -20
- package/lib/components/FieldModelRenderer.js +44 -31
- package/lib/components/FlowContextSelector.js +27 -5
- package/lib/components/MobilePopup.style.js +16 -5
- package/lib/components/dnd/index.js +9 -2
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
- package/lib/components/variables/VariableHybridInput.d.ts +8 -0
- package/lib/components/variables/VariableHybridInput.js +128 -12
- package/lib/components/variables/VariableInput.js +2 -1
- package/lib/components/variables/types.d.ts +18 -0
- package/lib/flowContext.d.ts +1 -1
- package/lib/flowContext.js +87 -36
- package/lib/flowI18n.js +3 -3
- package/lib/flowSettings.d.ts +5 -1
- package/lib/flowSettings.js +70 -0
- package/lib/locale/en-US.json +1 -0
- package/lib/locale/index.d.ts +2 -0
- package/lib/locale/zh-CN.json +1 -0
- package/lib/resources/apiResource.js +2 -1
- package/lib/resources/baseRecordResource.js +6 -17
- package/lib/resources/multiRecordResource.js +13 -3
- package/lib/resources/singleRecordResource.js +7 -2
- package/lib/runjs-context/helpers.js +12 -5
- package/lib/types.d.ts +12 -0
- package/lib/utils/dataSourceDirty.d.ts +20 -0
- package/lib/utils/dataSourceDirty.js +139 -0
- package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
- package/lib/utils/dirtyAwareApiClient.js +378 -0
- package/lib/utils/index.d.ts +1 -1
- package/lib/utils/index.js +11 -11
- package/lib/utils/openViewRouteState.d.ts +28 -0
- package/lib/utils/openViewRouteState.js +125 -0
- package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
- package/lib/utils/parsePathnameToViewParams.js +18 -1
- package/lib/utils/resolveRunJSObjectValues.js +3 -2
- package/lib/utils/runjsModuleLoader.js +0 -30
- package/lib/views/ViewNavigation.js +5 -0
- package/package.json +4 -4
- package/src/JSRunner.ts +112 -25
- package/src/__tests__/JSRunner.test.ts +4 -5
- package/src/__tests__/flowContext.test.ts +131 -0
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
- package/src/__tests__/flowSettings.test.ts +72 -0
- package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
- package/src/components/FieldModelRenderer.tsx +50 -36
- package/src/components/FlowContextSelector.tsx +36 -4
- package/src/components/MobilePopup.style.ts +22 -6
- package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
- package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
- package/src/components/dnd/index.tsx +11 -2
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
- package/src/components/variables/VariableHybridInput.tsx +166 -9
- package/src/components/variables/VariableInput.tsx +2 -1
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +23 -3
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +178 -0
- package/src/components/variables/__tests__/VariableInput.test.tsx +51 -5
- package/src/components/variables/types.ts +18 -0
- package/src/flowContext.ts +100 -33
- package/src/flowI18n.ts +8 -3
- package/src/flowSettings.ts +85 -1
- package/src/locale/en-US.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/resources/apiResource.ts +2 -1
- package/src/resources/baseRecordResource.ts +6 -23
- package/src/resources/multiRecordResource.ts +13 -3
- package/src/resources/singleRecordResource.ts +6 -1
- package/src/runjs-context/helpers.ts +12 -6
- package/src/types.ts +14 -0
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
- package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
- package/src/utils/dataSourceDirty.ts +126 -0
- package/src/utils/dirtyAwareApiClient.ts +430 -0
- package/src/utils/index.ts +10 -9
- package/src/utils/openViewRouteState.ts +107 -0
- package/src/utils/parsePathnameToViewParams.ts +23 -1
- package/src/utils/resolveRunJSObjectValues.ts +5 -2
- package/src/utils/runjsModuleLoader.ts +0 -32
- package/src/views/ViewNavigation.ts +6 -1
- package/src/views/__tests__/ViewNavigation.test.ts +15 -0
- package/lib/utils/safeGlobals.d.ts +0 -28
- package/lib/utils/safeGlobals.js +0 -367
- package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
- package/src/utils/__tests__/safeGlobals.test.ts +0 -106
- package/src/utils/safeGlobals.ts +0 -406
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
11
|
-
import { FlowEngineContext, FlowRunJSContext } from '../flowContext';
|
|
11
|
+
import { FlowContext, FlowEngineContext, FlowRunJSContext } from '../flowContext';
|
|
12
|
+
import { RUNJS_OPEN_VIEW_ROUTE_STATE } from '../utils/openViewRouteState';
|
|
12
13
|
import { FlowEngine } from '../flowEngine';
|
|
13
|
-
import { FlowContext } from '../flowContext';
|
|
14
14
|
import { setupRunJSContexts } from '../runjs-context/setup';
|
|
15
15
|
import { createJSRunnerWithVersion } from '..';
|
|
16
16
|
import { RunJSContextRegistry } from '../runjs-context/registry';
|
|
@@ -267,6 +267,19 @@ describe('RunJS Runtime Features', () => {
|
|
|
267
267
|
expect(runCtx.libs.dayjs).toBeDefined();
|
|
268
268
|
expect(runCtx.libs.antdIcons).toBeDefined();
|
|
269
269
|
});
|
|
270
|
+
|
|
271
|
+
it('should mark ctx.openView calls with route state only when RunJS passes display overrides', async () => {
|
|
272
|
+
const parentCtx = new FlowContext();
|
|
273
|
+
const openView = vi.fn(async () => undefined);
|
|
274
|
+
parentCtx.defineMethod('openView', openView);
|
|
275
|
+
|
|
276
|
+
const runCtx = new FlowRunJSContext(parentCtx);
|
|
277
|
+
await (runCtx as any).openView('popup', { mode: 'dialog', size: 'large' });
|
|
278
|
+
await (runCtx as any).openView('popup', { filterByTk: 1 });
|
|
279
|
+
|
|
280
|
+
expect(openView.mock.calls[0][1][RUNJS_OPEN_VIEW_ROUTE_STATE]).toEqual({ mode: 'dialog', size: 'large' });
|
|
281
|
+
expect(Object.prototype.hasOwnProperty.call(openView.mock.calls[1][1], RUNJS_OPEN_VIEW_ROUTE_STATE)).toBe(false);
|
|
282
|
+
});
|
|
270
283
|
});
|
|
271
284
|
|
|
272
285
|
describe('Actual code execution', () => {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { FlowModelRendererProps } from './FlowModelRenderer';
|
|
11
11
|
import { FlowModelRenderer } from './FlowModelRenderer';
|
|
12
12
|
import _ from 'lodash';
|
|
13
|
-
import React, { useEffect, useMemo, useRef } from 'react';
|
|
13
|
+
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
14
14
|
|
|
15
15
|
const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
|
|
16
16
|
'model',
|
|
@@ -28,49 +28,63 @@ const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
|
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
export function FieldModelRenderer(props: any) {
|
|
31
|
-
const { model, ...rest } = props;
|
|
31
|
+
const { model, onChange, ...rest } = props;
|
|
32
32
|
const composingRef = useRef(false);
|
|
33
33
|
|
|
34
|
-
const handleChange = (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
34
|
+
const handleChange = useCallback(
|
|
35
|
+
(e: any) => {
|
|
36
|
+
let val;
|
|
37
|
+
if (e && e.target && typeof e.target.value !== 'undefined') {
|
|
38
|
+
val = e.target.value;
|
|
39
|
+
} else if (
|
|
40
|
+
typeof e === 'string' ||
|
|
41
|
+
typeof e === 'number' ||
|
|
42
|
+
typeof e === 'boolean' ||
|
|
43
|
+
(typeof e === 'object' && !(e instanceof Event))
|
|
44
|
+
) {
|
|
45
|
+
val = e;
|
|
46
|
+
} else {
|
|
47
|
+
val = null;
|
|
48
|
+
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
const isComposing = composingRef.current || e?.nativeEvent?.isComposing;
|
|
51
|
+
if (isComposing) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
model.setProps({ value: val });
|
|
56
|
+
if (!composingRef.current) {
|
|
57
|
+
onChange?.(val);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
[model, onChange],
|
|
61
|
+
);
|
|
62
|
+
const handleCompositionStart = useCallback(() => {
|
|
55
63
|
composingRef.current = true;
|
|
56
|
-
};
|
|
64
|
+
}, []);
|
|
57
65
|
|
|
58
|
-
const handleCompositionEnd = (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
const handleCompositionEnd = useCallback(
|
|
67
|
+
(e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
|
|
68
|
+
composingRef.current = false;
|
|
69
|
+
if (flag) {
|
|
70
|
+
handleChange(e);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
[handleChange],
|
|
74
|
+
);
|
|
64
75
|
|
|
65
|
-
const modelProps =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
const modelProps = useMemo(
|
|
77
|
+
() => ({
|
|
78
|
+
onCompositionStart: handleCompositionStart,
|
|
79
|
+
onCompositionEnd: handleCompositionEnd,
|
|
80
|
+
..._.omit(rest, flowModelRendererPropKeys),
|
|
81
|
+
onChange: handleChange,
|
|
82
|
+
}),
|
|
83
|
+
[handleChange, handleCompositionEnd, handleCompositionStart, rest],
|
|
84
|
+
);
|
|
71
85
|
useEffect(() => {
|
|
72
86
|
model && model.setProps(modelProps);
|
|
73
|
-
}, [modelProps]);
|
|
87
|
+
}, [model, modelProps]);
|
|
74
88
|
|
|
75
89
|
return <FlowModelRenderer model={model} {...rest} />;
|
|
76
90
|
}
|
|
@@ -85,6 +85,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
85
85
|
value,
|
|
86
86
|
onChange,
|
|
87
87
|
children,
|
|
88
|
+
active,
|
|
88
89
|
metaTree,
|
|
89
90
|
showSearch = false,
|
|
90
91
|
parseValueToPath: customParseValueToPath = parseValueToPath,
|
|
@@ -92,6 +93,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
92
93
|
open,
|
|
93
94
|
onlyLeafSelectable = false,
|
|
94
95
|
ignoreFieldNames,
|
|
96
|
+
dropdownFooter,
|
|
95
97
|
...cascaderProps
|
|
96
98
|
}) => {
|
|
97
99
|
const { token } = theme.useToken();
|
|
@@ -265,13 +267,13 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
265
267
|
|
|
266
268
|
// 默认按钮组件
|
|
267
269
|
const defaultChildren = useMemo(() => {
|
|
268
|
-
const hasSelected = currentPath && currentPath.length > 0;
|
|
270
|
+
const hasSelected = active ?? Boolean(currentPath && currentPath.length > 0);
|
|
269
271
|
return (
|
|
270
272
|
<Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle}>
|
|
271
273
|
x
|
|
272
274
|
</Button>
|
|
273
275
|
);
|
|
274
|
-
}, [currentPath]);
|
|
276
|
+
}, [active, currentPath]);
|
|
275
277
|
|
|
276
278
|
// 处理选择变化事件
|
|
277
279
|
const handleChange = useCallback(
|
|
@@ -360,12 +362,41 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
360
362
|
[cascaderOnDropdownVisibleChange, open],
|
|
361
363
|
);
|
|
362
364
|
|
|
365
|
+
// Footer hint at the bottom of the dropdown. Defaults to the "double click to choose entire object" hint whenever
|
|
366
|
+
// non-leaf selection is allowed (double-clicking a non-leaf selects the whole object). Callers can override with
|
|
367
|
+
// their own node, or pass `null` to hide it.
|
|
368
|
+
const footerNode = useMemo(() => {
|
|
369
|
+
if (dropdownFooter !== undefined) {
|
|
370
|
+
return dropdownFooter;
|
|
371
|
+
}
|
|
372
|
+
if (onlyLeafSelectable) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
return (
|
|
376
|
+
<div
|
|
377
|
+
className={css`
|
|
378
|
+
padding: 6px 12px;
|
|
379
|
+
color: ${token.colorTextDescription};
|
|
380
|
+
border-top: 1px solid ${token.colorSplit};
|
|
381
|
+
font-size: ${token.fontSizeSM}px;
|
|
382
|
+
`}
|
|
383
|
+
>
|
|
384
|
+
{flowCtx.t('Double click to choose entire object')}
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
}, [dropdownFooter, onlyLeafSelectable, token, flowCtx]);
|
|
388
|
+
|
|
363
389
|
const renderDropdown = useCallback(
|
|
364
390
|
(menu: React.ReactElement) => {
|
|
365
391
|
const cascaderMenuNode = cascaderDropdownRender ? cascaderDropdownRender(menu) : menu;
|
|
366
392
|
const cascaderMenu = React.isValidElement(cascaderMenuNode) ? cascaderMenuNode : <>{cascaderMenuNode}</>;
|
|
367
393
|
if (!isSearchEnabled || children === null) {
|
|
368
|
-
return
|
|
394
|
+
return (
|
|
395
|
+
<>
|
|
396
|
+
{cascaderMenu}
|
|
397
|
+
{footerNode}
|
|
398
|
+
</>
|
|
399
|
+
);
|
|
369
400
|
}
|
|
370
401
|
|
|
371
402
|
return (
|
|
@@ -381,10 +412,11 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
381
412
|
/>
|
|
382
413
|
</div>
|
|
383
414
|
{cascaderMenu}
|
|
415
|
+
{footerNode}
|
|
384
416
|
</>
|
|
385
417
|
);
|
|
386
418
|
},
|
|
387
|
-
[cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText],
|
|
419
|
+
[cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText, footerNode],
|
|
388
420
|
);
|
|
389
421
|
|
|
390
422
|
const inlinePlaceholder =
|
|
@@ -11,7 +11,7 @@ import type { CSSInterpolation, CSSObject } from '@ant-design/cssinjs';
|
|
|
11
11
|
import { useStyleRegister } from '@ant-design/cssinjs';
|
|
12
12
|
import { merge } from '@formily/shared';
|
|
13
13
|
import type { ComponentTokenMap } from 'antd/es/theme/interface';
|
|
14
|
-
import {
|
|
14
|
+
import { useContext, useRef } from 'react';
|
|
15
15
|
import { ConfigProvider, theme } from 'antd';
|
|
16
16
|
|
|
17
17
|
const usePrefixCls = (
|
|
@@ -83,6 +83,10 @@ type UseComponentStyleResult = {
|
|
|
83
83
|
componentCls: string;
|
|
84
84
|
rootPrefixCls: string;
|
|
85
85
|
};
|
|
86
|
+
type WrapSSRCache = {
|
|
87
|
+
deps: unknown[];
|
|
88
|
+
wrapSSR: ReturnType<typeof useStyleRegister>;
|
|
89
|
+
};
|
|
86
90
|
|
|
87
91
|
const genStyleHook = <ComponentName extends OverrideComponent>(
|
|
88
92
|
component: ComponentName,
|
|
@@ -122,9 +126,18 @@ const genStyleHook = <ComponentName extends OverrideComponent>(
|
|
|
122
126
|
|
|
123
127
|
// useStyleRegister 有 BUG,会导致重复渲染,所以这里做了一层缓存
|
|
124
128
|
// 等 https://github.com/ant-design/cssinjs/pull/176 合并后,可以去掉这层缓存
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
const wrapSSRDeps = [theme, token, hashId, prefixCls, iconPrefixCls, rootPrefixCls, props];
|
|
130
|
+
const wrapSSRCacheRef = useRef<WrapSSRCache>();
|
|
131
|
+
const currentCache = wrapSSRCacheRef.current;
|
|
132
|
+
let memoizedWrapSSR: ReturnType<typeof useStyleRegister> = currentCache?.wrapSSR || wrapSSR;
|
|
133
|
+
|
|
134
|
+
if (!currentCache || wrapSSRDeps.some((dep, index) => dep !== currentCache.deps[index])) {
|
|
135
|
+
wrapSSRCacheRef.current = {
|
|
136
|
+
deps: wrapSSRDeps,
|
|
137
|
+
wrapSSR,
|
|
138
|
+
};
|
|
139
|
+
memoizedWrapSSR = wrapSSR;
|
|
140
|
+
}
|
|
128
141
|
|
|
129
142
|
return {
|
|
130
143
|
wrapSSR: memoizedWrapSSR,
|
|
@@ -140,7 +153,7 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
|
|
|
140
153
|
return {
|
|
141
154
|
[componentCls]: {
|
|
142
155
|
'.nb-mobile-action-drawer-header': {
|
|
143
|
-
height: 'var(--nb-mobile-page-header-height)',
|
|
156
|
+
height: 'var(--nb-mobile-page-header-height, 46px)',
|
|
144
157
|
display: 'flex',
|
|
145
158
|
alignItems: 'center',
|
|
146
159
|
justifyContent: 'space-between',
|
|
@@ -171,7 +184,10 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
|
|
|
171
184
|
'.nb-mobile-action-drawer-body': {
|
|
172
185
|
borderTopLeftRadius: 8,
|
|
173
186
|
borderTopRightRadius: 8,
|
|
174
|
-
maxHeight: 'calc(
|
|
187
|
+
maxHeight: 'calc(100vh - var(--nb-mobile-page-header-height, 46px))',
|
|
188
|
+
'@supports (height: 100dvh)': {
|
|
189
|
+
maxHeight: 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))',
|
|
190
|
+
},
|
|
175
191
|
overflowY: 'auto',
|
|
176
192
|
overflowX: 'hidden',
|
|
177
193
|
backgroundColor: token.colorBgLayout,
|
|
@@ -0,0 +1,165 @@
|
|
|
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, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { vi } from 'vitest';
|
|
13
|
+
import { FlowEngine } from '../../flowEngine';
|
|
14
|
+
import { FlowModel } from '../../models/flowModel';
|
|
15
|
+
import { FlowEngineProvider } from '../../provider';
|
|
16
|
+
import { FieldModelRenderer } from '../FieldModelRenderer';
|
|
17
|
+
|
|
18
|
+
class InputModel extends FlowModel {
|
|
19
|
+
render(): React.ReactNode {
|
|
20
|
+
return (
|
|
21
|
+
<input
|
|
22
|
+
data-testid="field-input"
|
|
23
|
+
value={this.props.value ?? ''}
|
|
24
|
+
onChange={this.props.onChange}
|
|
25
|
+
onCompositionStart={this.props.onCompositionStart}
|
|
26
|
+
onCompositionEnd={this.props.onCompositionEnd}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('FieldModelRenderer', () => {
|
|
33
|
+
it('updates model and form value for normal input changes', async () => {
|
|
34
|
+
const flowEngine = new FlowEngine();
|
|
35
|
+
const model = new InputModel({
|
|
36
|
+
uid: 'normal-input-field-model',
|
|
37
|
+
flowEngine,
|
|
38
|
+
props: {
|
|
39
|
+
value: '',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
43
|
+
const onChange = vi.fn();
|
|
44
|
+
|
|
45
|
+
render(
|
|
46
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
47
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
48
|
+
</FlowEngineProvider>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const input = await screen.findByTestId('field-input');
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
fireEvent.change(input, { target: { value: 'abc' } });
|
|
57
|
+
|
|
58
|
+
expect(model.props.value).toBe('abc');
|
|
59
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(onChange).toHaveBeenCalledWith('abc');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('updates model and form value when field components pass values directly', async () => {
|
|
64
|
+
const flowEngine = new FlowEngine();
|
|
65
|
+
const model = new InputModel({
|
|
66
|
+
uid: 'direct-value-field-model',
|
|
67
|
+
flowEngine,
|
|
68
|
+
props: {
|
|
69
|
+
value: '',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
73
|
+
const onChange = vi.fn();
|
|
74
|
+
|
|
75
|
+
render(
|
|
76
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
77
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
78
|
+
</FlowEngineProvider>,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await screen.findByTestId('field-input');
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
act(() => {
|
|
87
|
+
model.props.onChange(123);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(model.props.value).toBe(123);
|
|
91
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(onChange).toHaveBeenCalledWith(123);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('defers model and form value updates while IME composition is active', async () => {
|
|
96
|
+
const flowEngine = new FlowEngine();
|
|
97
|
+
const model = new InputModel({
|
|
98
|
+
uid: 'input-field-model',
|
|
99
|
+
flowEngine,
|
|
100
|
+
props: {
|
|
101
|
+
value: '',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
105
|
+
const onChange = vi.fn();
|
|
106
|
+
|
|
107
|
+
render(
|
|
108
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
109
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
110
|
+
</FlowEngineProvider>,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const input = await screen.findByTestId('field-input');
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
fireEvent.compositionStart(input);
|
|
119
|
+
fireEvent.change(input, { target: { value: 't' }, nativeEvent: { isComposing: true } });
|
|
120
|
+
|
|
121
|
+
expect(model.props.value).toBe('');
|
|
122
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
123
|
+
|
|
124
|
+
fireEvent.compositionEnd(input, { target: { value: 'te' } });
|
|
125
|
+
|
|
126
|
+
expect(model.props.value).toBe('te');
|
|
127
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(onChange).toHaveBeenCalledWith('te');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not commit composition value when composition end opts out', async () => {
|
|
132
|
+
const flowEngine = new FlowEngine();
|
|
133
|
+
const model = new InputModel({
|
|
134
|
+
uid: 'composition-opt-out-field-model',
|
|
135
|
+
flowEngine,
|
|
136
|
+
props: {
|
|
137
|
+
value: '',
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
141
|
+
const onChange = vi.fn();
|
|
142
|
+
|
|
143
|
+
render(
|
|
144
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
145
|
+
<FieldModelRenderer model={model} onChange={onChange} />
|
|
146
|
+
</FlowEngineProvider>,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
await screen.findByTestId('field-input');
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(model.props.onChange).toBeTypeOf('function');
|
|
152
|
+
expect(model.props.onCompositionStart).toBeTypeOf('function');
|
|
153
|
+
expect(model.props.onCompositionEnd).toBeTypeOf('function');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
act(() => {
|
|
157
|
+
model.props.onCompositionStart();
|
|
158
|
+
model.props.onChange({ target: { value: 'search' }, nativeEvent: { isComposing: true } });
|
|
159
|
+
model.props.onCompositionEnd({ target: { value: 'search' } }, false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(model.props.value).toBe('');
|
|
163
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
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 React, { CSSProperties, ReactNode } from 'react';
|
|
11
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
12
|
+
import { ConfigProvider } from 'antd';
|
|
13
|
+
import { vi } from 'vitest';
|
|
14
|
+
import { MobilePopup } from '../MobilePopup';
|
|
15
|
+
import { useMobileActionDrawerStyle } from '../MobilePopup.style';
|
|
16
|
+
|
|
17
|
+
vi.mock('antd-mobile', () => ({
|
|
18
|
+
Popup: ({
|
|
19
|
+
bodyClassName,
|
|
20
|
+
bodyStyle,
|
|
21
|
+
children,
|
|
22
|
+
className,
|
|
23
|
+
visible,
|
|
24
|
+
}: {
|
|
25
|
+
bodyClassName?: string;
|
|
26
|
+
bodyStyle?: CSSProperties;
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
visible?: boolean;
|
|
30
|
+
}) =>
|
|
31
|
+
visible ? (
|
|
32
|
+
<div className={className} data-testid="popup-root">
|
|
33
|
+
<div className={`adm-popup-body ${bodyClassName || ''}`} data-testid="popup-body" style={bodyStyle}>
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
) : null,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('antd-mobile-icons', () => ({
|
|
41
|
+
CloseOutline: () => <span data-testid="close-outline" />,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock('react-i18next', () => ({
|
|
45
|
+
useTranslation: () => ({
|
|
46
|
+
t: (key: string) => key,
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
function StyleProbe() {
|
|
51
|
+
const { componentCls, hashId } = useMobileActionDrawerStyle();
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={`${componentCls} ${hashId}`}>
|
|
55
|
+
<div className="nb-mobile-action-drawer-header" data-testid="drawer-header" />
|
|
56
|
+
<div className="nb-mobile-action-drawer-body" data-testid="drawer-body" />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getInjectedStyles() {
|
|
62
|
+
return Array.from(document.querySelectorAll('style'))
|
|
63
|
+
.map((style) => style.textContent || '')
|
|
64
|
+
.join('\n')
|
|
65
|
+
.replace(/\s+/g, '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('MobilePopup styles', () => {
|
|
69
|
+
it('mounts the action drawer class on the real popup body', async () => {
|
|
70
|
+
render(
|
|
71
|
+
<MobilePopup visible title="Edit" onClose={vi.fn()}>
|
|
72
|
+
<div style={{ height: 1200 }}>Long content</div>
|
|
73
|
+
</MobilePopup>,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const popupBody = await screen.findByTestId('popup-body');
|
|
77
|
+
|
|
78
|
+
expect(screen.getByTestId('popup-root')).toHaveClass('ant-nb-mobile-action-drawer');
|
|
79
|
+
expect(popupBody).toHaveClass('adm-popup-body');
|
|
80
|
+
expect(popupBody).toHaveClass('nb-mobile-action-drawer-body');
|
|
81
|
+
expect(popupBody).toHaveStyle({ padding: '0px' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('limits the mobile action drawer body by viewport height', async () => {
|
|
85
|
+
render(
|
|
86
|
+
<ConfigProvider>
|
|
87
|
+
<StyleProbe />
|
|
88
|
+
</ConfigProvider>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(getInjectedStyles()).toContain('.nb-mobile-action-drawer-body');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const styles = getInjectedStyles();
|
|
96
|
+
|
|
97
|
+
expect(styles).toContain('height:var(--nb-mobile-page-header-height,46px)');
|
|
98
|
+
expect(styles).toContain('max-height:calc(100vh-var(--nb-mobile-page-header-height,46px))');
|
|
99
|
+
expect(styles).toContain('max-height:calc(100dvh-var(--nb-mobile-page-header-height,46px))');
|
|
100
|
+
expect(styles).toContain('overflow-y:auto');
|
|
101
|
+
expect(styles).not.toContain('max-height:calc(100%-var(--nb-mobile-page-header-height))');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -35,6 +35,10 @@ type ToolbarDragAnchorDetail = {
|
|
|
35
35
|
point: ToolbarDragAnchorPoint | null;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
const getToolbarModelUidFromNode = (node: HTMLElement | null): string | null => {
|
|
39
|
+
return node?.closest<HTMLElement>('.nb-toolbar-container[data-model-uid]')?.getAttribute('data-model-uid') || null;
|
|
40
|
+
};
|
|
41
|
+
|
|
38
42
|
export const resolveOverlayAnchorTransform = ({
|
|
39
43
|
activeId,
|
|
40
44
|
active,
|
|
@@ -62,7 +66,7 @@ export const resolveOverlayAnchorTransform = ({
|
|
|
62
66
|
const resolveDraggableHostNode = (activatorNode: HTMLElement | null) => {
|
|
63
67
|
const ownerDocument = activatorNode?.ownerDocument;
|
|
64
68
|
const floatToolbarContainer = activatorNode?.closest<HTMLElement>('.nb-toolbar-container[data-model-uid]');
|
|
65
|
-
const toolbarModelUid =
|
|
69
|
+
const toolbarModelUid = getToolbarModelUidFromNode(activatorNode);
|
|
66
70
|
|
|
67
71
|
if (!ownerDocument || !toolbarModelUid) {
|
|
68
72
|
return activatorNode;
|
|
@@ -95,6 +99,7 @@ export const DragHandler: FC<{ model: FlowModel; children?: React.ReactNode }> =
|
|
|
95
99
|
const dragHandlerRef = useRef<HTMLSpanElement | null>(null);
|
|
96
100
|
const draggableNodeRef = useRef<HTMLElement | null>(null);
|
|
97
101
|
const pointerPressCleanupRef = useRef<(() => void) | null>(null);
|
|
102
|
+
const toolbarDragModelUidRef = useRef<string | null>(null);
|
|
98
103
|
const isDraggingRef = useRef(isDragging);
|
|
99
104
|
const isPointerPressActiveRef = useRef(false);
|
|
100
105
|
const isToolbarDragActiveRef = useRef(false);
|
|
@@ -126,9 +131,13 @@ export const DragHandler: FC<{ model: FlowModel; children?: React.ReactNode }> =
|
|
|
126
131
|
return;
|
|
127
132
|
}
|
|
128
133
|
|
|
134
|
+
const toolbarModelUid =
|
|
135
|
+
getToolbarModelUidFromNode(dragHandlerRef.current) || toolbarDragModelUidRef.current || model.uid;
|
|
136
|
+
toolbarDragModelUidRef.current = active ? toolbarModelUid : null;
|
|
137
|
+
|
|
129
138
|
ownerDocument.dispatchEvent(
|
|
130
139
|
new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
|
|
131
|
-
detail: { active, modelUid:
|
|
140
|
+
detail: { active, modelUid: toolbarModelUid },
|
|
132
141
|
}),
|
|
133
142
|
);
|
|
134
143
|
},
|