@nocobase/client-v2 2.1.0-alpha.30 → 2.1.0-alpha.32

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 (103) hide show
  1. package/es/components/form/JsonTextArea.d.ts +18 -0
  2. package/es/components/index.d.ts +1 -0
  3. package/es/flow/actions/dateRangeLimit.d.ts +9 -0
  4. package/es/flow/actions/index.d.ts +1 -0
  5. package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
  6. package/es/flow/index.d.ts +1 -0
  7. package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
  8. package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
  9. package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
  10. package/es/flow/models/actions/index.d.ts +3 -0
  11. package/es/flow/models/base/GridModel.d.ts +3 -1
  12. package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
  13. package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
  14. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +15 -6
  15. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
  16. package/es/flow/models/blocks/shared/filterOperators.d.ts +9 -0
  17. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +3 -0
  18. package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
  19. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
  20. package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
  21. package/es/flow-compat/data.d.ts +9 -2
  22. package/es/flow-compat/index.d.ts +1 -1
  23. package/es/index.d.ts +1 -0
  24. package/es/index.mjs +100 -93
  25. package/lib/index.js +101 -94
  26. package/package.json +6 -5
  27. package/src/BaseApplication.tsx +1 -1
  28. package/src/__tests__/app.test.tsx +23 -6
  29. package/src/__tests__/globalDeps.test.ts +5 -0
  30. package/src/components/form/JsonTextArea.tsx +129 -0
  31. package/src/components/index.ts +1 -0
  32. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
  33. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
  34. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
  35. package/src/flow/actions/__tests__/pattern.test.ts +190 -0
  36. package/src/flow/actions/dateRangeLimit.tsx +66 -0
  37. package/src/flow/actions/index.ts +1 -0
  38. package/src/flow/actions/linkageRules.tsx +119 -14
  39. package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
  40. package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
  41. package/src/flow/actions/openView.tsx +2 -1
  42. package/src/flow/actions/pattern.tsx +25 -2
  43. package/src/flow/actions/titleField.tsx +8 -3
  44. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
  45. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
  46. package/src/flow/components/FieldAssignValueInput.tsx +1 -0
  47. package/src/flow/components/filter/LinkageFilterItem.tsx +6 -5
  48. package/src/flow/components/filter/VariableFilterItem.tsx +14 -13
  49. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +33 -0
  50. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -5
  51. package/src/flow/index.ts +1 -0
  52. package/src/flow/internal/utils/__tests__/titleFieldQuickSync.test.ts +1 -0
  53. package/src/flow/internal/utils/titleFieldQuickSync.ts +2 -2
  54. package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
  55. package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
  56. package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
  57. package/src/flow/models/actions/FilterActionModel.tsx +17 -9
  58. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
  59. package/src/flow/models/actions/index.ts +3 -0
  60. package/src/flow/models/base/GridModel.tsx +21 -1
  61. package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
  62. package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
  63. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
  64. package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
  65. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
  66. package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
  67. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +200 -36
  68. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +270 -1
  69. package/src/flow/models/blocks/filter-form/__tests__/customFieldOperators.test.tsx +23 -0
  70. package/src/flow/models/blocks/filter-form/customFieldOperators.ts +12 -1
  71. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +22 -8
  72. package/src/flow/models/blocks/filter-form/fields/__tests__/FilterFormCustomFieldModel.recordSelect.test.tsx +18 -0
  73. package/src/flow/models/blocks/filter-manager/FilterManager.ts +51 -1
  74. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +75 -0
  75. package/src/flow/models/blocks/form/FormItemModel.tsx +48 -28
  76. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
  77. package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
  78. package/src/flow/models/blocks/shared/filterOperators.ts +14 -0
  79. package/src/flow/models/blocks/table/TableBlockModel.tsx +19 -3
  80. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
  81. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +48 -8
  82. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
  83. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
  84. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
  85. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +2 -0
  86. package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
  87. package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
  88. package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
  89. package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
  90. package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
  91. package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
  92. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
  93. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
  94. package/src/flow/models/fields/DividerItemModel.tsx +30 -15
  95. package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
  96. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
  97. package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
  98. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
  99. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
  100. package/src/flow-compat/data.ts +25 -3
  101. package/src/flow-compat/index.ts +7 -1
  102. package/src/index.ts +1 -0
  103. package/src/utils/globalDeps.ts +6 -0
@@ -177,6 +177,25 @@ const MemoFieldRenderer = React.memo(FieldModelRenderer, (prev, next) => {
177
177
  return prev.value === next.value && prev.model === next.model;
178
178
  });
179
179
 
180
+ export function buildRowPathFromFieldIndex(fieldIndex: unknown): Array<string | number> | null {
181
+ if (!Array.isArray(fieldIndex) || !fieldIndex.length) return null;
182
+ const out: Array<string | number> = [];
183
+ for (const entry of fieldIndex) {
184
+ if (typeof entry !== 'string') continue;
185
+ const [fieldName, indexStr] = entry.split(':');
186
+ const index = Number(indexStr);
187
+ if (!fieldName || !Number.isFinite(index)) continue;
188
+ out.push(fieldName, index);
189
+ }
190
+ return out.length ? out : null;
191
+ }
192
+
193
+ export function getLatestSubTableRowRecord(form: any, fieldIndex: unknown, fallbackRecord: any): any {
194
+ const latestRowPath = buildRowPathFromFieldIndex(fieldIndex);
195
+ const latestRecord = latestRowPath ? form?.getFieldValue?.(latestRowPath) : undefined;
196
+ return typeof latestRecord === 'undefined' ? fallbackRecord : latestRecord;
197
+ }
198
+
180
199
  function shouldCommitImmediately(value: any) {
181
200
  if (Array.isArray(value)) {
182
201
  return true;
@@ -194,7 +213,7 @@ function shouldCommitImmediately(value: any) {
194
213
  }
195
214
 
196
215
  const FieldModelRendererOptimize = React.memo((props: any) => {
197
- const { model, onChange, value, ...rest } = props;
216
+ const { model, onChange, value, commitOnChange, ...rest } = props;
198
217
  const pendingValueRef = React.useRef<any>(props?.value);
199
218
 
200
219
  useEffect(() => {
@@ -204,11 +223,11 @@ const FieldModelRendererOptimize = React.memo((props: any) => {
204
223
  const handleChange = React.useCallback(
205
224
  (value: any) => {
206
225
  pendingValueRef.current = value;
207
- if (shouldCommitImmediately(value)) {
226
+ if (commitOnChange || shouldCommitImmediately(value)) {
208
227
  onChange?.(value);
209
228
  }
210
229
  },
211
- [onChange],
230
+ [commitOnChange, onChange],
212
231
  );
213
232
 
214
233
  const handleCommit = React.useCallback(() => {
@@ -241,10 +260,11 @@ interface CellProps {
241
260
  rowFork?: any;
242
261
  memoKey?: string;
243
262
  width?: number;
263
+ commitOnChange?: boolean;
244
264
  }
245
265
 
246
266
  const MemoCell: React.FC<CellProps> = React.memo(
247
- ({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width }) => {
267
+ ({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width, commitOnChange }) => {
248
268
  const isNew = record?.__is_new__;
249
269
  return (
250
270
  <div
@@ -346,7 +366,11 @@ const MemoCell: React.FC<CellProps> = React.memo(
346
366
  }
347
367
  />
348
368
  ) : (
349
- <FieldModelRendererOptimize model={fork} id={[(parent as any).context.fieldPath, rowIdx]} />
369
+ <FieldModelRendererOptimize
370
+ model={fork}
371
+ id={[(parent as any).context.fieldPath, rowIdx]}
372
+ commitOnChange={commitOnChange}
373
+ />
350
374
  )}
351
375
  </FormItem>
352
376
  );
@@ -360,6 +384,7 @@ const MemoCell: React.FC<CellProps> = React.memo(
360
384
  prev.id === next.id &&
361
385
  prev.memoKey === next.memoKey &&
362
386
  prev.width === next.width &&
387
+ prev.commitOnChange === next.commitOnChange &&
363
388
  prev.rowIdx === next.rowIdx
364
389
  );
365
390
  },
@@ -428,6 +453,15 @@ export class SubTableColumnModel<
428
453
  return this.parent.collection;
429
454
  }
430
455
 
456
+ get hasFormulaColumn() {
457
+ return (
458
+ this.parent?.mapSubModels('columns', (column: SubTableColumnModel) => {
459
+ const field = column.collectionField;
460
+ return field?.interface === 'formula' || field?.type === 'formula';
461
+ }) || []
462
+ ).some(Boolean);
463
+ }
464
+
431
465
  onInit(options: any): void {
432
466
  super.onInit(options);
433
467
  this.context.defineProperty('resourceName', {
@@ -559,6 +593,9 @@ export class SubTableColumnModel<
559
593
  const rowForkKey = `row:${baseIndexKey}:${rowIdentity}:${String(rowIdx)}`;
560
594
  const rowFork: any = (() => {
561
595
  const fork = this.createFork({}, rowForkKey);
596
+ fork.context.defineProperty('subTableRowFork', {
597
+ value: true,
598
+ });
562
599
  const associationFieldPath =
563
600
  (this.parent as any)?.fieldPath ??
564
601
  (this.parent as any)?.context?.fieldPath ??
@@ -577,9 +614,11 @@ export class SubTableColumnModel<
577
614
  }
578
615
  fork.context.defineProperty('item', {
579
616
  get: () => {
617
+ const form = (fork.context as any)?.form || (this.context?.blockModel as any)?.context?.form;
618
+ const rowRecord = getLatestSubTableRowRecord(form, fork.context.fieldIndex, record);
580
619
  const parentItemCtx = (parentItem ?? this.context?.item) as any;
581
- const isNew = record?.__is_new__;
582
- const isStored = record?.__is_stored__;
620
+ const isNew = rowRecord?.__is_new__;
621
+ const isStored = rowRecord?.__is_stored__;
583
622
  const list = (this.parent as any)?.props?.value;
584
623
  const length = Array.isArray(list) ? list.length : undefined;
585
624
  return {
@@ -587,7 +626,7 @@ export class SubTableColumnModel<
587
626
  length,
588
627
  __is_new__: isNew,
589
628
  __is_stored__: isStored,
590
- value: record,
629
+ value: rowRecord,
591
630
  parentItem: parentItemCtx,
592
631
  };
593
632
  },
@@ -607,6 +646,7 @@ export class SubTableColumnModel<
607
646
  rowFork={rowFork}
608
647
  memoKey={rowForkKey}
609
648
  width={this.props.width}
649
+ commitOnChange={this.hasFormulaColumn}
610
650
  />
611
651
  );
612
652
  };
@@ -14,8 +14,41 @@ import { useTranslation } from 'react-i18next';
14
14
  import { PlusOutlined } from '@ant-design/icons';
15
15
  import React, { useEffect, useMemo, useState } from 'react';
16
16
  import { ActionWithoutPermission } from '../../../base/ActionModel';
17
+ import { parsePathString } from '../../../blocks/form/value-runtime/path';
17
18
  import { getSubTableRowIdentity, normalizeSubTableRows } from './rowIdentity';
18
19
 
20
+ type NamePath = Array<string | number>;
21
+
22
+ function isSamePathPrefix(prefix: NamePath, path: NamePath) {
23
+ if (!prefix.length || prefix.length > path.length) return false;
24
+ return prefix.every((seg, index) => seg === path[index]);
25
+ }
26
+
27
+ function isRelatedPath(a: NamePath, b: NamePath) {
28
+ return isSamePathPrefix(a, b) || isSamePathPrefix(b, a);
29
+ }
30
+
31
+ function normalizeChangedPath(path: unknown): NamePath | null {
32
+ const rawPath = Array.isArray(path) ? path : typeof path === 'string' ? [path] : null;
33
+ if (!rawPath) return null;
34
+ const normalized = rawPath.flatMap((seg) => {
35
+ if (typeof seg === 'number') return [seg];
36
+ if (typeof seg !== 'string') return [];
37
+ return parsePathString(seg).filter((parsed): parsed is string | number => typeof parsed !== 'object');
38
+ });
39
+ return normalized.length ? normalized : null;
40
+ }
41
+
42
+ function shouldRefreshForChangedPaths(fieldPath: unknown, changedPaths: unknown) {
43
+ const currentFieldPath = normalizeChangedPath(fieldPath);
44
+ if (!currentFieldPath) return false;
45
+ const paths = Array.isArray(changedPaths) ? changedPaths : [];
46
+ return paths.some((path) => {
47
+ const changedPath = normalizeChangedPath(path);
48
+ return changedPath ? isRelatedPath(currentFieldPath, changedPath) : false;
49
+ });
50
+ }
51
+
19
52
  export function SubTableField(props) {
20
53
  const { t } = useTranslation();
21
54
  const {
@@ -35,9 +68,12 @@ export function SubTableField(props) {
35
68
  resetPage,
36
69
  filterTargetKey = 'id',
37
70
  getCurrentValue,
71
+ fieldPathArray,
72
+ formValuesChangeEmitter,
38
73
  } = props;
39
74
  const [currentPage, setCurrentPage] = useState(1);
40
75
  const [currentPageSize, setCurrentPageSize] = useState(pageSize);
76
+ const [, forceRefresh] = useState(0);
41
77
  const rawCurrentValue = getCurrentValue();
42
78
  const currentValue = useMemo(() => normalizeSubTableRows(rawCurrentValue), [rawCurrentValue]);
43
79
  const getRecordIdentity = React.useCallback(
@@ -50,6 +86,17 @@ export function SubTableField(props) {
50
86
  useEffect(() => {
51
87
  resetPage && setCurrentPage(1);
52
88
  }, [resetPage]);
89
+ useEffect(() => {
90
+ if (!formValuesChangeEmitter?.on || !formValuesChangeEmitter?.off) return;
91
+ const listener = (payload: any) => {
92
+ if (!shouldRefreshForChangedPaths(fieldPathArray, payload?.changedPaths)) return;
93
+ forceRefresh((v) => v + 1);
94
+ };
95
+ formValuesChangeEmitter.on('formValuesChange', listener);
96
+ return () => {
97
+ formValuesChangeEmitter.off('formValuesChange', listener);
98
+ };
99
+ }, [fieldPathArray, formValuesChangeEmitter]);
53
100
  const applyValue = React.useCallback((nextValue: any) => onChange?.(normalizeSubTableRows(nextValue)), [onChange]);
54
101
  const getLatestValue = React.useCallback(() => normalizeSubTableRows(getCurrentValue()), [getCurrentValue]);
55
102
  useEffect(() => {
@@ -0,0 +1,42 @@
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, vi } from 'vitest';
11
+ import { getLatestSubTableRowRecord, buildRowPathFromFieldIndex } from '../SubTableColumnModel';
12
+
13
+ describe('SubTableColumnModel row record helpers', () => {
14
+ it('builds the row path from fieldIndex entries', () => {
15
+ expect(buildRowPathFromFieldIndex(['roles:0'])).toEqual(['roles', 0]);
16
+ expect(buildRowPathFromFieldIndex(['users:1', 'roles:2'])).toEqual(['users', 1, 'roles', 2]);
17
+ });
18
+
19
+ it('prefers the latest row value from form over the fallback record', () => {
20
+ const form = {
21
+ getFieldValue: vi.fn((path: any) => {
22
+ if (JSON.stringify(path) === JSON.stringify(['roles', 0])) {
23
+ return { uid: 'role-uid-1', __is_new__: true };
24
+ }
25
+ }),
26
+ };
27
+ const fallback = { uid: 'stale-role', __is_new__: false };
28
+
29
+ expect(getLatestSubTableRowRecord(form, ['roles:0'], fallback)).toEqual({
30
+ uid: 'role-uid-1',
31
+ __is_new__: true,
32
+ });
33
+ expect(form.getFieldValue).toHaveBeenCalledWith(['roles', 0]);
34
+ });
35
+
36
+ it('falls back to the record when latest row value is unavailable', () => {
37
+ const form = { getFieldValue: vi.fn(() => undefined) };
38
+ const fallback = { uid: 'stale-role', __is_new__: false };
39
+
40
+ expect(getLatestSubTableRowRecord(form, ['roles:0'], fallback)).toBe(fallback);
41
+ });
42
+ });
@@ -0,0 +1,122 @@
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 { EventEmitter } from 'events';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { act, render, screen } from '@nocobase/test/client';
14
+ import { SubTableField } from '../SubTableField';
15
+
16
+ vi.mock('react-i18next', async (importOriginal) => ({
17
+ ...(await importOriginal<any>()),
18
+ useTranslation: () => ({
19
+ t: (value: string) => value,
20
+ }),
21
+ }));
22
+
23
+ vi.mock('antd', async () => {
24
+ const actual = await vi.importActual<any>('antd');
25
+ return {
26
+ ...actual,
27
+ Table: ({ dataSource = [], columns = [] }: any) => (
28
+ <div data-testid="subtable">
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
+ describe('SubTableField refresh', () => {
44
+ it('rerenders from current form value when a nested subtable path changes', () => {
45
+ const emitter = new EventEmitter();
46
+ const store = {
47
+ roles: [{ __is_new__: true, __index__: 'row-1', uid: 'role-uid-1', name: '' }],
48
+ };
49
+ const columns = [
50
+ {
51
+ key: 'name',
52
+ dataIndex: 'name',
53
+ render: ({ value }: any) => <span>{value || 'empty'}</span>,
54
+ },
55
+ ];
56
+
57
+ render(
58
+ <SubTableField
59
+ columns={columns}
60
+ pageSize={10}
61
+ filterTargetKey="id"
62
+ fieldPathArray={['roles']}
63
+ formValuesChangeEmitter={emitter}
64
+ getCurrentValue={() => store.roles}
65
+ />,
66
+ );
67
+
68
+ expect(screen.getByTestId('cell-0-name')).toHaveTextContent('empty');
69
+
70
+ act(() => {
71
+ store.roles = [{ ...store.roles[0], name: 'role-uid-1' }];
72
+ emitter.emit('formValuesChange', {
73
+ source: 'linkage',
74
+ changedPaths: [['roles', 0, 'name']],
75
+ });
76
+ });
77
+
78
+ expect(screen.getByTestId('cell-0-name')).toHaveTextContent('role-uid-1');
79
+ });
80
+
81
+ it('ignores unrelated form value changes', () => {
82
+ const emitter = new EventEmitter();
83
+ let renderCount = 0;
84
+ const store = {
85
+ roles: [{ __is_new__: true, __index__: 'row-1', uid: 'role-uid-1', name: '' }],
86
+ };
87
+ const columns = [
88
+ {
89
+ key: 'name',
90
+ dataIndex: 'name',
91
+ render: ({ value }: any) => {
92
+ renderCount += 1;
93
+ return <span>{value || 'empty'}</span>;
94
+ },
95
+ },
96
+ ];
97
+
98
+ render(
99
+ <SubTableField
100
+ columns={columns}
101
+ pageSize={10}
102
+ filterTargetKey="id"
103
+ fieldPathArray={['roles']}
104
+ formValuesChangeEmitter={emitter}
105
+ getCurrentValue={() => store.roles}
106
+ />,
107
+ );
108
+
109
+ expect(renderCount).toBe(1);
110
+
111
+ act(() => {
112
+ store.roles = [{ ...store.roles[0], name: 'role-uid-1' }];
113
+ emitter.emit('formValuesChange', {
114
+ source: 'user',
115
+ changedPaths: [['profile', 'name']],
116
+ });
117
+ });
118
+
119
+ expect(renderCount).toBe(1);
120
+ expect(screen.getByTestId('cell-0-name')).toHaveTextContent('empty');
121
+ });
122
+ });
@@ -120,6 +120,8 @@ export class SubTableFieldModel extends AssociationFieldModel {
120
120
  parentFieldIndex={this.context.fieldIndex}
121
121
  parentItem={this.context.item}
122
122
  filterTargetKey={this.collection.filterTargetKey}
123
+ formValuesChangeEmitter={this.context.blockModel?.emitter}
124
+ fieldPathArray={this.parent?.context?.fieldPathArray}
123
125
  getCurrentValue={this.getCurrentValue}
124
126
  />
125
127
  );
@@ -0,0 +1,20 @@
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
+ export function hasAncestorModel(model: any, modelNames: string[]) {
11
+ let cursor = model;
12
+ while (cursor) {
13
+ const modelName = cursor?.constructor?.name;
14
+ if (modelName && modelNames.includes(modelName)) {
15
+ return true;
16
+ }
17
+ cursor = cursor?.parent;
18
+ }
19
+ return false;
20
+ }
@@ -11,10 +11,9 @@ import { CollectionField, tExpr } from '@nocobase/flow-engine';
11
11
  import { Tag } from 'antd';
12
12
  import { castArray, get } from 'lodash';
13
13
  import React from 'react';
14
- import { EllipsisWithTooltip } from '../../components';
14
+ import { EllipsisWithTooltip } from '../../components/EllipsisWithTooltip';
15
15
  import { openViewFlow } from '../../flows/openViewFlow';
16
16
  import { FieldModel } from '../base/FieldModel';
17
- import { EditFormModel } from '../blocks/form/EditFormModel';
18
17
 
19
18
  export function transformNestedData(inputData) {
20
19
  const resultArray = [];
@@ -36,6 +35,8 @@ export function transformNestedData(inputData) {
36
35
  const hasAssociationPathName = (parent: unknown): parent is { associationPathName?: string } =>
37
36
  !!parent && typeof parent === 'object' && 'associationPathName' in parent;
38
37
 
38
+ const hasUsableSourceId = (sourceId: unknown) => sourceId !== undefined && sourceId !== null && String(sourceId) !== '';
39
+
39
40
  export class ClickableFieldModel extends FieldModel {
40
41
  get collectionField(): CollectionField {
41
42
  return this.context.collectionField;
@@ -62,14 +63,18 @@ export class ClickableFieldModel extends FieldModel {
62
63
  const parentObj = associationPathName
63
64
  ? get(this.context.blockModel?.form?.getFieldsValue?.(true) || this.context.record, associationPathName)
64
65
  : this.context.record;
66
+ const sourceId = parentObj?.[sourceKey];
67
+ const useAssociationResource = hasUsableSourceId(sourceId);
65
68
  this.dispatchEvent(
66
69
  'click',
67
70
  {
68
71
  event,
69
72
  filterByTk,
70
- collectionName: this.collectionField.collection.name,
71
- associationName: `${sourceCollection.name}.${this.collectionField.name}`, // `${sourceCollection.name}.${this.collectionField.name}`,
72
- sourceId: parentObj[sourceKey],
73
+ collectionName: useAssociationResource
74
+ ? this.collectionField.collection.name
75
+ : targetCollection?.name || this.collectionField.target,
76
+ associationName: useAssociationResource ? `${sourceCollection.name}.${this.collectionField.name}` : null,
77
+ sourceId: useAssociationResource ? sourceId : null,
73
78
  },
74
79
  {
75
80
  debounce: true,
@@ -95,6 +100,10 @@ export class ClickableFieldModel extends FieldModel {
95
100
  const parentObj = associationPathName.includes('.')
96
101
  ? get(this.context.record, associationPathName.split('.')[0])
97
102
  : this.context.record;
103
+ const sourceId = hasUsableSourceId(parentObj?.[sourceKey])
104
+ ? parentObj?.[sourceKey]
105
+ : this.context.record?.[foreignKey];
106
+ const useAssociationResource = hasUsableSourceId(sourceId);
98
107
  let filterByTk = associationRecord?.[targetKey];
99
108
  if (associationField.interface === 'm2m') {
100
109
  // also incorrect for v1
@@ -106,10 +115,13 @@ export class ClickableFieldModel extends FieldModel {
106
115
  {
107
116
  event,
108
117
  filterByTk,
109
- collectionName: this.collectionField.collection.name,
110
- associationName: `${associationField.collection.name}.${this.collectionField.name}`,
111
- // list api, 如果append了关系字段的某个属性,它并不会将关系字段对应的 filterByTk (sourceKey) 属性值返回, 但是会返回foriegnKey对应的值
112
- sourceId: parentObj[sourceKey] || this.context.record[foreignKey],
118
+ collectionName: useAssociationResource
119
+ ? this.collectionField.collection.name
120
+ : targetCollection?.name || associationField.target || this.collectionField.collection.name,
121
+ associationName: useAssociationResource
122
+ ? `${associationField.collection.name}.${this.collectionField.name}`
123
+ : null,
124
+ sourceId: useAssociationResource ? sourceId : null,
113
125
  },
114
126
  {
115
127
  debounce: true,
@@ -12,17 +12,26 @@ import { EditableItemModel, useFlowModelContext } from '@nocobase/flow-engine';
12
12
  import React from 'react';
13
13
  import { DateTimeFieldModel } from './DateTimeFieldModel';
14
14
  import { MobileDatePicker } from '../mobile-components/MobileDatePicker';
15
+ import { useDateLimit } from './dateLimit';
15
16
 
16
17
  export const DateOnlyPicker = (props) => {
17
18
  const { value, format = 'YYYY-MM-DD', picker = 'date', showTime, ...rest } = props;
18
19
  const parsedValue = value && dayjs(value).isValid() ? dayjs(value) : null;
19
20
  const ctx = useFlowModelContext();
21
+ const { disabledDate, disabledTime, minDate, maxDate } = useDateLimit({
22
+ ...props,
23
+ currentForm: ctx.model?.context?.form,
24
+ });
20
25
  const componentProps = {
21
26
  ...rest,
22
27
  value: parsedValue,
23
28
  format,
24
29
  picker,
25
30
  showTime,
31
+ disabledDate,
32
+ disabledTime,
33
+ minDate,
34
+ maxDate,
26
35
  onChange: (val: any) => {
27
36
  const outputFormat = 'YYYY-MM-DD';
28
37
  if (!val) {
@@ -22,5 +22,9 @@ DateTimeFieldModel.registerFlow({
22
22
  use: 'dateDisplayFormat',
23
23
  title: tExpr('Date display format'),
24
24
  },
25
+ dateRangeLimit: {
26
+ use: 'dateRangeLimit',
27
+ title: tExpr('Date range limit'),
28
+ },
25
29
  },
26
30
  });
@@ -12,17 +12,26 @@ import React from 'react';
12
12
  import { EditableItemModel, useFlowModelContext } from '@nocobase/flow-engine';
13
13
  import { DateTimeFieldModel } from './DateTimeFieldModel';
14
14
  import { MobileDatePicker } from '../mobile-components/MobileDatePicker';
15
+ import { useDateLimit } from './dateLimit';
15
16
 
16
17
  export const DateTimeNoTzPicker = (props) => {
17
18
  const { value, format = 'YYYY-MM-DD HH:mm:ss', showTime, picker = 'date', onChange, ...rest } = props;
18
19
  const parsedValue = value ? dayjs(value) : null;
19
20
  const ctx = useFlowModelContext();
21
+ const { disabledDate, disabledTime, minDate, maxDate } = useDateLimit({
22
+ ...props,
23
+ currentForm: ctx.model?.context?.form,
24
+ });
20
25
  const componentProps = {
21
26
  ...rest,
22
27
  value: parsedValue,
23
28
  format,
24
29
  picker,
25
30
  showTime,
31
+ disabledDate,
32
+ disabledTime,
33
+ minDate,
34
+ maxDate,
26
35
  onChange: (val: any) => {
27
36
  if (!val) {
28
37
  return onChange(val);
@@ -12,6 +12,7 @@ import React from 'react';
12
12
  import { DateTimeFieldModel } from './DateTimeFieldModel';
13
13
  import { MobileDatePicker } from '../mobile-components/MobileDatePicker';
14
14
  import { DatePicker } from 'antd';
15
+ import { useDateLimit } from './dateLimit';
15
16
 
16
17
  function parseToDate(value: string | Date | dayjs.Dayjs | undefined, format?: string): Date | undefined {
17
18
  if (!value) return undefined;
@@ -49,12 +50,20 @@ function parseInitialValue(value: string | Date | undefined, format?: string): d
49
50
  export const DateTimeTzPicker = (props) => {
50
51
  const { value, format = 'YYYY-MM-DD HH:mm:ss', picker = 'date', showTime, ...rest } = props;
51
52
  const ctx = useFlowModelContext();
53
+ const { disabledDate, disabledTime, minDate, maxDate } = useDateLimit({
54
+ ...props,
55
+ currentForm: ctx.model?.context?.form,
56
+ });
52
57
  const componentProps = {
53
58
  ...rest,
54
59
  value: parseInitialValue(value, format),
55
60
  format,
56
61
  picker,
57
62
  showTime,
63
+ disabledDate,
64
+ disabledTime,
65
+ minDate,
66
+ maxDate,
58
67
  onChange: (val: any) => {
59
68
  let result = parseToDate(val, format);
60
69
  // Adjust to start of period for month/quarter/year pickers