@nocobase/client-v2 2.4.0-alpha.1 → 2.4.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 (64) 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/blocks/form/QuickEditFormModel.d.ts +1 -0
  10. package/es/index.mjs +77 -77
  11. package/lib/index.js +131 -131
  12. package/package.json +9 -8
  13. package/src/BaseApplication.tsx +2 -0
  14. package/src/RouteRepository.ts +14 -0
  15. package/src/__tests__/RouteRepository.test.ts +51 -0
  16. package/src/__tests__/app.test.tsx +13 -0
  17. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  18. package/src/collection-manager/field-configure.ts +2 -2
  19. package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
  20. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
  21. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
  22. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  23. package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
  24. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  25. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  26. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +39 -0
  27. package/src/flow/components/FieldAssignValueInput.tsx +4 -0
  28. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
  29. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
  30. package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
  31. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  32. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  33. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  34. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  35. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  36. package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
  37. package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
  38. package/src/flow/models/base/GridModel.tsx +0 -1
  39. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  40. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
  41. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  42. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  43. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  44. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  45. package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
  46. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  47. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
  48. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  49. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  50. package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
  51. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
  52. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  53. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  54. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +7 -1
  55. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
  56. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -0
  57. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +17 -10
  58. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
  59. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  60. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  61. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  62. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  63. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  64. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
@@ -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"
@@ -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) => {
@@ -209,7 +209,7 @@ function hasFormValueDrivenDataScopeField(fieldModel: any) {
209
209
  }
210
210
 
211
211
  const MemoFieldRenderer = React.memo(FieldModelRenderer, (prev, next) => {
212
- return prev.value === next.value && prev.model === next.model;
212
+ return prev.value === next.value && prev.model === next.model && prev.disabled === next.disabled;
213
213
  });
214
214
 
215
215
  export function buildRowPathFromFieldIndex(fieldIndex: unknown): Array<string | number> | null {
@@ -345,11 +345,14 @@ interface CellProps {
345
345
  memoKey?: string;
346
346
  width?: number;
347
347
  commitOnChange?: boolean;
348
+ disabled?: boolean;
348
349
  }
349
350
 
350
351
  const MemoCell: React.FC<CellProps> = React.memo(
351
- ({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width, commitOnChange }) => {
352
+ ({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width, commitOnChange, disabled }) => {
352
353
  const isNew = record?.__is_new__;
354
+ // 子表格整体被禁用时,列上的显示模式可能仍是“可编辑”,这里需要把父级的禁用状态一并合并进来,否则单元格仍可编辑。
355
+ const columnDisabled = disabled || parent.props.disabled;
353
356
  return (
354
357
  <div
355
358
  style={{
@@ -442,9 +445,7 @@ const MemoCell: React.FC<CellProps> = React.memo(
442
445
  style={{ marginBottom: 0 }}
443
446
  showLabel={false}
444
447
  disabled={
445
- parent.props.disabled ||
446
- (!isNew && parent.props.aclDisabled) ||
447
- (isNew && parent.props.aclCreateDisabled)
448
+ columnDisabled || (!isNew && parent.props.aclDisabled) || (isNew && parent.props.aclCreateDisabled)
448
449
  }
449
450
  >
450
451
  {fork.constructor.isLargeField ? (
@@ -456,9 +457,7 @@ const MemoCell: React.FC<CellProps> = React.memo(
456
457
  }}
457
458
  defaultValue={value}
458
459
  disabled={
459
- parent.props.disabled ||
460
- (!isNew && parent.props.aclDisabled) ||
461
- (isNew && parent.props.aclCreateDisabled)
460
+ columnDisabled || (!isNew && parent.props.aclDisabled) || (isNew && parent.props.aclCreateDisabled)
462
461
  }
463
462
  />
464
463
  ) : (
@@ -481,7 +480,8 @@ const MemoCell: React.FC<CellProps> = React.memo(
481
480
  prev.memoKey === next.memoKey &&
482
481
  prev.width === next.width &&
483
482
  prev.commitOnChange === next.commitOnChange &&
484
- prev.rowIdx === next.rowIdx
483
+ prev.rowIdx === next.rowIdx &&
484
+ prev.disabled === next.disabled
485
485
  );
486
486
  },
487
487
  );
@@ -620,6 +620,11 @@ export class SubTableColumnModel<
620
620
 
621
621
  onInit(options: any): void {
622
622
  super.onInit(options);
623
+ const resource = this.context.blockModel?.context?.resource;
624
+ const fieldPath = this.context.fieldPath;
625
+ if (resource?.addUpdateAssociationValues && fieldPath && this.collectionField?.isAssociationField?.()) {
626
+ resource.addUpdateAssociationValues(fieldPath);
627
+ }
623
628
  this.context.defineProperty('resourceName', {
624
629
  get: () => {
625
630
  return this.context.collectionField.collection.name;
@@ -740,7 +745,7 @@ export class SubTableColumnModel<
740
745
  }
741
746
  renderItem(): any {
742
747
  return (props) => {
743
- const { value, id, rowIdx, record, parentFieldIndex, parentItem } = props || {};
748
+ const { value, id, rowIdx, record, parentFieldIndex, parentItem, disabled: subTableDisabled } = props || {};
744
749
  // 子表格列模型本身没有行级 fieldIndex,上下文中无法把 `roles.name` 解析成 `roles[0].name`,
745
750
  // 导致“默认值/赋值规则”在对多关系字段下无法生效。
746
751
  // 这里为每一行创建一个 column fork,并注入 fieldIndex,让规则引擎能够按行解析与写入。
@@ -754,6 +759,7 @@ export class SubTableColumnModel<
754
759
  const fieldModel: any = this.subModels.field;
755
760
  const cellModeKey = [
756
761
  rowForkKey,
762
+ subTableDisabled ? 'disabled' : 'enabled',
757
763
  this.props.pattern,
758
764
  this.props.readPretty,
759
765
  this.props.titleField,
@@ -828,6 +834,7 @@ export class SubTableColumnModel<
828
834
  parentItem={parentItem}
829
835
  rowFork={rowFork}
830
836
  memoKey={cellModeKey}
837
+ disabled={subTableDisabled}
831
838
  width={this.props.width}
832
839
  commitOnChange={this.hasFormulaColumn || this.hasFormValueDrivenDataScopeColumn}
833
840
  />
@@ -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,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
+ });
@@ -0,0 +1,183 @@
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 {
11
+ FlowContext,
12
+ FlowEngine,
13
+ FlowEngineProvider,
14
+ FlowViewContextProvider,
15
+ type FlowModel,
16
+ } from '@nocobase/flow-engine';
17
+ import { cleanup, render, screen, waitFor } from '@testing-library/react';
18
+ import React from 'react';
19
+ import { afterEach, describe, expect, it, vi } from 'vitest';
20
+ import '../../../index';
21
+ import { BlockGridModel } from '../../../base/BlockGridModel';
22
+ import { RecordSelectFieldModel } from '../RecordSelectFieldModel';
23
+
24
+ type OpenViewHandler = (ctx: unknown, params: Record<string, unknown>) => void;
25
+
26
+ function getOpenViewHandler(): OpenViewHandler {
27
+ const flow = RecordSelectFieldModel.globalFlowRegistry.getFlow('popupSettings');
28
+ const handler = flow?.getStep('openView')?.serialize().handler;
29
+
30
+ if (!handler) {
31
+ throw new Error('popupSettings.openView handler is not registered');
32
+ }
33
+
34
+ return handler as OpenViewHandler;
35
+ }
36
+
37
+ function createContext(record: Record<string, unknown>) {
38
+ const open = vi.fn();
39
+ const sourceCollection = {
40
+ getFilterByTK: vi.fn((sourceRecord: Record<string, unknown>) => sourceRecord.id),
41
+ };
42
+ const model = {
43
+ uid: 'record-select-uid',
44
+ props: {
45
+ quickCreate: 'modalAdd',
46
+ allowMultiple: true,
47
+ },
48
+ context: {
49
+ inputArgs: {},
50
+ flowSettingsEnabled: false,
51
+ },
52
+ flowEngine: {
53
+ context: {
54
+ themeToken: {
55
+ colorBgLayout: '#fff',
56
+ },
57
+ },
58
+ },
59
+ };
60
+
61
+ return {
62
+ open,
63
+ context: {
64
+ inputArgs: { onChange: vi.fn() },
65
+ view: {
66
+ inputArgs: {
67
+ viewUid: 'approval-popup-uid',
68
+ },
69
+ },
70
+ viewer: { open },
71
+ layoutContentElement: {},
72
+ model,
73
+ collection: {
74
+ dataSourceKey: 'main',
75
+ filterTargetKey: 'id',
76
+ },
77
+ collectionField: {
78
+ type: 'hasMany',
79
+ target: 'orgs',
80
+ resourceName: 'users.orgs',
81
+ collection: sourceCollection,
82
+ },
83
+ record,
84
+ },
85
+ };
86
+ }
87
+
88
+ describe('RecordSelectFieldModel quick create popup', () => {
89
+ afterEach(() => {
90
+ cleanup();
91
+ vi.restoreAllMocks();
92
+ });
93
+
94
+ describe.each(['drawer', 'dialog'])('%s configuration mode', (mode) => {
95
+ it.each([
96
+ { scenario: 'configuration', modelEnabled: true, globalEnabled: false },
97
+ { scenario: 'global configuration', modelEnabled: true, globalEnabled: true },
98
+ { scenario: 'runtime', modelEnabled: false, globalEnabled: false },
99
+ { scenario: 'read-only configuration', modelEnabled: false, globalEnabled: true },
100
+ ])('uses the field configuration state in $scenario', async ({ modelEnabled, globalEnabled }) => {
101
+ const engine = new FlowEngine();
102
+ engine.registerModels({ BlockGridModel });
103
+ engine.flowSettings.enabled = globalEnabled;
104
+ engine.context.defineProperty('themeToken', {
105
+ value: { marginBlock: 16, marginSM: 8, paddingLG: 24 },
106
+ });
107
+ engine.context.defineProperty('t', { value: (key: string) => key });
108
+ const save = vi.fn(async (model: FlowModel) => model.serialize());
109
+ engine.setModelRepository({
110
+ findOne: vi.fn(async () => null),
111
+ save,
112
+ destroy: vi.fn(async () => true),
113
+ move: vi.fn(async () => undefined),
114
+ duplicate: vi.fn(async () => null),
115
+ });
116
+ const load = vi.spyOn(engine, 'loadOrCreateModel');
117
+ const { context, open } = createContext({});
118
+ context.model.context.flowSettingsEnabled = modelEnabled;
119
+ getOpenViewHandler()(context, { mode, size: 'medium' });
120
+ const config = open.mock.calls[0][0];
121
+ expect(config.inheritContext).toBe(false);
122
+
123
+ // Detached popups inherit the engine context, not the approval form's model context.
124
+ const popupContext = new FlowContext();
125
+ popupContext.addDelegate(engine.context);
126
+ popupContext.defineProperty('engine', { value: engine });
127
+ popupContext.defineProperty('view', {
128
+ value: { type: mode, inputArgs: config.inputArgs, Header: () => null, close: vi.fn() },
129
+ });
130
+ const { container } = render(
131
+ <FlowEngineProvider engine={engine}>
132
+ <FlowViewContextProvider context={popupContext}>{config.content()}</FlowViewContextProvider>
133
+ </FlowEngineProvider>,
134
+ );
135
+
136
+ await waitFor(() => expect(container.querySelector('.nb-block-grid')).toBeInTheDocument());
137
+ if (modelEnabled) {
138
+ expect(screen.getByRole('button', { name: /Add block/ })).toBeInTheDocument();
139
+ expect(save).toHaveBeenCalledOnce();
140
+ } else {
141
+ expect(screen.queryByRole('button', { name: /Add block/ })).not.toBeInTheDocument();
142
+ expect(save).not.toHaveBeenCalled();
143
+ }
144
+ expect(load).toHaveBeenCalledWith(
145
+ expect.objectContaining({ use: 'BlockGridModel', parentId: context.model.uid }),
146
+ { delegateToParent: false, delegate: popupContext, skipSave: !modelEnabled },
147
+ );
148
+ expect(engine.context.flowSettingsEnabled).toBe(globalEnabled);
149
+ });
150
+ });
151
+
152
+ it('passes the parent record reference when the parent record has a primary key', () => {
153
+ const { context, open } = createContext({ id: 1, nickname: 'Saved user' });
154
+
155
+ getOpenViewHandler()(context, { mode: 'drawer', size: 'medium' });
156
+
157
+ expect(open).toHaveBeenCalledOnce();
158
+ const { inputArgs } = open.mock.calls[0][0];
159
+ expect(inputArgs).toMatchObject({
160
+ scene: 'create',
161
+ collectionName: 'orgs',
162
+ associationName: 'users.orgs',
163
+ sourceId: 1,
164
+ });
165
+ expect(inputArgs).not.toHaveProperty('sourceAssociationName');
166
+ });
167
+
168
+ it('only passes sourceAssociationName when the parent record has no primary key yet', () => {
169
+ const { context, open } = createContext({});
170
+
171
+ getOpenViewHandler()(context, { mode: 'drawer', size: 'medium' });
172
+
173
+ expect(open).toHaveBeenCalledOnce();
174
+ const { inputArgs } = open.mock.calls[0][0];
175
+ expect(inputArgs).toMatchObject({
176
+ scene: 'create',
177
+ collectionName: 'orgs',
178
+ sourceAssociationName: 'users.orgs',
179
+ });
180
+ expect(inputArgs).not.toHaveProperty('associationName');
181
+ expect(inputArgs).not.toHaveProperty('sourceId');
182
+ });
183
+ });
@@ -17,14 +17,16 @@ import {
17
17
  observer,
18
18
  } from '@nocobase/flow-engine';
19
19
  import { Table } from 'antd';
20
+ import type { TableProps } from 'antd';
20
21
  import classNames from 'classnames';
21
22
  import { DragEndEvent } from '@dnd-kit/core';
22
23
  import { css } from '@emotion/css';
23
- import { isEmpty } from 'lodash';
24
+ import { get, isEmpty, orderBy } from 'lodash';
24
25
  import React, { useEffect, useMemo, useState, useCallback } from 'react';
25
26
  import { useTranslation } from 'react-i18next';
26
27
  import { FieldModel } from '../../base';
27
28
  import { DetailsItemModel } from '../../blocks/details/DetailsItemModel';
29
+ import { FormAssociationItemModel } from '../../blocks/form/FormAssociationItemModel';
28
30
  import { adjustColumnOrder } from '../../blocks/table/utils';
29
31
 
30
32
  const HeaderWrapperComponent = React.memo((props) => {
@@ -65,11 +67,31 @@ const AddFieldColumn = ({ model }) => {
65
67
  };
66
68
 
67
69
  const DisplayTable = (props) => {
68
- const { pageSize, value, size, collection, baseColumns, enableIndexColumn = true, model } = props;
70
+ const { pageSize, value: rawValue, size, collection, baseColumns, enableIndexColumn = true, model } = props;
71
+ const isFormAssociation = model.parent instanceof FormAssociationItemModel;
69
72
  const [currentPage, setCurrentPage] = useState(1);
70
73
  const [currentPageSize, setCurrentPageSize] = useState(pageSize);
74
+ const [localSort, setLocalSort] = useState<{ field: string; order: 'asc' | 'desc' }>();
71
75
  const { t } = useTranslation();
72
76
 
77
+ const value = useMemo(() => {
78
+ if (!isFormAssociation || Array.isArray(rawValue)) return rawValue;
79
+ if (rawValue && Array.isArray(rawValue.rows)) return rawValue.rows;
80
+ return rawValue && typeof rawValue === 'object' ? [rawValue] : [];
81
+ }, [isFormAssociation, rawValue]);
82
+
83
+ const sortedValue = useMemo(
84
+ () =>
85
+ isFormAssociation && localSort
86
+ ? orderBy(value, [(record) => get(record, localSort.field)], [localSort.order])
87
+ : value,
88
+ [isFormAssociation, localSort, value],
89
+ );
90
+
91
+ useEffect(() => {
92
+ if (isFormAssociation) setCurrentPage(1);
93
+ }, [isFormAssociation, rawValue]);
94
+
73
95
  useEffect(() => {
74
96
  setCurrentPageSize(pageSize);
75
97
  }, [pageSize]);
@@ -89,7 +111,7 @@ const DisplayTable = (props) => {
89
111
  return t('Total {{count}} items', { count: total });
90
112
  },
91
113
  } as any;
92
- }, [currentPage, currentPageSize, value]);
114
+ }, [currentPage, currentPageSize, value, t]);
93
115
 
94
116
  const getColumns = () => {
95
117
  const cols = adjustColumnOrder(
@@ -119,8 +141,20 @@ const DisplayTable = (props) => {
119
141
  }
120
142
  return cols;
121
143
  };
122
- const handleChange = useCallback(
123
- async (pagination, filters, sorter) => {
144
+ const handleChange = useCallback<NonNullable<TableProps<Record<string, unknown>>['onChange']>>(
145
+ async (pagination, filters, sorters, extra) => {
146
+ const sorter = Array.isArray(sorters) ? sorters[0] : sorters;
147
+ if (isFormAssociation) {
148
+ if (extra.action !== 'sort') return;
149
+ const column = sorter?.column as { sortField?: string } | undefined;
150
+ const sortField = column?.sortField || sorter?.field;
151
+ const fullPath = Array.isArray(sortField) ? sortField.join('.') : String(sortField ?? '');
152
+ const prefix = `${model.context.fieldPath}.`;
153
+ const field = fullPath.startsWith(prefix) ? fullPath.slice(prefix.length) : fullPath;
154
+ setLocalSort(sorter?.order && field ? { field, order: sorter.order === 'ascend' ? 'asc' : 'desc' } : undefined);
155
+ setCurrentPage(1);
156
+ return;
157
+ }
124
158
  //支持列点击排序
125
159
  if (!isEmpty(sorter)) {
126
160
  const resource = model.context.blockModel.resource;
@@ -138,7 +172,7 @@ const DisplayTable = (props) => {
138
172
  await resource.refresh();
139
173
  }
140
174
  },
141
- [model],
175
+ [isFormAssociation, model],
142
176
  );
143
177
 
144
178
  return (
@@ -147,7 +181,7 @@ const DisplayTable = (props) => {
147
181
  size={size}
148
182
  rowKey={collection.filterTargetKey}
149
183
  scroll={{ x: 'max-content' }}
150
- dataSource={value}
184
+ dataSource={sortedValue}
151
185
  columns={getColumns()}
152
186
  pagination={pagination}
153
187
  onChange={handleChange}
@@ -269,3 +303,4 @@ DisplaySubTableFieldModel.define({
269
303
  });
270
304
 
271
305
  DetailsItemModel.bindModelToInterface('DisplaySubTableFieldModel', ['m2m', 'o2m', 'mbm']);
306
+ FormAssociationItemModel.bindModelToInterface('DisplaySubTableFieldModel', ['m2m', 'o2m', 'mbm']);