@nocobase/client-v2 2.3.0-beta.11 → 2.3.0-beta.13

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 (29) hide show
  1. package/es/RouteRepository.d.ts +6 -0
  2. package/es/flow/components/filter/VariableFilterItem.d.ts +3 -1
  3. package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
  4. package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
  5. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
  6. package/es/index.mjs +63 -63
  7. package/lib/index.js +63 -63
  8. package/package.json +7 -7
  9. package/src/BaseApplication.tsx +2 -0
  10. package/src/RouteRepository.ts +14 -0
  11. package/src/__tests__/RouteRepository.test.ts +51 -0
  12. package/src/__tests__/app.test.tsx +13 -0
  13. package/src/flow/components/filter/VariableFilterItem.tsx +3 -1
  14. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  15. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  16. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  17. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  18. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  19. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  20. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  21. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  22. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +7 -1
  23. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -0
  24. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +17 -10
  25. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +1 -0
  26. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  27. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  28. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  29. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
@@ -23,6 +23,7 @@ import {
23
23
  } from '@nocobase/flow-engine';
24
24
  import { Button, Form, Skeleton, Space } from 'antd';
25
25
  import React from 'react';
26
+ import { findDataScopeStep, getQuickEditDataScopeFilter } from '../../../internal/utils/quickEditDataScope';
26
27
  import { FieldModel } from '../../base/FieldModel';
27
28
  import { FormComponent } from './FormBlockModel';
28
29
  import { FormItemModel } from './FormItemModel';
@@ -36,6 +37,14 @@ const QUICK_EDIT_MOBILE_CONTENT_PADDING = '8px var(--nb-mobile-page-tabs-content
36
37
  const QUICK_EDIT_MOBILE_ACTIONS_PADDING =
37
38
  '8px var(--nb-mobile-page-tabs-content-padding, 12px) calc(80px + env(safe-area-inset-bottom, 0px))';
38
39
  const QUICK_EDIT_MOBILE_MEDIA_QUERY = '(max-width: 768px)';
40
+ // Dropdown-style editors open upwards inside the desktop popover so the option list does not cover the Submit and
41
+ // Cancel buttons rendered right below the field. antd still flips the list downwards when there is no room above.
42
+ const QUICK_EDIT_DROPDOWN_UP_MODELS = [
43
+ 'SelectFieldModel',
44
+ 'RecordSelectFieldModel',
45
+ 'CascadeSelectFieldModel',
46
+ 'CascadeSelectListFieldModel',
47
+ ];
39
48
 
40
49
  type QuickEditViewBeforeClosePayload = {
41
50
  result?: unknown;
@@ -159,6 +168,7 @@ export class QuickEditFormModel extends FlowModel {
159
168
  __onSubmitSuccess;
160
169
  _fieldProps: any;
161
170
  _onOk: any;
171
+ _dataScopeFilter: Record<string, any> | undefined;
162
172
 
163
173
  get form() {
164
174
  return this.context.form;
@@ -212,6 +222,7 @@ export class QuickEditFormModel extends FlowModel {
212
222
  },
213
223
  sourceFieldModel ? { delegate: sourceFieldModel.context } : undefined,
214
224
  ) as QuickEditFormModel;
225
+ model._dataScopeFilter = getQuickEditDataScopeFilter(sourceFieldModel);
215
226
 
216
227
  const bodyStyles = {
217
228
  maxHeight: QUICK_EDIT_POPOVER_MAX_HEIGHT,
@@ -410,6 +421,13 @@ QuickEditFormModel.registerFlow({
410
421
  resource.setDataSourceKey(dataSourceKey);
411
422
  resource.setResourceName(collectionName);
412
423
  ctx.model.resource = resource;
424
+ // Seed the edited row before dispatching the field model's flows. An association data scope resolves
425
+ // `ctx.record` while the field renders, and so does the foreign key filter that one-to-one and one-to-many
426
+ // editors build, so the record has to be in place first.
427
+ if (ctx.inputArgs.filterByTk || ctx.inputArgs.record) {
428
+ resource.setFilterByTk(ctx.inputArgs.filterByTk);
429
+ resource.setData(ctx.inputArgs.record);
430
+ }
413
431
  const collectionField = ctx.model.collection.getField(fieldPath) as CollectionField;
414
432
  if (collectionField) {
415
433
  const binding = FormItemModel.getDefaultBindingByField(ctx, collectionField);
@@ -417,24 +435,42 @@ QuickEditFormModel.registerFlow({
417
435
  return;
418
436
  }
419
437
  const use = binding.modelName;
438
+ const stepParams: Record<string, Record<string, Record<string, unknown>>> = {
439
+ fieldSettings: {
440
+ init: {
441
+ dataSourceKey,
442
+ collectionName,
443
+ fieldPath,
444
+ },
445
+ },
446
+ };
447
+ const dataScopeFilter = ctx.model._dataScopeFilter;
448
+ if (dataScopeFilter) {
449
+ const dataScopeStep = findDataScopeStep(ctx.engine.getModelClass(use));
450
+ if (dataScopeStep) {
451
+ stepParams[dataScopeStep.flowKey] = {
452
+ ...stepParams[dataScopeStep.flowKey],
453
+ [dataScopeStep.stepKey]: { filter: dataScopeFilter },
454
+ };
455
+ }
456
+ }
420
457
  const fieldModel = ctx.model.addSubModel<FieldModel>('fields', {
421
458
  use,
422
459
  props:
423
460
  typeof binding.defaultProps === 'function'
424
461
  ? binding.defaultProps(ctx, collectionField)
425
462
  : binding.defaultProps,
426
- stepParams: {
427
- fieldSettings: {
428
- init: {
429
- dataSourceKey,
430
- collectionName,
431
- fieldPath,
432
- },
433
- },
434
- },
463
+ stepParams,
435
464
  });
436
465
  fieldModel.setProps(getQuickEditFieldProps(collectionField, ctx.model._fieldProps));
437
466
  fieldModel.setProps({ sourceFieldModelUid: ctx.inputArgs.sourceFieldModelUid });
467
+ if (
468
+ !ctx.model.context.isMobileLayout &&
469
+ QUICK_EDIT_DROPDOWN_UP_MODELS.includes(use) &&
470
+ fieldModel.props.placement == null
471
+ ) {
472
+ fieldModel.setProps({ placement: 'topLeft' });
473
+ }
438
474
  ctx.model.context.defineProperty('collectionField', {
439
475
  get: () => collectionField,
440
476
  });
@@ -442,8 +478,6 @@ QuickEditFormModel.registerFlow({
442
478
  await fieldModel.dispatchEvent('beforeRender');
443
479
  }
444
480
  if (ctx.inputArgs.filterByTk || ctx.inputArgs.record) {
445
- resource.setFilterByTk(ctx.inputArgs.filterByTk);
446
- resource.setData(ctx.inputArgs.record);
447
481
  ctx.model.form?.setFieldsValue(resource.getData());
448
482
  }
449
483
  },
@@ -9,7 +9,11 @@
9
9
 
10
10
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
11
  import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engine';
12
+ import '../../../index';
12
13
  import { QuickEditFormModel } from '../QuickEditFormModel';
14
+ import { dataScope } from '../../../../actions/dataScope';
15
+ import { RecordSelectFieldModel } from '../../../fields/AssociationFieldModel/RecordSelectFieldModel';
16
+ import { SelectFieldModel } from '../../../fields/SelectFieldModel';
13
17
 
14
18
  describe('QuickEditFormModel - quick edit save triggers API (regression)', () => {
15
19
  let engine: FlowEngine;
@@ -55,6 +59,54 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
55
59
  });
56
60
  };
57
61
 
62
+ const addMemberCollections = () => {
63
+ const ds = engine.context.dataSourceManager.getDataSource('main');
64
+ ds.addCollection({
65
+ name: 'departments',
66
+ filterTargetKey: 'id',
67
+ titleField: 'name',
68
+ fields: [
69
+ { name: 'id', type: 'integer', interface: 'number' },
70
+ { name: 'name', type: 'string', interface: 'input', uiSchema: { title: 'Name' } },
71
+ { name: 'level', type: 'string', interface: 'input', uiSchema: { title: 'Level' } },
72
+ ],
73
+ });
74
+ ds.addCollection({
75
+ name: 'members',
76
+ filterTargetKey: 'id',
77
+ fields: [
78
+ { name: 'id', type: 'integer', interface: 'number' },
79
+ { name: 'level', type: 'string', interface: 'input', uiSchema: { title: 'Level' } },
80
+ {
81
+ name: 'department',
82
+ type: 'belongsTo',
83
+ interface: 'm2o',
84
+ target: 'departments',
85
+ targetKey: 'id',
86
+ uiSchema: { title: 'Department' },
87
+ },
88
+ ],
89
+ });
90
+ };
91
+
92
+ const openQuickEditForAssociation = async (dataScopeFilter?: Record<string, unknown>) => {
93
+ engine.registerModels({ QuickEditFormModel, RecordSelectFieldModel });
94
+ engine.registerActions({ dataScope });
95
+ addMemberCollections();
96
+ const model = engine.createModel<QuickEditFormModel>({
97
+ use: QuickEditFormModel,
98
+ uid: `qe-association-${dataScopeFilter ? 'scoped' : 'plain'}`,
99
+ stepParams: {
100
+ quickEditFormSettings: {
101
+ init: { dataSourceKey: 'main', collectionName: 'members', fieldPath: 'department' },
102
+ },
103
+ },
104
+ });
105
+ model._dataScopeFilter = dataScopeFilter;
106
+ await model.applyFlow('quickEditFormSettings', { filterByTk: 7, record: { id: 7, level: 'senior' } });
107
+ return model;
108
+ };
109
+
58
110
  it('uses source field context when opening quick edit', async () => {
59
111
  engine.registerModels({ QuickEditFormModel });
60
112
  engine.context.defineProperty('pageActive', { value: { value: false } });
@@ -414,4 +466,72 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
414
466
 
415
467
  expect(api.request).toHaveBeenCalledTimes(1);
416
468
  });
469
+
470
+ it('opens dropdown editors upwards in the desktop popover so they do not cover the actions', async () => {
471
+ engine.registerModels({ QuickEditFormModel, SelectFieldModel });
472
+ const ds = engine.context.dataSourceManager.getDataSource('main');
473
+ ds.addCollection({
474
+ name: 'users',
475
+ fields: [
476
+ {
477
+ name: 'tags',
478
+ type: 'array',
479
+ interface: 'multipleSelect',
480
+ uiSchema: { title: 'Tags', enum: [{ label: 'A', value: 'a' }] },
481
+ },
482
+ ],
483
+ });
484
+
485
+ const desktopModel = engine.createModel<QuickEditFormModel>({
486
+ use: QuickEditFormModel,
487
+ uid: 'qe-desktop',
488
+ stepParams: {
489
+ quickEditFormSettings: {
490
+ init: { dataSourceKey: 'main', collectionName: 'users', fieldPath: 'tags' },
491
+ },
492
+ },
493
+ });
494
+ await desktopModel.applyFlow('quickEditFormSettings', { record: {} });
495
+ const desktopField = desktopModel.subModels.fields[0];
496
+ expect(desktopField.use).toBe('SelectFieldModel');
497
+ expect(desktopField.props.placement).toBe('topLeft');
498
+
499
+ const mobileModel = engine.createModel<QuickEditFormModel>({
500
+ use: QuickEditFormModel,
501
+ uid: 'qe-mobile',
502
+ stepParams: {
503
+ quickEditFormSettings: {
504
+ init: { dataSourceKey: 'main', collectionName: 'users', fieldPath: 'tags' },
505
+ },
506
+ },
507
+ });
508
+ mobileModel.context.defineProperty('isMobileLayout', { value: true });
509
+ await mobileModel.applyFlow('quickEditFormSettings', { record: {} });
510
+ expect(mobileModel.subModels.fields[0].props.placement).toBeUndefined();
511
+ });
512
+
513
+ it('hands the column data scope to the association editor built for the popover', async () => {
514
+ const filter = { logic: '$and', items: [{ path: 'level', operator: 'eq', value: '{{ctx.record.level}}' }] };
515
+
516
+ const model = await openQuickEditForAssociation(filter);
517
+
518
+ const fieldModel = model.subModels.fields[0];
519
+ expect(fieldModel.use).toBe('RecordSelectFieldModel');
520
+ expect(fieldModel.getStepParams('selectSettings', 'dataScope')).toEqual({ filter });
521
+ });
522
+
523
+ it('leaves the association editor unscoped when the column has no data scope', async () => {
524
+ const model = await openQuickEditForAssociation();
525
+
526
+ expect(model.subModels.fields[0].getStepParams('selectSettings', 'dataScope')).toBeUndefined();
527
+ });
528
+
529
+ it('seeds the edited row before the editor flows run so record variables resolve', async () => {
530
+ const filter = { logic: '$and', items: [{ path: 'level', operator: 'eq', value: '{{ctx.record.level}}' }] };
531
+
532
+ const model = await openQuickEditForAssociation(filter);
533
+
534
+ expect(model.resource.getData()).toMatchObject({ id: 7, level: 'senior' });
535
+ expect(JSON.stringify(model.subModels.fields[0].resource.getFilter() ?? null)).toContain('senior');
536
+ });
417
537
  });
@@ -37,6 +37,7 @@ import { getRowKey } from './utils';
37
37
  import { getSavedAssociationTitleField, getTableColumnSortField } from './sortUtils';
38
38
  import { getFieldBindingUse, rebuildFieldSubModel } from '../../../internal/utils/rebuildFieldSubModel';
39
39
  import { getSavedDateTimeFormatParams, resolveDateTimeDisplayProps } from '../../../utils/dateTimeDisplayProps';
40
+ import { QuickEditDataScopeInput, resolveQuickEditDataScopeStep } from '../../../internal/utils/quickEditDataScope';
40
41
 
41
42
  export function FieldDeletePlaceholder(props: any) {
42
43
  const { t } = useTranslation();
@@ -542,6 +543,36 @@ TableColumnModel.registerFlow({
542
543
  ctx.model.setProps('editable', ctx.model.associationPathName ? false : params.editable);
543
544
  },
544
545
  },
546
+ quickEditDataScope: {
547
+ title: tExpr('Data scope'),
548
+ uiMode: {
549
+ type: 'dialog',
550
+ props: {
551
+ width: 800,
552
+ },
553
+ },
554
+ uiSchema: {
555
+ filter: {
556
+ type: 'object',
557
+ 'x-decorator': 'FormItem',
558
+ 'x-component': QuickEditDataScopeInput,
559
+ },
560
+ },
561
+ useRawParams: true,
562
+ defaultParams: {
563
+ filter: { logic: '$and', items: [] },
564
+ },
565
+ hideInSettings(ctx) {
566
+ if (ctx.model.associationPathName || !ctx.model.props.editable) {
567
+ return true;
568
+ }
569
+ return !resolveQuickEditDataScopeStep(ctx, ctx.collectionField);
570
+ },
571
+ handler() {
572
+ // Settings-only step. The filter is kept raw on the column and handed to the editor model that
573
+ // QuickEditFormModel builds when the popover opens, so there is nothing to apply to the column itself.
574
+ },
575
+ },
545
576
  model: {
546
577
  title: tExpr('Field component'),
547
578
  use: 'displayFieldComponent',
@@ -9,8 +9,36 @@
9
9
 
10
10
  import { FlowEngine } from '@nocobase/flow-engine';
11
11
  import { describe, expect, it, vi } from 'vitest';
12
+ // Prime the model module graph so the editable field bindings used by the quick edit data scope setting are registered.
13
+ import '../../../index';
14
+ import { RecordSelectFieldModel } from '../../../fields/AssociationFieldModel/RecordSelectFieldModel';
12
15
  import { TableColumnModel } from '../TableColumnModel';
13
16
 
17
+ const createQuickEditDataScopeCtx = (
18
+ overrides: { associationPathName?: string; editable?: boolean; isAssociation?: boolean } = {},
19
+ ) => {
20
+ const engine = new FlowEngine();
21
+ engine.registerModels({ RecordSelectFieldModel });
22
+ const { associationPathName, editable = true, isAssociation = true } = overrides;
23
+ return {
24
+ engine,
25
+ model: {
26
+ associationPathName,
27
+ props: { editable },
28
+ },
29
+ collectionField: {
30
+ interface: isAssociation ? 'm2o' : 'input',
31
+ isAssociationField: () => isAssociation,
32
+ targetCollection: isAssociation ? { template: 'general' } : undefined,
33
+ },
34
+ };
35
+ };
36
+
37
+ const getQuickEditDataScopeStep = (engine: FlowEngine) => {
38
+ const model = new TableColumnModel({ uid: 'table-column-quick-edit-data-scope', flowEngine: engine } as any);
39
+ return model.getFlow('tableColumnSettings')?.steps?.quickEditDataScope as any;
40
+ };
41
+
14
42
  describe('TableColumnModel sorter settings', () => {
15
43
  it('clamps custom column width to the minimum value', () => {
16
44
  const engine = new FlowEngine();
@@ -30,6 +58,42 @@ describe('TableColumnModel sorter settings', () => {
30
58
  expect(setProps).toHaveBeenCalledWith('width', 10);
31
59
  });
32
60
 
61
+ it('offers the quick edit data scope on an editable association column', async () => {
62
+ const engine = new FlowEngine();
63
+ const step = getQuickEditDataScopeStep(engine);
64
+
65
+ const hidden = await step.hideInSettings(createQuickEditDataScopeCtx());
66
+
67
+ expect(hidden).toBe(false);
68
+ });
69
+
70
+ it('hides the quick edit data scope when quick edit is off', async () => {
71
+ const engine = new FlowEngine();
72
+ const step = getQuickEditDataScopeStep(engine);
73
+
74
+ const hidden = await step.hideInSettings(createQuickEditDataScopeCtx({ editable: false }));
75
+
76
+ expect(hidden).toBe(true);
77
+ });
78
+
79
+ it('hides the quick edit data scope for non-association columns', async () => {
80
+ const engine = new FlowEngine();
81
+ const step = getQuickEditDataScopeStep(engine);
82
+
83
+ const hidden = await step.hideInSettings(createQuickEditDataScopeCtx({ isAssociation: false }));
84
+
85
+ expect(hidden).toBe(true);
86
+ });
87
+
88
+ it('hides the quick edit data scope for relation path columns', async () => {
89
+ const engine = new FlowEngine();
90
+ const step = getQuickEditDataScopeStep(engine);
91
+
92
+ const hidden = await step.hideInSettings(createQuickEditDataScopeCtx({ associationPathName: 'department' }));
93
+
94
+ expect(hidden).toBe(true);
95
+ });
96
+
33
97
  it('hides quick edit setting for relation path columns added from association groups', async () => {
34
98
  const engine = new FlowEngine();
35
99
  const model = new TableColumnModel({ uid: 'table-column-relation-path-quick-edit', flowEngine: engine } as any);
@@ -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 });
@@ -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
  />
@@ -192,6 +192,7 @@ export function SubTableField(props) {
192
192
  rowIdx: pageRowIdx,
193
193
  id: `field-${String(columnKey)}-${rowBindingKey}`,
194
194
  value: text,
195
+ disabled,
195
196
  parentFieldIndex,
196
197
  parentItem,
197
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
+ });