@nocobase/client-v2 2.1.0-alpha.23 → 2.1.0-alpha.25

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.0-alpha.23",
3
+ "version": "2.1.0-alpha.25",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -24,9 +24,9 @@
24
24
  "@formily/antd-v5": "1.2.3",
25
25
  "@formily/react": "^2.2.27",
26
26
  "@formily/shared": "^2.2.27",
27
- "@nocobase/flow-engine": "2.1.0-alpha.23",
28
- "@nocobase/sdk": "2.1.0-alpha.23",
29
- "@nocobase/shared": "2.1.0-alpha.23",
27
+ "@nocobase/flow-engine": "2.1.0-alpha.25",
28
+ "@nocobase/sdk": "2.1.0-alpha.25",
29
+ "@nocobase/shared": "2.1.0-alpha.25",
30
30
  "antd": "5.24.2",
31
31
  "classnames": "^2.3.1",
32
32
  "dayjs": "^1.11.10",
@@ -35,5 +35,5 @@
35
35
  "react-i18next": "^11.15.1",
36
36
  "react-router-dom": "^6.30.1"
37
37
  },
38
- "gitHead": "baa19dafe25e85b680b2fea7451f202831930c1c"
38
+ "gitHead": "63e4aaa625f3108fe41238e85bb13dee37fe1f48"
39
39
  }
@@ -8,11 +8,21 @@
8
8
  */
9
9
 
10
10
  import { PlusOutlined } from '@ant-design/icons';
11
- import { AddSubModelButton, DragOverlayConfig, FlowSettingsButton } from '@nocobase/flow-engine';
11
+ import {
12
+ AddSubModelButton,
13
+ DragOverlayConfig,
14
+ FlowSettingsButton,
15
+ buildSubModelGroups,
16
+ buildSubModelItems,
17
+ type SubModelItem,
18
+ type SubModelItemsType,
19
+ } from '@nocobase/flow-engine';
12
20
  import React from 'react';
13
21
  import { FilterManager } from '../blocks/filter-manager/FilterManager';
14
22
  import { GridModel } from './GridModel';
15
23
 
24
+ const SELECT_SCENE_ALLOWED_OTHER_BLOCK_MODELS = ['JSBlockModel', 'IframeBlockModel', 'MarkdownBlockModel'] as const;
25
+
16
26
  export class BlockGridModel extends GridModel {
17
27
  dragOverlayConfig: DragOverlayConfig = {
18
28
  // 列内插入
@@ -53,6 +63,36 @@ export class BlockGridModel extends GridModel {
53
63
  return this.context.filterManager;
54
64
  }
55
65
 
66
+ get addBlockItems(): SubModelItemsType | undefined {
67
+ if (this.context.view?.inputArgs?.scene !== 'select') {
68
+ return undefined;
69
+ }
70
+
71
+ return async (ctx) => {
72
+ const items = await buildSubModelGroups(['DataBlockModel', 'FilterBlockModel'])(ctx);
73
+ const allowedOtherBlockModels = SELECT_SCENE_ALLOWED_OTHER_BLOCK_MODELS.filter((modelName) =>
74
+ Boolean(ctx.engine.getModelClass(modelName)),
75
+ );
76
+ const otherBlockItems = (
77
+ await Promise.all(allowedOtherBlockModels.map((modelName) => buildSubModelItems(modelName)(ctx)))
78
+ )
79
+ .flat()
80
+ .sort((a, b) => (a.sort ?? 1000) - (b.sort ?? 1000));
81
+
82
+ if (otherBlockItems.length > 0) {
83
+ items.push({
84
+ key: 'select-scene-other-blocks',
85
+ type: 'group',
86
+ label: ctx.t('Other blocks'),
87
+ sort: 1000,
88
+ children: otherBlockItems,
89
+ } satisfies SubModelItem);
90
+ }
91
+
92
+ return items;
93
+ };
94
+ }
95
+
56
96
  serialize() {
57
97
  const data = super.serialize();
58
98
  data['filterManager'] = this.filterManager.getFilterConfigs();
@@ -60,8 +100,14 @@ export class BlockGridModel extends GridModel {
60
100
  }
61
101
 
62
102
  renderAddSubModelButton() {
103
+ const items = this.addBlockItems;
63
104
  return (
64
- <AddSubModelButton model={this} subModelKey="items" subModelBaseClasses={this.subModelBaseClasses}>
105
+ <AddSubModelButton
106
+ model={this}
107
+ subModelKey="items"
108
+ items={items}
109
+ subModelBaseClasses={items ? undefined : this.subModelBaseClasses}
110
+ >
65
111
  <FlowSettingsButton icon={<PlusOutlined />} data-flow-add-block>
66
112
  {this.context.t('Add block')}
67
113
  </FlowSettingsButton>
@@ -0,0 +1,83 @@
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, FlowModel } from '@nocobase/flow-engine';
11
+ import { beforeEach, describe, expect, it } from 'vitest';
12
+ import { BlockGridModel } from '../BlockGridModel';
13
+
14
+ class BlockModel extends FlowModel {}
15
+ BlockModel.define({ label: 'Other blocks' });
16
+
17
+ class DataBlockModel extends BlockModel {}
18
+ DataBlockModel.define({ label: 'Data blocks' });
19
+
20
+ class FilterBlockModel extends BlockModel {}
21
+ FilterBlockModel.define({ label: 'Filter blocks' });
22
+
23
+ class SelectTableBlockModel extends DataBlockModel {}
24
+ SelectTableBlockModel.define({ label: 'Table', children: false });
25
+
26
+ class SelectFilterBlockModel extends FilterBlockModel {}
27
+ SelectFilterBlockModel.define({ label: 'Filter', children: false });
28
+
29
+ class JSBlockModel extends BlockModel {}
30
+ JSBlockModel.define({ label: 'JS block' });
31
+
32
+ class IframeBlockModel extends BlockModel {}
33
+ IframeBlockModel.define({ label: 'Iframe' });
34
+
35
+ class MarkdownBlockModel extends BlockModel {}
36
+ MarkdownBlockModel.define({ label: 'Markdown' });
37
+
38
+ class ActionPanelBlockModel extends BlockModel {}
39
+ ActionPanelBlockModel.define({ label: 'Action panel' });
40
+
41
+ class ReferenceBlockModel extends BlockModel {}
42
+ ReferenceBlockModel.define({ label: 'Block template' });
43
+
44
+ describe('BlockGridModel - select scene add block menu', () => {
45
+ let engine: FlowEngine;
46
+
47
+ beforeEach(() => {
48
+ engine = new FlowEngine();
49
+ engine.registerModels({
50
+ BlockModel,
51
+ DataBlockModel,
52
+ FilterBlockModel,
53
+ BlockGridModel,
54
+ SelectTableBlockModel,
55
+ SelectFilterBlockModel,
56
+ JSBlockModel,
57
+ IframeBlockModel,
58
+ MarkdownBlockModel,
59
+ ActionPanelBlockModel,
60
+ ReferenceBlockModel,
61
+ });
62
+ });
63
+
64
+ it('keeps only JS, iframe and markdown under other blocks in select scene', async () => {
65
+ engine.context.defineProperty('view', { value: { inputArgs: { scene: 'select' } } });
66
+ const model = engine.createModel<BlockGridModel>({ use: 'BlockGridModel' });
67
+
68
+ const itemsSource = model.addBlockItems;
69
+ expect(typeof itemsSource).toBe('function');
70
+
71
+ const items = await itemsSource!(model.context);
72
+ const otherBlocks = items.find((item) => item.key === 'select-scene-other-blocks');
73
+
74
+ expect(otherBlocks).toBeTruthy();
75
+ expect(otherBlocks?.type).toBe('group');
76
+ expect(Array.isArray(otherBlocks?.children)).toBe(true);
77
+
78
+ const childKeys = (otherBlocks?.children as any[]).map((item) => item.key);
79
+ expect(childKeys).toEqual(['JSBlockModel', 'IframeBlockModel', 'MarkdownBlockModel']);
80
+ expect(childKeys).not.toContain('ActionPanelBlockModel');
81
+ expect(childKeys).not.toContain('ReferenceBlockModel');
82
+ });
83
+ });
@@ -1706,6 +1706,299 @@ describe('FormValueRuntime (form assign rules)', () => {
1706
1706
  expect(formStub.getFieldValue(['users', 0, 'name'])).toBe('');
1707
1707
  });
1708
1708
 
1709
+ it('clears explicit state for deleted to-many rows even when allValues is stale', () => {
1710
+ const engineEmitter = new EventEmitter();
1711
+ const blockEmitter = new EventEmitter();
1712
+ const formStub = createFormStub({
1713
+ roles: [{ title: 'Z', __is_new__: true }],
1714
+ });
1715
+
1716
+ const blockModel: any = {
1717
+ uid: 'form-assign-default-create-readd-row',
1718
+ flowEngine: { emitter: engineEmitter },
1719
+ emitter: blockEmitter,
1720
+ dispatchEvent: vi.fn(),
1721
+ getAclActionName: () => 'create',
1722
+ };
1723
+
1724
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1725
+ runtime.mount({ sync: true });
1726
+
1727
+ lodashSet((formStub as any).__store, ['roles', 0, 'title'], 'custom');
1728
+ runtime.handleFormFieldsChange([{ name: ['roles', 0, 'title'], touched: true } as any]);
1729
+ expect((runtime as any).findExplicitHit('roles[0].title')).toBe('roles[0].title');
1730
+
1731
+ const staleAllValues = { roles: [{ title: 'custom', __is_new__: true }] };
1732
+ lodashSet((formStub as any).__store, ['roles'], []);
1733
+ runtime.handleFormValuesChange({ roles: [] }, staleAllValues);
1734
+
1735
+ expect((runtime as any).findExplicitHit('roles[0].title')).toBeNull();
1736
+ });
1737
+
1738
+ it('moves explicit state with filterTargetKey identified to-many rows after deleting a preceding row', () => {
1739
+ const engineEmitter = new EventEmitter();
1740
+ const blockEmitter = new EventEmitter();
1741
+ const formStub = createFormStub({
1742
+ roles: [
1743
+ { code: 'admin', title: 'same-title' },
1744
+ { code: 'editor', title: 'custom' },
1745
+ ],
1746
+ });
1747
+
1748
+ const blockModel: any = {
1749
+ uid: 'form-assign-default-create-shift-row',
1750
+ flowEngine: { emitter: engineEmitter },
1751
+ emitter: blockEmitter,
1752
+ dispatchEvent: vi.fn(),
1753
+ getAclActionName: () => 'create',
1754
+ };
1755
+
1756
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1757
+ runtime.mount({ sync: true });
1758
+
1759
+ const blockCtx = createFieldContext(runtime);
1760
+ const roleRowCollection: any = { getField: () => null, filterTargetKey: 'code' };
1761
+ const rolesField: any = { type: 'hasMany', isAssociationField: () => true, targetCollection: roleRowCollection };
1762
+ const rootCollection: any = { getField: (name: string) => (name === 'roles' ? rolesField : null) };
1763
+ blockCtx.defineProperty('collection', { value: rootCollection });
1764
+ blockModel.context = blockCtx;
1765
+
1766
+ lodashSet((formStub as any).__store, ['roles', 1, 'title'], 'same-title');
1767
+ runtime.handleFormFieldsChange([{ name: ['roles', 1, 'title'], touched: true } as any]);
1768
+ expect((runtime as any).findExplicitHit('roles[1].title')).toBe('roles[1].title');
1769
+
1770
+ const shiftedRow = formStub.getFieldValue(['roles', 1]);
1771
+ lodashSet((formStub as any).__store, ['roles'], [shiftedRow]);
1772
+ runtime.handleFormValuesChange({ roles: [shiftedRow] }, formStub.getFieldsValue());
1773
+
1774
+ expect((runtime as any).findExplicitHit('roles[1].title')).toBeNull();
1775
+ expect((runtime as any).findExplicitHit('roles[0].title')).toBe('roles[0].title');
1776
+ });
1777
+
1778
+ it('moves observable binding writes with filterTargetKey identified rows after deleting a preceding row', async () => {
1779
+ const engineEmitter = new EventEmitter();
1780
+ const blockEmitter = new EventEmitter();
1781
+ const formStub = createFormStub({
1782
+ roles: [{ code: 'admin' }, { code: 'editor' }],
1783
+ });
1784
+
1785
+ const blockModel: any = {
1786
+ uid: 'form-assign-default-observable-shift-row',
1787
+ flowEngine: { emitter: engineEmitter },
1788
+ emitter: blockEmitter,
1789
+ dispatchEvent: vi.fn(),
1790
+ getAclActionName: () => 'create',
1791
+ };
1792
+
1793
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1794
+ runtime.mount({ sync: true });
1795
+
1796
+ const blockCtx = createFieldContext(runtime);
1797
+ const roleRowCollection: any = { getField: () => null, filterTargetKey: 'code' };
1798
+ const rolesField: any = { type: 'hasMany', isAssociationField: () => true, targetCollection: roleRowCollection };
1799
+ const rootCollection: any = { getField: (name: string) => (name === 'roles' ? rolesField : null) };
1800
+ blockCtx.defineProperty('collection', { value: rootCollection });
1801
+ blockCtx.defineProperty('fieldIndex', { value: ['roles:1'] });
1802
+ blockModel.context = blockCtx;
1803
+
1804
+ const defaultMeta = observable({ label: 'Editor' });
1805
+ await runtime.setFormValues(blockCtx, [{ path: 'roles.meta', value: defaultMeta }], {
1806
+ source: 'linkage',
1807
+ markExplicit: false,
1808
+ });
1809
+
1810
+ expect(formStub.getFieldValue(['roles', 1, 'meta'])).toEqual({ label: 'Editor' });
1811
+
1812
+ const shiftedRow = formStub.getFieldValue(['roles', 1]);
1813
+ lodashSet((formStub as any).__store, ['roles'], [shiftedRow]);
1814
+ runtime.handleFormValuesChange({ roles: [shiftedRow] }, formStub.getFieldsValue());
1815
+
1816
+ defaultMeta.label = 'Updated';
1817
+
1818
+ await waitFor(() => expect(formStub.getFieldValue(['roles', 0, 'meta'])).toEqual({ label: 'Updated' }));
1819
+ expect(formStub.getFieldValue(['roles', 1])).toBeUndefined();
1820
+ });
1821
+
1822
+ it('skips stale to-many row rules after the row index is out of bounds', async () => {
1823
+ const engineEmitter = new EventEmitter();
1824
+ const blockEmitter = new EventEmitter();
1825
+ const formStub = createFormStub({
1826
+ roles: [{ title: 'Z', __is_new__: true, __index__: 'row-1' }],
1827
+ });
1828
+
1829
+ const blockModel: any = {
1830
+ uid: 'form-assign-default-stale-row',
1831
+ flowEngine: { emitter: engineEmitter },
1832
+ emitter: blockEmitter,
1833
+ dispatchEvent: vi.fn(),
1834
+ getAclActionName: () => 'create',
1835
+ };
1836
+
1837
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1838
+ runtime.mount({ sync: true });
1839
+
1840
+ const blockCtx = createFieldContext(runtime);
1841
+ const roleRowCollection: any = { getField: () => null };
1842
+ const rolesField: any = { type: 'hasMany', isAssociationField: () => true, targetCollection: roleRowCollection };
1843
+ const rootCollection: any = { getField: (name: string) => (name === 'roles' ? rolesField : null) };
1844
+ blockCtx.defineProperty('collection', { value: rootCollection });
1845
+ blockModel.context = blockCtx;
1846
+
1847
+ const masterModel: any = {
1848
+ uid: 'roles.title',
1849
+ subModels: { field: {} },
1850
+ getStepParams(flowKey: string, stepKey: string) {
1851
+ if (flowKey === 'fieldSettings' && stepKey === 'init') {
1852
+ return { fieldPath: 'roles.title' };
1853
+ }
1854
+ return undefined;
1855
+ },
1856
+ };
1857
+ const masterCtx = createFieldContext(runtime);
1858
+ masterCtx.defineProperty('blockModel', { value: blockModel });
1859
+ masterCtx.defineProperty('model', { value: masterModel });
1860
+ masterModel.context = masterCtx;
1861
+
1862
+ const staleRow: any = {
1863
+ uid: 'roles.title',
1864
+ isFork: true,
1865
+ forkId: 'roles:stale',
1866
+ subModels: { field: {} },
1867
+ getStepParams(flowKey: string, stepKey: string) {
1868
+ if (flowKey === 'fieldSettings' && stepKey === 'init') {
1869
+ return { fieldPath: 'roles.title' };
1870
+ }
1871
+ return undefined;
1872
+ },
1873
+ };
1874
+ const staleCtx = createFieldContext(runtime);
1875
+ staleCtx.defineProperty('blockModel', { value: blockModel });
1876
+ staleCtx.defineProperty('fieldIndex', { value: ['roles:1'] });
1877
+ staleCtx.defineProperty('item', {
1878
+ value: {
1879
+ index: 1,
1880
+ length: 1,
1881
+ __is_new__: true,
1882
+ value: { title: 'Z', __is_new__: true, __index__: 'row-1' },
1883
+ },
1884
+ });
1885
+ staleCtx.defineProperty('model', { value: staleRow });
1886
+ staleRow.context = staleCtx;
1887
+ masterModel.forks = new Set([staleRow]);
1888
+
1889
+ blockCtx.defineProperty('engine', {
1890
+ value: {
1891
+ forEachModel: (cb: any) => {
1892
+ cb(masterModel);
1893
+ },
1894
+ },
1895
+ });
1896
+
1897
+ runtime.syncAssignRules([
1898
+ {
1899
+ key: 'r1',
1900
+ enable: true,
1901
+ targetPath: 'roles.title',
1902
+ mode: 'default',
1903
+ condition: { logic: '$and', items: [] },
1904
+ value: 'Z',
1905
+ },
1906
+ ]);
1907
+
1908
+ await new Promise((resolve) => setTimeout(resolve, 20));
1909
+ expect(formStub.getFieldValue(['roles'])).toHaveLength(1);
1910
+ expect(formStub.getFieldValue(['roles', 1])).toBeUndefined();
1911
+ });
1912
+
1913
+ it('skips to-many row rules when filterTargetKey identity does not match the current row at that index', async () => {
1914
+ const engineEmitter = new EventEmitter();
1915
+ const blockEmitter = new EventEmitter();
1916
+ const formStub = createFormStub({
1917
+ roles: [{ code: 'admin', title: '' }],
1918
+ });
1919
+
1920
+ const blockModel: any = {
1921
+ uid: 'form-assign-default-mismatched-row',
1922
+ flowEngine: { emitter: engineEmitter },
1923
+ emitter: blockEmitter,
1924
+ dispatchEvent: vi.fn(),
1925
+ getAclActionName: () => 'create',
1926
+ };
1927
+
1928
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1929
+ runtime.mount({ sync: true });
1930
+
1931
+ const blockCtx = createFieldContext(runtime);
1932
+ const roleRowCollection: any = { getField: () => null, filterTargetKey: 'code' };
1933
+ const rolesField: any = { type: 'hasMany', isAssociationField: () => true, targetCollection: roleRowCollection };
1934
+ const rootCollection: any = { getField: (name: string) => (name === 'roles' ? rolesField : null) };
1935
+ blockCtx.defineProperty('collection', { value: rootCollection });
1936
+ blockModel.context = blockCtx;
1937
+
1938
+ const masterModel: any = {
1939
+ uid: 'roles.title',
1940
+ subModels: { field: {} },
1941
+ getStepParams(flowKey: string, stepKey: string) {
1942
+ if (flowKey === 'fieldSettings' && stepKey === 'init') {
1943
+ return { fieldPath: 'roles.title' };
1944
+ }
1945
+ return undefined;
1946
+ },
1947
+ };
1948
+ const masterCtx = createFieldContext(runtime);
1949
+ masterCtx.defineProperty('blockModel', { value: blockModel });
1950
+ masterCtx.defineProperty('model', { value: masterModel });
1951
+ masterModel.context = masterCtx;
1952
+
1953
+ const mismatchedRow: any = {
1954
+ uid: 'roles.title',
1955
+ isFork: true,
1956
+ forkId: 'roles:mismatched',
1957
+ subModels: { field: {} },
1958
+ getStepParams(flowKey: string, stepKey: string) {
1959
+ if (flowKey === 'fieldSettings' && stepKey === 'init') {
1960
+ return { fieldPath: 'roles.title' };
1961
+ }
1962
+ return undefined;
1963
+ },
1964
+ };
1965
+ const rowCtx = createFieldContext(runtime);
1966
+ rowCtx.defineProperty('blockModel', { value: blockModel });
1967
+ rowCtx.defineProperty('fieldIndex', { value: ['roles:0'] });
1968
+ rowCtx.defineProperty('item', {
1969
+ value: {
1970
+ index: 0,
1971
+ length: 1,
1972
+ value: { code: 'editor', title: 'custom' },
1973
+ },
1974
+ });
1975
+ rowCtx.defineProperty('model', { value: mismatchedRow });
1976
+ mismatchedRow.context = rowCtx;
1977
+ masterModel.forks = new Set([mismatchedRow]);
1978
+
1979
+ blockCtx.defineProperty('engine', {
1980
+ value: {
1981
+ forEachModel: (cb: any) => {
1982
+ cb(masterModel);
1983
+ },
1984
+ },
1985
+ });
1986
+
1987
+ runtime.syncAssignRules([
1988
+ {
1989
+ key: 'r1',
1990
+ enable: true,
1991
+ targetPath: 'roles.title',
1992
+ mode: 'default',
1993
+ condition: { logic: '$and', items: [] },
1994
+ value: 'Z',
1995
+ },
1996
+ ]);
1997
+
1998
+ await new Promise((resolve) => setTimeout(resolve, 20));
1999
+ expect(formStub.getFieldValue(['roles', 0, 'title'])).toBe('');
2000
+ });
2001
+
1709
2002
  it('keeps default-disabled for edited to-many leaf when onValuesChange only provides top-level path', async () => {
1710
2003
  const engineEmitter = new EventEmitter();
1711
2004
  const blockEmitter = new EventEmitter();
@@ -82,8 +82,9 @@ describe('SubTableColumnModel (nested subform)', () => {
82
82
  parentFieldIndex: ['users:0'],
83
83
  });
84
84
 
85
- const rowFork = column.getFork('row:users:0:1');
86
- expect(rowFork?.context?.fieldIndex).toEqual(['users:0', 'roles:1']);
85
+ const [rowFork] = Array.from(column.forks ?? []);
86
+ expect(rowFork).toBeDefined();
87
+ expect((rowFork as any)?.context?.fieldIndex).toEqual(['users:0', 'roles:1']);
87
88
 
88
89
  runtime.syncAssignRules([
89
90
  {
@@ -17,6 +17,7 @@ import { namePathToPathKey, parsePathString, pathKeyToNamePath } from './path';
17
17
  import type { FormAssignRuleItem, FormValueWriteMeta, NamePath, Patch, SetOptions, ValueSource } from './types';
18
18
  import { createTxId, isEmptyValue } from './utils';
19
19
  import { isToManyAssociationField } from '../../../../internal/utils/modelUtils';
20
+ import { getSubTableRowIdentity } from '../../../fields/AssociationFieldModel/SubTableFieldModel/rowIdentity';
20
21
 
21
22
  /** Symbol to indicate rule value resolution should be skipped */
22
23
  const SKIP_RULE_VALUE = Symbol('SKIP_RULE_VALUE');
@@ -51,6 +52,7 @@ type RuntimeRule = {
51
52
 
52
53
  type ObservableBinding = {
53
54
  source: ValueSource;
55
+ pathKey: string;
54
56
  dispose: () => void;
55
57
  };
56
58
 
@@ -1172,7 +1174,6 @@ export class RuleEngine {
1172
1174
 
1173
1175
  const ruleContext = this.prepareRuleContext(rule);
1174
1176
  const { baseCtx, targetNamePath, targetKey, clearDeps, disposeBinding } = ruleContext;
1175
-
1176
1177
  if (!this.shouldRunRule(rule, targetNamePath, targetKey, baseCtx)) {
1177
1178
  clearDeps(state);
1178
1179
  disposeBinding();
@@ -1487,18 +1488,59 @@ export class RuleEngine {
1487
1488
  // 编辑态默认值规则:
1488
1489
  // - 顶层编辑表单:不应用默认值
1489
1490
  // - 子表单:仅对“新增行/新增对象”(__is_new__ = true)应用默认值
1490
- let item: any;
1491
+ return this.getItemContext(baseCtx)?.__is_new__ === true;
1492
+ }
1493
+
1494
+ private isStaleToManyItemContext(baseCtx: any): boolean {
1495
+ const item = this.getItemContext(baseCtx);
1496
+ if (!Number.isFinite(item?.index) || !Number.isFinite(item?.length)) return false;
1497
+ return item.index < 0 || item.index >= item.length;
1498
+ }
1499
+
1500
+ private getItemContext(baseCtx: any) {
1491
1501
  try {
1492
- item = baseCtx?.item;
1502
+ return baseCtx?.item;
1493
1503
  } catch {
1494
- item = undefined;
1504
+ return undefined;
1495
1505
  }
1506
+ }
1496
1507
 
1497
- if (!item || typeof item !== 'object') {
1498
- return false;
1508
+ private getRowTargetKey(baseCtx: any, rowPath: NamePath): string | string[] {
1509
+ let collection = this.getRootCollection() || this.getCollectionFromContext(baseCtx);
1510
+ let field: any;
1511
+ for (const seg of rowPath) {
1512
+ if (typeof seg === 'number') continue;
1513
+ if (typeof seg !== 'string' || !seg || !collection?.getField) break;
1514
+
1515
+ field = collection?.getField?.(seg);
1516
+ if (!field?.isAssociationField?.()) break;
1517
+ collection = field?.targetCollection;
1499
1518
  }
1500
1519
 
1501
- return item.__is_new__ === true;
1520
+ const raw = field?.targetCollection?.filterTargetKey ?? field?.targetCollection?.filterByTk ?? field?.targetKey;
1521
+ if (Array.isArray(raw)) {
1522
+ const keys = raw.filter((key): key is string => typeof key === 'string' && !!key);
1523
+ return keys.length ? keys : 'id';
1524
+ }
1525
+ return typeof raw === 'string' && raw ? raw : 'id';
1526
+ }
1527
+
1528
+ private isMismatchedToManyItemContext(baseCtx: any, targetNamePath: NamePath): boolean {
1529
+ const item = this.getItemContext(baseCtx);
1530
+ if (!item) return false;
1531
+
1532
+ for (let i = targetNamePath.length - 1; i >= 0; i--) {
1533
+ if (typeof targetNamePath[i] !== 'number') continue;
1534
+
1535
+ const rowPath = targetNamePath.slice(0, i + 1);
1536
+ const targetKey = this.getRowTargetKey(baseCtx, rowPath);
1537
+ const currentRow = this.options.getFormValueAtPath(rowPath);
1538
+ const currentIdentity = getSubTableRowIdentity(currentRow, targetKey);
1539
+ const itemIdentity = getSubTableRowIdentity(item.value, targetKey);
1540
+ return !!currentIdentity && !!itemIdentity && currentIdentity !== itemIdentity;
1541
+ }
1542
+
1543
+ return false;
1502
1544
  }
1503
1545
 
1504
1546
  private shouldRunRule(
@@ -1509,6 +1551,8 @@ export class RuleEngine {
1509
1551
  ): boolean {
1510
1552
  if (!rule.getEnabled()) return false;
1511
1553
  if (!targetNamePath || !targetKey) return false;
1554
+ if (this.isStaleToManyItemContext(baseCtx)) return false;
1555
+ if (this.isMismatchedToManyItemContext(baseCtx, targetNamePath)) return false;
1512
1556
  if (rule.source === 'default') {
1513
1557
  if (!this.shouldApplyDefaultRuleInCurrentState(baseCtx)) return false;
1514
1558
  if (this.options.findExplicitHit(targetKey)) return false;
@@ -1808,6 +1852,19 @@ export class RuleEngine {
1808
1852
  }
1809
1853
  })();
1810
1854
 
1855
+ // Row/grid rules resolve target paths through fieldIndex (for example `roles.title`
1856
+ // -> `roles[1].title`). When a row is deleted or reordered, the rule must reschedule
1857
+ // even if its value/condition does not reference ctx.item directly.
1858
+ const fieldIndex = baseCtx?.model?.context?.fieldIndex ?? baseCtx?.fieldIndex;
1859
+ const shouldWatchFieldIndex = Array.isArray(fieldIndex) && fieldIndex.some((it) => typeof it === 'string');
1860
+ if (shouldWatchFieldIndex) {
1861
+ const fieldIndexDisposer = reaction(
1862
+ () => this.getFieldIndexSignature(baseCtx),
1863
+ () => this.scheduleRule(rule.id),
1864
+ );
1865
+ state.depDisposers.push(fieldIndexDisposer);
1866
+ }
1867
+
1811
1868
  for (const depKey of deps) {
1812
1869
  if (depKey === 'fv:*') {
1813
1870
  continue;
@@ -1841,14 +1898,9 @@ export class RuleEngine {
1841
1898
  const subPath = sep >= 0 ? rest.slice(sep + 1) : '';
1842
1899
  const depPath = subPath ? (parsePathString(subPath).filter((seg) => typeof seg !== 'object') as NamePath) : [];
1843
1900
 
1844
- // 特殊变量:item 为 RuleEngine 注入的计算属性(不直接存在于 baseCtx 上),其 parentItem/index 链依赖 fieldIndex。
1901
+ // 特殊变量:item 为 RuleEngine 注入的计算属性(不直接存在于 baseCtx 上)。
1902
+ // fieldIndex 的变化已统一在上面监听,这里只补 item 自身取值的依赖。
1845
1903
  if (varName === 'item') {
1846
- const fieldIndexDisposer = reaction(
1847
- () => this.getFieldIndexSignature(baseCtx),
1848
- () => this.scheduleRule(rule.id),
1849
- );
1850
- state.depDisposers.push(fieldIndexDisposer);
1851
-
1852
1904
  if (depPath.length) {
1853
1905
  const trackingFormValues = this.options.createTrackingFormValues({ deps: new Set(), wildcard: false });
1854
1906
  const itemValueDisposer = reaction(