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

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 (64) hide show
  1. package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
  2. package/es/flow/index.d.ts +1 -0
  3. package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
  4. package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
  5. package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
  6. package/es/flow/models/actions/index.d.ts +3 -0
  7. package/es/flow/models/base/GridModel.d.ts +3 -1
  8. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +15 -6
  9. package/es/flow/models/blocks/shared/filterOperators.d.ts +9 -0
  10. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +1 -0
  11. package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
  12. package/es/flow-compat/data.d.ts +9 -2
  13. package/es/flow-compat/index.d.ts +1 -1
  14. package/es/index.d.ts +1 -0
  15. package/es/index.mjs +90 -90
  16. package/lib/index.js +87 -87
  17. package/package.json +5 -5
  18. package/src/BaseApplication.tsx +1 -1
  19. package/src/__tests__/app.test.tsx +23 -6
  20. package/src/__tests__/globalDeps.test.ts +5 -0
  21. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
  22. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
  23. package/src/flow/actions/linkageRules.tsx +8 -1
  24. package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
  25. package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
  26. package/src/flow/actions/titleField.tsx +8 -3
  27. package/src/flow/components/FieldAssignValueInput.tsx +1 -0
  28. package/src/flow/components/filter/LinkageFilterItem.tsx +6 -5
  29. package/src/flow/components/filter/VariableFilterItem.tsx +14 -13
  30. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +33 -0
  31. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -5
  32. package/src/flow/index.ts +1 -0
  33. package/src/flow/internal/utils/__tests__/titleFieldQuickSync.test.ts +1 -0
  34. package/src/flow/internal/utils/titleFieldQuickSync.ts +2 -2
  35. package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
  36. package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
  37. package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
  38. package/src/flow/models/actions/FilterActionModel.tsx +17 -9
  39. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
  40. package/src/flow/models/actions/index.ts +3 -0
  41. package/src/flow/models/base/GridModel.tsx +21 -1
  42. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
  43. package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
  44. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +200 -36
  45. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +270 -1
  46. package/src/flow/models/blocks/filter-form/__tests__/customFieldOperators.test.tsx +23 -0
  47. package/src/flow/models/blocks/filter-form/customFieldOperators.ts +12 -1
  48. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +22 -8
  49. package/src/flow/models/blocks/filter-form/fields/__tests__/FilterFormCustomFieldModel.recordSelect.test.tsx +18 -0
  50. package/src/flow/models/blocks/filter-manager/FilterManager.ts +51 -1
  51. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +75 -0
  52. package/src/flow/models/blocks/form/FormItemModel.tsx +48 -28
  53. package/src/flow/models/blocks/shared/filterOperators.ts +14 -0
  54. package/src/flow/models/blocks/table/TableBlockModel.tsx +19 -3
  55. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
  56. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +21 -5
  57. package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
  58. package/src/flow/models/fields/DividerItemModel.tsx +30 -15
  59. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
  60. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
  61. package/src/flow-compat/data.ts +25 -3
  62. package/src/flow-compat/index.ts +7 -1
  63. package/src/index.ts +1 -0
  64. package/src/utils/globalDeps.ts +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.0-alpha.30",
3
+ "version": "2.1.0-alpha.31",
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.30",
28
- "@nocobase/sdk": "2.1.0-alpha.30",
29
- "@nocobase/shared": "2.1.0-alpha.30",
27
+ "@nocobase/flow-engine": "2.1.0-alpha.31",
28
+ "@nocobase/sdk": "2.1.0-alpha.31",
29
+ "@nocobase/shared": "2.1.0-alpha.31",
30
30
  "ahooks": "^3.7.2",
31
31
  "antd": "5.24.2",
32
32
  "classnames": "^2.3.1",
@@ -36,5 +36,5 @@
36
36
  "react-i18next": "^11.15.1",
37
37
  "react-router-dom": "^6.30.1"
38
38
  },
39
- "gitHead": "292ae0ad87f195ed201b274902d21ecd96f5ddd0"
39
+ "gitHead": "e2bc6b461a9bfd336043069c3211c9c5b01ebcc3"
40
40
  }
@@ -356,7 +356,7 @@ export abstract class BaseApplication<
356
356
  addRoutes() {
357
357
  this.router.add('not-found', {
358
358
  path: '*',
359
- Component: this.components['AppNotFound'],
359
+ Component: () => null,
360
360
  });
361
361
  }
362
362
 
@@ -72,29 +72,46 @@ describe('app', () => {
72
72
  });
73
73
  });
74
74
 
75
- it('should mount the app and display "Not Found"', async () => {
75
+ it('should mount the base app with blank fallback route', async () => {
76
76
  const app = createMockClient();
77
77
  const element = document.createElement('div');
78
78
  act(() => {
79
79
  app.mount(element);
80
80
  });
81
- await waitFor(() => expect(element.textContent).toContain('Sorry, the page you visited does not exist.'));
81
+ await waitFor(() => expect(element.querySelector('.ant-app')).not.toBeNull());
82
+ expect(element.textContent).toBe('');
82
83
  });
83
84
 
84
- it('should render default "Not Found" view', async () => {
85
+ it('should render blank fallback route by default', async () => {
85
86
  const app = createMockClient();
86
87
  await renderApp(app);
87
- expect(screen.getByText('Sorry, the page you visited does not exist.')).toBeInTheDocument();
88
+ expect(screen.queryByText('Sorry, the page you visited does not exist.')).not.toBeInTheDocument();
88
89
  });
89
90
 
90
- it('should render custom "Not Found" component', async () => {
91
+ it('should not render custom "Not Found" component before builtin routes are added', async () => {
91
92
  class PluginHelloClient extends Plugin {}
92
93
  const app = createMockClient({
93
94
  plugins: [PluginHelloClient],
94
95
  components: { AppNotFound: () => <div>Not Found2</div> },
95
96
  });
96
97
  await renderApp(app);
97
- expect(screen.getByText('Not Found2')).toBeInTheDocument();
98
+ expect(screen.queryByText('Not Found2')).not.toBeInTheDocument();
99
+ });
100
+
101
+ it('should render builtin "Not Found" view after builtin routes are added', async () => {
102
+ const app = createMockClient({
103
+ plugins: [NocoBaseBuildInPlugin as any],
104
+ router: { type: 'memory', initialEntries: ['/missing'] },
105
+ });
106
+ app.apiMock.onGet('app:getLang').reply(200, {
107
+ data: {
108
+ lang: 'en-US',
109
+ resources: { client: {} },
110
+ cron: {},
111
+ },
112
+ });
113
+ await renderApp(app);
114
+ expect(screen.getByText('Sorry, the page you visited does not exist.')).toBeInTheDocument();
98
115
  });
99
116
 
100
117
  it('should support app provider functionality', async () => {
@@ -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();
@@ -24,5 +26,8 @@ describe('client-v2 defineGlobalDeps', () => {
24
26
  expect(define).toHaveBeenCalledWith('@nocobase/client-v2', expect.any(Function));
25
27
  expect(define).toHaveBeenCalledWith('@nocobase/flow-engine', expect.any(Function));
26
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));
27
32
  });
28
33
  });
@@ -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
+ });
@@ -161,4 +161,46 @@ describe('linkageRulesRefresh action', () => {
161
161
  expect(ctx.resolveJsonTemplate).toHaveBeenCalled();
162
162
  expect(handler).toHaveBeenCalledWith(ctx, { value: ['x'] });
163
163
  });
164
+
165
+ it('passes raw params to useRawParams linkage actions', async () => {
166
+ const handler = vi.fn(async () => {});
167
+ const rawParams = {
168
+ value: [
169
+ {
170
+ key: 'r1',
171
+ enable: true,
172
+ condition: { logic: '$and', items: [] },
173
+ actions: [
174
+ {
175
+ name: 'linkageRunjs',
176
+ params: {
177
+ value: {
178
+ script: 'return ctx.formValues.amount',
179
+ },
180
+ },
181
+ },
182
+ ],
183
+ },
184
+ ],
185
+ };
186
+ const model: any = {
187
+ isFork: false,
188
+ forks: new Set(),
189
+ getFlow: vi.fn(() => ({})),
190
+ getStepParams: vi.fn(() => rawParams),
191
+ };
192
+ const ctx: any = {
193
+ model,
194
+ resolveJsonTemplate: vi.fn(async () => ({ value: ['resolved'] })),
195
+ getAction: vi.fn(() => ({ useRawParams: true, handler })),
196
+ };
197
+
198
+ await linkageRulesRefresh.handler(ctx, {
199
+ actionName: 'actionLinkageRules',
200
+ flowKey: 'buttonSettings',
201
+ });
202
+
203
+ expect(ctx.resolveJsonTemplate).not.toHaveBeenCalled();
204
+ expect(handler).toHaveBeenCalledWith(ctx, rawParams);
205
+ });
164
206
  });
@@ -51,7 +51,12 @@ import {
51
51
  getCollectionFromModel,
52
52
  isToManyAssociationField,
53
53
  } from '../internal/utils/modelUtils';
54
- import { namePathToPathKey, parsePathString, resolveDynamicNamePath } from '../models/blocks/form/value-runtime/path';
54
+ import {
55
+ namePathToPathKey,
56
+ parsePathString,
57
+ resolveDynamicNamePath,
58
+ } from '../models/blocks/form/value-runtime/path';
59
+ import { ensureFormValueDrivenLinkageRefresh } from './linkageRulesFormValueRefresh';
55
60
 
56
61
  interface LinkageRule {
57
62
  /** 随机生成的字符串 */
@@ -2081,6 +2086,7 @@ export const blockLinkageRules = defineAction({
2081
2086
  },
2082
2087
  useRawParams: true,
2083
2088
  handler: async (ctx, params) => {
2089
+ ensureFormValueDrivenLinkageRefresh(ctx, params, 'blockLinkageRules');
2084
2090
  const resolved = await resolveLinkageRulesParamsPreservingRunJsScripts(ctx, params);
2085
2091
  return commonLinkageRulesHandler(ctx, resolved);
2086
2092
  },
@@ -2107,6 +2113,7 @@ export const actionLinkageRules = defineAction({
2107
2113
  },
2108
2114
  useRawParams: true,
2109
2115
  handler: async (ctx, params) => {
2116
+ ensureFormValueDrivenLinkageRefresh(ctx, params, 'actionLinkageRules');
2110
2117
  const resolved = await resolveLinkageRulesParamsPreservingRunJsScripts(ctx, params);
2111
2118
  return commonLinkageRulesHandler(ctx, resolved);
2112
2119
  },