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

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 (85) 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/components/FieldAssignValueInput.d.ts +2 -0
  5. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
  6. package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
  7. package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
  8. package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
  9. package/es/flow/models/actions/LinkActionUtils.d.ts +1 -0
  10. package/es/flow/models/blocks/assign-form/assignFormValue.d.ts +29 -0
  11. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
  12. package/es/index.mjs +79 -79
  13. package/lib/index.js +84 -84
  14. package/package.json +9 -8
  15. package/src/BaseApplication.tsx +2 -0
  16. package/src/RouteRepository.ts +14 -0
  17. package/src/__tests__/RouteRepository.test.ts +51 -0
  18. package/src/__tests__/app.test.tsx +13 -0
  19. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  20. package/src/collection-manager/field-configure.ts +2 -2
  21. package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
  22. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
  23. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
  24. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  25. package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
  26. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  27. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  28. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  29. package/src/flow/components/FieldAssignValueInput.tsx +4 -0
  30. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
  31. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
  32. package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
  33. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  34. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  35. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  36. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  37. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  38. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  39. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  40. package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
  41. package/src/flow/models/actions/LinkActionModel.tsx +11 -9
  42. package/src/flow/models/actions/LinkActionUtils.ts +4 -2
  43. package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
  44. package/src/flow/models/actions/__tests__/LinkActionModel.test.ts +14 -0
  45. package/src/flow/models/base/GridModel.tsx +0 -1
  46. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  47. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +40 -12
  48. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  49. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.multipleSelect.test.tsx +216 -0
  50. package/src/flow/models/blocks/assign-form/__tests__/assignFormValue.test.ts +41 -0
  51. package/src/flow/models/blocks/assign-form/assignFormValue.ts +64 -0
  52. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  53. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  54. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  55. package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
  56. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  57. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
  58. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  59. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  60. package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
  61. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
  62. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  63. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  64. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +7 -1
  65. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
  66. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +6 -3
  67. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -0
  68. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +47 -17
  69. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
  70. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
  71. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  72. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +26 -2
  73. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  74. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  75. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  76. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  77. package/src/flow/models/fields/InputFieldModel.tsx +1 -1
  78. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  79. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  80. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  81. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +16 -0
  82. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
  83. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
  84. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -0
  85. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
@@ -25,6 +25,7 @@ import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable }
25
25
  import { CSS } from '@dnd-kit/utilities';
26
26
  import { AssociationFieldModel } from './AssociationFieldModel';
27
27
  import { transformNestedData } from '../ClickableFieldModel';
28
+ import { syncQuickEditPreventClose } from '../../../internal/utils/quickEditViewContainer';
28
29
 
29
30
  type CascadeHydrateStatus = 'pending' | 'done';
30
31
 
@@ -373,7 +374,12 @@ export class CascadeSelectInnerFieldModel extends AssociationFieldModel {
373
374
  this.setProps({ onPopupScroll: fn });
374
375
  }
375
376
  set onDropdownVisibleChange(fn) {
376
- this.setProps({ onDropdownVisibleChange: fn });
377
+ this.setProps({
378
+ onDropdownVisibleChange: (visible: boolean) => {
379
+ syncQuickEditPreventClose(this, visible);
380
+ fn(visible);
381
+ },
382
+ });
377
383
  }
378
384
  set onSearch(fn) {
379
385
  this.setProps({ onSearch: fn });
@@ -19,7 +19,7 @@ import {
19
19
  createCurrentRecordMetaFactory,
20
20
  useFlowModel,
21
21
  } from '@nocobase/flow-engine';
22
- import { Table, Button, Space } from 'antd';
22
+ import { Table, Button, Space, theme } from 'antd';
23
23
  import { uid } from '@formily/shared';
24
24
  import classNames from 'classnames';
25
25
  import { DragEndEvent } from '@dnd-kit/core';
@@ -190,6 +190,7 @@ const AddFieldColumn = ({ model }) => {
190
190
  };
191
191
 
192
192
  const DisplayTable = (props) => {
193
+ const { token } = theme.useToken();
193
194
  const {
194
195
  pageSize,
195
196
  value,
@@ -269,7 +270,7 @@ const DisplayTable = (props) => {
269
270
  },
270
271
  },
271
272
  ...baseColumns,
272
- { key: 'empty' },
273
+ { key: 'empty', render: () => <div style={{ minHeight: token.controlHeight }} /> },
273
274
  ].filter(Boolean),
274
275
  ) as any[];
275
276
 
@@ -283,7 +284,7 @@ const DisplayTable = (props) => {
283
284
  }
284
285
 
285
286
  return cols;
286
- }, [baseColumns, enableIndexColumn, currentPage, currentPageSize, isConfigMode, model]);
287
+ }, [baseColumns, enableIndexColumn, currentPage, currentPageSize, isConfigMode, model, token.controlHeight]);
287
288
  return (
288
289
  <Table
289
290
  tableLayout="fixed"
@@ -35,6 +35,7 @@ import {
35
35
  import {
36
36
  buildOpenerUids,
37
37
  LabelByField,
38
+ normalizeAssociationFieldNames,
38
39
  type AssociationFieldNames,
39
40
  useAssociationValueHydration,
40
41
  } from './recordSelectShared';
@@ -331,6 +332,7 @@ function RecordPickerField(props) {
331
332
  const { fieldNames, onClick, disabled } = props;
332
333
  const ctx = useFlowContext();
333
334
  const allowMultiple = canRecordPickerSelectMultiple(ctx.collectionField, props.allowMultiple);
335
+ const normalizedFieldNames = normalizeAssociationFieldNames(fieldNames, ctx.collectionField?.targetCollection);
334
336
  useEffect(() => {
335
337
  ctx.model.selectedRows.value = props.value;
336
338
  }, [ctx.model.selectedRows, props.value]);
@@ -338,22 +340,23 @@ function RecordPickerField(props) {
338
340
  model: ctx.model,
339
341
  value: props.value,
340
342
  isMultiple: allowMultiple,
341
- fieldNames,
343
+ fieldNames: normalizedFieldNames,
342
344
  onChange: props.onChange,
343
345
  });
344
346
 
345
347
  return (
346
348
  <Select
347
349
  {...props}
350
+ fieldNames={normalizedFieldNames}
348
351
  open={false}
349
352
  onClick={(e) => {
350
353
  if (!disabled) {
351
354
  onClick(e);
352
355
  }
353
356
  }}
354
- value={normalizeRecordPickerValue(props.value, fieldNames, allowMultiple)}
357
+ value={normalizeRecordPickerValue(props.value, normalizedFieldNames, allowMultiple)}
355
358
  labelRender={(item) => {
356
- return <LabelByField option={item} fieldNames={fieldNames} />;
359
+ return <LabelByField option={item} fieldNames={normalizedFieldNames} />;
357
360
  }}
358
361
  labelInValue
359
362
  mode={allowMultiple ? 'multiple' : undefined}
@@ -41,6 +41,7 @@ import { BlockSceneEnum } from '../../base/BlockModel';
41
41
  import { ActionWithoutPermission } from '../../base/ActionModel';
42
42
  import { EditFormModel } from '../../blocks';
43
43
  import { hasAncestorModel } from './recordSelectSettingsUtils';
44
+ import { syncQuickEditPreventClose } from '../../../internal/utils/quickEditViewContainer';
44
45
 
45
46
  function RemoteModelRenderer({ options }) {
46
47
  const ctx = useFlowViewContext();
@@ -65,6 +66,11 @@ function RemoteModelRenderer({ options }) {
65
66
 
66
67
  export function CreateContent({ model, toOne = false }) {
67
68
  const ctx = useFlowContext();
69
+ // Quick-create popups isolate business context but must retain the field's configuration mode.
70
+ ctx.defineProperty('flowSettingsEnabled', {
71
+ get: () => model.context.flowSettingsEnabled,
72
+ cache: false,
73
+ });
68
74
  const { Header, type } = ctx.view;
69
75
  model._closeView = ctx.view.close;
70
76
  return (
@@ -308,6 +314,7 @@ const LazySelect = (props: Readonly<LazySelectProps>) => {
308
314
  }
309
315
  setKeepOpenRuntime({ preventCloseOnSelect: false, open });
310
316
  }
317
+ syncQuickEditPreventClose(model, open);
311
318
  others.onDropdownVisibleChange?.(open);
312
319
  }}
313
320
  optionRender={({ data }) => {
@@ -874,6 +881,8 @@ RecordSelectFieldModel.registerFlow({
874
881
  dataSourceKey: ctx.collection.dataSourceKey,
875
882
  collectionName: ctx.collectionField?.target,
876
883
  ...(associationName && sourceId != null ? { associationName, sourceId } : {}),
884
+ // 上级记录暂无主键(如审批配置态表单)时不能传 associationName,否则弹窗内新建的区块会绑定到关联资源;改传 sourceAssociationName,仅用于展示“当前弹窗上级记录”变量
885
+ ...(associationName && sourceId == null ? { sourceAssociationName: associationName } : {}),
877
886
  collectionField: ctx.collectionField,
878
887
  openerUids,
879
888
  onChange: (e) => {
@@ -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' };