@nocobase/client-v2 2.1.0-beta.30 → 2.1.0-beta.33

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 (101) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/PluginManager.d.ts +1 -0
  3. package/es/components/form/DrawerFormLayout.d.ts +49 -0
  4. package/es/components/form/EnvVariableInput.d.ts +42 -0
  5. package/es/components/form/FileSizeInput.d.ts +27 -0
  6. package/es/components/form/createFormRegistry.d.ts +33 -0
  7. package/es/components/form/index.d.ts +13 -0
  8. package/es/components/index.d.ts +1 -1
  9. package/es/flow/components/FieldAssignRulesEditor.d.ts +1 -0
  10. package/es/flow/components/code-editor/index.d.ts +1 -0
  11. package/es/flow/internal/utils/enumOptionsUtils.d.ts +5 -0
  12. package/es/flow/models/actions/AssociationActionUtils.d.ts +5 -0
  13. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +4 -0
  14. package/es/flow/models/blocks/filter-manager/FilterManager.d.ts +5 -1
  15. package/es/flow/models/blocks/shared/legacyDefaultValueMigrationBase.d.ts +1 -0
  16. package/es/flow/models/blocks/table/TableSelectModel.d.ts +8 -0
  17. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +4 -0
  18. package/es/flow/models/fields/ClickableFieldModel.d.ts +3 -0
  19. package/es/flow/models/fields/DisplayEnumFieldModel.d.ts +6 -0
  20. package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +2 -2
  21. package/es/flow/models/utils/displayValueUtils.d.ts +12 -0
  22. package/es/flow-compat/passwordUtils.d.ts +1 -1
  23. package/es/index.mjs +119 -108
  24. package/es/utils/remotePlugins.d.ts +0 -4
  25. package/lib/index.js +122 -111
  26. package/package.json +9 -5
  27. package/src/BaseApplication.tsx +14 -8
  28. package/src/PluginManager.ts +1 -0
  29. package/src/__tests__/app.test.tsx +28 -1
  30. package/src/__tests__/globalDeps.test.ts +1 -0
  31. package/src/__tests__/remotePlugins.test.ts +29 -18
  32. package/src/components/form/DrawerFormLayout.tsx +103 -0
  33. package/src/components/form/EnvVariableInput.tsx +126 -0
  34. package/src/components/form/FileSizeInput.tsx +105 -0
  35. package/src/components/form/createFormRegistry.ts +60 -0
  36. package/src/components/form/index.tsx +14 -0
  37. package/src/components/index.ts +1 -1
  38. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +92 -13
  39. package/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +173 -0
  40. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +476 -1
  41. package/src/flow/actions/__tests__/pattern.test.ts +134 -0
  42. package/src/flow/actions/__tests__/titleField.test.ts +45 -0
  43. package/src/flow/actions/filterFormDefaultValues.tsx +30 -9
  44. package/src/flow/actions/formAssignRules.tsx +24 -9
  45. package/src/flow/actions/linkageRules.tsx +240 -258
  46. package/src/flow/actions/pattern.tsx +41 -6
  47. package/src/flow/actions/setTargetDataScope.tsx +32 -3
  48. package/src/flow/actions/titleField.tsx +4 -2
  49. package/src/flow/actions/validation.tsx +1 -1
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -4
  51. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +13 -5
  52. package/src/flow/components/DynamicFlowsIcon.tsx +87 -13
  53. package/src/flow/components/FieldAssignRulesEditor.tsx +2 -0
  54. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +195 -8
  55. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  56. package/src/flow/components/code-editor/index.tsx +12 -8
  57. package/src/flow/components/filter/LinkageFilterItem.tsx +9 -2
  58. package/src/flow/components/filter/VariableFilterItem.tsx +2 -6
  59. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +71 -0
  60. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -0
  61. package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -1
  62. package/src/flow/internal/utils/enumOptionsUtils.ts +29 -0
  63. package/src/flow/models/actions/AssociateActionModel.tsx +2 -2
  64. package/src/flow/models/actions/AssociationActionUtils.ts +14 -0
  65. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +63 -0
  66. package/src/flow/models/base/CollectionBlockModel.tsx +7 -0
  67. package/src/flow/models/base/PageModel/RootPageModel.tsx +1 -1
  68. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +33 -9
  69. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +53 -13
  70. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +63 -3
  71. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +33 -1
  72. package/src/flow/models/blocks/filter-form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  73. package/src/flow/models/blocks/filter-manager/FilterManager.ts +66 -2
  74. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +270 -0
  75. package/src/flow/models/blocks/form/FormActionModel.tsx +2 -8
  76. package/src/flow/models/blocks/form/FormBlockModel.tsx +8 -5
  77. package/src/flow/models/blocks/form/FormItemModel.tsx +1 -1
  78. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +30 -0
  79. package/src/flow/models/blocks/form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  80. package/src/flow/models/blocks/form/value-runtime/rules.ts +6 -1
  81. package/src/flow/models/blocks/form/value-runtime/runtime.ts +6 -1
  82. package/src/flow/models/blocks/shared/legacyDefaultValueMigrationBase.ts +21 -5
  83. package/src/flow/models/blocks/table/TableBlockModel.tsx +11 -6
  84. package/src/flow/models/blocks/table/TableColumnModel.tsx +8 -2
  85. package/src/flow/models/blocks/table/TableSelectModel.tsx +36 -26
  86. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowClick.test.ts +69 -0
  87. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +132 -1
  88. package/src/flow/models/blocks/table/__tests__/TableSelectModel.test.ts +41 -0
  89. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +144 -3
  90. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +170 -1
  91. package/src/flow/models/fields/ClickableFieldModel.tsx +55 -6
  92. package/src/flow/models/fields/DisplayEnumFieldModel.tsx +44 -0
  93. package/src/flow/models/fields/DisplayTitleFieldModel.tsx +40 -7
  94. package/src/flow/models/fields/SelectFieldModel.tsx +31 -1
  95. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +202 -1
  96. package/src/flow/models/fields/__tests__/DisplayEnumFieldModel.test.tsx +39 -0
  97. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +2 -1
  98. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +7 -0
  99. package/src/flow/models/utils/displayValueUtils.ts +57 -0
  100. package/src/utils/globalDeps.ts +11 -0
  101. package/src/utils/remotePlugins.ts +7 -27
@@ -699,11 +699,14 @@ FormBlockModel.registerFlow({
699
699
  linkageRules: {
700
700
  use: 'fieldLinkageRules',
701
701
  afterParamsSave(ctx) {
702
- // 保存后,自动运行一次
703
- ctx.model.applyFlow('eventSettings', {
704
- changedValues: {},
705
- allValues: ctx.form?.getFieldsValue(true),
706
- });
702
+ // FlowSettings 保存后还会触发一次 beforeRender/rerender,
703
+ // 这里延迟到该刷新之后再重放联动规则,避免字段组件被 beforeRender 恢复为原始属性。
704
+ setTimeout(() => {
705
+ ctx.model.applyFlow('eventSettings', {
706
+ changedValues: {},
707
+ allValues: ctx.form?.getFieldsValue(true),
708
+ });
709
+ }, 0);
707
710
  },
708
711
  },
709
712
  },
@@ -431,7 +431,7 @@ FormItemModel.registerFlow({
431
431
  },
432
432
  defaultParams: (ctx: any) => {
433
433
  const titleField =
434
- ctx.model.props.titleField || ctx.model.context.collectionField.targetCollectionTitleFieldName;
434
+ ctx.model.props.titleField || ctx.model?.context?.collectionField?.targetCollectionTitleFieldName;
435
435
  return {
436
436
  titleField: titleField,
437
437
  };
@@ -14,6 +14,11 @@ import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engi
14
14
  // 直接从 models 聚合导入,避免局部文件相互引用顺序导致的循环依赖
15
15
  import { FormBlockContent, FormBlockModel, FormComponent } from '../../../..';
16
16
  import { Form } from 'antd';
17
+
18
+ afterEach(() => {
19
+ vi.useRealTimers();
20
+ });
21
+
17
22
  // -----------------------------
18
23
  // Helpers
19
24
  // -----------------------------
@@ -319,6 +324,31 @@ describe('FormBlockModel (form/formValues injection & server resolve anchors)',
319
324
  expect(flows.has('eventSettings')).toBe(true);
320
325
  });
321
326
 
327
+ it('replays linkage rules after the settings rerender tick', async () => {
328
+ vi.useFakeTimers();
329
+ const engine = new FlowEngine();
330
+ const TestFormModel = await createTestFormModelSubclass();
331
+ const model = new TestFormModel({ uid: 'form-linkage-save', flowEngine: engine } as any);
332
+ const applyFlow = vi.spyOn(model, 'applyFlow').mockResolvedValue(undefined as any);
333
+ const flow = model.getFlow('eventSettings') as any;
334
+ const afterParamsSave = flow?.steps?.linkageRules?.afterParamsSave;
335
+ const ctx: any = {
336
+ model,
337
+ form: {
338
+ getFieldsValue: vi.fn(() => ({ status: 'draft' })),
339
+ },
340
+ };
341
+
342
+ afterParamsSave(ctx);
343
+
344
+ expect(applyFlow).not.toHaveBeenCalled();
345
+ await vi.advanceTimersByTimeAsync(0);
346
+ expect(applyFlow).toHaveBeenCalledWith('eventSettings', {
347
+ changedValues: {},
348
+ allValues: { status: 'draft' },
349
+ });
350
+ });
351
+
322
352
  it('delegates layout/assignRules/linkageRules stepParams to grid model', async () => {
323
353
  const model = await setupFormModel();
324
354
  const engine = model.flowEngine as FlowEngine;
@@ -18,6 +18,7 @@ function createMockFieldModel(options: { uid: string; props?: Record<string, any
18
18
  const model: any = {
19
19
  uid: options.uid,
20
20
  props: { ...(options.props || {}) },
21
+ _options: { props: { ...(options.props || {}) } },
21
22
  stepParams: { ...(options.stepParams || {}) },
22
23
  emitter: { emit: vi.fn() },
23
24
  setProps(patch: any) {
@@ -124,7 +125,9 @@ describe('legacyDefaultValueMigration', () => {
124
125
 
125
126
  // field1: props.initialValue removed, stepParams cleared for initialValue
126
127
  expect(field1.props.initialValue).toBeUndefined();
128
+ expect(field1._options.props.initialValue).toBeUndefined();
127
129
  expect(field1.props.keep).toBe(true);
130
+ expect(field1._options.props.keep).toBe(true);
128
131
  expect(field1.stepParams.editItemSettings?.initialValue).toBeUndefined();
129
132
  expect(field1.stepParams.otherFlow?.s?.x).toBe(1);
130
133
  // field2: legacy flow cleared
@@ -1508,16 +1508,21 @@ export class RuleEngine {
1508
1508
  private getRowTargetKey(baseCtx: any, rowPath: NamePath): string | string[] {
1509
1509
  let collection = this.getRootCollection() || this.getCollectionFromContext(baseCtx);
1510
1510
  let field: any;
1511
+ let lastAssociationField: any;
1511
1512
  for (const seg of rowPath) {
1512
1513
  if (typeof seg === 'number') continue;
1513
1514
  if (typeof seg !== 'string' || !seg || !collection?.getField) break;
1514
1515
 
1515
1516
  field = collection?.getField?.(seg);
1516
1517
  if (!field?.isAssociationField?.()) break;
1518
+ lastAssociationField = field;
1517
1519
  collection = field?.targetCollection;
1518
1520
  }
1519
1521
 
1520
- const raw = field?.targetCollection?.filterTargetKey ?? field?.targetCollection?.filterByTk ?? field?.targetKey;
1522
+ const raw =
1523
+ lastAssociationField?.targetCollection?.filterTargetKey ??
1524
+ lastAssociationField?.targetCollection?.filterByTk ??
1525
+ lastAssociationField?.targetKey;
1521
1526
  if (Array.isArray(raw)) {
1522
1527
  const keys = raw.filter((key): key is string => typeof key === 'string' && !!key);
1523
1528
  return keys.length ? keys : 'id';
@@ -394,16 +394,21 @@ export class FormValueRuntime {
394
394
  private getArrayItemTargetKey(arrayPath?: NamePath): string | string[] {
395
395
  let collection = this.model?.context?.collection;
396
396
  let field: any;
397
+ let lastAssociationField: any;
397
398
  for (const seg of arrayPath || []) {
398
399
  if (typeof seg === 'number') continue;
399
400
  if (typeof seg !== 'string' || !collection?.getField) break;
400
401
 
401
402
  field = collection?.getField?.(seg);
402
403
  if (!field?.isAssociationField?.()) break;
404
+ lastAssociationField = field;
403
405
  collection = field?.targetCollection;
404
406
  }
405
407
 
406
- const raw = field?.targetCollection?.filterTargetKey ?? field?.targetCollection?.filterByTk ?? field?.targetKey;
408
+ const raw =
409
+ lastAssociationField?.targetCollection?.filterTargetKey ??
410
+ lastAssociationField?.targetCollection?.filterByTk ??
411
+ lastAssociationField?.targetKey;
407
412
  if (Array.isArray(raw)) {
408
413
  const keys = raw.filter((key): key is string => typeof key === 'string' && !!key);
409
414
  return keys.length ? keys : 'id';
@@ -23,6 +23,11 @@ export interface LegacyClearer {
23
23
  (model: any): void;
24
24
  }
25
25
 
26
+ export function hasPersistedAssignRulesValue(model: any, flowKey: string, stepKey: string): boolean {
27
+ const params = model?.getStepParams?.(flowKey, stepKey);
28
+ return !!params && Object.prototype.hasOwnProperty.call(params, 'value');
29
+ }
30
+
26
31
  function getPropsInitialValue(model: any): any | undefined {
27
32
  if (!model) return undefined;
28
33
  const props = typeof model.getProps === 'function' ? model.getProps() : model.props;
@@ -70,15 +75,26 @@ function deleteStepParams(model: any, flowKey: string, stepKey: string) {
70
75
  model.emitter?.emit?.('onStepParamsChanged');
71
76
  }
72
77
 
78
+ function deletePropsInitialValue(model: any) {
79
+ if (!model) return;
80
+
81
+ model.setProps?.({ initialValue: undefined });
82
+
83
+ if (model.props && Object.prototype.hasOwnProperty.call(model.props, 'initialValue')) {
84
+ delete model.props.initialValue;
85
+ }
86
+
87
+ const optionsProps = model._options?.props;
88
+ if (optionsProps && Object.prototype.hasOwnProperty.call(optionsProps, 'initialValue')) {
89
+ delete optionsProps.initialValue;
90
+ }
91
+ }
92
+
73
93
  export function createLegacyClearer(flowKeys: string[]): LegacyClearer {
74
94
  return (model: any): void => {
75
95
  if (!model) return;
76
96
 
77
- model.setProps?.({ initialValue: undefined });
78
-
79
- if (model.props && Object.prototype.hasOwnProperty.call(model.props, 'initialValue')) {
80
- delete model.props.initialValue;
81
- }
97
+ deletePropsInitialValue(model);
82
98
 
83
99
  for (const flowKey of flowKeys) {
84
100
  deleteStepParams(model, flowKey, 'initialValue');
@@ -932,12 +932,12 @@ const HighPerformanceTable = React.memo(
932
932
 
933
933
  return {
934
934
  onClick: async (event) => {
935
- if (highlightedRowKey !== rowKey) {
936
- defineClickedRowRecordVariable(model, record);
937
- await model.dispatchEvent('rowClick', { record, rowIndex, event });
935
+ const selected = highlightedRowKey !== rowKey;
936
+ defineClickedRowRecordVariable(model, selected ? record : null);
937
+ try {
938
+ await model.dispatchEvent('rowClick', { record, rowIndex, event, selected });
939
+ } finally {
938
940
  removeClickedRowRecordVariable(model);
939
- } else {
940
- await model.dispatchEvent('rowClick', { record, rowIndex, event });
941
941
  }
942
942
  },
943
943
  rowIndex,
@@ -1072,7 +1072,12 @@ TableBlockModel.registerEvents({
1072
1072
 
1073
1073
  const model = ctx.model as TableBlockModel;
1074
1074
  const rowKey = getRowKey(ctx.inputArgs.record, model.collection.filterTargetKey);
1075
- if (model.props.highlightedRowKey !== rowKey) {
1075
+ const selected = ctx.inputArgs.selected;
1076
+ if (selected === true) {
1077
+ model.highlightRow(ctx.inputArgs.record);
1078
+ } else if (selected === false) {
1079
+ model.clearHighlight();
1080
+ } else if (model.props.highlightedRowKey !== rowKey) {
1076
1081
  model.highlightRow(ctx.inputArgs.record);
1077
1082
  } else {
1078
1083
  model.clearHighlight();
@@ -449,6 +449,9 @@ TableColumnModel.registerFlow({
449
449
  quickEdit: {
450
450
  title: tExpr('Enable quick edit'),
451
451
  uiMode: { type: 'switch', key: 'editable' },
452
+ hideInSettings(ctx) {
453
+ return !!ctx.model.associationPathName;
454
+ },
452
455
  defaultParams(ctx) {
453
456
  if (ctx.model.collectionField.readonly || ctx.model.associationPathName) {
454
457
  return {
@@ -460,7 +463,7 @@ TableColumnModel.registerFlow({
460
463
  };
461
464
  },
462
465
  handler(ctx, params) {
463
- ctx.model.setProps('editable', params.editable);
466
+ ctx.model.setProps('editable', ctx.model.associationPathName ? false : params.editable);
464
467
  },
465
468
  },
466
469
  model: {
@@ -535,10 +538,13 @@ TableColumnModel.registerFlow({
535
538
  fieldModel.setStepParams('fieldSettings', 'init', fieldSettingsInit);
536
539
  await fieldModel.dispatchEvent('beforeRender', undefined, { useCache: false });
537
540
  }
541
+ if (targetUse) {
542
+ ctx.model.setStepParams('tableColumnSettings', 'model', { use: targetUse });
543
+ }
538
544
  ctx.model.setProps(targetCollectionField.getComponentProps());
539
545
  },
540
546
  defaultParams: (ctx: any) => {
541
- const titleField = ctx.model.context.collectionField.targetCollectionTitleFieldName;
547
+ const titleField = ctx.model?.context?.collectionField?.targetCollectionTitleFieldName;
542
548
  return {
543
549
  label: getSavedAssociationTitleField(ctx.model) || titleField,
544
550
  };
@@ -12,45 +12,55 @@ import { castArray } from 'lodash';
12
12
  import { BlockSceneEnum } from '../../base/BlockModel';
13
13
  import { TableBlockModel } from './TableBlockModel';
14
14
 
15
+ export function getAssociationSelectForeignKeyFilter(collectionField: any) {
16
+ const isOToAny = ['oho', 'o2m'].includes(collectionField?.interface);
17
+ const foreignKey = collectionField?.foreignKey;
18
+ if (!isOToAny || !foreignKey) {
19
+ return null;
20
+ }
21
+ return {
22
+ [foreignKey]: {
23
+ $is: null,
24
+ },
25
+ };
26
+ }
27
+
28
+ export function getAssociationSelectAssociatedRecordsFilter(collection: any, associatedRecords: any[] = []) {
29
+ const targetKey = collection?.filterTargetKey || 'id';
30
+ const filterKeys = associatedRecords
31
+ .map((record) => record?.[targetKey])
32
+ .filter((value) => value !== undefined && value !== null && value !== '');
33
+
34
+ if (!filterKeys.length) {
35
+ return null;
36
+ }
37
+
38
+ return {
39
+ [`${targetKey}.$ne`]: filterKeys,
40
+ };
41
+ }
42
+
15
43
  export class TableSelectModel extends TableBlockModel {
16
44
  static scene = BlockSceneEnum.select;
17
45
  rowSelectionProps: any = observable.deep({});
18
46
  onInit(options: any) {
19
47
  super.onInit(options);
20
48
  const collectionField = this.context.view.inputArgs.collectionField || {};
21
- const isOToAny = ['oho', 'o2m'].includes(collectionField?.interface);
22
- const sourceId = this.context.view.inputArgs.sourceId;
23
- if (isOToAny) {
24
- const foreignKey = collectionField.foreignKey;
49
+ const foreignKeyFilter = getAssociationSelectForeignKeyFilter(collectionField);
50
+ if (foreignKeyFilter) {
25
51
  const filterGroupKey = `${this.uid}-${collectionField.name}`;
26
- if (foreignKey) {
27
- if (sourceId != null) {
28
- this.resource.addFilterGroup(filterGroupKey, {
29
- $or: [{ [foreignKey]: { $is: null } }, { [foreignKey]: { $eq: sourceId } }],
30
- });
31
- } else {
32
- this.resource.addFilterGroup(filterGroupKey, {
33
- [foreignKey]: {
34
- $is: null,
35
- },
36
- });
37
- }
38
- }
52
+ this.resource.addFilterGroup(filterGroupKey, foreignKeyFilter);
39
53
  }
40
54
 
41
55
  Object.assign(this.rowSelectionProps, this.context.view.inputArgs.rowSelectionProps || {});
42
56
 
43
57
  const getSelectedRows = this.rowSelectionProps?.defaultSelectedRows;
44
58
  const selectData = typeof getSelectedRows === 'function' ? getSelectedRows() : getSelectedRows;
45
- const data = (selectData && castArray(selectData)) || [];
46
- const filterKeys = data
47
- .map((v) => {
48
- return v[this.collection.filterTargetKey];
49
- })
50
- .filter(Boolean);
51
- this.resource.addFilterGroup(`${this.uid}-select`, {
52
- [`${this.collection.filterTargetKey}.$ne`]: filterKeys,
53
- });
59
+ const data = [...castArray(selectData || []), ...castArray(this.context.view.inputArgs.associatedRecords || [])];
60
+ const associatedRecordsFilter = getAssociationSelectAssociatedRecordsFilter(this.collection, data);
61
+ if (associatedRecordsFilter) {
62
+ this.resource.addFilterGroup(`${this.uid}-select`, associatedRecordsFilter);
63
+ }
54
64
  }
55
65
  }
56
66
 
@@ -0,0 +1,69 @@
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 { FlowEngine } from '@nocobase/flow-engine';
11
+ import { describe, expect, it } from 'vitest';
12
+ import '@nocobase/client';
13
+ import { TableBlockModel } from '../TableBlockModel';
14
+
15
+ function createTableModel() {
16
+ const engine = new FlowEngine();
17
+ engine.registerModels({ TableBlockModel });
18
+
19
+ const ds = engine.dataSourceManager.getDataSource('main');
20
+ ds.addCollection({
21
+ name: 'posts',
22
+ filterTargetKey: 'id',
23
+ fields: [
24
+ { name: 'id', type: 'integer', interface: 'number' },
25
+ { name: 'title', type: 'string', interface: 'input' },
26
+ ],
27
+ });
28
+
29
+ return engine.createModel<TableBlockModel>({
30
+ uid: 'posts-table',
31
+ use: 'TableBlockModel',
32
+ stepParams: {
33
+ resourceSettings: {
34
+ init: {
35
+ dataSourceKey: 'main',
36
+ collectionName: 'posts',
37
+ },
38
+ },
39
+ },
40
+ });
41
+ }
42
+
43
+ describe('TableBlockModel rowClick event', () => {
44
+ it('highlights the clicked row when selected is true', async () => {
45
+ const model = createTableModel();
46
+ const record = { id: 1, title: 'first post' };
47
+ const rowClick = model.getEvent('rowClick');
48
+
49
+ await rowClick?.handler({ model, inputArgs: { record, selected: true } } as any, {
50
+ condition: { logic: '$and', items: [] },
51
+ });
52
+
53
+ expect(model.props.highlightedRowKey).toBe(1);
54
+ });
55
+
56
+ it('clears the highlighted row when selected is false', async () => {
57
+ const model = createTableModel();
58
+ const record = { id: 1, title: 'first post' };
59
+ const rowClick = model.getEvent('rowClick');
60
+
61
+ model.highlightRow(record);
62
+
63
+ await rowClick?.handler({ model, inputArgs: { record, selected: false } } as any, {
64
+ condition: { logic: '$and', items: [] },
65
+ });
66
+
67
+ expect(model.props.highlightedRowKey).toBeNull();
68
+ });
69
+ });
@@ -8,10 +8,46 @@
8
8
  */
9
9
 
10
10
  import { FlowEngine } from '@nocobase/flow-engine';
11
- import { describe, expect, it } from 'vitest';
11
+ import { describe, expect, it, vi } from 'vitest';
12
12
  import { TableColumnModel } from '../TableColumnModel';
13
13
 
14
14
  describe('TableColumnModel sorter settings', () => {
15
+ it('hides quick edit setting for relation path columns added from association groups', async () => {
16
+ const engine = new FlowEngine();
17
+ const model = new TableColumnModel({ uid: 'table-column-relation-path-quick-edit', flowEngine: engine } as any);
18
+ const quickEditStep = model.getFlow('tableColumnSettings')?.steps?.quickEdit as any;
19
+
20
+ const hidden = await quickEditStep.hideInSettings({
21
+ model: {
22
+ associationPathName: 'department',
23
+ },
24
+ });
25
+
26
+ expect(hidden).toBe(true);
27
+ });
28
+
29
+ it('keeps quick edit disabled for relation path columns even when params enable it', () => {
30
+ const engine = new FlowEngine();
31
+ const model = new TableColumnModel({
32
+ uid: 'table-column-relation-path-disable-quick-edit',
33
+ flowEngine: engine,
34
+ } as any);
35
+ const quickEditStep = model.getFlow('tableColumnSettings')?.steps?.quickEdit as any;
36
+ const setProps = vi.fn();
37
+
38
+ quickEditStep.handler(
39
+ {
40
+ model: {
41
+ associationPathName: 'department',
42
+ setProps,
43
+ },
44
+ },
45
+ { editable: true },
46
+ );
47
+
48
+ expect(setProps).toHaveBeenCalledWith('editable', false);
49
+ });
50
+
15
51
  it('hides sortable setting for association fields', async () => {
16
52
  const engine = new FlowEngine();
17
53
  const model = new TableColumnModel({ uid: 'table-column-association-sorter', flowEngine: engine } as any);
@@ -69,4 +105,99 @@ describe('TableColumnModel sorter settings', () => {
69
105
 
70
106
  expect(hidden).toBe(true);
71
107
  });
108
+
109
+ it('updates field component setting when association title field changes', async () => {
110
+ const engine = new FlowEngine();
111
+ const model = new TableColumnModel({ uid: 'table-column-title-field-component', flowEngine: engine } as any);
112
+ const titleFieldStep = model.getFlow('tableColumnSettings')?.steps?.fieldNames as any;
113
+ const setStepParams = vi.fn();
114
+ const setProps = vi.fn();
115
+ const dispatchEvent = vi.fn();
116
+ const targetCollectionField = {
117
+ getComponentProps: () => ({}),
118
+ };
119
+
120
+ await titleFieldStep.beforeParamsSave(
121
+ {
122
+ collectionField: {
123
+ isAssociationField: () => true,
124
+ targetCollection: {
125
+ name: 'departments',
126
+ getField: () => targetCollectionField,
127
+ },
128
+ },
129
+ model: {
130
+ collectionField: {
131
+ dataSourceKey: 'main',
132
+ },
133
+ constructor: {
134
+ getDefaultBindingByField: () => ({
135
+ modelName: 'DisplayTextFieldModel',
136
+ }),
137
+ },
138
+ subModels: {
139
+ field: {
140
+ use: 'DisplayTextFieldModel',
141
+ setStepParams,
142
+ dispatchEvent,
143
+ },
144
+ },
145
+ setStepParams,
146
+ setProps,
147
+ },
148
+ },
149
+ { label: 'code' },
150
+ { label: 'name' },
151
+ );
152
+
153
+ expect(setStepParams).toHaveBeenCalledWith('tableColumnSettings', 'model', { use: 'DisplayTextFieldModel' });
154
+ });
155
+
156
+ it('does not update field component setting when title field refresh fails', async () => {
157
+ const engine = new FlowEngine();
158
+ const model = new TableColumnModel({ uid: 'table-column-title-field-component-failed', flowEngine: engine } as any);
159
+ const titleFieldStep = model.getFlow('tableColumnSettings')?.steps?.fieldNames as any;
160
+ const setStepParams = vi.fn();
161
+ const setProps = vi.fn();
162
+ const targetCollectionField = {
163
+ getComponentProps: () => ({}),
164
+ };
165
+
166
+ await expect(
167
+ titleFieldStep.beforeParamsSave(
168
+ {
169
+ collectionField: {
170
+ isAssociationField: () => true,
171
+ targetCollection: {
172
+ name: 'departments',
173
+ getField: () => targetCollectionField,
174
+ },
175
+ },
176
+ model: {
177
+ collectionField: {
178
+ dataSourceKey: 'main',
179
+ },
180
+ constructor: {
181
+ getDefaultBindingByField: () => ({
182
+ modelName: 'DisplayTextFieldModel',
183
+ }),
184
+ },
185
+ subModels: {
186
+ field: {
187
+ use: 'DisplayTextFieldModel',
188
+ setStepParams: vi.fn(),
189
+ dispatchEvent: vi.fn().mockRejectedValue(new Error('beforeRender failed')),
190
+ },
191
+ },
192
+ setStepParams,
193
+ setProps,
194
+ },
195
+ },
196
+ { label: 'code' },
197
+ { label: 'name' },
198
+ ),
199
+ ).rejects.toThrow('beforeRender failed');
200
+
201
+ expect(setStepParams).not.toHaveBeenCalledWith('tableColumnSettings', 'model', expect.anything());
202
+ });
72
203
  });
@@ -0,0 +1,41 @@
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 { describe, expect, it } from 'vitest';
11
+ import '@nocobase/client';
12
+ import { getAssociationSelectAssociatedRecordsFilter, getAssociationSelectForeignKeyFilter } from '../TableSelectModel';
13
+
14
+ describe('TableSelectModel', () => {
15
+ it('filters out already associated o2m records in association select table', () => {
16
+ expect(
17
+ getAssociationSelectForeignKeyFilter({
18
+ interface: 'o2m',
19
+ name: 'orders',
20
+ foreignKey: 'f_y2quq75zibi',
21
+ }),
22
+ ).toEqual({
23
+ f_y2quq75zibi: {
24
+ $is: null,
25
+ },
26
+ });
27
+ });
28
+
29
+ it('filters out already associated m2m records in association select table', () => {
30
+ expect(
31
+ getAssociationSelectAssociatedRecordsFilter(
32
+ {
33
+ filterTargetKey: 'id',
34
+ },
35
+ [{ id: 11 }, { id: 12 }],
36
+ ),
37
+ ).toEqual({
38
+ 'id.$ne': [11, 12],
39
+ });
40
+ });
41
+ });