@nocobase/client-v2 2.3.0-beta.2 → 2.3.0-beta.21

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/es/RouteRepository.d.ts +6 -0
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  3. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  4. package/es/flow/admin-shell/admin-layout/MobileTopbarActionsContext.d.ts +10 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  6. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
  7. package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
  8. package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
  9. package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
  10. package/es/flow/models/actions/LinkActionUtils.d.ts +1 -0
  11. package/es/flow/models/blocks/assign-form/assignFormValue.d.ts +29 -0
  12. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
  13. package/es/index.mjs +100 -100
  14. package/lib/index.js +102 -102
  15. package/package.json +9 -8
  16. package/src/BaseApplication.tsx +2 -0
  17. package/src/RouteRepository.ts +14 -0
  18. package/src/__tests__/RouteRepository.test.ts +51 -0
  19. package/src/__tests__/app.test.tsx +13 -0
  20. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  21. package/src/collection-manager/field-configure.ts +2 -2
  22. package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
  23. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
  24. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
  25. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  26. package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
  27. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  28. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  29. package/src/flow/admin-shell/admin-layout/MobileTopbarActionsContext.ts +13 -0
  30. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +13 -2
  31. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.mobile.test.tsx +205 -0
  32. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  33. package/src/flow/components/FieldAssignValueInput.tsx +4 -0
  34. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
  35. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
  36. package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
  37. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  38. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  39. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  40. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  41. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  42. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  43. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  44. package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
  45. package/src/flow/models/actions/LinkActionModel.tsx +11 -9
  46. package/src/flow/models/actions/LinkActionUtils.ts +4 -2
  47. package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
  48. package/src/flow/models/actions/__tests__/LinkActionModel.test.ts +14 -0
  49. package/src/flow/models/base/GridModel.tsx +0 -1
  50. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  51. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +40 -12
  52. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  53. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.multipleSelect.test.tsx +216 -0
  54. package/src/flow/models/blocks/assign-form/__tests__/assignFormValue.test.ts +41 -0
  55. package/src/flow/models/blocks/assign-form/assignFormValue.ts +64 -0
  56. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  57. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  58. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  59. package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
  60. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  61. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
  62. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  63. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  64. package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
  65. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
  66. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  67. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  68. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +11 -2
  69. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
  70. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +6 -3
  71. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +13 -1
  72. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +47 -17
  73. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
  74. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
  75. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  76. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +26 -2
  77. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  78. package/src/flow/models/fields/AssociationFieldModel/__tests__/associationSelect.search.test.ts +113 -0
  79. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  80. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  81. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  82. package/src/flow/models/fields/InputFieldModel.tsx +1 -1
  83. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  84. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  85. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  86. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +16 -0
  87. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
  88. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
  89. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -0
  90. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
  91. package/src/flow/models/topbar/UserCenterTopbarActionModel.tsx +8 -4
@@ -94,6 +94,10 @@ export function FieldWithoutPermissionPlaceholder({ targetModel }) {
94
94
  );
95
95
  }
96
96
 
97
+ const LARGE_FIELD_TEXT_INTERFACES = ['textarea', 'richText', 'json', 'markdown', 'vditor'];
98
+
99
+ const LARGE_FIELD_INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, [role="button"], [role="link"]';
100
+
97
101
  const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultValue, disabled, ...others }: any) => {
98
102
  const flowEngine = useFlowEngine();
99
103
  const ref = useRef(null);
@@ -116,17 +120,32 @@ const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultV
116
120
 
117
121
  return <FieldModelRenderer model={model} {...rest} onChange={handleChange} />;
118
122
  };
119
- const handleClick = async (e) => {
123
+ const handleClick = async (e: React.MouseEvent<HTMLDivElement>) => {
120
124
  if (disabled) {
121
125
  return;
122
126
  }
127
+ const target = e.target as HTMLElement;
128
+ // 预览弹窗等通过 portal 渲染的内容,其点击事件也会沿 React 树冒泡到这里,不应触发编辑
129
+ if (!ref.current?.contains(target)) {
130
+ return;
131
+ }
132
+ // 只读组件自身可交互的部分(如附件缩略图点击预览)交给只读组件处理,不打开编辑弹窗
133
+ if (target.closest(LARGE_FIELD_INTERACTIVE_SELECTOR)) {
134
+ return;
135
+ }
123
136
  e.preventDefault();
124
137
  e.stopPropagation();
138
+ // 默认挂在 #nocobase-app-container 上,它带 transform 会形成独立层叠上下文;当子表格位于挂在 body 上的弹窗(如审批处理弹窗)里时,
139
+ // 编辑弹层会被弹窗遮住无法操作,此时改挂到 body
140
+ const appContainer = document.querySelector('#nocobase-app-container');
141
+ const getLargeFieldPopupContainer = () =>
142
+ appContainer && appContainer.contains(ref.current) ? (appContainer as HTMLElement) : document.body;
125
143
  try {
126
144
  await flowEngine.context.viewer.open({
127
145
  type: 'popover',
128
- target: e.target,
146
+ target,
129
147
  placement: 'rightTop',
148
+ getPopupContainer: getLargeFieldPopupContainer,
130
149
  styles: {
131
150
  body: {
132
151
  minWidth: 400,
@@ -141,9 +160,10 @@ const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultV
141
160
  }
142
161
  };
143
162
  const collectionField = model.context.collectionField;
163
+ const isTextLike = LARGE_FIELD_TEXT_INTERFACES.includes(collectionField.interface);
144
164
 
145
165
  const content = useMemo(() => {
146
- if (['textarea', 'richText', 'json', 'markdown', 'vditor'].includes(collectionField.interface)) {
166
+ if (isTextLike) {
147
167
  const inputValue =
148
168
  collectionField.interface === 'json' && defaultValue
149
169
  ? JSON.stringify(defaultValue, null, 2)
@@ -151,13 +171,15 @@ const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultV
151
171
 
152
172
  return <Input value={inputValue} disabled={disabled} style={{ width: '100%' }} />;
153
173
  } else {
174
+ // 只读组件保持可交互:禁用时仍可点击附件缩略图预览,与 client v1 一致
154
175
  return (
155
176
  <Space>
156
- <FlowModelRenderer model={fieldModel} uid={fieldModel?.uid} /> <EditOutlined className="edit-icon" />
177
+ <FlowModelRenderer model={fieldModel} uid={fieldModel?.uid} />
178
+ {disabled ? null : <EditOutlined className="edit-icon" />}
157
179
  </Space>
158
180
  );
159
181
  }
160
- }, [collectionField.interface, defaultValue, disabled, fieldModel]);
182
+ }, [collectionField.interface, defaultValue, disabled, fieldModel, isTextLike]);
161
183
  return (
162
184
  <div
163
185
  ref={ref}
@@ -170,11 +192,12 @@ const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultV
170
192
  width: '100%',
171
193
  maxHeight: 300,
172
194
  overflowY: 'auto',
173
- cursor: 'pointer',
195
+ cursor: disabled ? 'default' : 'pointer',
174
196
  }}
175
197
  >
176
198
  <span
177
- style={{ pointerEvents: 'none', display: 'block', width: '100%' }} // 不拦截点击
199
+ // 文本类大字段只是占位展示,不拦截点击,由外层统一打开编辑弹窗
200
+ style={{ pointerEvents: isTextLike ? 'none' : undefined, display: 'block', width: '100%' }}
178
201
  >
179
202
  {content}
180
203
  </span>
@@ -209,7 +232,7 @@ function hasFormValueDrivenDataScopeField(fieldModel: any) {
209
232
  }
210
233
 
211
234
  const MemoFieldRenderer = React.memo(FieldModelRenderer, (prev, next) => {
212
- return prev.value === next.value && prev.model === next.model;
235
+ return prev.value === next.value && prev.model === next.model && prev.disabled === next.disabled;
213
236
  });
214
237
 
215
238
  export function buildRowPathFromFieldIndex(fieldIndex: unknown): Array<string | number> | null {
@@ -345,11 +368,14 @@ interface CellProps {
345
368
  memoKey?: string;
346
369
  width?: number;
347
370
  commitOnChange?: boolean;
371
+ disabled?: boolean;
348
372
  }
349
373
 
350
374
  const MemoCell: React.FC<CellProps> = React.memo(
351
- ({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width, commitOnChange }) => {
375
+ ({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width, commitOnChange, disabled }) => {
352
376
  const isNew = record?.__is_new__;
377
+ // 子表格整体被禁用时,列上的显示模式可能仍是“可编辑”,这里需要把父级的禁用状态一并合并进来,否则单元格仍可编辑。
378
+ const columnDisabled = disabled || parent.props.disabled;
353
379
  return (
354
380
  <div
355
381
  style={{
@@ -442,9 +468,7 @@ const MemoCell: React.FC<CellProps> = React.memo(
442
468
  style={{ marginBottom: 0 }}
443
469
  showLabel={false}
444
470
  disabled={
445
- parent.props.disabled ||
446
- (!isNew && parent.props.aclDisabled) ||
447
- (isNew && parent.props.aclCreateDisabled)
471
+ columnDisabled || (!isNew && parent.props.aclDisabled) || (isNew && parent.props.aclCreateDisabled)
448
472
  }
449
473
  >
450
474
  {fork.constructor.isLargeField ? (
@@ -456,9 +480,7 @@ const MemoCell: React.FC<CellProps> = React.memo(
456
480
  }}
457
481
  defaultValue={value}
458
482
  disabled={
459
- parent.props.disabled ||
460
- (!isNew && parent.props.aclDisabled) ||
461
- (isNew && parent.props.aclCreateDisabled)
483
+ columnDisabled || (!isNew && parent.props.aclDisabled) || (isNew && parent.props.aclCreateDisabled)
462
484
  }
463
485
  />
464
486
  ) : (
@@ -481,7 +503,8 @@ const MemoCell: React.FC<CellProps> = React.memo(
481
503
  prev.memoKey === next.memoKey &&
482
504
  prev.width === next.width &&
483
505
  prev.commitOnChange === next.commitOnChange &&
484
- prev.rowIdx === next.rowIdx
506
+ prev.rowIdx === next.rowIdx &&
507
+ prev.disabled === next.disabled
485
508
  );
486
509
  },
487
510
  );
@@ -620,6 +643,11 @@ export class SubTableColumnModel<
620
643
 
621
644
  onInit(options: any): void {
622
645
  super.onInit(options);
646
+ const resource = this.context.blockModel?.context?.resource;
647
+ const fieldPath = this.context.fieldPath;
648
+ if (resource?.addUpdateAssociationValues && fieldPath && this.collectionField?.isAssociationField?.()) {
649
+ resource.addUpdateAssociationValues(fieldPath);
650
+ }
623
651
  this.context.defineProperty('resourceName', {
624
652
  get: () => {
625
653
  return this.context.collectionField.collection.name;
@@ -740,7 +768,7 @@ export class SubTableColumnModel<
740
768
  }
741
769
  renderItem(): any {
742
770
  return (props) => {
743
- const { value, id, rowIdx, record, parentFieldIndex, parentItem } = props || {};
771
+ const { value, id, rowIdx, record, parentFieldIndex, parentItem, disabled: subTableDisabled } = props || {};
744
772
  // 子表格列模型本身没有行级 fieldIndex,上下文中无法把 `roles.name` 解析成 `roles[0].name`,
745
773
  // 导致“默认值/赋值规则”在对多关系字段下无法生效。
746
774
  // 这里为每一行创建一个 column fork,并注入 fieldIndex,让规则引擎能够按行解析与写入。
@@ -754,6 +782,7 @@ export class SubTableColumnModel<
754
782
  const fieldModel: any = this.subModels.field;
755
783
  const cellModeKey = [
756
784
  rowForkKey,
785
+ subTableDisabled ? 'disabled' : 'enabled',
757
786
  this.props.pattern,
758
787
  this.props.readPretty,
759
788
  this.props.titleField,
@@ -828,6 +857,7 @@ export class SubTableColumnModel<
828
857
  parentItem={parentItem}
829
858
  rowFork={rowFork}
830
859
  memoKey={cellModeKey}
860
+ disabled={subTableDisabled}
831
861
  width={this.props.width}
832
862
  commitOnChange={this.hasFormulaColumn || this.hasFormValueDrivenDataScopeColumn}
833
863
  />
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { CloseOutlined, ZoomInOutlined } from '@ant-design/icons';
11
- import { Table, Form, Space, Button } from 'antd';
11
+ import { Table, Form, Space, Button, theme } from 'antd';
12
12
  import { css } from '@emotion/css';
13
13
  import { useTranslation } from 'react-i18next';
14
14
  import { PlusOutlined } from '@ant-design/icons';
@@ -51,6 +51,7 @@ function shouldRefreshForChangedPaths(fieldPath: unknown, changedPaths: unknown)
51
51
 
52
52
  export function SubTableField(props) {
53
53
  const { t } = useTranslation();
54
+ const { token } = theme.useToken();
54
55
  const {
55
56
  onChange,
56
57
  columns,
@@ -180,6 +181,9 @@ export function SubTableField(props) {
180
181
  // the current array index so Form.Item can rebind after reordering/removal.
181
182
  const rowBindingKey = `${rowIdentity}:${pageRowIdx}`;
182
183
  const columnKey = col.dataIndex ?? col.key ?? 'cell';
184
+ if (col.key === '_empty') {
185
+ return <div style={{ minHeight: token.controlHeight }} />;
186
+ }
183
187
  if (!col.render) {
184
188
  return;
185
189
  }
@@ -188,6 +192,7 @@ export function SubTableField(props) {
188
192
  rowIdx: pageRowIdx,
189
193
  id: `field-${String(columnKey)}-${rowBindingKey}`,
190
194
  value: text,
195
+ disabled,
191
196
  parentFieldIndex,
192
197
  parentItem,
193
198
  onChange: (value) => {
@@ -0,0 +1,178 @@
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 { createPortal } from 'react-dom';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { render, fireEvent } from '@nocobase/test/client';
14
+ import { FlowEngine, FlowModel, FlowEngineProvider, largeField } from '@nocobase/flow-engine';
15
+ import { SubTableColumnModel } from '../SubTableColumnModel';
16
+
17
+ const previewClicked = vi.fn();
18
+
19
+ class FakePreviewModel extends FlowModel {
20
+ render() {
21
+ return (
22
+ <span>
23
+ <div role="button" tabIndex={0} data-testid="thumb" onClick={previewClicked}>
24
+ file.png
25
+ </div>
26
+ {createPortal(<div data-testid="portal">preview modal</div>, document.body)}
27
+ </span>
28
+ );
29
+ }
30
+ }
31
+
32
+ @largeField()
33
+ class FakeUploadModel extends FlowModel {
34
+ render() {
35
+ return <div data-testid="uploader">uploader</div>;
36
+ }
37
+ }
38
+
39
+ type CellRenderer = React.FC<Record<string, unknown>>;
40
+
41
+ function setup({
42
+ disabled,
43
+ fieldInterface = 'attachment',
44
+ container,
45
+ }: {
46
+ disabled: boolean;
47
+ fieldInterface?: string;
48
+ container?: HTMLElement;
49
+ }) {
50
+ previewClicked.mockClear();
51
+ const engine = new FlowEngine();
52
+ engine.registerModels({ FakePreviewModel, FakeUploadModel });
53
+ const open = vi.fn();
54
+ engine.context.defineProperty('viewer', { value: { open } });
55
+
56
+ const field = engine.createModel<FakeUploadModel>({ use: 'FakeUploadModel', uid: 'f1' });
57
+ field.context.defineProperty('fieldPath', { value: 'items.files' });
58
+ field.context.defineProperty('collectionField', { value: { interface: fieldInterface } });
59
+ field.setSubModel('readPrettyField', { use: 'FakePreviewModel', uid: 'p1' });
60
+
61
+ const column = Object.create(SubTableColumnModel.prototype);
62
+ const ctx: Record<string, unknown> = {};
63
+ ctx.defineProperty = (key: string, options: PropertyDescriptor) =>
64
+ Object.defineProperty(ctx, key, { ...options, configurable: true });
65
+ Object.defineProperties(column, {
66
+ parent: {
67
+ value: {
68
+ context: { fieldIndex: [], collectionField: { name: 'items' } },
69
+ collection: { filterTargetKey: 'id' },
70
+ props: { value: [{ id: 1 }] },
71
+ },
72
+ },
73
+ subModels: { value: { field } },
74
+ props: { value: { disabled: false } },
75
+ context: { value: { fieldIndex: [], t: (s: string) => s } },
76
+ hasFormulaColumn: { value: false },
77
+ hasFormValueDrivenDataScopeColumn: { value: false },
78
+ });
79
+ column.mapSubModels = (_key: string, fn: (m: FlowModel) => React.ReactNode) => [fn(field)];
80
+ column.createFork = () => ({ context: ctx, uid: 'rowfork', flowEngine: undefined });
81
+ column.createRowItemContextPropertyOptions = () => ({});
82
+
83
+ const Cell: CellRenderer = SubTableColumnModel.prototype.renderItem.call(column);
84
+ const utils = render(
85
+ <FlowEngineProvider engine={engine}>
86
+ <Cell value={[{ id: 1, url: '/file.png' }]} id={1} rowIdx={0} record={{ id: 1 }} disabled={disabled} />
87
+ </FlowEngineProvider>,
88
+ container ? { container } : undefined,
89
+ );
90
+ return { ...utils, open };
91
+ }
92
+
93
+ function getCellTrigger(element: HTMLElement) {
94
+ return element.closest('div[style*="cursor"]') as HTMLElement;
95
+ }
96
+
97
+ describe('SubTableColumnModel large field cell', () => {
98
+ it('previews the attachment instead of opening the editor when the thumbnail is clicked', async () => {
99
+ const { findByTestId, open } = setup({ disabled: false });
100
+ const thumb = await findByTestId('thumb');
101
+
102
+ expect(thumb.closest('span[style*="pointer-events: none"]')).toBeNull();
103
+ fireEvent.click(thumb);
104
+
105
+ expect(previewClicked).toHaveBeenCalledTimes(1);
106
+ expect(open).not.toHaveBeenCalled();
107
+ });
108
+
109
+ it('opens the editor when the editable cell itself is clicked', async () => {
110
+ const { findByTestId, open, container } = setup({ disabled: false });
111
+ const thumb = await findByTestId('thumb');
112
+
113
+ expect(container.querySelector('.edit-icon')).not.toBeNull();
114
+ fireEvent.click(getCellTrigger(thumb));
115
+
116
+ expect(open).toHaveBeenCalledTimes(1);
117
+ });
118
+
119
+ it('ignores clicks bubbling from portal content such as the preview modal', async () => {
120
+ const { findByTestId, open } = setup({ disabled: false });
121
+ fireEvent.click(await findByTestId('portal'));
122
+
123
+ expect(open).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('still allows previewing but never editing when the cell is disabled', async () => {
127
+ const { findByTestId, open, container } = setup({ disabled: true });
128
+ const thumb = await findByTestId('thumb');
129
+ const trigger = getCellTrigger(thumb);
130
+
131
+ expect(trigger.style.cursor).toBe('default');
132
+ expect(container.querySelector('.edit-icon')).toBeNull();
133
+
134
+ fireEvent.click(thumb);
135
+ fireEvent.click(trigger);
136
+
137
+ expect(previewClicked).toHaveBeenCalledTimes(1);
138
+ expect(open).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it('mounts the editor popover on body when the cell is rendered outside the app container (e.g. in a body-level modal)', async () => {
142
+ const appContainer = document.createElement('div');
143
+ appContainer.id = 'nocobase-app-container';
144
+ document.body.appendChild(appContainer);
145
+ try {
146
+ const { findByTestId, open } = setup({ disabled: false });
147
+ fireEvent.click(getCellTrigger(await findByTestId('thumb')));
148
+
149
+ expect(open.mock.calls[0][0].getPopupContainer()).toBe(document.body);
150
+ } finally {
151
+ appContainer.remove();
152
+ }
153
+ });
154
+
155
+ it('keeps the editor popover in the app container when the cell is rendered inside it', async () => {
156
+ const appContainer = document.createElement('div');
157
+ appContainer.id = 'nocobase-app-container';
158
+ document.body.appendChild(appContainer);
159
+ try {
160
+ const { findByTestId, open } = setup({ disabled: false, container: appContainer });
161
+ fireEvent.click(getCellTrigger(await findByTestId('thumb')));
162
+
163
+ expect(open.mock.calls[0][0].getPopupContainer()).toBe(appContainer);
164
+ } finally {
165
+ appContainer.remove();
166
+ }
167
+ });
168
+
169
+ it('keeps text-like large fields as a click-through placeholder that opens the editor', async () => {
170
+ const { container, open } = setup({ disabled: false, fieldInterface: 'textarea' });
171
+ const input = container.querySelector('input') as HTMLInputElement;
172
+
173
+ expect(input.closest('span[style*="pointer-events: none"]')).not.toBeNull();
174
+ fireEvent.click(getCellTrigger(input));
175
+
176
+ expect(open).toHaveBeenCalledTimes(1);
177
+ });
178
+ });
@@ -0,0 +1,90 @@
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 { describe, expect, it, vi } from 'vitest';
12
+ import { render, screen } from '@nocobase/test/client';
13
+ import { SubTableField } from '../SubTableField';
14
+
15
+ vi.mock('react-i18next', async (importOriginal) => ({
16
+ ...(await importOriginal<any>()),
17
+ useTranslation: () => ({
18
+ t: (value: string) => value,
19
+ }),
20
+ }));
21
+
22
+ vi.mock('antd', async () => {
23
+ const actual = await vi.importActual<any>('antd');
24
+ return {
25
+ ...actual,
26
+ Table: ({ dataSource = [], columns = [] }: any) => (
27
+ <div data-testid="subtable">
28
+ <div data-testid="column-keys">{columns.map((column: any) => String(column.key)).join(',')}</div>
29
+ {dataSource.map((record: any, rowIdx: number) => (
30
+ <div data-testid={`row-${rowIdx}`} key={record.__index__ || rowIdx}>
31
+ {columns.map((column: any) => (
32
+ <div data-testid={`cell-${rowIdx}-${String(column.dataIndex || column.key)}`} key={column.key}>
33
+ {column.render?.(record[column.dataIndex], record, rowIdx)}
34
+ </div>
35
+ ))}
36
+ </div>
37
+ ))}
38
+ </div>
39
+ ),
40
+ };
41
+ });
42
+
43
+ function renderSubTableField(disabled?: boolean) {
44
+ const renderArgs: any[] = [];
45
+ const columns = [
46
+ {
47
+ key: 'name',
48
+ dataIndex: 'name',
49
+ render: (args: any) => {
50
+ renderArgs.push(args);
51
+ return <span>{args.value || 'empty'}</span>;
52
+ },
53
+ },
54
+ ];
55
+
56
+ render(
57
+ <SubTableField
58
+ columns={columns}
59
+ disabled={disabled}
60
+ pageSize={10}
61
+ filterTargetKey="id"
62
+ fieldPathArray={['roles']}
63
+ getCurrentValue={() => [{ __index__: 'row-1', id: 1, name: 'admin' }]}
64
+ />,
65
+ );
66
+
67
+ return renderArgs;
68
+ }
69
+
70
+ describe('SubTableField disabled', () => {
71
+ it('passes the disabled state down to every column cell', () => {
72
+ const renderArgs = renderSubTableField(true);
73
+
74
+ expect(renderArgs).toHaveLength(1);
75
+ expect(renderArgs[0].disabled).toBe(true);
76
+ });
77
+
78
+ it('keeps cells editable when the sub-table is not disabled', () => {
79
+ const renderArgs = renderSubTableField(false);
80
+
81
+ expect(renderArgs).toHaveLength(1);
82
+ expect(renderArgs[0].disabled).toBe(false);
83
+ });
84
+
85
+ it('hides the remove column when the sub-table is disabled', () => {
86
+ renderSubTableField(true);
87
+
88
+ expect(screen.getByTestId('column-keys')).not.toHaveTextContent('delete');
89
+ });
90
+ });
@@ -7,8 +7,9 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { FlowContext, FlowEngine, FlowModel } from '@nocobase/flow-engine';
11
- import { renderHook, waitFor } from '@testing-library/react';
10
+ import React from 'react';
11
+ import { FlowContext, FlowEngine, FlowModel, FlowModelProvider } from '@nocobase/flow-engine';
12
+ import { render, renderHook, waitFor } from '@testing-library/react';
12
13
  import { describe, expect, it, vi } from 'vitest';
13
14
  import {
14
15
  buildCurrentItemTitle,
@@ -84,6 +85,29 @@ describe('RecordPickerFieldModel item context', () => {
84
85
  expect(normalizeRecordPickerValue(undefined, fieldNames, false)).toBeUndefined();
85
86
  });
86
87
 
88
+ it('renders existing values before field names are initialized', () => {
89
+ const engine = new FlowEngine();
90
+ engine.registerModels({ RecordPickerFieldModel });
91
+ const field = engine.createModel<RecordPickerFieldModel>({
92
+ use: 'RecordPickerFieldModel',
93
+ uid: 'record-picker-without-field-names',
94
+ });
95
+ field.context.defineProperty('collectionField', {
96
+ value: {
97
+ type: 'hasMany',
98
+ targetCollection: {
99
+ filterTargetKey: 'id',
100
+ titleCollectionField: { name: 'name' },
101
+ },
102
+ },
103
+ });
104
+ field.setProps({ value: [{ id: 1, name: 'Alice' }], onClick: vi.fn(), onChange: vi.fn() });
105
+
106
+ const result = render(React.createElement(FlowModelProvider, { model: field }, field.render()));
107
+
108
+ expect(result.getByRole('combobox')).toBeTruthy();
109
+ });
110
+
87
111
  it('queues ID-only picker values for association label hydration', () => {
88
112
  const statusMap = new Map<string, 'pending' | 'done'>();
89
113
  const fieldNames = { label: 'name', value: 'id' };