@nocobase/plugin-ui-templates 2.0.0-alpha.57

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 (106) hide show
  1. package/LICENSE.txt +172 -0
  2. package/build.config.ts +12 -0
  3. package/client.js +1 -0
  4. package/dist/client/collections/flowModelTemplates.d.ts +67 -0
  5. package/dist/client/components/FlowModelTemplatesPage.d.ts +12 -0
  6. package/dist/client/components/TemplateSelectOption.d.ts +20 -0
  7. package/dist/client/constants.d.ts +9 -0
  8. package/dist/client/hooks/useFlowModelTemplateActions.d.ts +24 -0
  9. package/dist/client/index.d.ts +13 -0
  10. package/dist/client/index.js +10 -0
  11. package/dist/client/locale.d.ts +18 -0
  12. package/dist/client/menuExtensions.d.ts +9 -0
  13. package/dist/client/models/ReferenceBlockModel.d.ts +47 -0
  14. package/dist/client/models/ReferenceFormGridModel.d.ts +38 -0
  15. package/dist/client/models/SubModelTemplateImporterModel.d.ts +55 -0
  16. package/dist/client/models/referenceShared.d.ts +23 -0
  17. package/dist/client/openViewActionExtensions.d.ts +10 -0
  18. package/dist/client/schemas/flowModelTemplates.d.ts +11 -0
  19. package/dist/client/subModelMenuExtensions.d.ts +10 -0
  20. package/dist/client/utils/infiniteSelect.d.ts +28 -0
  21. package/dist/client/utils/refHost.d.ts +20 -0
  22. package/dist/client/utils/templateCompatibility.d.ts +91 -0
  23. package/dist/client.d.ts +9 -0
  24. package/dist/client.js +42 -0
  25. package/dist/externalVersion.js +24 -0
  26. package/dist/index.d.ts +10 -0
  27. package/dist/index.js +48 -0
  28. package/dist/locale/de-DE.json +14 -0
  29. package/dist/locale/en-US.json +72 -0
  30. package/dist/locale/es-ES.json +14 -0
  31. package/dist/locale/fr-FR.json +14 -0
  32. package/dist/locale/hu-HU.json +14 -0
  33. package/dist/locale/id-ID.json +14 -0
  34. package/dist/locale/it-IT.json +14 -0
  35. package/dist/locale/ja-JP.json +14 -0
  36. package/dist/locale/ko-KR.json +14 -0
  37. package/dist/locale/nl-NL.json +14 -0
  38. package/dist/locale/pt-BR.json +14 -0
  39. package/dist/locale/ru-RU.json +14 -0
  40. package/dist/locale/tr-TR.json +14 -0
  41. package/dist/locale/uk-UA.json +14 -0
  42. package/dist/locale/vi-VN.json +14 -0
  43. package/dist/locale/zh-CN.json +71 -0
  44. package/dist/locale/zh-TW.json +14 -0
  45. package/dist/server/collections/flowModelTemplateUsages.d.ts +11 -0
  46. package/dist/server/collections/flowModelTemplateUsages.js +71 -0
  47. package/dist/server/collections/flowModelTemplates.d.ts +11 -0
  48. package/dist/server/collections/flowModelTemplates.js +96 -0
  49. package/dist/server/index.d.ts +9 -0
  50. package/dist/server/index.js +42 -0
  51. package/dist/server/plugin.d.ts +17 -0
  52. package/dist/server/plugin.js +242 -0
  53. package/dist/server/resources/flowModelTemplateUsages.d.ts +19 -0
  54. package/dist/server/resources/flowModelTemplateUsages.js +91 -0
  55. package/dist/server/resources/flowModelTemplates.d.ts +20 -0
  56. package/dist/server/resources/flowModelTemplates.js +267 -0
  57. package/package.json +37 -0
  58. package/server.js +1 -0
  59. package/src/client/__tests__/openViewActionExtensions.test.ts +1208 -0
  60. package/src/client/collections/flowModelTemplates.ts +131 -0
  61. package/src/client/components/FlowModelTemplatesPage.tsx +78 -0
  62. package/src/client/components/TemplateSelectOption.tsx +106 -0
  63. package/src/client/constants.ts +10 -0
  64. package/src/client/hooks/useFlowModelTemplateActions.tsx +137 -0
  65. package/src/client/index.ts +54 -0
  66. package/src/client/locale.ts +40 -0
  67. package/src/client/menuExtensions.tsx +1033 -0
  68. package/src/client/models/ReferenceBlockModel.tsx +793 -0
  69. package/src/client/models/ReferenceFormGridModel.tsx +302 -0
  70. package/src/client/models/SubModelTemplateImporterModel.tsx +634 -0
  71. package/src/client/models/__tests__/ReferenceBlockModel.test.tsx +482 -0
  72. package/src/client/models/__tests__/ReferenceFormGridModel.test.tsx +175 -0
  73. package/src/client/models/__tests__/SubModelTemplateImporterModel.test.ts +447 -0
  74. package/src/client/models/referenceShared.tsx +99 -0
  75. package/src/client/openViewActionExtensions.tsx +981 -0
  76. package/src/client/schemas/flowModelTemplates.ts +264 -0
  77. package/src/client/subModelMenuExtensions.ts +103 -0
  78. package/src/client/utils/infiniteSelect.ts +150 -0
  79. package/src/client/utils/refHost.ts +44 -0
  80. package/src/client/utils/templateCompatibility.ts +374 -0
  81. package/src/client.ts +10 -0
  82. package/src/index.ts +11 -0
  83. package/src/locale/de-DE.json +14 -0
  84. package/src/locale/en-US.json +72 -0
  85. package/src/locale/es-ES.json +14 -0
  86. package/src/locale/fr-FR.json +14 -0
  87. package/src/locale/hu-HU.json +14 -0
  88. package/src/locale/id-ID.json +14 -0
  89. package/src/locale/it-IT.json +14 -0
  90. package/src/locale/ja-JP.json +14 -0
  91. package/src/locale/ko-KR.json +14 -0
  92. package/src/locale/nl-NL.json +14 -0
  93. package/src/locale/pt-BR.json +14 -0
  94. package/src/locale/ru-RU.json +14 -0
  95. package/src/locale/tr-TR.json +14 -0
  96. package/src/locale/uk-UA.json +14 -0
  97. package/src/locale/vi-VN.json +14 -0
  98. package/src/locale/zh-CN.json +71 -0
  99. package/src/locale/zh-TW.json +14 -0
  100. package/src/server/__tests__/template-usage.test.ts +351 -0
  101. package/src/server/collections/flowModelTemplateUsages.ts +51 -0
  102. package/src/server/collections/flowModelTemplates.ts +76 -0
  103. package/src/server/index.ts +10 -0
  104. package/src/server/plugin.ts +236 -0
  105. package/src/server/resources/flowModelTemplateUsages.ts +61 -0
  106. package/src/server/resources/flowModelTemplates.ts +251 -0
@@ -0,0 +1,1208 @@
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 { FlowContext, FlowEngine } from '@nocobase/flow-engine';
12
+ import type { ActionDefinition } from '@nocobase/flow-engine';
13
+ import { registerOpenViewPopupTemplateAction } from '../openViewActionExtensions';
14
+
15
+ describe('openViewActionExtensions (popup template)', () => {
16
+ it('resolves popupTemplateUid to uid and delegates to base', async () => {
17
+ const engine = new FlowEngine();
18
+ const baseBefore = vi.fn(async () => {});
19
+ const baseHandler = vi.fn(async (_ctx: any, params: any) => params);
20
+
21
+ const baseOpenView: ActionDefinition = {
22
+ name: 'openView',
23
+ title: 'openView',
24
+ uiSchema: {
25
+ mode: { type: 'string' },
26
+ size: { type: 'string' },
27
+ uid: { type: 'string' },
28
+ },
29
+ beforeParamsSave: baseBefore as any,
30
+ handler: baseHandler as any,
31
+ };
32
+ engine.registerActions({ openView: baseOpenView });
33
+
34
+ registerOpenViewPopupTemplateAction(engine);
35
+ const enhanced = engine.getAction('openView') as any;
36
+ expect(typeof enhanced?.beforeParamsSave).toBe('function');
37
+
38
+ const api = {
39
+ resource: (name: string) => {
40
+ if (name !== 'flowModelTemplates') throw new Error('unexpected resource');
41
+ return {
42
+ get: vi.fn(async () => ({ data: { data: { uid: 'tpl-1', targetUid: 'popup-1' } } })),
43
+ };
44
+ },
45
+ };
46
+ const ctx: any = new FlowContext();
47
+ ctx.engine = engine;
48
+ ctx.api = api;
49
+ ctx.t = (k: string) => k;
50
+ const params: any = { popupTemplateUid: 'tpl-1', uid: 'old' };
51
+
52
+ await enhanced.beforeParamsSave(ctx, params, {});
53
+ expect(params.uid).toBe('popup-1');
54
+ expect(baseBefore).toHaveBeenCalledTimes(1);
55
+ expect(baseBefore.mock.calls[0][1]).toEqual({ uid: 'popup-1' });
56
+
57
+ // runtime 依赖“保存时已回填”的 uid,因此这里直接传入已解析的 uid
58
+ const out = await enhanced.handler(ctx, { uid: 'popup-1', popupTemplateUid: 'tpl-1' });
59
+ expect(baseHandler).toHaveBeenCalledTimes(1);
60
+ expect(out?.uid).toEqual('popup-1');
61
+ });
62
+
63
+ it('clears filterByTk/sourceId when template does not define them (avoid leaking record defaults)', async () => {
64
+ const engine = new FlowEngine();
65
+ const baseBefore = vi.fn(async () => {});
66
+ const baseOpenView: ActionDefinition = {
67
+ name: 'openView',
68
+ title: 'openView',
69
+ uiSchema: {
70
+ uid: { type: 'string' },
71
+ },
72
+ beforeParamsSave: baseBefore as any,
73
+ handler: vi.fn(async () => undefined) as any,
74
+ };
75
+ engine.registerActions({ openView: baseOpenView });
76
+
77
+ registerOpenViewPopupTemplateAction(engine);
78
+ const enhanced = engine.getAction('openView') as any;
79
+
80
+ const api = {
81
+ resource: (name: string) => {
82
+ if (name !== 'flowModelTemplates') throw new Error('unexpected resource');
83
+ return {
84
+ get: vi.fn(async () => ({
85
+ data: {
86
+ data: {
87
+ uid: 'tpl-1',
88
+ targetUid: 'popup-1',
89
+ dataSourceKey: 'main',
90
+ collectionName: 'users',
91
+ // template does NOT define filterByTk/sourceId
92
+ },
93
+ },
94
+ })),
95
+ };
96
+ },
97
+ };
98
+ const model: any = {
99
+ getStepParams: vi.fn((flowKey: string, stepKey: string) => {
100
+ if (flowKey === 'resourceSettings' && stepKey === 'init') {
101
+ return { dataSourceKey: 'main', collectionName: 'users' };
102
+ }
103
+ return {};
104
+ }),
105
+ parent: null,
106
+ };
107
+ const ctx: any = { engine, api, model, t: (k: string) => k };
108
+ const params: any = {
109
+ popupTemplateUid: 'tpl-1',
110
+ uid: 'old',
111
+ dataSourceKey: 'main',
112
+ collectionName: 'users',
113
+ filterByTk: '{{ ctx.record.id }}',
114
+ sourceId: '{{ ctx.resource.sourceId }}',
115
+ };
116
+
117
+ await enhanced.beforeParamsSave(ctx, params, {});
118
+ expect(params.uid).toBe('popup-1');
119
+ expect('filterByTk' in params).toBe(false);
120
+ expect('sourceId' in params).toBe(false);
121
+
122
+ expect(baseBefore).toHaveBeenCalledTimes(1);
123
+ const forwarded = baseBefore.mock.calls[0][1] as any;
124
+ expect(forwarded?.popupTemplateUid).toBeUndefined();
125
+ expect(forwarded?.filterByTk).toBeUndefined();
126
+ expect(forwarded?.sourceId).toBeUndefined();
127
+ });
128
+
129
+ it('treats collection-scene popup template as no filterByTk even if template stores ctx.record expr', async () => {
130
+ const engine = new FlowEngine();
131
+ class CollectionOnlyModel {
132
+ static _isScene(scene: string) {
133
+ return scene === 'collection';
134
+ }
135
+ }
136
+ engine.registerModels({ AddNewActionModel: CollectionOnlyModel } as any);
137
+
138
+ const baseBefore = vi.fn(async () => {});
139
+ const baseOpenView: ActionDefinition = {
140
+ name: 'openView',
141
+ title: 'openView',
142
+ uiSchema: {
143
+ uid: { type: 'string' },
144
+ },
145
+ beforeParamsSave: baseBefore as any,
146
+ handler: vi.fn(async () => undefined) as any,
147
+ };
148
+ engine.registerActions({ openView: baseOpenView });
149
+
150
+ registerOpenViewPopupTemplateAction(engine);
151
+ const enhanced = engine.getAction('openView') as any;
152
+
153
+ const api = {
154
+ resource: (name: string) => {
155
+ if (name !== 'flowModelTemplates') throw new Error('unexpected resource');
156
+ return {
157
+ get: vi.fn(async () => ({
158
+ data: {
159
+ data: {
160
+ uid: 'tpl-collection',
161
+ targetUid: 'popup-1',
162
+ useModel: 'AddNewActionModel',
163
+ dataSourceKey: 'main',
164
+ collectionName: 'users',
165
+ filterByTk: '{{ ctx.record.id }}',
166
+ sourceId: '{{ ctx.resource.sourceId }}',
167
+ },
168
+ },
169
+ })),
170
+ };
171
+ },
172
+ };
173
+ const model: any = {
174
+ getStepParams: vi.fn((flowKey: string, stepKey: string) => {
175
+ if (flowKey === 'resourceSettings' && stepKey === 'init') {
176
+ return { dataSourceKey: 'main', collectionName: 'users' };
177
+ }
178
+ return {};
179
+ }),
180
+ parent: null,
181
+ };
182
+ const ctx: any = { engine, api, model, t: (k: string) => k };
183
+ const params: any = {
184
+ popupTemplateUid: 'tpl-collection',
185
+ uid: 'old',
186
+ dataSourceKey: 'main',
187
+ collectionName: 'users',
188
+ filterByTk: '{{ ctx.record.id }}',
189
+ sourceId: '{{ ctx.resource.sourceId }}',
190
+ };
191
+
192
+ await enhanced.beforeParamsSave(ctx, params, {});
193
+ expect(params.uid).toBe('popup-1');
194
+ expect(params.popupTemplateHasFilterByTk).toBe(false);
195
+ expect(params.popupTemplateHasSourceId).toBe(false);
196
+ expect('filterByTk' in params).toBe(false);
197
+ expect('sourceId' in params).toBe(false);
198
+ });
199
+
200
+ it('runtime overrides resource keys via shadow ctx (non-relation template in relation trigger)', async () => {
201
+ const engine = new FlowEngine();
202
+ let capturedCtx: any;
203
+ let capturedParams: any;
204
+ const baseHandler = vi.fn(async (ctxArg: any, paramsArg: any) => {
205
+ capturedCtx = ctxArg;
206
+ capturedParams = paramsArg;
207
+ return undefined;
208
+ });
209
+
210
+ const baseOpenView: ActionDefinition = {
211
+ name: 'openView',
212
+ title: 'openView',
213
+ uiSchema: {
214
+ uid: { type: 'string' },
215
+ },
216
+ handler: baseHandler as any,
217
+ };
218
+ engine.registerActions({ openView: baseOpenView });
219
+
220
+ registerOpenViewPopupTemplateAction(engine);
221
+ const enhanced = engine.getAction('openView') as any;
222
+
223
+ const baseInputArgs: any = {
224
+ dataSourceKey: 'main',
225
+ collectionName: 'users',
226
+ associationName: 'users.roles',
227
+ filterByTk: 'role-1',
228
+ sourceId: 'user-1',
229
+ defaultInputKeys: ['filterByTk', 'sourceId'],
230
+ };
231
+ const ctx: any = new FlowContext();
232
+ ctx.engine = engine;
233
+ ctx.t = (k: string) => k;
234
+ ctx.collectionField = {
235
+ isAssociationField: () => true,
236
+ };
237
+ ctx.defineProperty('inputArgs', { value: baseInputArgs });
238
+
239
+ await enhanced.handler(ctx, {
240
+ popupTemplateUid: 'tpl-1',
241
+ uid: 'popup-1',
242
+ dataSourceKey: 'main',
243
+ collectionName: 'roles',
244
+ // non-relation template: no associationName
245
+ filterByTk: 'tpl-filter',
246
+ });
247
+
248
+ expect(baseHandler).toHaveBeenCalledTimes(1);
249
+ expect(capturedParams?.popupTemplateUid).toBeUndefined();
250
+ expect(capturedParams?.uid).toBe('popup-1');
251
+
252
+ // should not mutate original ctx.inputArgs
253
+ expect(ctx.inputArgs.collectionName).toBe('users');
254
+ expect(ctx.inputArgs.associationName).toBe('users.roles');
255
+ expect(ctx.inputArgs.filterByTk).toBe('role-1');
256
+
257
+ // shadow ctx should override resource keys (keep runtime record context from inputArgs)
258
+ expect(capturedCtx).not.toBe(ctx);
259
+ expect(capturedCtx?.inputArgs?.dataSourceKey).toBe('main');
260
+ expect(capturedCtx?.inputArgs?.collectionName).toBe('roles');
261
+ expect('associationName' in (capturedCtx?.inputArgs || {})).toBe(false);
262
+ expect(capturedCtx?.inputArgs?.filterByTk).toBe('role-1');
263
+ expect(capturedCtx?.inputArgs?.sourceId).toBe(null);
264
+ expect(capturedCtx?.inputArgs?.defaultInputKeys || []).toContain('filterByTk');
265
+ expect(capturedCtx?.inputArgs?.defaultInputKeys || []).not.toContain('sourceId');
266
+ });
267
+
268
+ it('infers target filterByTk from belongsTo record when reusing target collection template in relation field', async () => {
269
+ const engine = new FlowEngine();
270
+ let capturedCtx: any;
271
+ const baseHandler = vi.fn(async (ctxArg: any) => {
272
+ capturedCtx = ctxArg;
273
+ return undefined;
274
+ });
275
+
276
+ const baseOpenView: ActionDefinition = {
277
+ name: 'openView',
278
+ title: 'openView',
279
+ uiSchema: {
280
+ uid: { type: 'string' },
281
+ },
282
+ handler: baseHandler as any,
283
+ };
284
+ engine.registerActions({ openView: baseOpenView });
285
+
286
+ registerOpenViewPopupTemplateAction(engine);
287
+ const enhanced = engine.getAction('openView') as any;
288
+
289
+ // 模拟 C 表记录(filterTargetKey 是 nanoid),以及 belongsTo D(D 主键为 bigint)
290
+ const cRecord: any = {
291
+ cUnique: 'kEOzJ5VIJueYAitvGQPRPN',
292
+ dId: 123,
293
+ d: { id: 123 },
294
+ };
295
+
296
+ // 关系字段上下文:inputArgs.filterByTk 来自 C 的 filterTargetKey(string),但目标弹窗需要 D 的 id(number)
297
+ const baseInputArgs: any = {
298
+ dataSourceKey: 'main',
299
+ collectionName: 'c',
300
+ associationName: 'c.d',
301
+ filterByTk: cRecord.cUnique,
302
+ };
303
+
304
+ const assocField: any = {
305
+ isAssociationField: () => true,
306
+ name: 'd',
307
+ foreignKey: 'dId',
308
+ targetKey: 'id',
309
+ targetCollection: { dataSourceKey: 'main', name: 'd', filterTargetKey: 'id' },
310
+ collection: { dataSourceKey: 'main', name: 'c' },
311
+ };
312
+
313
+ const ctx: any = new FlowContext();
314
+ ctx.engine = engine;
315
+ ctx.t = (k: string) => k;
316
+ ctx.collectionField = assocField;
317
+ ctx.defineProperty('record', { value: cRecord });
318
+ ctx.defineProperty('inputArgs', { value: baseInputArgs });
319
+
320
+ await enhanced.handler(ctx, {
321
+ popupTemplateUid: 'tpl-1',
322
+ uid: 'popup-1',
323
+ dataSourceKey: 'main',
324
+ collectionName: 'd',
325
+ // 配置时误填(来自 C 的 filterTargetKey),运行时应从 belongsTo 的 dId/d.id 推断覆盖
326
+ filterByTk: cRecord.cUnique,
327
+ popupTemplateHasFilterByTk: true,
328
+ });
329
+
330
+ expect(baseHandler).toHaveBeenCalledTimes(1);
331
+ // should not mutate original ctx.inputArgs
332
+ expect(ctx.inputArgs.filterByTk).toBe(cRecord.cUnique);
333
+
334
+ // runtime ctx 应推断出 D 的 filterByTk(bigint id)
335
+ expect(capturedCtx).not.toBe(ctx);
336
+ expect(capturedCtx?.inputArgs?.collectionName).toBe('d');
337
+ expect(capturedCtx?.inputArgs?.filterByTk).toBe(123);
338
+ });
339
+
340
+ it('runtime clears filterByTk/sourceId in shadow ctx when template does not provide them', async () => {
341
+ const engine = new FlowEngine();
342
+ let capturedCtx: any;
343
+ const baseHandler = vi.fn(async (ctxArg: any) => {
344
+ capturedCtx = ctxArg;
345
+ return undefined;
346
+ });
347
+
348
+ const baseOpenView: ActionDefinition = {
349
+ name: 'openView',
350
+ title: 'openView',
351
+ uiSchema: {
352
+ uid: { type: 'string' },
353
+ },
354
+ handler: baseHandler as any,
355
+ };
356
+ engine.registerActions({ openView: baseOpenView });
357
+
358
+ registerOpenViewPopupTemplateAction(engine);
359
+ const enhanced = engine.getAction('openView') as any;
360
+
361
+ const baseInputArgs: any = {
362
+ dataSourceKey: 'main',
363
+ collectionName: 'posts',
364
+ filterByTk: 'record-1',
365
+ sourceId: 'source-1',
366
+ defaultInputKeys: ['filterByTk', 'sourceId'],
367
+ };
368
+ const ctx: any = new FlowContext();
369
+ ctx.engine = engine;
370
+ ctx.t = (k: string) => k;
371
+ ctx.defineProperty('inputArgs', { value: baseInputArgs });
372
+
373
+ await enhanced.handler(ctx, {
374
+ popupTemplateUid: 'tpl-1',
375
+ uid: 'popup-1',
376
+ dataSourceKey: 'main',
377
+ collectionName: 'users',
378
+ // template does NOT provide filterByTk/sourceId/associationName
379
+ });
380
+
381
+ expect(baseHandler).toHaveBeenCalledTimes(1);
382
+ // should not mutate original ctx.inputArgs
383
+ expect(ctx.inputArgs.collectionName).toBe('posts');
384
+ expect(ctx.inputArgs.filterByTk).toBe('record-1');
385
+ expect(ctx.inputArgs.sourceId).toBe('source-1');
386
+
387
+ // shadow ctx should clear filterByTk/sourceId (avoid fallback to actionDefaults)
388
+ expect(capturedCtx).not.toBe(ctx);
389
+ expect(capturedCtx?.inputArgs?.collectionName).toBe('users');
390
+ expect(capturedCtx?.inputArgs?.filterByTk).toBe(null);
391
+ expect(capturedCtx?.inputArgs?.sourceId).toBe(null);
392
+ expect(capturedCtx?.inputArgs?.defaultInputKeys).toBeUndefined();
393
+ });
394
+
395
+ it('runtime clears filterByTk in shadow ctx for association template when template does not need record context', async () => {
396
+ const engine = new FlowEngine();
397
+ let capturedCtx: any;
398
+ const baseHandler = vi.fn(async (ctxArg: any) => {
399
+ capturedCtx = ctxArg;
400
+ return undefined;
401
+ });
402
+
403
+ const baseOpenView: ActionDefinition = {
404
+ name: 'openView',
405
+ title: 'openView',
406
+ uiSchema: {
407
+ uid: { type: 'string' },
408
+ },
409
+ handler: baseHandler as any,
410
+ };
411
+ engine.registerActions({ openView: baseOpenView });
412
+
413
+ registerOpenViewPopupTemplateAction(engine);
414
+ const enhanced = engine.getAction('openView') as any;
415
+
416
+ const baseInputArgs: any = {
417
+ dataSourceKey: 'main',
418
+ collectionName: 'users',
419
+ associationName: 'users.roles',
420
+ filterByTk: 'role-1',
421
+ sourceId: 'user-1',
422
+ defaultInputKeys: ['filterByTk', 'sourceId'],
423
+ };
424
+ const ctx: any = new FlowContext();
425
+ ctx.engine = engine;
426
+ ctx.t = (k: string) => k;
427
+ ctx.collectionField = {
428
+ isAssociationField: () => true,
429
+ };
430
+ ctx.defineProperty('inputArgs', { value: baseInputArgs });
431
+
432
+ await enhanced.handler(ctx, {
433
+ popupTemplateUid: 'tpl-1',
434
+ uid: 'popup-1',
435
+ dataSourceKey: 'main',
436
+ collectionName: 'roles',
437
+ // association template (keeps association context), but collection-scene (no filterByTk)
438
+ associationName: 'users.roles',
439
+ popupTemplateHasFilterByTk: false,
440
+ });
441
+
442
+ expect(baseHandler).toHaveBeenCalledTimes(1);
443
+ // should not mutate original ctx.inputArgs
444
+ expect(ctx.inputArgs.filterByTk).toBe('role-1');
445
+
446
+ // shadow ctx should clear filterByTk (avoid leaking record context into "add" template)
447
+ // 模板不需要 sourceId 时,也应该清除 sourceId,避免传递到不需要它的弹窗
448
+ expect(capturedCtx).not.toBe(ctx);
449
+ expect(capturedCtx?.inputArgs?.collectionName).toBe('roles');
450
+ expect(capturedCtx?.inputArgs?.associationName).toBe('users.roles');
451
+ expect(capturedCtx?.inputArgs?.filterByTk).toBe(null);
452
+ expect(capturedCtx?.inputArgs?.sourceId).toBe(null);
453
+ expect(capturedCtx?.inputArgs?.defaultInputKeys || []).not.toContain('filterByTk');
454
+ expect(capturedCtx?.inputArgs?.defaultInputKeys || []).not.toContain('sourceId');
455
+ });
456
+
457
+ it('injects placeholder filterByTk when template expects record context but record is unavailable', async () => {
458
+ const engine = new FlowEngine();
459
+ let capturedCtx: any;
460
+ const baseHandler = vi.fn(async (ctxArg: any) => {
461
+ capturedCtx = ctxArg;
462
+ return undefined;
463
+ });
464
+
465
+ const baseOpenView: ActionDefinition = {
466
+ name: 'openView',
467
+ title: 'openView',
468
+ uiSchema: {
469
+ uid: { type: 'string' },
470
+ },
471
+ handler: baseHandler as any,
472
+ };
473
+ engine.registerActions({ openView: baseOpenView });
474
+
475
+ registerOpenViewPopupTemplateAction(engine);
476
+ const enhanced = engine.getAction('openView') as any;
477
+
478
+ const baseInputArgs: any = {
479
+ dataSourceKey: 'main',
480
+ collectionName: 'posts',
481
+ defaultInputKeys: ['filterByTk'],
482
+ };
483
+ const ctx: any = new FlowContext();
484
+ ctx.engine = engine;
485
+ ctx.t = (k: string) => k;
486
+ ctx.view = { inputArgs: {} };
487
+ ctx.defineProperty('inputArgs', { value: baseInputArgs });
488
+
489
+ await enhanced.handler(ctx, {
490
+ popupTemplateUid: 'tpl-1',
491
+ popupTemplateHasFilterByTk: true,
492
+ uid: 'popup-1',
493
+ dataSourceKey: 'main',
494
+ collectionName: 'users',
495
+ // runtime resolved filterByTk is undefined (e.g. ctx.record is unavailable in preview)
496
+ filterByTk: undefined,
497
+ });
498
+
499
+ expect(baseHandler).toHaveBeenCalledTimes(1);
500
+ expect(capturedCtx?.inputArgs?.collectionName).toBe('users');
501
+ expect(capturedCtx?.inputArgs?.filterByTk).toBe('__popupTemplateFilterByTk__');
502
+ expect(capturedCtx?.inputArgs?.defaultInputKeys).toBeUndefined();
503
+ });
504
+
505
+ it('infers record-scene needs filterByTk when template row misses filterByTk', async () => {
506
+ const engine = new FlowEngine();
507
+ class RecordOnlyModel {
508
+ static _isScene(scene: string) {
509
+ return scene === 'record';
510
+ }
511
+ }
512
+ engine.registerModels({ EditActionModel: RecordOnlyModel } as any);
513
+
514
+ let capturedCtx: any;
515
+ const baseHandler = vi.fn(async (ctxArg: any) => {
516
+ capturedCtx = ctxArg;
517
+ return undefined;
518
+ });
519
+
520
+ const baseOpenView: ActionDefinition = {
521
+ name: 'openView',
522
+ title: 'openView',
523
+ uiSchema: {
524
+ uid: { type: 'string' },
525
+ },
526
+ handler: baseHandler as any,
527
+ };
528
+ engine.registerActions({ openView: baseOpenView });
529
+
530
+ registerOpenViewPopupTemplateAction(engine);
531
+ const enhanced = engine.getAction('openView') as any;
532
+
533
+ const api = {
534
+ resource: (name: string) => {
535
+ if (name !== 'flowModelTemplates') throw new Error('unexpected resource');
536
+ return {
537
+ get: vi.fn(async () => ({
538
+ data: {
539
+ data: {
540
+ uid: 'tpl-record',
541
+ targetUid: 'popup-1',
542
+ useModel: 'EditActionModel',
543
+ dataSourceKey: 'main',
544
+ collectionName: 'users',
545
+ // old template: filterByTk missing
546
+ filterByTk: null,
547
+ },
548
+ },
549
+ })),
550
+ };
551
+ },
552
+ };
553
+
554
+ const baseInputArgs: any = {
555
+ dataSourceKey: 'main',
556
+ collectionName: 'users',
557
+ defaultInputKeys: ['filterByTk'],
558
+ };
559
+ const ctx: any = new FlowContext();
560
+ ctx.engine = engine;
561
+ ctx.api = api;
562
+ ctx.t = (k: string) => k;
563
+ ctx.view = { inputArgs: {} };
564
+ ctx.defineProperty('inputArgs', { value: baseInputArgs });
565
+
566
+ await enhanced.handler(ctx, {
567
+ popupTemplateUid: 'tpl-record',
568
+ uid: 'popup-1',
569
+ dataSourceKey: 'main',
570
+ collectionName: 'users',
571
+ filterByTk: undefined,
572
+ });
573
+
574
+ expect(baseHandler).toHaveBeenCalledTimes(1);
575
+ expect(capturedCtx?.inputArgs?.filterByTk).toBe('__popupTemplateFilterByTk__');
576
+ });
577
+
578
+ it('runtime clears filterByTk when template is collection-scene even if template stores record expr', async () => {
579
+ const engine = new FlowEngine();
580
+ class CollectionOnlyModel {
581
+ static _isScene(scene: string) {
582
+ return scene === 'collection';
583
+ }
584
+ }
585
+ engine.registerModels({ AddNewActionModel: CollectionOnlyModel } as any);
586
+
587
+ let capturedCtx: any;
588
+ const baseHandler = vi.fn(async (ctxArg: any) => {
589
+ capturedCtx = ctxArg;
590
+ return undefined;
591
+ });
592
+
593
+ const baseOpenView: ActionDefinition = {
594
+ name: 'openView',
595
+ title: 'openView',
596
+ uiSchema: {
597
+ uid: { type: 'string' },
598
+ },
599
+ handler: baseHandler as any,
600
+ };
601
+ engine.registerActions({ openView: baseOpenView });
602
+
603
+ registerOpenViewPopupTemplateAction(engine);
604
+ const enhanced = engine.getAction('openView') as any;
605
+
606
+ const api = {
607
+ resource: (name: string) => {
608
+ if (name !== 'flowModelTemplates') throw new Error('unexpected resource');
609
+ return {
610
+ get: vi.fn(async () => ({
611
+ data: {
612
+ data: {
613
+ uid: 'tpl-collection',
614
+ targetUid: 'popup-1',
615
+ useModel: 'AddNewActionModel',
616
+ dataSourceKey: 'main',
617
+ collectionName: 'users',
618
+ filterByTk: '{{ ctx.record.id }}',
619
+ },
620
+ },
621
+ })),
622
+ };
623
+ },
624
+ };
625
+
626
+ const baseInputArgs: any = {
627
+ dataSourceKey: 'main',
628
+ collectionName: 'users',
629
+ filterByTk: 'record-1',
630
+ defaultInputKeys: ['filterByTk'],
631
+ };
632
+ const ctx: any = new FlowContext();
633
+ ctx.engine = engine;
634
+ ctx.api = api;
635
+ ctx.t = (k: string) => k;
636
+ ctx.defineProperty('inputArgs', { value: baseInputArgs });
637
+
638
+ await enhanced.handler(ctx, {
639
+ popupTemplateUid: 'tpl-collection',
640
+ uid: 'popup-1',
641
+ dataSourceKey: 'main',
642
+ collectionName: 'users',
643
+ filterByTk: 'record-1',
644
+ });
645
+
646
+ expect(baseHandler).toHaveBeenCalledTimes(1);
647
+ expect(ctx.inputArgs.filterByTk).toBe('record-1');
648
+ expect(capturedCtx?.inputArgs?.filterByTk).toBe(null);
649
+ });
650
+
651
+ it('does not clear filterByTk/sourceId when template declares them but runtime resolves to undefined', async () => {
652
+ const engine = new FlowEngine();
653
+ let capturedCtx: any;
654
+ const baseHandler = vi.fn(async (ctxArg: any) => {
655
+ capturedCtx = ctxArg;
656
+ return undefined;
657
+ });
658
+
659
+ const baseOpenView: ActionDefinition = {
660
+ name: 'openView',
661
+ title: 'openView',
662
+ uiSchema: {
663
+ uid: { type: 'string' },
664
+ },
665
+ handler: baseHandler as any,
666
+ };
667
+ engine.registerActions({ openView: baseOpenView });
668
+
669
+ registerOpenViewPopupTemplateAction(engine);
670
+ const enhanced = engine.getAction('openView') as any;
671
+
672
+ const baseInputArgs: any = {
673
+ dataSourceKey: 'main',
674
+ collectionName: 'users',
675
+ filterByTk: 'record-1',
676
+ sourceId: 'source-1',
677
+ defaultInputKeys: ['filterByTk', 'sourceId'],
678
+ };
679
+ const ctx: any = new FlowContext();
680
+ ctx.engine = engine;
681
+ ctx.t = (k: string) => k;
682
+ ctx.defineProperty('inputArgs', { value: baseInputArgs });
683
+
684
+ await enhanced.handler(ctx, {
685
+ popupTemplateUid: 'tpl-1',
686
+ popupTemplateHasFilterByTk: true,
687
+ popupTemplateHasSourceId: true,
688
+ uid: 'popup-1',
689
+ dataSourceKey: 'main',
690
+ collectionName: 'users',
691
+ filterByTk: undefined,
692
+ sourceId: undefined,
693
+ });
694
+
695
+ expect(baseHandler).toHaveBeenCalledTimes(1);
696
+ expect(capturedCtx).not.toBe(ctx);
697
+ expect(capturedCtx?.inputArgs?.filterByTk).toBe('record-1');
698
+ expect(capturedCtx?.inputArgs?.sourceId).toBe('source-1');
699
+ });
700
+
701
+ it('runtime overrides resource keys via shadow ctx in copy mode (popupTemplateContext)', async () => {
702
+ const engine = new FlowEngine();
703
+ let capturedCtx: any;
704
+ let capturedParams: any;
705
+ const baseHandler = vi.fn(async (ctxArg: any, paramsArg: any) => {
706
+ capturedCtx = ctxArg;
707
+ capturedParams = paramsArg;
708
+ return undefined;
709
+ });
710
+
711
+ const baseOpenView: ActionDefinition = {
712
+ name: 'openView',
713
+ title: 'openView',
714
+ uiSchema: {
715
+ uid: { type: 'string' },
716
+ },
717
+ handler: baseHandler as any,
718
+ };
719
+ engine.registerActions({ openView: baseOpenView });
720
+
721
+ registerOpenViewPopupTemplateAction(engine);
722
+ const enhanced = engine.getAction('openView') as any;
723
+
724
+ const baseInputArgs: any = {
725
+ dataSourceKey: 'main',
726
+ collectionName: 'users',
727
+ associationName: 'users.roles',
728
+ filterByTk: 'role-1',
729
+ sourceId: 'user-1',
730
+ defaultInputKeys: ['filterByTk', 'sourceId'],
731
+ };
732
+ const ctx: any = new FlowContext();
733
+ ctx.engine = engine;
734
+ ctx.t = (k: string) => k;
735
+ ctx.collectionField = {
736
+ isAssociationField: () => true,
737
+ };
738
+ ctx.defineProperty('inputArgs', { value: baseInputArgs });
739
+
740
+ await enhanced.handler(ctx, {
741
+ popupTemplateContext: true,
742
+ uid: 'popup-1',
743
+ dataSourceKey: 'main',
744
+ collectionName: 'roles',
745
+ filterByTk: 'tpl-filter',
746
+ });
747
+
748
+ expect(baseHandler).toHaveBeenCalledTimes(1);
749
+ expect(capturedParams?.popupTemplateUid).toBeUndefined();
750
+ expect(capturedParams?.popupTemplateContext).toBeUndefined();
751
+ expect(capturedParams?.uid).toBe('popup-1');
752
+
753
+ // should not mutate original ctx.inputArgs
754
+ expect(ctx.inputArgs.collectionName).toBe('users');
755
+ expect(ctx.inputArgs.associationName).toBe('users.roles');
756
+ expect(ctx.inputArgs.filterByTk).toBe('role-1');
757
+
758
+ // shadow ctx should override resource keys (keep runtime record context from inputArgs)
759
+ expect(capturedCtx).not.toBe(ctx);
760
+ expect(capturedCtx?.inputArgs?.dataSourceKey).toBe('main');
761
+ expect(capturedCtx?.inputArgs?.collectionName).toBe('roles');
762
+ expect('associationName' in (capturedCtx?.inputArgs || {})).toBe(false);
763
+ expect(capturedCtx?.inputArgs?.filterByTk).toBe('role-1');
764
+ expect(capturedCtx?.inputArgs?.sourceId).toBe(null);
765
+ expect(capturedCtx?.inputArgs?.defaultInputKeys || []).toContain('filterByTk');
766
+ expect(capturedCtx?.inputArgs?.defaultInputKeys || []).not.toContain('sourceId');
767
+ });
768
+
769
+ it('keeps object filterByTk in shadow ctx (composite target key)', async () => {
770
+ const engine = new FlowEngine();
771
+ let capturedCtx: any;
772
+ const baseHandler = vi.fn(async (ctxArg: any) => {
773
+ capturedCtx = ctxArg;
774
+ return undefined;
775
+ });
776
+
777
+ const baseOpenView: ActionDefinition = {
778
+ name: 'openView',
779
+ title: 'openView',
780
+ uiSchema: {
781
+ uid: { type: 'string' },
782
+ },
783
+ handler: baseHandler as any,
784
+ };
785
+ engine.registerActions({ openView: baseOpenView });
786
+
787
+ registerOpenViewPopupTemplateAction(engine);
788
+ const enhanced = engine.getAction('openView') as any;
789
+
790
+ const compositeTk = { Code1: 'C1', Code2: 'C2' };
791
+ const baseInputArgs: any = {
792
+ dataSourceKey: 'main',
793
+ collectionName: 'composites',
794
+ filterByTk: compositeTk,
795
+ defaultInputKeys: ['filterByTk'],
796
+ };
797
+ const ctx: any = new FlowContext();
798
+ ctx.engine = engine;
799
+ ctx.t = (k: string) => k;
800
+ ctx.view = { inputArgs: {} };
801
+ ctx.defineProperty('inputArgs', { value: baseInputArgs });
802
+
803
+ await enhanced.handler(ctx, {
804
+ popupTemplateContext: true,
805
+ uid: 'popup-1',
806
+ dataSourceKey: 'main',
807
+ collectionName: 'composites',
808
+ filterByTk: 'tpl-filter',
809
+ });
810
+
811
+ expect(baseHandler).toHaveBeenCalledTimes(1);
812
+ expect(capturedCtx).not.toBe(ctx);
813
+ expect(capturedCtx?.inputArgs?.filterByTk).toEqual(compositeTk);
814
+ });
815
+
816
+ it('rejects popup template when dataSourceKey/collectionName mismatches current context', async () => {
817
+ const engine = new FlowEngine();
818
+ const baseBefore = vi.fn(async () => {});
819
+ const baseOpenView: ActionDefinition = {
820
+ name: 'openView',
821
+ title: 'openView',
822
+ uiSchema: {
823
+ uid: { type: 'string' },
824
+ },
825
+ beforeParamsSave: baseBefore as any,
826
+ handler: vi.fn(async () => undefined) as any,
827
+ };
828
+ engine.registerActions({ openView: baseOpenView });
829
+
830
+ registerOpenViewPopupTemplateAction(engine);
831
+ const enhanced = engine.getAction('openView') as any;
832
+
833
+ const api = {
834
+ resource: (name: string) => {
835
+ if (name !== 'flowModelTemplates') throw new Error('unexpected resource');
836
+ return {
837
+ get: vi.fn(async () => ({
838
+ data: {
839
+ data: {
840
+ uid: 'tpl-1',
841
+ targetUid: 'popup-1',
842
+ dataSourceKey: 'main',
843
+ collectionName: 'posts',
844
+ },
845
+ },
846
+ })),
847
+ };
848
+ },
849
+ };
850
+ const model: any = {
851
+ getStepParams: vi.fn((flowKey: string, stepKey: string) => {
852
+ if (flowKey === 'resourceSettings' && stepKey === 'init') {
853
+ return { dataSourceKey: 'main', collectionName: 'users' };
854
+ }
855
+ return {};
856
+ }),
857
+ parent: null,
858
+ };
859
+ const ctx: any = { engine, api, model, t: (k: string) => k };
860
+ const params: any = { popupTemplateUid: 'tpl-1', uid: 'old' };
861
+
862
+ await expect(enhanced.beforeParamsSave(ctx, params, {})).rejects.toThrow('Template collection mismatch');
863
+ expect(baseBefore).toHaveBeenCalledTimes(0);
864
+ });
865
+
866
+ it('rejects popup template when associationName mismatches current context', async () => {
867
+ const engine = new FlowEngine();
868
+ const baseBefore = vi.fn(async () => {});
869
+ const baseOpenView: ActionDefinition = {
870
+ name: 'openView',
871
+ title: 'openView',
872
+ uiSchema: {
873
+ uid: { type: 'string' },
874
+ },
875
+ beforeParamsSave: baseBefore as any,
876
+ handler: vi.fn(async () => undefined) as any,
877
+ };
878
+ engine.registerActions({ openView: baseOpenView });
879
+
880
+ registerOpenViewPopupTemplateAction(engine);
881
+ const enhanced = engine.getAction('openView') as any;
882
+
883
+ const api = {
884
+ resource: (name: string) => {
885
+ if (name !== 'flowModelTemplates') throw new Error('unexpected resource');
886
+ return {
887
+ get: vi.fn(async () => ({
888
+ data: {
889
+ data: {
890
+ uid: 'tpl-1',
891
+ targetUid: 'popup-1',
892
+ dataSourceKey: 'main',
893
+ collectionName: 'users',
894
+ associationName: 'users.departments',
895
+ },
896
+ },
897
+ })),
898
+ };
899
+ },
900
+ };
901
+ const model: any = {
902
+ getStepParams: vi.fn((flowKey: string, stepKey: string) => {
903
+ if (flowKey === 'resourceSettings' && stepKey === 'init') {
904
+ return { dataSourceKey: 'main', collectionName: 'users', associationName: 'users.roles' };
905
+ }
906
+ return {};
907
+ }),
908
+ parent: null,
909
+ };
910
+ const ctx: any = { engine, api, model, t: (k: string) => k };
911
+ const params: any = { popupTemplateUid: 'tpl-1', uid: 'old' };
912
+
913
+ await expect(enhanced.beforeParamsSave(ctx, params, {})).rejects.toThrow('Template association mismatch');
914
+ expect(baseBefore).toHaveBeenCalledTimes(0);
915
+ });
916
+
917
+ it('allows non-relation popup template when target collection matches (relation field)', async () => {
918
+ const engine = new FlowEngine();
919
+ const baseBefore = vi.fn(async () => {});
920
+ const baseOpenView: ActionDefinition = {
921
+ name: 'openView',
922
+ title: 'openView',
923
+ uiSchema: {
924
+ uid: { type: 'string' },
925
+ },
926
+ beforeParamsSave: baseBefore as any,
927
+ handler: vi.fn(async () => undefined) as any,
928
+ };
929
+ engine.registerActions({ openView: baseOpenView });
930
+
931
+ registerOpenViewPopupTemplateAction(engine);
932
+ const enhanced = engine.getAction('openView') as any;
933
+
934
+ const api = {
935
+ resource: (name: string) => {
936
+ if (name !== 'flowModelTemplates') throw new Error('unexpected resource');
937
+ return {
938
+ get: vi.fn(async () => ({
939
+ data: {
940
+ data: {
941
+ uid: 'tpl-1',
942
+ targetUid: 'popup-1',
943
+ dataSourceKey: 'main',
944
+ collectionName: 'roles',
945
+ // non-relation template: no associationName
946
+ },
947
+ },
948
+ })),
949
+ };
950
+ },
951
+ };
952
+ const model: any = {
953
+ getStepParams: vi.fn((flowKey: string, stepKey: string) => {
954
+ if (flowKey === 'resourceSettings' && stepKey === 'init') {
955
+ return { dataSourceKey: 'main', collectionName: 'users', associationName: 'users.roles' };
956
+ }
957
+ return {};
958
+ }),
959
+ parent: null,
960
+ };
961
+ const dataSourceManager = {
962
+ getCollection: vi.fn((_ds: string, name: string) => {
963
+ if (name !== 'users') return undefined;
964
+ return {
965
+ getFieldByPath: vi.fn((path: string) => {
966
+ if (path !== 'roles') return undefined;
967
+ return { targetCollection: { dataSourceKey: 'main', name: 'roles' } };
968
+ }),
969
+ getField: vi.fn((path: string) => {
970
+ if (path !== 'roles') return undefined;
971
+ return { targetCollection: { dataSourceKey: 'main', name: 'roles' } };
972
+ }),
973
+ };
974
+ }),
975
+ };
976
+ const ctx: any = { engine, api, model, dataSourceManager, t: (k: string) => k };
977
+ const params: any = { popupTemplateUid: 'tpl-1', uid: 'old', associationName: 'users.roles' };
978
+
979
+ await expect(enhanced.beforeParamsSave(ctx, params, {})).resolves.toBeUndefined();
980
+ expect(params.uid).toBe('popup-1');
981
+ expect('associationName' in params).toBe(false);
982
+ });
983
+
984
+ it('rejects record-scene popup template when association context cannot provide filterByTk (collection scene)', async () => {
985
+ const engine = new FlowEngine();
986
+ class CollectionOnlyModel {
987
+ static _isScene(scene: string) {
988
+ return scene === 'collection';
989
+ }
990
+ }
991
+ engine.registerModels({ CollectionOnlyModel } as any);
992
+
993
+ const baseBefore = vi.fn(async () => {});
994
+ const baseOpenView: ActionDefinition = {
995
+ name: 'openView',
996
+ title: 'openView',
997
+ uiSchema: {
998
+ uid: { type: 'string' },
999
+ },
1000
+ beforeParamsSave: baseBefore as any,
1001
+ handler: vi.fn(async () => undefined) as any,
1002
+ };
1003
+ engine.registerActions({ openView: baseOpenView });
1004
+
1005
+ registerOpenViewPopupTemplateAction(engine);
1006
+ const enhanced = engine.getAction('openView') as any;
1007
+
1008
+ const api = {
1009
+ resource: (name: string) => {
1010
+ if (name !== 'flowModelTemplates') throw new Error('unexpected resource');
1011
+ return {
1012
+ get: vi.fn(async () => ({
1013
+ data: {
1014
+ data: {
1015
+ uid: 'tpl-1',
1016
+ targetUid: 'popup-1',
1017
+ dataSourceKey: 'main',
1018
+ collectionName: 'roles',
1019
+ // record-scene popup template (needs filterByTk)
1020
+ filterByTk: '{{ ctx.record.id }}',
1021
+ },
1022
+ },
1023
+ })),
1024
+ };
1025
+ },
1026
+ };
1027
+ const dataSourceManager = {
1028
+ getCollection: vi.fn((_ds: string, name: string) => {
1029
+ if (name !== 'users') return undefined;
1030
+ return {
1031
+ getFieldByPath: vi.fn((path: string) => {
1032
+ if (path !== 'roles') return undefined;
1033
+ return { targetCollection: { dataSourceKey: 'main', name: 'roles' } };
1034
+ }),
1035
+ getField: vi.fn((path: string) => {
1036
+ if (path !== 'roles') return undefined;
1037
+ return { targetCollection: { dataSourceKey: 'main', name: 'roles' } };
1038
+ }),
1039
+ };
1040
+ }),
1041
+ };
1042
+ const model: any = {
1043
+ constructor: { name: 'CollectionOnlyModel' },
1044
+ getStepParams: vi.fn((flowKey: string, stepKey: string) => {
1045
+ if (flowKey === 'resourceSettings' && stepKey === 'init') {
1046
+ return { dataSourceKey: 'main', collectionName: 'users', associationName: 'users.roles' };
1047
+ }
1048
+ return {};
1049
+ }),
1050
+ parent: null,
1051
+ };
1052
+ const ctx: any = { engine, api, model, dataSourceManager, t: (k: string) => k };
1053
+ const params: any = { popupTemplateUid: 'tpl-1', uid: 'old', associationName: 'users.roles' };
1054
+
1055
+ await expect(enhanced.beforeParamsSave(ctx, params, {})).rejects.toThrow('Cannot resolve template parameter');
1056
+ expect(baseBefore).toHaveBeenCalledTimes(0);
1057
+ });
1058
+
1059
+ it('allows record-scene popup template in association context when current action is record scene', async () => {
1060
+ const engine = new FlowEngine();
1061
+ class RecordOnlyModel {
1062
+ static _isScene(scene: string) {
1063
+ return scene === 'record';
1064
+ }
1065
+ }
1066
+ engine.registerModels({ RecordOnlyModel } as any);
1067
+
1068
+ const baseBefore = vi.fn(async () => {});
1069
+ const baseOpenView: ActionDefinition = {
1070
+ name: 'openView',
1071
+ title: 'openView',
1072
+ uiSchema: {
1073
+ uid: { type: 'string' },
1074
+ },
1075
+ beforeParamsSave: baseBefore as any,
1076
+ handler: vi.fn(async () => undefined) as any,
1077
+ };
1078
+ engine.registerActions({ openView: baseOpenView });
1079
+
1080
+ registerOpenViewPopupTemplateAction(engine);
1081
+ const enhanced = engine.getAction('openView') as any;
1082
+
1083
+ const api = {
1084
+ resource: (name: string) => {
1085
+ if (name !== 'flowModelTemplates') throw new Error('unexpected resource');
1086
+ return {
1087
+ get: vi.fn(async () => ({
1088
+ data: {
1089
+ data: {
1090
+ uid: 'tpl-1',
1091
+ targetUid: 'popup-1',
1092
+ dataSourceKey: 'main',
1093
+ collectionName: 'roles',
1094
+ filterByTk: '{{ ctx.record.id }}',
1095
+ },
1096
+ },
1097
+ })),
1098
+ };
1099
+ },
1100
+ };
1101
+ const dataSourceManager = {
1102
+ getCollection: vi.fn((_ds: string, name: string) => {
1103
+ if (name !== 'users') return undefined;
1104
+ return {
1105
+ getFieldByPath: vi.fn((path: string) => {
1106
+ if (path !== 'roles') return undefined;
1107
+ return { targetCollection: { dataSourceKey: 'main', name: 'roles' } };
1108
+ }),
1109
+ getField: vi.fn((path: string) => {
1110
+ if (path !== 'roles') return undefined;
1111
+ return { targetCollection: { dataSourceKey: 'main', name: 'roles' } };
1112
+ }),
1113
+ };
1114
+ }),
1115
+ };
1116
+ const model: any = {
1117
+ constructor: { name: 'RecordOnlyModel' },
1118
+ getStepParams: vi.fn((flowKey: string, stepKey: string) => {
1119
+ if (flowKey === 'resourceSettings' && stepKey === 'init') {
1120
+ return { dataSourceKey: 'main', collectionName: 'users', associationName: 'users.roles' };
1121
+ }
1122
+ return {};
1123
+ }),
1124
+ parent: null,
1125
+ };
1126
+ const ctx: any = { engine, api, model, dataSourceManager, t: (k: string) => k };
1127
+ const params: any = { popupTemplateUid: 'tpl-1', uid: 'old', associationName: 'users.roles' };
1128
+
1129
+ await expect(enhanced.beforeParamsSave(ctx, params, {})).resolves.toBeUndefined();
1130
+ expect(params.uid).toBe('popup-1');
1131
+ expect(params.collectionName).toBe('roles');
1132
+ expect(params.filterByTk).toBe('{{ ctx.record.id }}');
1133
+ });
1134
+
1135
+ it('uses action params to resolve target collection for relation popups', async () => {
1136
+ const engine = new FlowEngine();
1137
+ const baseBefore = vi.fn(async () => {});
1138
+ const baseOpenView: ActionDefinition = {
1139
+ name: 'openView',
1140
+ title: 'openView',
1141
+ uiSchema: {
1142
+ uid: { type: 'string' },
1143
+ },
1144
+ beforeParamsSave: baseBefore as any,
1145
+ handler: vi.fn(async () => undefined) as any,
1146
+ };
1147
+ engine.registerActions({ openView: baseOpenView });
1148
+
1149
+ registerOpenViewPopupTemplateAction(engine);
1150
+ const enhanced = engine.getAction('openView') as any;
1151
+
1152
+ const api = {
1153
+ resource: (name: string) => {
1154
+ if (name !== 'flowModelTemplates') throw new Error('unexpected resource');
1155
+ return {
1156
+ get: vi.fn(async () => ({
1157
+ data: {
1158
+ data: {
1159
+ uid: 'tpl-1',
1160
+ targetUid: 'popup-1',
1161
+ dataSourceKey: 'main',
1162
+ collectionName: 'posts',
1163
+ associationName: 'comments',
1164
+ },
1165
+ },
1166
+ })),
1167
+ };
1168
+ },
1169
+ };
1170
+ const dataSourceManager = {
1171
+ getCollection: vi.fn((_ds: string, name: string) => {
1172
+ if (name !== 'posts') return undefined;
1173
+ return {
1174
+ getFieldByPath: vi.fn((path: string) => {
1175
+ if (path !== 'comments') return undefined;
1176
+ return { targetCollection: { dataSourceKey: 'main', name: 'comments' } };
1177
+ }),
1178
+ getField: vi.fn((path: string) => {
1179
+ if (path !== 'comments') return undefined;
1180
+ return { targetCollection: { dataSourceKey: 'main', name: 'comments' } };
1181
+ }),
1182
+ };
1183
+ }),
1184
+ };
1185
+ const model: any = {
1186
+ getStepParams: vi.fn((flowKey: string, stepKey: string) => {
1187
+ if (flowKey === 'resourceSettings' && stepKey === 'init') {
1188
+ // 当前触发上下文是源集合(如表单:posts),并不会携带 associationName
1189
+ return { dataSourceKey: 'main', collectionName: 'posts' };
1190
+ }
1191
+ return {};
1192
+ }),
1193
+ parent: null,
1194
+ };
1195
+ const ctx: any = { engine, api, model, dataSourceManager, t: (k: string) => k };
1196
+ const params: any = {
1197
+ popupTemplateUid: 'tpl-1',
1198
+ uid: 'old',
1199
+ dataSourceKey: 'main',
1200
+ collectionName: 'posts',
1201
+ associationName: 'comments',
1202
+ };
1203
+
1204
+ await expect(enhanced.beforeParamsSave(ctx, params, {})).resolves.toBeUndefined();
1205
+ expect(params.uid).toBe('popup-1');
1206
+ expect(baseBefore).toHaveBeenCalledTimes(1);
1207
+ });
1208
+ });