@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.
Files changed (91) hide show
  1. package/lib/JSRunner.d.ts +1 -0
  2. package/lib/JSRunner.js +110 -20
  3. package/lib/components/FieldModelRenderer.js +44 -31
  4. package/lib/components/FlowContextSelector.js +27 -5
  5. package/lib/components/MobilePopup.style.js +16 -5
  6. package/lib/components/dnd/index.js +9 -2
  7. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
  8. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
  9. package/lib/components/variables/VariableHybridInput.d.ts +8 -0
  10. package/lib/components/variables/VariableHybridInput.js +128 -12
  11. package/lib/components/variables/VariableInput.js +2 -1
  12. package/lib/components/variables/types.d.ts +18 -0
  13. package/lib/flowContext.d.ts +1 -1
  14. package/lib/flowContext.js +87 -36
  15. package/lib/flowI18n.js +3 -3
  16. package/lib/flowSettings.d.ts +5 -1
  17. package/lib/flowSettings.js +70 -0
  18. package/lib/locale/en-US.json +1 -0
  19. package/lib/locale/index.d.ts +2 -0
  20. package/lib/locale/zh-CN.json +1 -0
  21. package/lib/resources/apiResource.js +2 -1
  22. package/lib/resources/baseRecordResource.js +6 -17
  23. package/lib/resources/multiRecordResource.js +13 -3
  24. package/lib/resources/singleRecordResource.js +7 -2
  25. package/lib/runjs-context/helpers.js +12 -5
  26. package/lib/types.d.ts +12 -0
  27. package/lib/utils/dataSourceDirty.d.ts +20 -0
  28. package/lib/utils/dataSourceDirty.js +139 -0
  29. package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
  30. package/lib/utils/dirtyAwareApiClient.js +378 -0
  31. package/lib/utils/index.d.ts +1 -1
  32. package/lib/utils/index.js +11 -11
  33. package/lib/utils/openViewRouteState.d.ts +28 -0
  34. package/lib/utils/openViewRouteState.js +125 -0
  35. package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
  36. package/lib/utils/parsePathnameToViewParams.js +18 -1
  37. package/lib/utils/resolveRunJSObjectValues.js +3 -2
  38. package/lib/utils/runjsModuleLoader.js +0 -30
  39. package/lib/views/ViewNavigation.js +5 -0
  40. package/package.json +4 -4
  41. package/src/JSRunner.ts +112 -25
  42. package/src/__tests__/JSRunner.test.ts +4 -5
  43. package/src/__tests__/flowContext.test.ts +131 -0
  44. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
  45. package/src/__tests__/flowI18n.test.ts +11 -0
  46. package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
  47. package/src/__tests__/flowSettings.test.ts +72 -0
  48. package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
  49. package/src/components/FieldModelRenderer.tsx +50 -36
  50. package/src/components/FlowContextSelector.tsx +36 -4
  51. package/src/components/MobilePopup.style.ts +22 -6
  52. package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
  53. package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
  54. package/src/components/dnd/index.tsx +11 -2
  55. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
  56. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
  57. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
  58. package/src/components/variables/VariableHybridInput.tsx +166 -9
  59. package/src/components/variables/VariableInput.tsx +2 -1
  60. package/src/components/variables/__tests__/FlowContextSelector.test.tsx +23 -3
  61. package/src/components/variables/__tests__/VariableHybridInput.test.tsx +178 -0
  62. package/src/components/variables/__tests__/VariableInput.test.tsx +51 -5
  63. package/src/components/variables/types.ts +18 -0
  64. package/src/flowContext.ts +100 -33
  65. package/src/flowI18n.ts +8 -3
  66. package/src/flowSettings.ts +85 -1
  67. package/src/locale/en-US.json +1 -0
  68. package/src/locale/zh-CN.json +1 -0
  69. package/src/resources/apiResource.ts +2 -1
  70. package/src/resources/baseRecordResource.ts +6 -23
  71. package/src/resources/multiRecordResource.ts +13 -3
  72. package/src/resources/singleRecordResource.ts +6 -1
  73. package/src/runjs-context/helpers.ts +12 -6
  74. package/src/types.ts +14 -0
  75. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
  76. package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
  77. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
  78. package/src/utils/dataSourceDirty.ts +126 -0
  79. package/src/utils/dirtyAwareApiClient.ts +430 -0
  80. package/src/utils/index.ts +10 -9
  81. package/src/utils/openViewRouteState.ts +107 -0
  82. package/src/utils/parsePathnameToViewParams.ts +23 -1
  83. package/src/utils/resolveRunJSObjectValues.ts +5 -2
  84. package/src/utils/runjsModuleLoader.ts +0 -32
  85. package/src/views/ViewNavigation.ts +6 -1
  86. package/src/views/__tests__/ViewNavigation.test.ts +15 -0
  87. package/lib/utils/safeGlobals.d.ts +0 -28
  88. package/lib/utils/safeGlobals.js +0 -367
  89. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
  90. package/src/utils/__tests__/safeGlobals.test.ts +0 -106
  91. 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 = (e: any) => {
35
- let val;
36
- if (e && e.target && typeof e.target.value !== 'undefined') {
37
- val = e.target.value;
38
- } else if (
39
- typeof e === 'string' ||
40
- typeof e === 'number' ||
41
- typeof e === 'boolean' ||
42
- (typeof e === 'object' && !(e instanceof Event))
43
- ) {
44
- val = e;
45
- } else {
46
- val = null;
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
- model.setProps({ value: val });
50
- if (!composingRef.current) {
51
- props.onChange?.(val);
52
- }
53
- };
54
- const handleCompositionStart = () => {
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 = (e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
59
- composingRef.current = false;
60
- if (flag) {
61
- props.onChange(e);
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
- onCompositionStart: handleCompositionStart,
67
- onCompositionEnd: handleCompositionEnd,
68
- ..._.omit(rest, flowModelRendererPropKeys),
69
- onChange: handleChange,
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 cascaderMenu;
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 { useMemo, useContext } from 'react';
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 memoizedWrapSSR = useMemo(() => {
126
- return wrapSSR;
127
- }, [theme, token, hashId, prefixCls, iconPrefixCls, rootPrefixCls, props]);
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(100% - var(--nb-mobile-page-header-height))',
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 = floatToolbarContainer?.getAttribute('data-model-uid');
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: model.uid },
140
+ detail: { active, modelUid: toolbarModelUid },
132
141
  }),
133
142
  );
134
143
  },