@nocobase/flow-engine 2.2.0-beta.8 → 2.3.0-alpha.1

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 (42) hide show
  1. package/lib/components/FlowContextSelector.js +55 -12
  2. package/lib/components/FormItem.js +11 -7
  3. package/lib/components/MobilePopup.js +28 -10
  4. package/lib/components/MobilePopup.style.js +11 -1
  5. package/lib/components/variables/VariableHybridInput.d.ts +9 -0
  6. package/lib/components/variables/VariableHybridInput.js +146 -17
  7. package/lib/components/variables/VariableInput.js +19 -7
  8. package/lib/components/variables/VariableTag.js +48 -36
  9. package/lib/components/variables/types.d.ts +21 -0
  10. package/lib/flowContext.js +10 -6
  11. package/lib/flowEngine.js +6 -0
  12. package/lib/flowI18n.js +3 -3
  13. package/lib/types.d.ts +3 -1
  14. package/lib/types.js +1 -0
  15. package/lib/utils/dirtyAwareApiClient.js +267 -13
  16. package/lib/utils/loadedPageCache.d.ts +1 -0
  17. package/lib/utils/loadedPageCache.js +6 -0
  18. package/package.json +4 -4
  19. package/src/__tests__/flowContext.test.ts +23 -0
  20. package/src/__tests__/flowI18n.test.ts +11 -0
  21. package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
  22. package/src/components/FlowContextSelector.tsx +66 -11
  23. package/src/components/FormItem.tsx +12 -7
  24. package/src/components/MobilePopup.style.ts +12 -1
  25. package/src/components/MobilePopup.tsx +30 -10
  26. package/src/components/__tests__/FormItem.test.tsx +17 -2
  27. package/src/components/__tests__/MobilePopup.test.tsx +109 -0
  28. package/src/components/variables/VariableHybridInput.tsx +185 -14
  29. package/src/components/variables/VariableInput.tsx +32 -7
  30. package/src/components/variables/VariableTag.tsx +51 -37
  31. package/src/components/variables/__tests__/FlowContextSelector.test.tsx +60 -3
  32. package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
  33. package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
  34. package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
  35. package/src/components/variables/types.ts +21 -0
  36. package/src/flowContext.ts +4 -3
  37. package/src/flowEngine.ts +6 -0
  38. package/src/flowI18n.ts +8 -3
  39. package/src/types.ts +2 -0
  40. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +321 -0
  41. package/src/utils/dirtyAwareApiClient.ts +325 -13
  42. package/src/utils/loadedPageCache.ts +7 -0
@@ -1607,6 +1607,29 @@ describe('FlowEngine context', () => {
1607
1607
  await expect((engine.context as any).getVar('foo.bar')).rejects.toThrow();
1608
1608
  });
1609
1609
 
1610
+ it('model.context.getVar should resolve the latest ctx.auth.user after user changes', async () => {
1611
+ const engine = new FlowEngine();
1612
+ engine.context.defineProperty('api', {
1613
+ value: { auth: { role: 'admin', locale: 'en-US', token: 'token-1' } },
1614
+ });
1615
+ engine.context.defineProperty('user', { value: { id: 1, nickname: 'Alice' } });
1616
+
1617
+ class AuthUserModel extends FlowModel {}
1618
+ engine.registerModels({ AuthUserModel });
1619
+ const model = engine.createModel({ use: 'AuthUserModel' });
1620
+
1621
+ expect(await model.context.getVar('ctx.auth.user.id')).toBe(1);
1622
+ expect(await model.context.getVar('ctx.auth.token')).toBe('token-1');
1623
+
1624
+ engine.context.api.auth.role = 'member';
1625
+ engine.context.api.auth.token = 'token-2';
1626
+ engine.context.defineProperty('user', { value: { id: 2, nickname: 'Bob' } });
1627
+
1628
+ expect(await model.context.getVar('ctx.auth.user.id')).toBe(2);
1629
+ expect(await model.context.getVar('ctx.auth.roleName')).toBe('member');
1630
+ expect(await model.context.getVar('ctx.auth.token')).toBe('token-2');
1631
+ });
1632
+
1610
1633
  it('engine.context.runAction should resolve action from engine.getAction', async () => {
1611
1634
  const engine = new FlowEngine();
1612
1635
 
@@ -17,6 +17,17 @@ describe('FlowI18n', () => {
17
17
  expect(i18n.translate("{{ t('Hello') }}")).toBe('你好');
18
18
  });
19
19
 
20
+ it('keeps embedded quotes of a different type inside the key', () => {
21
+ // A single-quoted key whose text contains double quotes (and vice versa) must not be truncated at the first inner
22
+ // quote.
23
+ const key = 'Unlike "Post-action event", it listens for data changes.';
24
+ const table: Record<string, string> = { [key]: '与“操作后事件”不同,它监听数据变动。' };
25
+ const i18n = new FlowI18n({ i18n: { t: (k: string) => table[k] ?? k } });
26
+
27
+ expect(i18n.translate(`{{t('${key}', { ns: "workflow" })}}`)).toBe(table[key]);
28
+ expect(i18n.translate(`{{t("It's here", { ns: "workflow" })}}`)).toBe("It's here");
29
+ });
30
+
20
31
  it('template compile ignores malformed options', () => {
21
32
  const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
22
33
  const i18n = new FlowI18n({ i18n: { t: (k: string) => k } });
@@ -340,9 +340,9 @@ describe('ViewScopedFlowEngine', () => {
340
340
  const repository = new DirtyPageRepository();
341
341
  root.setModelRepository(repository);
342
342
 
343
- class ParentModel extends FlowModel {}
344
- class PageModel extends FlowModel {}
345
343
  class BlockModel extends FlowModel {}
344
+ class PageModel extends FlowModel<{ parent?: FlowModel; subModels: { items: BlockModel[] } }> {}
345
+ class ParentModel extends FlowModel<{ parent?: FlowModel; subModels: { page?: PageModel } }> {}
346
346
  root.registerModels({ ParentModel, PageModel, BlockModel });
347
347
 
348
348
  const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'popup-action' });
@@ -357,7 +357,10 @@ describe('ViewScopedFlowEngine', () => {
357
357
  items: [{ use: 'BlockModel', uid: 'stale-block' }],
358
358
  },
359
359
  });
360
- const staleBlock = stalePage.findSubModel('items' as any, (item) => item.uid === 'stale-block') as FlowModel;
360
+ const staleBlock = stalePage.findSubModel('items', (item) => item.uid === 'stale-block');
361
+ if (!staleBlock) {
362
+ throw new Error('Expected stale block to be loaded');
363
+ }
361
364
  parent.setSubModel('page', stalePage);
362
365
  oldScoped.unlinkFromStack();
363
366
 
@@ -372,7 +375,7 @@ describe('ViewScopedFlowEngine', () => {
372
375
  },
373
376
  };
374
377
 
375
- root.flowSettings.enable();
378
+ await root.flowSettings.enable();
376
379
  await staleBlock.saveStepParams();
377
380
  root.flowSettings.disable();
378
381
  repository.findOneCalls = 0;
@@ -388,8 +391,8 @@ describe('ViewScopedFlowEngine', () => {
388
391
 
389
392
  expect(repository.findOneCalls).toBe(1);
390
393
  expect(loaded).not.toBe(stalePage);
391
- expect((parent.subModels as any).page).toBe(loaded);
392
- expect(loaded?.mapSubModels('items' as any, (item) => item.uid)).toEqual(['fresh-block']);
394
+ expect(parent.subModels.page).toBe(loaded);
395
+ expect(loaded?.mapSubModels('items', (item) => item.uid)).toEqual(['fresh-block']);
393
396
 
394
397
  repository.findOneCalls = 0;
395
398
  const nextRuntimeScoped = createViewScopedEngine(root);
@@ -405,6 +408,69 @@ describe('ViewScopedFlowEngine', () => {
405
408
  expect(loadedAgain?.uid).toBe('popup-page');
406
409
  });
407
410
 
411
+ it('reloads a page after it was loaded in flow settings mode', async () => {
412
+ const root = new FlowEngine();
413
+ const repository = new DirtyPageRepository();
414
+ root.setModelRepository(repository);
415
+
416
+ class ParentModel extends FlowModel {}
417
+ class PageModel extends FlowModel {}
418
+ class BlockModel extends FlowModel {}
419
+ root.registerModels({ ParentModel, PageModel, BlockModel });
420
+
421
+ const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'settings-popup-action' });
422
+ repository.data = {
423
+ use: 'PageModel',
424
+ uid: 'settings-popup-page',
425
+ parentId: parent.uid,
426
+ subKey: 'page',
427
+ subType: 'object',
428
+ subModels: {
429
+ items: [{ use: 'BlockModel', uid: 'stale-settings-block' }],
430
+ },
431
+ };
432
+
433
+ await root.flowSettings.enable();
434
+ const designScoped = createViewScopedEngine(root);
435
+ const designLoaded = await designScoped.loadOrCreateModel<PageModel>({
436
+ async: true,
437
+ parentId: parent.uid,
438
+ subKey: 'page',
439
+ subType: 'object',
440
+ use: 'PageModel',
441
+ });
442
+ expect(repository.findOneCalls).toBe(1);
443
+ expect(designLoaded?.mapSubModels('items', (item) => item.uid)).toEqual(['stale-settings-block']);
444
+ designScoped.unlinkFromStack();
445
+
446
+ repository.data = {
447
+ use: 'PageModel',
448
+ uid: 'settings-popup-page',
449
+ parentId: parent.uid,
450
+ subKey: 'page',
451
+ subType: 'object',
452
+ subModels: {
453
+ items: [{ use: 'BlockModel', uid: 'fresh-settings-block' }],
454
+ },
455
+ };
456
+ root.flowSettings.disable();
457
+ repository.findOneCalls = 0;
458
+
459
+ const runtimeScoped = createViewScopedEngine(root);
460
+ const runtimeLoaded = await runtimeScoped.loadOrCreateModel<PageModel>({
461
+ async: true,
462
+ parentId: parent.uid,
463
+ subKey: 'page',
464
+ subType: 'object',
465
+ use: 'PageModel',
466
+ });
467
+
468
+ expect(repository.findOneCalls).toBe(1);
469
+ expect(runtimeLoaded).not.toBe(designLoaded);
470
+ expect(parent.subModels.page).toBe(runtimeLoaded);
471
+ expect(runtimeLoaded?.mapSubModels('items', (item) => item.uid)).toEqual(['fresh-settings-block']);
472
+ });
473
+
408
474
  it('does not bypass loaded page cache after a non-config save', async () => {
409
475
  const root = new FlowEngine();
410
476
  const repository = new DirtyPageRepository();
@@ -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
  }
@@ -160,7 +160,7 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
160
160
  borderBottom: `1px solid ${token.colorSplit}`,
161
161
  position: 'sticky',
162
162
  top: 0,
163
- backgroundColor: 'white',
163
+ backgroundColor: token.colorBgContainer,
164
164
  zIndex: 1000,
165
165
 
166
166
  // to match the button named 'Add block'
@@ -172,12 +172,23 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
172
172
  '.nb-mobile-action-drawer-placeholder': {
173
173
  display: 'inline-block',
174
174
  padding: 12,
175
+ flex: '0 0 auto',
175
176
  visibility: 'hidden',
176
177
  },
177
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
+
178
188
  '.nb-mobile-action-drawer-close-icon': {
179
189
  display: 'inline-block',
180
190
  padding: 12,
191
+ flex: '0 0 auto',
181
192
  cursor: 'pointer',
182
193
  },
183
194
 
@@ -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 style = useMemo(() => {
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
- padding: 0,
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>
@@ -7,8 +7,10 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, expect, it } from 'vitest';
11
- import { formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
10
+ import { render } from '@testing-library/react';
11
+ import React from 'react';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { FormItem, formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
12
14
 
13
15
  describe('FormItem', () => {
14
16
  it('keeps vertical label-to-value spacing consistent with v1', () => {
@@ -22,4 +24,17 @@ describe('FormItem', () => {
22
24
  marginBottom: 12,
23
25
  });
24
26
  });
27
+
28
+ it('does not forward model-internal globalSort props to field children', () => {
29
+ const Field = vi.fn(() => <input aria-label="field" />);
30
+
31
+ render(
32
+ <FormItem globalSort={['title']} placeholder="Title">
33
+ <Field />
34
+ </FormItem>,
35
+ );
36
+
37
+ expect(Field).toHaveBeenCalledWith(expect.not.objectContaining({ globalSort: expect.anything() }), {});
38
+ expect(Field).toHaveBeenCalledWith(expect.objectContaining({ placeholder: 'Title' }), {});
39
+ });
25
40
  });
@@ -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
+ });