@nocobase/flow-engine 2.2.0-beta.1 → 2.2.0-beta.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/JSRunner.d.ts +1 -0
- package/lib/JSRunner.js +110 -20
- package/lib/components/FieldModelRenderer.js +44 -31
- package/lib/components/MobilePopup.js +28 -10
- package/lib/components/MobilePopup.style.js +27 -6
- 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/flowContext.d.ts +1 -1
- package/lib/flowContext.js +97 -42
- package/lib/flowEngine.js +6 -0
- 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 +632 -0
- package/lib/utils/index.d.ts +1 -1
- package/lib/utils/index.js +11 -11
- package/lib/utils/loadedPageCache.d.ts +1 -0
- package/lib/utils/loadedPageCache.js +6 -0
- 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 +154 -0
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -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/__tests__/viewScopedFlowEngine.test.ts +72 -6
- package/src/components/FieldModelRenderer.tsx +50 -36
- package/src/components/MobilePopup.style.ts +34 -7
- package/src/components/MobilePopup.tsx +30 -10
- package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
- package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
- package/src/components/__tests__/MobilePopup.test.tsx +109 -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/flowContext.ts +104 -36
- package/src/flowEngine.ts +6 -0
- 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 +713 -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 +742 -0
- package/src/utils/index.ts +10 -9
- package/src/utils/loadedPageCache.ts +7 -0
- 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
|
@@ -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,14 +153,14 @@ 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',
|
|
147
160
|
borderBottom: `1px solid ${token.colorSplit}`,
|
|
148
161
|
position: 'sticky',
|
|
149
162
|
top: 0,
|
|
150
|
-
backgroundColor:
|
|
163
|
+
backgroundColor: token.colorBgContainer,
|
|
151
164
|
zIndex: 1000,
|
|
152
165
|
|
|
153
166
|
// to match the button named 'Add block'
|
|
@@ -159,19 +172,33 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
|
|
|
159
172
|
'.nb-mobile-action-drawer-placeholder': {
|
|
160
173
|
display: 'inline-block',
|
|
161
174
|
padding: 12,
|
|
175
|
+
flex: '0 0 auto',
|
|
162
176
|
visibility: 'hidden',
|
|
163
177
|
},
|
|
164
178
|
|
|
179
|
+
'.nb-mobile-action-drawer-title': {
|
|
180
|
+
flex: '1 1 auto',
|
|
181
|
+
minWidth: 0,
|
|
182
|
+
overflow: 'hidden',
|
|
183
|
+
textAlign: 'center',
|
|
184
|
+
textOverflow: 'ellipsis',
|
|
185
|
+
whiteSpace: 'nowrap',
|
|
186
|
+
},
|
|
187
|
+
|
|
165
188
|
'.nb-mobile-action-drawer-close-icon': {
|
|
166
189
|
display: 'inline-block',
|
|
167
190
|
padding: 12,
|
|
191
|
+
flex: '0 0 auto',
|
|
168
192
|
cursor: 'pointer',
|
|
169
193
|
},
|
|
170
194
|
|
|
171
195
|
'.nb-mobile-action-drawer-body': {
|
|
172
196
|
borderTopLeftRadius: 8,
|
|
173
197
|
borderTopRightRadius: 8,
|
|
174
|
-
maxHeight: 'calc(
|
|
198
|
+
maxHeight: 'calc(100vh - var(--nb-mobile-page-header-height, 46px))',
|
|
199
|
+
'@supports (height: 100dvh)': {
|
|
200
|
+
maxHeight: 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))',
|
|
201
|
+
},
|
|
175
202
|
overflowY: 'auto',
|
|
176
203
|
overflowX: 'hidden',
|
|
177
204
|
backgroundColor: token.colorBgLayout,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { ConfigProvider } from 'antd';
|
|
11
|
-
import React, { FC, ReactNode, useMemo } from 'react';
|
|
11
|
+
import React, { FC, ReactNode, useCallback, useMemo } from 'react';
|
|
12
12
|
import { useMobileActionDrawerStyle } from './MobilePopup.style';
|
|
13
13
|
import { useTranslation } from 'react-i18next';
|
|
14
14
|
import { lazy } from '../lazy-helper';
|
|
@@ -31,11 +31,33 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
|
|
|
31
31
|
const { t } = useTranslation();
|
|
32
32
|
const { componentCls, hashId } = useMobileActionDrawerStyle();
|
|
33
33
|
|
|
34
|
-
const
|
|
34
|
+
const bodyStyles = (props as MobilePopupProps & { styles?: { body?: React.CSSProperties } }).styles?.body;
|
|
35
|
+
const popupStyle = useMemo(() => {
|
|
35
36
|
return {
|
|
36
|
-
minHeight,
|
|
37
|
+
minHeight: bodyStyles?.minHeight ?? minHeight,
|
|
38
|
+
height: bodyStyles?.height,
|
|
39
|
+
maxHeight: bodyStyles?.maxHeight,
|
|
37
40
|
};
|
|
38
|
-
}, [minHeight]);
|
|
41
|
+
}, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, minHeight]);
|
|
42
|
+
|
|
43
|
+
const bodyStyle = useMemo(() => {
|
|
44
|
+
return {
|
|
45
|
+
padding: 0,
|
|
46
|
+
...bodyStyles,
|
|
47
|
+
};
|
|
48
|
+
}, [bodyStyles]);
|
|
49
|
+
|
|
50
|
+
const handleCloseKeyDown = useCallback(
|
|
51
|
+
(event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
52
|
+
if (event.key !== 'Enter' && event.key !== ' ') {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
closePopup();
|
|
58
|
+
},
|
|
59
|
+
[closePopup],
|
|
60
|
+
);
|
|
39
61
|
|
|
40
62
|
const theme = useMemo(() => {
|
|
41
63
|
return {
|
|
@@ -57,11 +79,8 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
|
|
|
57
79
|
onClose={closePopup}
|
|
58
80
|
onMaskClick={closePopup}
|
|
59
81
|
bodyClassName="nb-mobile-action-drawer-body"
|
|
60
|
-
bodyStyle={
|
|
61
|
-
|
|
62
|
-
}}
|
|
63
|
-
maskStyle={style}
|
|
64
|
-
style={style}
|
|
82
|
+
bodyStyle={bodyStyle}
|
|
83
|
+
style={popupStyle}
|
|
65
84
|
destroyOnClose
|
|
66
85
|
>
|
|
67
86
|
<div className="nb-mobile-action-drawer-header">
|
|
@@ -69,13 +88,14 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
|
|
|
69
88
|
<span className="nb-mobile-action-drawer-placeholder">
|
|
70
89
|
<CloseOutline />
|
|
71
90
|
</span>
|
|
72
|
-
<span>{title}</span>
|
|
91
|
+
<span className="nb-mobile-action-drawer-title">{title}</span>
|
|
73
92
|
<span
|
|
74
93
|
className="nb-mobile-action-drawer-close-icon"
|
|
75
94
|
onClick={closePopup}
|
|
76
95
|
role="button"
|
|
77
96
|
tabIndex={0}
|
|
78
97
|
aria-label={t('Close')}
|
|
98
|
+
onKeyDown={handleCloseKeyDown}
|
|
79
99
|
>
|
|
80
100
|
<CloseOutline />
|
|
81
101
|
</span>
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
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 from 'react';
|
|
11
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { MobilePopup } from '../MobilePopup';
|
|
14
|
+
|
|
15
|
+
vi.mock('react-i18next', () => ({
|
|
16
|
+
useTranslation: () => ({
|
|
17
|
+
t: (key: string) => key,
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('../MobilePopup.style', () => ({
|
|
22
|
+
useMobileActionDrawerStyle: () => ({
|
|
23
|
+
componentCls: 'nb-mobile-action-drawer',
|
|
24
|
+
hashId: 'hash',
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('../../lazy-helper', () => ({
|
|
29
|
+
lazy: (_loader: unknown, name: string) => {
|
|
30
|
+
if (name === 'Popup') {
|
|
31
|
+
const Popup = ({
|
|
32
|
+
children,
|
|
33
|
+
bodyStyle,
|
|
34
|
+
maskStyle,
|
|
35
|
+
style,
|
|
36
|
+
}: {
|
|
37
|
+
children: React.ReactNode;
|
|
38
|
+
bodyStyle?: React.CSSProperties;
|
|
39
|
+
maskStyle?: React.CSSProperties;
|
|
40
|
+
style?: React.CSSProperties;
|
|
41
|
+
}) => (
|
|
42
|
+
<div data-testid="mobile-popup" style={style}>
|
|
43
|
+
<div data-testid="mobile-popup-mask" style={maskStyle} />
|
|
44
|
+
<div data-testid="mobile-popup-body" style={bodyStyle}>
|
|
45
|
+
{children}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return { Popup };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
CloseOutline: () => <span data-testid="close-outline" />,
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
describe('MobilePopup', () => {
|
|
60
|
+
const MobilePopupWithDrawerStyles = MobilePopup as React.ComponentType<
|
|
61
|
+
React.ComponentProps<typeof MobilePopup> & { styles?: { body?: React.CSSProperties } }
|
|
62
|
+
>;
|
|
63
|
+
|
|
64
|
+
it('applies drawer body styles as max bounds without forcing fixed half-window height', () => {
|
|
65
|
+
render(
|
|
66
|
+
<MobilePopupWithDrawerStyles visible title="Title" styles={{ body: { maxHeight: '50vh' } }} onClose={vi.fn()}>
|
|
67
|
+
body
|
|
68
|
+
</MobilePopupWithDrawerStyles>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(screen.getByTestId('mobile-popup')).toHaveStyle({
|
|
72
|
+
maxHeight: '50vh',
|
|
73
|
+
});
|
|
74
|
+
expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
|
|
75
|
+
maxHeight: '50vh',
|
|
76
|
+
});
|
|
77
|
+
expect(screen.getByTestId('mobile-popup-mask')).not.toHaveStyle({
|
|
78
|
+
maxHeight: '50vh',
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('closes from the header close icon with Enter or Space', () => {
|
|
83
|
+
const onClose = vi.fn();
|
|
84
|
+
render(
|
|
85
|
+
<MobilePopup visible title="Title" onClose={onClose}>
|
|
86
|
+
body
|
|
87
|
+
</MobilePopup>,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const closeButton = screen.getByRole('button', { name: 'Close' });
|
|
91
|
+
fireEvent.keyDown(closeButton, { key: 'Enter' });
|
|
92
|
+
fireEvent.keyDown(closeButton, { key: ' ' });
|
|
93
|
+
|
|
94
|
+
expect(onClose).toHaveBeenCalledTimes(2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('keeps long header titles in a constrained title element separate from the close button', () => {
|
|
98
|
+
const longTitle = 'A very long table column title that should not push the close button outside the drawer';
|
|
99
|
+
|
|
100
|
+
render(
|
|
101
|
+
<MobilePopup visible title={longTitle} onClose={vi.fn()}>
|
|
102
|
+
body
|
|
103
|
+
</MobilePopup>,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(screen.getByText(longTitle)).toHaveClass('nb-mobile-action-drawer-title');
|
|
107
|
+
expect(screen.getByRole('button', { name: 'Close' })).toHaveClass('nb-mobile-action-drawer-close-icon');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -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
|
},
|