@nocobase/client-v2 2.1.0-beta.24 → 2.1.0-beta.26

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 (57) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/flow/actions/dataScopeFilter.d.ts +9 -0
  3. package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
  4. package/es/flow/index.d.ts +1 -0
  5. package/es/flow/internal/utils/rebuildFieldSubModel.d.ts +2 -1
  6. package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
  7. package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
  8. package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
  9. package/es/flow/models/actions/index.d.ts +3 -0
  10. package/es/flow/models/base/GridModel.d.ts +3 -1
  11. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +1 -0
  12. package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
  13. package/es/flow/models/fields/JSFieldModel.d.ts +5 -0
  14. package/es/index.d.ts +1 -0
  15. package/es/index.mjs +101 -101
  16. package/lib/index.js +99 -99
  17. package/package.json +6 -5
  18. package/src/BaseApplication.tsx +4 -0
  19. package/src/__tests__/globalDeps.test.ts +6 -0
  20. package/src/__tests__/remotePlugins.test.ts +27 -0
  21. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +158 -0
  22. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
  23. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
  24. package/src/flow/actions/dataScope.tsx +6 -4
  25. package/src/flow/actions/dataScopeFilter.ts +70 -0
  26. package/src/flow/actions/linkageRules.tsx +8 -1
  27. package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
  28. package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
  29. package/src/flow/actions/setTargetDataScope.tsx +6 -5
  30. package/src/flow/index.ts +1 -0
  31. package/src/flow/internal/utils/__tests__/rebuildFieldSubModel.test.ts +77 -2
  32. package/src/flow/internal/utils/rebuildFieldSubModel.ts +21 -5
  33. package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
  34. package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
  35. package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
  36. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
  37. package/src/flow/models/actions/index.ts +3 -0
  38. package/src/flow/models/base/GridModel.tsx +21 -1
  39. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
  40. package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
  41. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +9 -5
  42. package/src/flow/models/blocks/filter-form/__tests__/FilterFormBlockModel.cleanup.test.ts +138 -0
  43. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +22 -0
  44. package/src/flow/models/blocks/table/JSColumnModel.tsx +30 -2
  45. package/src/flow/models/blocks/table/TableBlockModel.tsx +8 -1
  46. package/src/flow/models/blocks/table/TableColumnModel.tsx +1 -0
  47. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +51 -0
  48. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +49 -0
  49. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
  50. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +21 -5
  51. package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
  52. package/src/flow/models/fields/JSFieldModel.tsx +54 -14
  53. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
  54. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
  55. package/src/index.ts +1 -0
  56. package/src/utils/globalDeps.ts +10 -0
  57. package/src/utils/requirejs.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.0-beta.24",
3
+ "version": "2.1.0-beta.26",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -24,9 +24,10 @@
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-beta.24",
28
- "@nocobase/sdk": "2.1.0-beta.24",
29
- "@nocobase/shared": "2.1.0-beta.24",
27
+ "@nocobase/flow-engine": "2.1.0-beta.26",
28
+ "@nocobase/sdk": "2.1.0-beta.26",
29
+ "@nocobase/shared": "2.1.0-beta.26",
30
+ "ahooks": "^3.7.2",
30
31
  "antd": "5.24.2",
31
32
  "classnames": "^2.3.1",
32
33
  "dayjs": "^1.11.10",
@@ -35,5 +36,5 @@
35
36
  "react-i18next": "^11.15.1",
36
37
  "react-router-dom": "^6.30.1"
37
38
  },
38
- "gitHead": "f77b85530a2d127d9bfe4dca3a26fbb02c1139ba"
39
+ "gitHead": "b17e1a72057813fa27d8435bf0f2af67ea4b059f"
39
40
  }
@@ -189,6 +189,7 @@ export abstract class BaseApplication<
189
189
  this.configureContext();
190
190
  this.addBaseProviders();
191
191
  this.addCustomProviders();
192
+ this.addFinalProviders();
192
193
  this.addReactRouterComponents();
193
194
  this.addProviders(options.providers || []);
194
195
  this.ws = this.createWebSocketClient(options);
@@ -338,6 +339,9 @@ export abstract class BaseApplication<
338
339
  this.use(FlowEngineProvider, { engine: this.flowEngine });
339
340
  this.use(GlobalThemeProvider);
340
341
  this.use(AntdAppProvider);
342
+ }
343
+
344
+ protected addFinalProviders() {
341
345
  this.use(FlowEngineGlobalsContextProvider);
342
346
  }
343
347
 
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { defineGlobalDeps } from '../utils/globalDeps';
11
11
 
12
+ vi.mock('../index', () => ({}));
13
+
12
14
  describe('client-v2 defineGlobalDeps', () => {
13
15
  it('should register shared AMD dependencies for remote plugins', () => {
14
16
  const define = vi.fn();
@@ -23,5 +25,9 @@ describe('client-v2 defineGlobalDeps', () => {
23
25
  expect(define).toHaveBeenCalledWith('@nocobase/utils/client', expect.any(Function));
24
26
  expect(define).toHaveBeenCalledWith('@nocobase/client-v2', expect.any(Function));
25
27
  expect(define).toHaveBeenCalledWith('@nocobase/flow-engine', expect.any(Function));
28
+ expect(define).toHaveBeenCalledWith('ahooks', expect.any(Function));
29
+ expect(define).toHaveBeenCalledWith('dayjs', expect.any(Function));
30
+ expect(define).toHaveBeenCalledWith('lodash', expect.any(Function));
31
+ expect(define).toHaveBeenCalledWith('@emotion/css', expect.any(Function));
26
32
  });
27
33
  });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { Plugin } from '../Plugin';
11
+ import { getRequireJs } from '../utils/requirejs';
11
12
  import { defineDevPlugins, definePluginClient, getPlugins } from '../utils/remotePlugins';
12
13
 
13
14
  describe('client-v2 remotePlugins', () => {
@@ -69,4 +70,30 @@ describe('client-v2 remotePlugins', () => {
69
70
  expect(mockDefine).toHaveBeenCalledWith('@nocobase/demo/client-v2', expect.any(Function));
70
71
  expect(mockDefine).not.toHaveBeenCalledWith('@nocobase/demo/client', expect.any(Function));
71
72
  });
73
+
74
+ it('should not append duplicate .js for plugin URLs without query strings', () => {
75
+ const requirejs = getRequireJs();
76
+
77
+ requirejs.requirejs.config({
78
+ paths: {
79
+ '@nocobase/demo': '/static/plugins/@nocobase/demo/dist/client-v2/index.js',
80
+ },
81
+ });
82
+
83
+ expect(requirejs.requirejs.toUrl('@nocobase/demo')).toBe('/static/plugins/@nocobase/demo/dist/client-v2/index.js');
84
+ });
85
+
86
+ it('should keep hashed plugin URLs unchanged', () => {
87
+ const requirejs = getRequireJs();
88
+
89
+ requirejs.requirejs.config({
90
+ paths: {
91
+ '@nocobase/demo': '/static/plugins/@nocobase/demo/dist/client-v2/index.js?hash=12345678',
92
+ },
93
+ });
94
+
95
+ expect(requirejs.requirejs.toUrl('@nocobase/demo')).toBe(
96
+ '/static/plugins/@nocobase/demo/dist/client-v2/index.js?hash=12345678',
97
+ );
98
+ });
72
99
  });
@@ -0,0 +1,158 @@
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 { dataScope } from '../dataScope';
12
+ import { normalizeDataScopeFilter } from '../dataScopeFilter';
13
+ import { setTargetDataScope } from '../setTargetDataScope';
14
+
15
+ describe('normalizeDataScopeFilter', () => {
16
+ it('keeps null when a right-side variable resolves to empty', () => {
17
+ const rawFilter = {
18
+ logic: '$and',
19
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
20
+ };
21
+ const resolvedFilter = {
22
+ logic: '$and',
23
+ items: [{ path: 'departmentId', operator: '$eq', value: undefined }],
24
+ };
25
+
26
+ expect(normalizeDataScopeFilter(rawFilter, resolvedFilter)).toEqual({
27
+ $and: [{ departmentId: { $eq: null } }],
28
+ });
29
+ });
30
+
31
+ it('keeps null when a right-side variable resolves to an empty string', () => {
32
+ const rawFilter = {
33
+ logic: '$and',
34
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
35
+ };
36
+ const resolvedFilter = {
37
+ logic: '$and',
38
+ items: [{ path: 'departmentId', operator: '$eq', value: '' }],
39
+ };
40
+
41
+ expect(normalizeDataScopeFilter(rawFilter, resolvedFilter)).toEqual({
42
+ $and: [{ departmentId: { $eq: null } }],
43
+ });
44
+ });
45
+
46
+ it('still prunes empty constant values', () => {
47
+ const filter = {
48
+ logic: '$and',
49
+ items: [{ path: 'departmentId', operator: '$eq', value: undefined }],
50
+ };
51
+
52
+ expect(normalizeDataScopeFilter(filter, filter)).toBeUndefined();
53
+ });
54
+
55
+ it('handles nested groups and keeps non-empty values unchanged', () => {
56
+ const rawFilter = {
57
+ logic: '$and',
58
+ items: [
59
+ { path: 'status', operator: '$eq', value: 'active' },
60
+ {
61
+ logic: '$or',
62
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
63
+ },
64
+ ],
65
+ };
66
+ const resolvedFilter = {
67
+ logic: '$and',
68
+ items: [
69
+ { path: 'status', operator: '$eq', value: 'active' },
70
+ {
71
+ logic: '$or',
72
+ items: [{ path: 'departmentId', operator: '$eq', value: null }],
73
+ },
74
+ ],
75
+ };
76
+
77
+ expect(normalizeDataScopeFilter(rawFilter, resolvedFilter)).toEqual({
78
+ $and: [{ status: { $eq: 'active' } }, { $or: [{ departmentId: { $eq: null } }] }],
79
+ });
80
+ });
81
+
82
+ it('does not preserve explicit null constants', () => {
83
+ const filter = {
84
+ logic: '$and',
85
+ items: [{ path: 'departmentId', operator: '$eq', value: null }],
86
+ };
87
+
88
+ expect(normalizeDataScopeFilter(filter, filter)).toBeUndefined();
89
+ });
90
+
91
+ it('dataScope handler sends null for empty variable dependencies', async () => {
92
+ const resource = {
93
+ addFilterGroup: vi.fn(),
94
+ removeFilterGroup: vi.fn(),
95
+ };
96
+ const ctx = {
97
+ model: {
98
+ uid: 'field-1',
99
+ resource,
100
+ },
101
+ resolveJsonTemplate: vi.fn(async (template) => ({
102
+ ...template,
103
+ items: [{ ...template.items[0], value: undefined }],
104
+ })),
105
+ };
106
+ const params = {
107
+ filter: {
108
+ logic: '$and',
109
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
110
+ },
111
+ };
112
+
113
+ await (dataScope as any).handler(ctx, params);
114
+
115
+ expect(resource.addFilterGroup).toHaveBeenCalledWith('field-1', {
116
+ $and: [{ departmentId: { $eq: null } }],
117
+ });
118
+ expect(resource.removeFilterGroup).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('setTargetDataScope handler sends null for empty variable dependencies', async () => {
122
+ const resource = {
123
+ addFilterGroup: vi.fn(),
124
+ removeFilterGroup: vi.fn(),
125
+ hasData: vi.fn(() => false),
126
+ refresh: vi.fn(),
127
+ };
128
+ const targetModel = { resource };
129
+ const ctx = {
130
+ model: {
131
+ uid: 'action-1',
132
+ scheduleModelOperation: vi.fn((_uid, callback) => callback(targetModel)),
133
+ },
134
+ resolveJsonTemplate: vi.fn(async (template) => ({
135
+ ...template,
136
+ filter: {
137
+ ...template.filter,
138
+ items: [{ ...template.filter.items[0], value: undefined }],
139
+ },
140
+ })),
141
+ };
142
+ const params = {
143
+ targetBlockUid: 'target-1',
144
+ filter: {
145
+ logic: '$and',
146
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
147
+ },
148
+ };
149
+
150
+ await (setTargetDataScope as any).handler(ctx, params);
151
+
152
+ expect(ctx.model.scheduleModelOperation).toHaveBeenCalledWith('target-1', expect.any(Function));
153
+ expect(resource.addFilterGroup).toHaveBeenCalledWith('setTargetDataScope_action-1', {
154
+ $and: [{ departmentId: { $eq: null } }],
155
+ });
156
+ expect(resource.removeFilterGroup).not.toHaveBeenCalled();
157
+ });
158
+ });
@@ -0,0 +1,438 @@
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 { FlowContext, FlowRuntimeContext } from '@nocobase/flow-engine';
11
+ import { waitFor } from '@testing-library/react';
12
+ import { EventEmitter } from 'events';
13
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import { actionLinkageRules, blockLinkageRules } from '../linkageRules';
15
+
16
+ function createRule(overrides: any = {}) {
17
+ return {
18
+ key: 'r1',
19
+ title: 'r1',
20
+ enable: true,
21
+ condition: { logic: '$and', items: [] },
22
+ actions: [],
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function createRuntime(
28
+ params: any,
29
+ options: { fieldIndex?: string[]; fieldIndexRef?: { current: string[] }; actionHandler?: any; engineEmitter?: any } = {},
30
+ ) {
31
+ const formEmitter = new EventEmitter();
32
+ const formBlock: any = {
33
+ uid: 'form-block',
34
+ emitter: formEmitter,
35
+ formValueRuntime: {},
36
+ };
37
+ const actionHandler = options.actionHandler || vi.fn(async () => {});
38
+ const linkageRunjsHandler = vi.fn(async () => {});
39
+ const modelContext: any = new FlowContext();
40
+ modelContext.defineProperty('blockModel', { value: formBlock });
41
+ modelContext.defineProperty('app', {
42
+ value: {
43
+ jsonLogic: {
44
+ apply: () => true,
45
+ },
46
+ },
47
+ });
48
+ modelContext.defineProperty('fieldIndex', {
49
+ get: () => options.fieldIndexRef?.current || options.fieldIndex || [],
50
+ cache: false,
51
+ });
52
+ modelContext.defineMethod('resolveJsonTemplate', async (_template: any) => _template);
53
+ modelContext.defineMethod('getAction', (name: string) => {
54
+ if (name === 'actionLinkageRules') {
55
+ return { useRawParams: true, handler: actionHandler };
56
+ }
57
+ if (name === 'blockLinkageRules') {
58
+ return { useRawParams: true, handler: actionHandler };
59
+ }
60
+ if (name === 'linkageRunjs') {
61
+ return { handler: linkageRunjsHandler };
62
+ }
63
+ return undefined;
64
+ });
65
+ modelContext.defineMethod('getActions', () => new Map());
66
+ modelContext.defineMethod('t', (s: string) => s);
67
+
68
+ const model: any = {
69
+ uid: 'action-model',
70
+ context: modelContext,
71
+ flowEngine: options.engineEmitter ? { emitter: options.engineEmitter } : undefined,
72
+ isFork: false,
73
+ forks: new Set(),
74
+ getFlow: vi.fn(() => ({})),
75
+ getStepParams: vi.fn(() => params),
76
+ getAction: (name: string) => modelContext.getAction(name),
77
+ getActions: () => new Map(),
78
+ translate: (s: string) => s,
79
+ };
80
+ const ctx: any = new FlowRuntimeContext(model, 'buttonSettings');
81
+ ctx.defineMethod('resolveJsonTemplate', async (_template: any) => _template);
82
+
83
+ return {
84
+ ctx,
85
+ model,
86
+ formEmitter,
87
+ actionHandler,
88
+ linkageRunjsHandler,
89
+ };
90
+ }
91
+
92
+ describe('linkageRules: form value driven refresh', () => {
93
+ beforeEach(() => {
94
+ vi.useRealTimers();
95
+ });
96
+
97
+ it('refreshes action linkage rules when a ctx.formValues dependency changes', async () => {
98
+ const params = {
99
+ value: [
100
+ createRule({
101
+ condition: {
102
+ logic: '$and',
103
+ items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
104
+ },
105
+ }),
106
+ ],
107
+ };
108
+ const { ctx, formEmitter, actionHandler } = createRuntime(params);
109
+
110
+ await actionLinkageRules.handler(ctx, params);
111
+ formEmitter.emit('formValuesChange', {
112
+ source: 'user',
113
+ txId: 'tx-1',
114
+ changedPaths: [['other']],
115
+ });
116
+ await new Promise((resolve) => setTimeout(resolve, 0));
117
+ expect(actionHandler).not.toHaveBeenCalled();
118
+
119
+ formEmitter.emit('formValuesChange', {
120
+ source: 'user',
121
+ txId: 'tx-2',
122
+ changedPaths: [['status']],
123
+ });
124
+
125
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
126
+ expect(actionHandler.mock.calls[0][0].inputArgs).toMatchObject({
127
+ source: 'user',
128
+ txId: 'tx-2',
129
+ linkageTxId: 'tx-2',
130
+ changedPaths: [['status']],
131
+ });
132
+ });
133
+
134
+ it('refreshes block linkage rules when a ctx.formValues dependency changes', async () => {
135
+ const params = {
136
+ value: [
137
+ createRule({
138
+ condition: {
139
+ logic: '$and',
140
+ items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
141
+ },
142
+ }),
143
+ ],
144
+ };
145
+ const { ctx, formEmitter, actionHandler } = createRuntime(params);
146
+
147
+ await blockLinkageRules.handler(ctx, params);
148
+ formEmitter.emit('formValuesChange', {
149
+ source: 'user',
150
+ txId: 'tx-1',
151
+ changedPaths: [['status']],
152
+ });
153
+
154
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
155
+ });
156
+
157
+ it('dedupes subscriptions for repeated handler runs', async () => {
158
+ const params = {
159
+ value: [
160
+ createRule({
161
+ condition: {
162
+ logic: '$and',
163
+ items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
164
+ },
165
+ }),
166
+ ],
167
+ };
168
+ const { ctx, formEmitter, actionHandler } = createRuntime(params);
169
+
170
+ await actionLinkageRules.handler(ctx, params);
171
+ await actionLinkageRules.handler(ctx, params);
172
+
173
+ expect(formEmitter.listenerCount('formValuesChange')).toBe(1);
174
+
175
+ formEmitter.emit('formValuesChange', {
176
+ source: 'user',
177
+ txId: 'tx-1',
178
+ changedPaths: [['status']],
179
+ });
180
+
181
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
182
+ });
183
+
184
+ it('keeps action refresh subscription after the action is hidden and unmounted', async () => {
185
+ const engineEmitter = new EventEmitter();
186
+ const params = {
187
+ value: [
188
+ createRule({
189
+ condition: {
190
+ logic: '$and',
191
+ items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
192
+ },
193
+ }),
194
+ ],
195
+ };
196
+ const { ctx, model, formEmitter, actionHandler } = createRuntime(params, { engineEmitter });
197
+
198
+ await actionLinkageRules.handler(ctx, params);
199
+ expect(formEmitter.listenerCount('formValuesChange')).toBe(1);
200
+
201
+ engineEmitter.emit('model:unmounted', { model });
202
+ expect(formEmitter.listenerCount('formValuesChange')).toBe(1);
203
+
204
+ formEmitter.emit('formValuesChange', {
205
+ source: 'user',
206
+ txId: 'tx-1',
207
+ changedPaths: [['status']],
208
+ });
209
+
210
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
211
+ });
212
+
213
+ it('maps ctx.item.value dependencies to the current row path', async () => {
214
+ const params = {
215
+ value: [
216
+ createRule({
217
+ condition: {
218
+ logic: '$and',
219
+ items: [{ path: '{{ ctx.item.value.nickname }}', operator: '$eq', value: 'A' }],
220
+ },
221
+ }),
222
+ ],
223
+ };
224
+ const { ctx, formEmitter, actionHandler } = createRuntime(params, { fieldIndex: ['users:1'] });
225
+
226
+ await actionLinkageRules.handler(ctx, params);
227
+ formEmitter.emit('formValuesChange', {
228
+ source: 'user',
229
+ txId: 'tx-1',
230
+ changedPaths: [['users', 0, 'nickname']],
231
+ });
232
+ await new Promise((resolve) => setTimeout(resolve, 0));
233
+ expect(actionHandler).not.toHaveBeenCalled();
234
+
235
+ formEmitter.emit('formValuesChange', {
236
+ source: 'user',
237
+ txId: 'tx-2',
238
+ changedPaths: [['users', 1, 'nickname']],
239
+ });
240
+
241
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
242
+ });
243
+
244
+ it('parses object-patch changedPaths before matching ctx.item.value dependencies', async () => {
245
+ const params = {
246
+ value: [
247
+ createRule({
248
+ condition: {
249
+ logic: '$and',
250
+ items: [{ path: '{{ ctx.item.value.nickname }}', operator: '$eq', value: 'A' }],
251
+ },
252
+ }),
253
+ ],
254
+ };
255
+ const { ctx, formEmitter, actionHandler } = createRuntime(params, { fieldIndex: ['users:1'] });
256
+
257
+ await actionLinkageRules.handler(ctx, params);
258
+ formEmitter.emit('formValuesChange', {
259
+ source: 'user',
260
+ txId: 'tx-1',
261
+ changedPaths: [['users[1].nickname']],
262
+ });
263
+
264
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
265
+ });
266
+
267
+ it('recomputes ctx.item.value dependencies from the latest fieldIndex', async () => {
268
+ const fieldIndexRef = { current: ['users:1'] };
269
+ const params = {
270
+ value: [
271
+ createRule({
272
+ condition: {
273
+ logic: '$and',
274
+ items: [{ path: '{{ ctx.item.value.nickname }}', operator: '$eq', value: 'A' }],
275
+ },
276
+ }),
277
+ ],
278
+ };
279
+ const { ctx, formEmitter, actionHandler } = createRuntime(params, { fieldIndexRef });
280
+
281
+ await actionLinkageRules.handler(ctx, params);
282
+ fieldIndexRef.current = ['users:0'];
283
+ formEmitter.emit('formValuesChange', {
284
+ source: 'user',
285
+ txId: 'tx-1',
286
+ changedPaths: [['users', 0, 'nickname']],
287
+ });
288
+
289
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
290
+ });
291
+
292
+ it('maps ctx.item.index dependencies to the current list path', async () => {
293
+ const params = {
294
+ value: [
295
+ createRule({
296
+ condition: {
297
+ logic: '$and',
298
+ items: [{ path: '{{ ctx.item.index }}', operator: '$eq', value: 1 }],
299
+ },
300
+ }),
301
+ ],
302
+ };
303
+ const { ctx, formEmitter, actionHandler } = createRuntime(params, { fieldIndex: ['users:1'] });
304
+
305
+ await actionLinkageRules.handler(ctx, params);
306
+ formEmitter.emit('formValuesChange', {
307
+ source: 'user',
308
+ txId: 'tx-1',
309
+ changedPaths: [['users', 1, 'nickname']],
310
+ });
311
+ await new Promise((resolve) => setTimeout(resolve, 0));
312
+ expect(actionHandler).not.toHaveBeenCalled();
313
+
314
+ formEmitter.emit('formValuesChange', {
315
+ source: 'user',
316
+ txId: 'tx-2',
317
+ changedPaths: [['users']],
318
+ });
319
+
320
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
321
+ });
322
+
323
+ it('collects ctx.formValues dependencies from linkageRunjs scripts without resolving the script early', async () => {
324
+ const params = {
325
+ value: [
326
+ createRule({
327
+ actions: [
328
+ {
329
+ key: 'a1',
330
+ name: 'linkageRunjs',
331
+ params: {
332
+ value: {
333
+ script: 'return ctx.formValues.amount',
334
+ },
335
+ },
336
+ },
337
+ ],
338
+ }),
339
+ ],
340
+ };
341
+ const { ctx, formEmitter, actionHandler, linkageRunjsHandler } = createRuntime(params);
342
+
343
+ await actionLinkageRules.handler(ctx, params);
344
+ expect(linkageRunjsHandler).toHaveBeenCalledTimes(1);
345
+
346
+ formEmitter.emit('formValuesChange', {
347
+ source: 'user',
348
+ txId: 'tx-1',
349
+ changedPaths: [['amount']],
350
+ });
351
+
352
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
353
+ });
354
+
355
+ it('reruns once with the latest relevant change after a refresh is already running', async () => {
356
+ let resolveFirstRefresh: () => void = () => undefined;
357
+ const firstRefresh = new Promise<void>((resolve) => {
358
+ resolveFirstRefresh = resolve;
359
+ });
360
+ const actionHandler = vi
361
+ .fn()
362
+ .mockImplementationOnce(() => firstRefresh)
363
+ .mockImplementation(async () => undefined);
364
+ const params = {
365
+ value: [
366
+ createRule({
367
+ condition: {
368
+ logic: '$and',
369
+ items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
370
+ },
371
+ }),
372
+ ],
373
+ };
374
+ const { ctx, formEmitter } = createRuntime(params, { actionHandler });
375
+
376
+ await actionLinkageRules.handler(ctx, params);
377
+ formEmitter.emit('formValuesChange', {
378
+ source: 'user',
379
+ txId: 'tx-1',
380
+ changedPaths: [['status']],
381
+ });
382
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
383
+
384
+ formEmitter.emit('formValuesChange', {
385
+ source: 'user',
386
+ txId: 'tx-2',
387
+ changedPaths: [['status']],
388
+ });
389
+ await new Promise((resolve) => setTimeout(resolve, 0));
390
+ expect(actionHandler).toHaveBeenCalledTimes(1);
391
+
392
+ resolveFirstRefresh();
393
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(2));
394
+ });
395
+
396
+ it('does not recursively refresh while handling its own linkage write event', async () => {
397
+ const params = {
398
+ value: [
399
+ createRule({
400
+ condition: {
401
+ logic: '$and',
402
+ items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
403
+ },
404
+ }),
405
+ ],
406
+ };
407
+ const formEmitter = new EventEmitter();
408
+ const actionHandler = vi.fn(async () => {
409
+ formEmitter.emit('formValuesChange', {
410
+ source: 'linkage',
411
+ txId: 'tx-linkage',
412
+ linkageTxId: 'tx-1',
413
+ changedPaths: [['status']],
414
+ });
415
+ });
416
+ const runtime = createRuntime(params, { actionHandler });
417
+ runtime.formEmitter.removeAllListeners();
418
+ formEmitter.on('formValuesChange', (...args) => runtime.formEmitter.emit('formValuesChange', ...args));
419
+
420
+ await actionLinkageRules.handler(runtime.ctx, params);
421
+ formEmitter.emit('formValuesChange', {
422
+ source: 'user',
423
+ txId: 'tx-1',
424
+ changedPaths: [['status']],
425
+ });
426
+
427
+ await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
428
+
429
+ runtime.formEmitter.emit('formValuesChange', {
430
+ source: 'linkage',
431
+ txId: 'tx-linkage-late',
432
+ linkageTxId: 'tx-1',
433
+ changedPaths: [['status']],
434
+ });
435
+ await new Promise((resolve) => setTimeout(resolve, 0));
436
+ expect(actionHandler).toHaveBeenCalledTimes(1);
437
+ });
438
+ });