@nocobase/flow-engine 2.2.0-alpha.1 → 2.2.0-alpha.10

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 (115) hide show
  1. package/lib/JSRunner.d.ts +1 -0
  2. package/lib/JSRunner.js +110 -20
  3. package/lib/acl/Acl.d.ts +2 -1
  4. package/lib/acl/Acl.js +28 -0
  5. package/lib/components/FieldModelRenderer.js +44 -31
  6. package/lib/components/FlowContextSelector.js +55 -12
  7. package/lib/components/FormItem.js +11 -7
  8. package/lib/components/MobilePopup.js +39 -10
  9. package/lib/components/MobilePopup.style.js +27 -6
  10. package/lib/components/dnd/index.js +9 -2
  11. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
  12. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
  13. package/lib/components/subModel/LazyDropdown.js +41 -26
  14. package/lib/components/variables/VariableHybridInput.d.ts +9 -0
  15. package/lib/components/variables/VariableHybridInput.js +146 -17
  16. package/lib/components/variables/VariableInput.js +19 -7
  17. package/lib/components/variables/VariableTag.js +48 -36
  18. package/lib/components/variables/types.d.ts +21 -0
  19. package/lib/flowContext.d.ts +1 -1
  20. package/lib/flowContext.js +97 -42
  21. package/lib/flowEngine.js +6 -0
  22. package/lib/flowI18n.js +3 -3
  23. package/lib/flowSettings.d.ts +5 -1
  24. package/lib/flowSettings.js +70 -0
  25. package/lib/locale/en-US.json +3 -0
  26. package/lib/locale/index.d.ts +6 -0
  27. package/lib/locale/zh-CN.json +3 -0
  28. package/lib/resources/apiResource.js +2 -1
  29. package/lib/resources/baseRecordResource.js +6 -17
  30. package/lib/resources/multiRecordResource.js +13 -3
  31. package/lib/resources/singleRecordResource.js +7 -2
  32. package/lib/runjs-context/helpers.js +12 -5
  33. package/lib/types.d.ts +15 -1
  34. package/lib/types.js +1 -0
  35. package/lib/utils/dataSourceDirty.d.ts +20 -0
  36. package/lib/utils/dataSourceDirty.js +139 -0
  37. package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
  38. package/lib/utils/dirtyAwareApiClient.js +632 -0
  39. package/lib/utils/index.d.ts +1 -1
  40. package/lib/utils/index.js +11 -11
  41. package/lib/utils/loadedPageCache.d.ts +1 -0
  42. package/lib/utils/loadedPageCache.js +6 -0
  43. package/lib/utils/openViewRouteState.d.ts +28 -0
  44. package/lib/utils/openViewRouteState.js +125 -0
  45. package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
  46. package/lib/utils/parsePathnameToViewParams.js +18 -1
  47. package/lib/utils/resolveRunJSObjectValues.js +3 -2
  48. package/lib/utils/runjsModuleLoader.js +0 -30
  49. package/lib/views/ViewNavigation.js +5 -0
  50. package/package.json +4 -4
  51. package/src/JSRunner.ts +112 -25
  52. package/src/__tests__/JSRunner.test.ts +4 -5
  53. package/src/__tests__/flowContext.test.ts +154 -0
  54. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
  55. package/src/__tests__/flowI18n.test.ts +11 -0
  56. package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
  57. package/src/__tests__/flowSettings.test.ts +72 -0
  58. package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
  59. package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
  60. package/src/acl/Acl.tsx +36 -1
  61. package/src/acl/__tests__/Acl.test.tsx +70 -0
  62. package/src/components/FieldModelRenderer.tsx +50 -36
  63. package/src/components/FlowContextSelector.tsx +66 -11
  64. package/src/components/FormItem.tsx +12 -7
  65. package/src/components/MobilePopup.style.ts +34 -7
  66. package/src/components/MobilePopup.tsx +42 -10
  67. package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
  68. package/src/components/__tests__/FormItem.test.tsx +17 -2
  69. package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
  70. package/src/components/__tests__/MobilePopup.test.tsx +150 -0
  71. package/src/components/dnd/index.tsx +11 -2
  72. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
  73. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
  74. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
  75. package/src/components/subModel/LazyDropdown.tsx +44 -26
  76. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
  77. package/src/components/variables/VariableHybridInput.tsx +185 -14
  78. package/src/components/variables/VariableInput.tsx +32 -7
  79. package/src/components/variables/VariableTag.tsx +51 -37
  80. package/src/components/variables/__tests__/FlowContextSelector.test.tsx +60 -3
  81. package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
  82. package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
  83. package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
  84. package/src/components/variables/types.ts +21 -0
  85. package/src/flowContext.ts +104 -36
  86. package/src/flowEngine.ts +6 -0
  87. package/src/flowI18n.ts +8 -3
  88. package/src/flowSettings.ts +85 -1
  89. package/src/locale/__tests__/index.test.ts +21 -0
  90. package/src/locale/en-US.json +3 -0
  91. package/src/locale/zh-CN.json +3 -0
  92. package/src/resources/apiResource.ts +2 -1
  93. package/src/resources/baseRecordResource.ts +6 -23
  94. package/src/resources/multiRecordResource.ts +13 -3
  95. package/src/resources/singleRecordResource.ts +6 -1
  96. package/src/runjs-context/helpers.ts +12 -6
  97. package/src/types.ts +16 -0
  98. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +713 -0
  99. package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
  100. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
  101. package/src/utils/dataSourceDirty.ts +126 -0
  102. package/src/utils/dirtyAwareApiClient.ts +742 -0
  103. package/src/utils/index.ts +10 -9
  104. package/src/utils/loadedPageCache.ts +7 -0
  105. package/src/utils/openViewRouteState.ts +107 -0
  106. package/src/utils/parsePathnameToViewParams.ts +23 -1
  107. package/src/utils/resolveRunJSObjectValues.ts +5 -2
  108. package/src/utils/runjsModuleLoader.ts +0 -32
  109. package/src/views/ViewNavigation.ts +6 -1
  110. package/src/views/__tests__/ViewNavigation.test.ts +15 -0
  111. package/lib/utils/safeGlobals.d.ts +0 -28
  112. package/lib/utils/safeGlobals.js +0 -367
  113. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
  114. package/src/utils/__tests__/safeGlobals.test.ts +0 -106
  115. package/src/utils/safeGlobals.ts +0 -406
@@ -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
  }
@@ -40,6 +40,18 @@ type SelectedPathInfo = {
40
40
  meta?: ContextSelectorItem['meta'];
41
41
  };
42
42
 
43
+ type MetaNodeTooltipOptions = { tooltip?: React.ReactNode };
44
+
45
+ function getMetaNodeTooltip(meta?: ContextSelectorItem['meta']): React.ReactNode {
46
+ if (!meta) {
47
+ return undefined;
48
+ }
49
+
50
+ const metaWithTooltip = meta as ContextSelectorItem['meta'] & MetaNodeTooltipOptions;
51
+ const options = meta.options as MetaNodeTooltipOptions | undefined;
52
+ return metaWithTooltip.tooltip ?? options?.tooltip;
53
+ }
54
+
43
55
  const normalizePath = (path: unknown): string[] | undefined => {
44
56
  if (!Array.isArray(path)) {
45
57
  return undefined;
@@ -85,6 +97,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
85
97
  value,
86
98
  onChange,
87
99
  children,
100
+ active,
88
101
  metaTree,
89
102
  showSearch = false,
90
103
  parseValueToPath: customParseValueToPath = parseValueToPath,
@@ -92,6 +105,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
92
105
  open,
93
106
  onlyLeafSelectable = false,
94
107
  ignoreFieldNames,
108
+ dropdownFooter,
95
109
  ...cascaderProps
96
110
  }) => {
97
111
  const { token } = theme.useToken();
@@ -119,17 +133,28 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
119
133
 
120
134
  // 文本国际化:仅当 label 为字符串时进行翻译
121
135
  const baseLabel = typeof o.label === 'string' ? flowCtx.t(o.label) : o.label;
136
+ const labelText = typeof baseLabel === 'string' ? baseLabel : String(o.value);
137
+ const tooltip = getMetaNodeTooltip(meta);
138
+ const tooltipTitle = disabled
139
+ ? disabledReason || tooltip || flowCtx.t('This variable is not available')
140
+ : tooltip;
122
141
 
123
- const label = disabled ? (
142
+ const label = tooltipTitle ? (
124
143
  <span>
125
144
  {baseLabel}
126
145
  <Tooltip
127
- title={disabledReason || flowCtx.t('This variable is not available')}
128
- placement="right"
129
- overlayClassName="flow-variable-disabled-tip"
146
+ title={typeof tooltipTitle === 'string' ? flowCtx.t(tooltipTitle) : tooltipTitle}
147
+ placement="top"
148
+ classNames={{ root: 'flow-variable-tip' }}
130
149
  destroyTooltipOnHide
131
150
  >
132
- <QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.35)' }} />
151
+ <QuestionCircleOutlined
152
+ aria-label={`${labelText} tooltip`}
153
+ style={{
154
+ marginLeft: token.marginXXS,
155
+ color: disabled ? token.colorTextDisabled : token.colorTextDescription,
156
+ }}
157
+ />
133
158
  </Tooltip>
134
159
  </span>
135
160
  ) : (
@@ -144,7 +169,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
144
169
  };
145
170
  });
146
171
  },
147
- [flowCtx],
172
+ [flowCtx, token.colorTextDescription, token.colorTextDisabled, token.marginXXS],
148
173
  );
149
174
 
150
175
  // 用于强制重新渲染的状态
@@ -265,13 +290,13 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
265
290
 
266
291
  // 默认按钮组件
267
292
  const defaultChildren = useMemo(() => {
268
- const hasSelected = currentPath && currentPath.length > 0;
293
+ const hasSelected = active ?? Boolean(currentPath && currentPath.length > 0);
269
294
  return (
270
- <Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle}>
295
+ <Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle} disabled={cascaderProps.disabled}>
271
296
  x
272
297
  </Button>
273
298
  );
274
- }, [currentPath]);
299
+ }, [active, cascaderProps.disabled, currentPath]);
275
300
 
276
301
  // 处理选择变化事件
277
302
  const handleChange = useCallback(
@@ -360,12 +385,41 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
360
385
  [cascaderOnDropdownVisibleChange, open],
361
386
  );
362
387
 
388
+ // Footer hint at the bottom of the dropdown. Defaults to the "double click to choose entire object" hint whenever
389
+ // non-leaf selection is allowed (double-clicking a non-leaf selects the whole object). Callers can override with
390
+ // their own node, or pass `null` to hide it.
391
+ const footerNode = useMemo(() => {
392
+ if (dropdownFooter !== undefined) {
393
+ return dropdownFooter;
394
+ }
395
+ if (onlyLeafSelectable) {
396
+ return null;
397
+ }
398
+ return (
399
+ <div
400
+ className={css`
401
+ padding: 6px 12px;
402
+ color: ${token.colorTextDescription};
403
+ border-top: 1px solid ${token.colorSplit};
404
+ font-size: ${token.fontSizeSM}px;
405
+ `}
406
+ >
407
+ {flowCtx.t('Double click to choose entire object')}
408
+ </div>
409
+ );
410
+ }, [dropdownFooter, onlyLeafSelectable, token, flowCtx]);
411
+
363
412
  const renderDropdown = useCallback(
364
413
  (menu: React.ReactElement) => {
365
414
  const cascaderMenuNode = cascaderDropdownRender ? cascaderDropdownRender(menu) : menu;
366
415
  const cascaderMenu = React.isValidElement(cascaderMenuNode) ? cascaderMenuNode : <>{cascaderMenuNode}</>;
367
416
  if (!isSearchEnabled || children === null) {
368
- return cascaderMenu;
417
+ return (
418
+ <>
419
+ {cascaderMenu}
420
+ {footerNode}
421
+ </>
422
+ );
369
423
  }
370
424
 
371
425
  return (
@@ -381,10 +435,11 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
381
435
  />
382
436
  </div>
383
437
  {cascaderMenu}
438
+ {footerNode}
384
439
  </>
385
440
  );
386
441
  },
387
- [cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText],
442
+ [cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText, footerNode],
388
443
  );
389
444
 
390
445
  const inlinePlaceholder =
@@ -54,15 +54,20 @@ const formItemPropKeys: (keyof ExtendedFormItemProps)[] = [
54
54
  'showLabel',
55
55
  ];
56
56
 
57
+ const modelInternalPropKeys = ['globalSort'];
58
+
57
59
  export const FormItem = ({
58
60
  children,
59
61
  showLabel = true,
60
62
  labelWidth,
61
63
  ...rest
62
64
  }: ExtendedFormItemProps & ChildExtraProps) => {
65
+ const forwardedRest = Object.fromEntries(
66
+ Object.entries(rest).filter(([key]) => !modelInternalPropKeys.includes(key)),
67
+ ) as ExtendedFormItemProps & ChildExtraProps;
63
68
  // 过滤掉 Form.Item 专用 props,只保留要传给子组件的
64
69
  const childProps = Object.fromEntries(
65
- Object.entries(rest).filter(([key]) => !formItemPropKeys.includes(key as keyof ExtendedFormItemProps)),
70
+ Object.entries(forwardedRest).filter(([key]) => !formItemPropKeys.includes(key as keyof ExtendedFormItemProps)),
66
71
  );
67
72
 
68
73
  const processedChildren =
@@ -74,7 +79,7 @@ export const FormItem = ({
74
79
  }
75
80
  return child;
76
81
  });
77
- const { label, labelWrap, colon = true, layout } = rest;
82
+ const { label, labelWrap, colon = true, layout } = forwardedRest;
78
83
  const effectiveLabelWrap = !layout || layout === 'vertical' ? true : labelWrap;
79
84
  const labelColStyle =
80
85
  layout === 'vertical' ? { width: labelWidth, ...verticalFormItemLabelStyle } : { width: labelWidth };
@@ -122,17 +127,17 @@ export const FormItem = ({
122
127
  };
123
128
  return (
124
129
  <Form.Item
125
- {...rest}
126
- style={{ ...formItemStyle, ...rest.style }}
130
+ {...forwardedRest}
131
+ style={{ ...formItemStyle, ...forwardedRest.style }}
127
132
  labelCol={{ style: labelColStyle }}
128
133
  layout={layout}
129
134
  label={renderLabel()}
130
135
  colon={false}
131
- extra={rest.extra && <span style={{ whiteSpace: 'pre-wrap' }}>{rest.extra}</span>}
136
+ extra={forwardedRest.extra && <span style={{ whiteSpace: 'pre-wrap' }}>{forwardedRest.extra}</span>}
132
137
  tooltip={
133
- rest.tooltip &&
138
+ forwardedRest.tooltip &&
134
139
  ({
135
- title: rest.tooltip,
140
+ title: forwardedRest.tooltip,
136
141
  overlayInnerStyle: { whiteSpace: 'pre-line' },
137
142
  } as TooltipProps)
138
143
  }
@@ -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,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: 'white',
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(100% - var(--nb-mobile-page-header-height))',
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';
@@ -26,16 +26,50 @@ 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
- const style = useMemo(() => {
42
+ const bodyStyles = (props as MobilePopupProps & { styles?: { body?: React.CSSProperties } }).styles?.body;
43
+ const defaultMaxHeight = getMobilePopupMaxHeight();
44
+ const popupStyle = useMemo(() => {
35
45
  return {
36
- minHeight,
46
+ minHeight: bodyStyles?.minHeight ?? minHeight,
47
+ height: bodyStyles?.height,
48
+ maxHeight: bodyStyles?.maxHeight ?? defaultMaxHeight,
37
49
  };
38
- }, [minHeight]);
50
+ }, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, defaultMaxHeight, minHeight]);
51
+
52
+ const bodyStyle = useMemo<React.CSSProperties>(() => {
53
+ return {
54
+ padding: 0,
55
+ maxHeight: defaultMaxHeight,
56
+ overflowY: 'auto',
57
+ overflowX: 'hidden',
58
+ ...bodyStyles,
59
+ };
60
+ }, [bodyStyles, defaultMaxHeight]);
61
+
62
+ const handleCloseKeyDown = useCallback(
63
+ (event: React.KeyboardEvent<HTMLSpanElement>) => {
64
+ if (event.key !== 'Enter' && event.key !== ' ') {
65
+ return;
66
+ }
67
+
68
+ event.preventDefault();
69
+ closePopup();
70
+ },
71
+ [closePopup],
72
+ );
39
73
 
40
74
  const theme = useMemo(() => {
41
75
  return {
@@ -57,11 +91,8 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
57
91
  onClose={closePopup}
58
92
  onMaskClick={closePopup}
59
93
  bodyClassName="nb-mobile-action-drawer-body"
60
- bodyStyle={{
61
- padding: 0,
62
- }}
63
- maskStyle={style}
64
- style={style}
94
+ bodyStyle={bodyStyle}
95
+ style={popupStyle}
65
96
  destroyOnClose
66
97
  >
67
98
  <div className="nb-mobile-action-drawer-header">
@@ -69,13 +100,14 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
69
100
  <span className="nb-mobile-action-drawer-placeholder">
70
101
  <CloseOutline />
71
102
  </span>
72
- <span>{title}</span>
103
+ <span className="nb-mobile-action-drawer-title">{title}</span>
73
104
  <span
74
105
  className="nb-mobile-action-drawer-close-icon"
75
106
  onClick={closePopup}
76
107
  role="button"
77
108
  tabIndex={0}
78
109
  aria-label={t('Close')}
110
+ onKeyDown={handleCloseKeyDown}
79
111
  >
80
112
  <CloseOutline />
81
113
  </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
+ });