@nocobase/flow-engine 2.0.0-beta.2 → 2.0.0-beta.20

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 (124) hide show
  1. package/lib/BlockScopedFlowEngine.js +0 -1
  2. package/lib/JSRunner.d.ts +6 -0
  3. package/lib/JSRunner.js +2 -1
  4. package/lib/ViewScopedFlowEngine.js +3 -0
  5. package/lib/acl/Acl.js +13 -3
  6. package/lib/components/dnd/gridDragPlanner.d.ts +1 -0
  7. package/lib/components/dnd/gridDragPlanner.js +53 -1
  8. package/lib/components/settings/wrappers/component/SwitchWithTitle.js +2 -1
  9. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +11 -3
  10. package/lib/components/variables/VariableInput.js +8 -2
  11. package/lib/data-source/index.js +6 -0
  12. package/lib/executor/FlowExecutor.d.ts +2 -1
  13. package/lib/executor/FlowExecutor.js +156 -22
  14. package/lib/flowContext.d.ts +4 -1
  15. package/lib/flowContext.js +176 -107
  16. package/lib/flowEngine.d.ts +21 -0
  17. package/lib/flowEngine.js +38 -0
  18. package/lib/flowSettings.js +12 -10
  19. package/lib/index.d.ts +3 -0
  20. package/lib/index.js +16 -0
  21. package/lib/models/CollectionFieldModel.d.ts +1 -0
  22. package/lib/models/CollectionFieldModel.js +3 -2
  23. package/lib/models/flowModel.d.ts +7 -0
  24. package/lib/models/flowModel.js +66 -1
  25. package/lib/provider.js +7 -6
  26. package/lib/resources/baseRecordResource.d.ts +5 -0
  27. package/lib/resources/baseRecordResource.js +24 -0
  28. package/lib/resources/multiRecordResource.d.ts +1 -0
  29. package/lib/resources/multiRecordResource.js +11 -4
  30. package/lib/resources/singleRecordResource.js +2 -0
  31. package/lib/resources/sqlResource.d.ts +1 -0
  32. package/lib/resources/sqlResource.js +8 -3
  33. package/lib/runjs-context/contexts/base.js +10 -4
  34. package/lib/runjsLibs.d.ts +28 -0
  35. package/lib/runjsLibs.js +532 -0
  36. package/lib/scheduler/ModelOperationScheduler.d.ts +2 -0
  37. package/lib/scheduler/ModelOperationScheduler.js +21 -21
  38. package/lib/types.d.ts +15 -0
  39. package/lib/utils/createCollectionContextMeta.js +1 -0
  40. package/lib/utils/index.d.ts +2 -0
  41. package/lib/utils/index.js +10 -0
  42. package/lib/utils/params-resolvers.js +16 -9
  43. package/lib/utils/resolveModuleUrl.d.ts +58 -0
  44. package/lib/utils/resolveModuleUrl.js +65 -0
  45. package/lib/utils/runjsModuleLoader.d.ts +58 -0
  46. package/lib/utils/runjsModuleLoader.js +422 -0
  47. package/lib/utils/runjsTemplateCompat.d.ts +35 -0
  48. package/lib/utils/runjsTemplateCompat.js +743 -0
  49. package/lib/utils/safeGlobals.d.ts +5 -9
  50. package/lib/utils/safeGlobals.js +129 -17
  51. package/lib/views/createViewMeta.d.ts +0 -7
  52. package/lib/views/createViewMeta.js +19 -70
  53. package/lib/views/index.d.ts +1 -2
  54. package/lib/views/index.js +4 -3
  55. package/lib/views/useDialog.js +8 -3
  56. package/lib/views/useDrawer.js +7 -2
  57. package/lib/views/usePage.d.ts +4 -0
  58. package/lib/views/usePage.js +43 -6
  59. package/lib/views/usePopover.js +4 -1
  60. package/lib/views/viewEvents.d.ts +17 -0
  61. package/lib/views/viewEvents.js +90 -0
  62. package/package.json +4 -4
  63. package/src/BlockScopedFlowEngine.ts +2 -5
  64. package/src/JSRunner.ts +8 -1
  65. package/src/ViewScopedFlowEngine.ts +4 -0
  66. package/src/__tests__/createViewMeta.popup.test.ts +62 -1
  67. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +63 -0
  68. package/src/__tests__/flowSettings.open.test.tsx +69 -15
  69. package/src/__tests__/provider.test.tsx +0 -5
  70. package/src/__tests__/runjsExternalLibs.test.ts +242 -0
  71. package/src/__tests__/runjsLibsLazyLoading.test.ts +44 -0
  72. package/src/__tests__/runjsPreprocessDefault.test.ts +49 -0
  73. package/src/acl/Acl.tsx +3 -3
  74. package/src/components/__tests__/gridDragPlanner.test.ts +141 -1
  75. package/src/components/dnd/gridDragPlanner.ts +60 -0
  76. package/src/components/settings/wrappers/component/SwitchWithTitle.tsx +2 -1
  77. package/src/components/settings/wrappers/component/__tests__/InlineControls.test.tsx +74 -0
  78. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +11 -3
  79. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +63 -4
  80. package/src/components/variables/VariableInput.tsx +8 -2
  81. package/src/data-source/index.ts +6 -0
  82. package/src/executor/FlowExecutor.ts +193 -23
  83. package/src/executor/__tests__/flowExecutor.test.ts +66 -0
  84. package/src/flowContext.ts +234 -118
  85. package/src/flowEngine.ts +41 -0
  86. package/src/flowSettings.ts +12 -11
  87. package/src/index.ts +10 -0
  88. package/src/models/CollectionFieldModel.tsx +3 -1
  89. package/src/models/__tests__/dispatchEvent.when.test.ts +356 -0
  90. package/src/models/__tests__/flowModel.clone.test.ts +416 -0
  91. package/src/models/__tests__/flowModel.test.ts +16 -0
  92. package/src/models/flowModel.tsx +94 -1
  93. package/src/provider.tsx +9 -7
  94. package/src/resources/__tests__/multiRecordResource.test.ts +44 -0
  95. package/src/resources/__tests__/sqlResource.test.ts +60 -0
  96. package/src/resources/baseRecordResource.ts +31 -0
  97. package/src/resources/multiRecordResource.ts +11 -4
  98. package/src/resources/singleRecordResource.ts +3 -0
  99. package/src/resources/sqlResource.ts +8 -3
  100. package/src/runjs-context/contexts/base.ts +9 -2
  101. package/src/runjsLibs.ts +622 -0
  102. package/src/scheduler/ModelOperationScheduler.ts +23 -21
  103. package/src/types.ts +26 -1
  104. package/src/utils/__tests__/params-resolvers.test.ts +40 -0
  105. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +38 -0
  106. package/src/utils/__tests__/runjsTemplateCompat.test.ts +159 -0
  107. package/src/utils/__tests__/safeGlobals.test.ts +49 -2
  108. package/src/utils/createCollectionContextMeta.ts +1 -0
  109. package/src/utils/index.ts +6 -0
  110. package/src/utils/params-resolvers.ts +23 -9
  111. package/src/utils/resolveModuleUrl.ts +91 -0
  112. package/src/utils/runjsModuleLoader.ts +553 -0
  113. package/src/utils/runjsTemplateCompat.ts +828 -0
  114. package/src/utils/safeGlobals.ts +133 -16
  115. package/src/views/__tests__/FlowView.usePage.test.tsx +54 -1
  116. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +35 -8
  117. package/src/views/__tests__/viewEvents.resolveOpenerEngine.test.ts +28 -0
  118. package/src/views/createViewMeta.ts +22 -75
  119. package/src/views/index.tsx +1 -2
  120. package/src/views/useDialog.tsx +9 -2
  121. package/src/views/useDrawer.tsx +8 -1
  122. package/src/views/usePage.tsx +51 -5
  123. package/src/views/usePopover.tsx +4 -1
  124. package/src/views/viewEvents.ts +55 -0
@@ -36,6 +36,8 @@ export interface LifecycleEvent {
36
36
  error?: any;
37
37
  inputArgs?: Record<string, any>;
38
38
  result?: any;
39
+ flowKey?: string;
40
+ stepKey?: string;
39
41
  }
40
42
 
41
43
  type ScheduledItem = {
@@ -162,37 +164,37 @@ export class ModelOperationScheduler {
162
164
  const emitter = this.engine.emitter;
163
165
  if (!emitter || typeof emitter.on !== 'function') return;
164
166
 
165
- const onCreated = (e: LifecycleEvent) => {
166
- this.processLifecycleEvent(e.uid, { ...e, type: 'created' });
167
+ const onCreated = async (e: LifecycleEvent) => {
168
+ await this.processLifecycleEvent(e.uid, { ...e, type: 'created' });
167
169
  };
168
170
  emitter.on('model:created', onCreated);
169
171
  this.unbindHandlers.push(() => emitter.off('model:created', onCreated));
170
172
 
171
- const onMounted = (e: LifecycleEvent) => {
172
- this.processLifecycleEvent(e.uid, { ...e, type: 'mounted' });
173
+ const onMounted = async (e: LifecycleEvent) => {
174
+ await this.processLifecycleEvent(e.uid, { ...e, type: 'mounted' });
173
175
  };
174
176
  emitter.on('model:mounted', onMounted);
175
177
  this.unbindHandlers.push(() => emitter.off('model:mounted', onMounted));
176
178
 
177
- const onGenericBeforeStart = (e: LifecycleEvent) => {
178
- this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:start' });
179
+ const onGenericBeforeStart = async (e: LifecycleEvent) => {
180
+ await this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:start' });
179
181
  };
180
182
  emitter.on('model:event:beforeRender:start', onGenericBeforeStart);
181
183
  this.unbindHandlers.push(() => emitter.off('model:event:beforeRender:start', onGenericBeforeStart));
182
184
 
183
- const onGenericBeforeEnd = (e: LifecycleEvent) => {
184
- this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:end' });
185
+ const onGenericBeforeEnd = async (e: LifecycleEvent) => {
186
+ await this.processLifecycleEvent(e.uid, { ...e, type: 'event:beforeRender:end' });
185
187
  };
186
188
  emitter.on('model:event:beforeRender:end', onGenericBeforeEnd);
187
189
  this.unbindHandlers.push(() => emitter.off('model:event:beforeRender:end', onGenericBeforeEnd));
188
190
 
189
- const onUnmounted = (e: LifecycleEvent) => {
190
- this.processLifecycleEvent(e.uid, { ...e, type: 'unmounted' });
191
+ const onUnmounted = async (e: LifecycleEvent) => {
192
+ await this.processLifecycleEvent(e.uid, { ...e, type: 'unmounted' });
191
193
  };
192
194
  emitter.on('model:unmounted', onUnmounted);
193
195
  this.unbindHandlers.push(() => emitter.off('model:unmounted', onUnmounted));
194
196
 
195
- const onDestroyed = (e: LifecycleEvent) => {
197
+ const onDestroyed = async (e: LifecycleEvent) => {
196
198
  const targetBucket = this.itemsByTargetUid.get(e.uid);
197
199
  const event = { ...e, type: 'destroyed' as const };
198
200
  if (targetBucket && targetBucket.size) {
@@ -201,7 +203,7 @@ export class ModelOperationScheduler {
201
203
  const it = this.itemsById.get(id);
202
204
  if (!it) continue;
203
205
  if (this.shouldTrigger(it.options.when, event)) {
204
- void this.tryExecuteOnce(id, event);
206
+ await this.tryExecuteOnce(id, event);
205
207
  } else {
206
208
  this.internalCancel(id);
207
209
  }
@@ -220,14 +222,14 @@ export class ModelOperationScheduler {
220
222
  if (this.subscribedEventNames.has(name)) return;
221
223
  this.subscribedEventNames.add(name);
222
224
  const emitter = this.engine.emitter;
223
- const onStart = (e: LifecycleEvent) => {
224
- this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:start` as const });
225
+ const onStart = async (e: LifecycleEvent) => {
226
+ await this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:start` as LifecycleType });
225
227
  };
226
- const onEnd = (e: LifecycleEvent) => {
227
- this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:end` as const });
228
+ const onEnd = async (e: LifecycleEvent) => {
229
+ await this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:end` as LifecycleType });
228
230
  };
229
- const onError = (e: LifecycleEvent) => {
230
- this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:error` as const });
231
+ const onError = async (e: LifecycleEvent) => {
232
+ await this.processLifecycleEvent(e.uid, { ...e, type: `event:${name}:error` as LifecycleType });
231
233
  };
232
234
  emitter.on(`model:event:${name}:start`, onStart);
233
235
  emitter.on(`model:event:${name}:end`, onEnd);
@@ -239,12 +241,12 @@ export class ModelOperationScheduler {
239
241
 
240
242
  private parseEventWhen(when?: ScheduleWhen): { name: string; phase: 'start' | 'end' | 'error' } | null {
241
243
  if (!when || typeof when !== 'string') return null;
242
- const m = /^event:([^:]+):(start|end|error)$/.exec(when);
244
+ const m = /^event:(.+):(start|end|error)$/.exec(when);
243
245
  if (!m) return null;
244
246
  return { name: m[1], phase: m[2] as 'start' | 'end' | 'error' };
245
247
  }
246
248
 
247
- private processLifecycleEvent(targetUid: string, event: LifecycleEvent) {
249
+ private async processLifecycleEvent(targetUid: string, event: LifecycleEvent) {
248
250
  const targetBucket = this.itemsByTargetUid.get(targetUid);
249
251
  if (!targetBucket || targetBucket.size === 0) return;
250
252
  const ids = Array.from(targetBucket.keys());
@@ -253,7 +255,7 @@ export class ModelOperationScheduler {
253
255
  if (!item) continue;
254
256
  const should = this.shouldTrigger(item.options.when, event);
255
257
  if (!should) continue;
256
- void this.tryExecuteOnce(id, event);
258
+ await this.tryExecuteOnce(id, event);
257
259
  }
258
260
  }
259
261
 
package/src/types.ts CHANGED
@@ -213,12 +213,37 @@ export type FlowEventName =
213
213
  // fallback to any string for extensibility
214
214
  | (string & {});
215
215
 
216
+ /**
217
+ * 事件流的执行时机(phase)。
218
+ *
219
+ * 说明:
220
+ * - 缺省(phase 未配置)表示保持现有行为;
221
+ * - 当配置了 phase 时,运行时会将其映射为 `scheduleModelOperation` 的 `when` 锚点;
222
+ * - phase 同时适用于动态事件流(实例级)与静态流(内置)。
223
+ */
224
+ export type FlowEventPhase =
225
+ | 'beforeAllFlows'
226
+ | 'afterAllFlows'
227
+ | 'beforeFlow'
228
+ | 'afterFlow'
229
+ | 'beforeStep'
230
+ | 'afterStep';
231
+
216
232
  /**
217
233
  * Flow 事件类型(供 FlowDefinitionOptions.on 使用)。
218
234
  */
219
235
  export type FlowEvent<TModel extends FlowModel = FlowModel> =
220
236
  | FlowEventName
221
- | { eventName: FlowEventName; defaultParams?: Record<string, any> };
237
+ | {
238
+ eventName: FlowEventName;
239
+ defaultParams?: Record<string, any>;
240
+ /** 动态事件流的执行时机(默认 beforeAllFlows) */
241
+ phase?: FlowEventPhase;
242
+ /** phase 为 beforeFlow/afterFlow/beforeStep/afterStep 时使用 */
243
+ flowKey?: string;
244
+ /** phase 为 beforeStep/afterStep 时使用 */
245
+ stepKey?: string;
246
+ };
222
247
 
223
248
  /**
224
249
  * 事件分发选项。
@@ -491,6 +491,46 @@ describe('resolveExpressions', () => {
491
491
  ],
492
492
  });
493
493
  });
494
+
495
+ test('should resolve dot-only path with dashed keys', async () => {
496
+ ctx.defineProperty('formValues', {
497
+ value: {
498
+ 'oho-test': {
499
+ 'o2m-users': [1, 2],
500
+ },
501
+ },
502
+ });
503
+
504
+ const params = '{{ctx.formValues.oho-test.o2m-users}}';
505
+
506
+ const result = await resolveExpressions(params, ctx);
507
+
508
+ expect(result).toEqual([1, 2]);
509
+ });
510
+
511
+ test('should resolve dashed keys inside template strings', async () => {
512
+ ctx.defineProperty('formValues', {
513
+ value: {
514
+ 'oho-test': {
515
+ 'o2m-users': 'X',
516
+ },
517
+ },
518
+ });
519
+
520
+ const params = 'prefix {{ctx.formValues.oho-test.o2m-users}} suffix';
521
+
522
+ const result = await resolveExpressions(params, ctx);
523
+
524
+ expect(result).toEqual('prefix X suffix');
525
+ });
526
+
527
+ test('should not treat subtraction as dashed key path', async () => {
528
+ const params = '{{ctx.aa.bb-ctx.cc}}';
529
+
530
+ const result = await resolveExpressions(params, ctx);
531
+
532
+ expect(result).toEqual(5);
533
+ });
494
534
  });
495
535
 
496
536
  // 测试高级功能:多表达式模板字符串
@@ -0,0 +1,38 @@
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 { beforeEach, describe, expect, it } from 'vitest';
11
+ import { runjsRequireAsync } from '../runjsModuleLoader';
12
+ import { __resetRunJSSafeGlobalsRegistryForTests, createSafeWindow } from '../safeGlobals';
13
+
14
+ beforeEach(() => {
15
+ __resetRunJSSafeGlobalsRegistryForTests();
16
+ });
17
+
18
+ describe('runjsRequireAsync auto whitelist', () => {
19
+ it('should allow safeWindow to access globals introduced during requireAsync', async () => {
20
+ const key = '__nb_require_async_added_global__';
21
+ delete (window as any)[key];
22
+
23
+ const safeWin: any = createSafeWindow();
24
+ expect(() => safeWin[key]).toThrow(/not allowed/);
25
+
26
+ const requirejs: any = (deps: string[], onLoad: (...args: any[]) => void) => {
27
+ // Simulate a remote library attaching itself to the real window.
28
+ (window as any)[key] = { ok: true };
29
+ onLoad(undefined);
30
+ };
31
+
32
+ await runjsRequireAsync(requirejs, 'https://example.com/fake-lib.js');
33
+
34
+ expect(safeWin[key]).toEqual({ ok: true });
35
+
36
+ delete (window as any)[key];
37
+ });
38
+ });
@@ -0,0 +1,159 @@
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, it, expect, vi } from 'vitest';
11
+ import * as jsxTransform from '../../utils/jsxTransform';
12
+ import { prepareRunJsCode, preprocessRunJsTemplates } from '../runjsTemplateCompat';
13
+
14
+ describe('runjsTemplateCompat', () => {
15
+ describe('preprocessRunJsTemplates', () => {
16
+ it('hoists bare {{ }} placeholders into top-level resolved vars', () => {
17
+ const src = `const a = {{ctx.user.id}};`;
18
+ const out = preprocessRunJsTemplates(src);
19
+ expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");`);
20
+ expect(out).toContain(`const a = __runjs_ctx_tpl_0;`);
21
+ });
22
+
23
+ it('replaces string literals containing {{ }} via split/join, without injecting await into nested functions', () => {
24
+ const src = `const s = '{{ctx.user.id}}';`;
25
+ const out = preprocessRunJsTemplates(src);
26
+ expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");`);
27
+ expect(out).toContain(`__runjs_templateValueToString(__runjs_ctx_tpl_0, "{{ctx.user.id}}")`);
28
+ expect(out).toContain(`.split("{{ctx.user.id}}").join(`);
29
+ });
30
+
31
+ it('does not transform arguments inside explicit ctx.resolveJsonTemplate(...) call', () => {
32
+ const src = `const v = await ctx.resolveJsonTemplate('{{ctx.user.id}}');\nconst s = '{{ctx.user.id}}';`;
33
+ const out = preprocessRunJsTemplates(src);
34
+ // inside call: keep raw
35
+ expect(out).toContain(`await ctx.resolveJsonTemplate('{{ctx.user.id}}')`);
36
+ // outside call: transform
37
+ expect(out).toContain(`__runjs_templateValueToString(__runjs_ctx_tpl_0, "{{ctx.user.id}}")`);
38
+ });
39
+
40
+ it('keeps template markers in comments unchanged', () => {
41
+ const src = `// {{ctx.user.id}}\nconst a = 1;`;
42
+ const out = preprocessRunJsTemplates(src);
43
+ expect(out).toBe(src);
44
+ });
45
+
46
+ it('supports template literals containing {{ }}', () => {
47
+ const src = 'const s = `hi {{ctx.user.name}}`;';
48
+ const out = preprocessRunJsTemplates(src);
49
+ expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.name}}");`);
50
+ expect(out).toContain(`\`hi {{ctx.user.name}}\``);
51
+ expect(out).toContain(`.split("{{ctx.user.name}}").join(`);
52
+ });
53
+
54
+ it('does not rewrite non-ctx {{ }} patterns (e.g. JSX style object) while still rewriting ctx placeholders', () => {
55
+ const src = `const id = {{ctx.user.id}};\nctx.render(<div style={{ width: '100%' }} />);`;
56
+ const out = preprocessRunJsTemplates(src);
57
+ expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");`);
58
+ expect(out).toContain(`style={{ width: '100%' }}`);
59
+ expect(out).not.toContain(`resolveJsonTemplate("{{ width: '100%' }}")`);
60
+ });
61
+
62
+ it('avoids injecting await into non-async nested function bodies', () => {
63
+ const src = `
64
+ function f() {
65
+ return {{ctx.user.id}};
66
+ }
67
+ return f();
68
+ `.trim();
69
+ const out = preprocessRunJsTemplates(src);
70
+ // The only await should be in the top-level preamble
71
+ expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");`);
72
+ expect(out).toContain(`return __runjs_ctx_tpl_0;`);
73
+ expect(out).not.toContain(`return (await ctx.resolveJsonTemplate`);
74
+ });
75
+
76
+ it('is tolerant to already-preprocessed code (idempotent heuristic)', () => {
77
+ const src =
78
+ `const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");\n` +
79
+ `const s = ("{{ctx.user.id}}").split("{{ctx.user.id}}").join(__runjs_templateValueToString(__runjs_ctx_tpl_0, "{{ctx.user.id}}"));`;
80
+ const out = preprocessRunJsTemplates(src);
81
+ expect(out).toBe(src);
82
+ });
83
+
84
+ it('rewrites object literal string keys via computed property to keep syntax valid', () => {
85
+ const src = `const o = { '{{ctx.user.id}}': 1 };`;
86
+ const out = preprocessRunJsTemplates(src);
87
+ expect(out).toContain(`const __runjs_ctx_tpl_0 = await ctx.resolveJsonTemplate("{{ctx.user.id}}");`);
88
+ expect(out).toContain(`{ [`);
89
+ expect(out).toContain(`]: 1 }`);
90
+ expect(out).toContain(`.split("{{ctx.user.id}}").join(`);
91
+ });
92
+ });
93
+
94
+ describe('prepareRunJsCode', () => {
95
+ it('preprocesses templates and compiles JSX', async () => {
96
+ const src = `
97
+ const name = '{{ctx.user.name}}';
98
+ ctx.render(<div className="x">{name}</div>);
99
+ `.trim();
100
+ const out = await prepareRunJsCode(src, { preprocessTemplates: true });
101
+ expect(out).toMatch(/ctx\.React\.createElement/);
102
+ expect(out).toMatch(/ctx\.resolveJsonTemplate/);
103
+ });
104
+
105
+ it('injects ctx.libs ensure preamble for member access', async () => {
106
+ const src = `return ctx.libs.lodash;`;
107
+ const out = await prepareRunJsCode(src, { preprocessTemplates: false });
108
+ expect(out).toContain(`/* __runjs_ensure_libs */`);
109
+ expect(out).toContain(`await ctx.__ensureLibs(["lodash"]);`);
110
+ });
111
+
112
+ it('injects ctx.libs ensure preamble for bracket access with string literal', async () => {
113
+ const src = `return ctx.libs['lodash'];`;
114
+ const out = await prepareRunJsCode(src, { preprocessTemplates: false });
115
+ expect(out).toContain(`await ctx.__ensureLibs(["lodash"]);`);
116
+ });
117
+
118
+ it('injects ctx.libs ensure preamble for object destructuring', async () => {
119
+ const src = `const { lodash } = ctx.libs;\nreturn lodash;`;
120
+ const out = await prepareRunJsCode(src, { preprocessTemplates: false });
121
+ expect(out).toContain(`await ctx.__ensureLibs(["lodash"]);`);
122
+ });
123
+
124
+ it('does not inject ctx.libs preamble when ctx.libs only appears in string/comment', async () => {
125
+ const src = `// ctx.libs.lodash\nconst s = "ctx.libs.lodash";\nreturn s;`;
126
+ const out = await prepareRunJsCode(src, { preprocessTemplates: false });
127
+ expect(out).not.toContain(`__runjs_ensure_libs`);
128
+ });
129
+
130
+ it('is idempotent for already-prepared code', async () => {
131
+ const src = `return ctx.libs['lodash'];`;
132
+ const out1 = await prepareRunJsCode(src, { preprocessTemplates: false });
133
+ const out2 = await prepareRunJsCode(out1, { preprocessTemplates: false });
134
+ expect(out2).toBe(out1);
135
+ expect(out2.match(/__runjs_ensure_libs/g)?.length ?? 0).toBe(1);
136
+ });
137
+
138
+ it('does not break JSX attribute string values when preprocessing templates', async () => {
139
+ const src = `ctx.render(<Input title="{{ctx.user.name}}" />);`;
140
+ const out = await prepareRunJsCode(src, { preprocessTemplates: true });
141
+ expect(out).toMatch(/ctx\.React\.createElement/);
142
+ expect(out).toMatch(/title:\s*\(/);
143
+ expect(out).toContain(`.split("{{ctx.user.name}}").join(`);
144
+ });
145
+
146
+ it('caches prepared code by source and preprocessTemplates option', async () => {
147
+ const spy = vi.spyOn(jsxTransform, 'compileRunJs');
148
+ const src = `/* cache-test */\nconst a = 1;\nreturn a;`;
149
+
150
+ await prepareRunJsCode(src, { preprocessTemplates: false });
151
+ await prepareRunJsCode(src, { preprocessTemplates: false });
152
+ await prepareRunJsCode(src, { preprocessTemplates: true });
153
+ await prepareRunJsCode(src, { preprocessTemplates: true });
154
+
155
+ expect(spy).toHaveBeenCalledTimes(2);
156
+ spy.mockRestore();
157
+ });
158
+ });
159
+ });
@@ -7,8 +7,19 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, expect, it } from 'vitest';
11
- import { createSafeDocument, createSafeWindow, createSafeNavigator } from '../safeGlobals';
10
+ import { beforeEach, describe, expect, it } from 'vitest';
11
+ import {
12
+ __resetRunJSSafeGlobalsRegistryForTests,
13
+ createSafeDocument,
14
+ createSafeNavigator,
15
+ createSafeWindow,
16
+ registerRunJSSafeDocumentGlobals,
17
+ registerRunJSSafeWindowGlobals,
18
+ } from '../safeGlobals';
19
+
20
+ beforeEach(() => {
21
+ __resetRunJSSafeGlobalsRegistryForTests();
22
+ });
12
23
 
13
24
  describe('safeGlobals', () => {
14
25
  it('createSafeWindow exposes only allowed globals and extras', () => {
@@ -21,6 +32,24 @@ describe('safeGlobals', () => {
21
32
  expect(() => win.location.href).toThrow(/not allowed/);
22
33
  });
23
34
 
35
+ it('createSafeWindow allows writing new props and reading them back', () => {
36
+ const win: any = createSafeWindow();
37
+ win.someLib = { ok: true };
38
+ expect(win.someLib).toEqual({ ok: true });
39
+ expect('someLib' in win).toBe(true);
40
+ });
41
+
42
+ it('createSafeWindow can access dynamically registered globals from real window', () => {
43
+ const key = '__nb_safe_window_global__';
44
+ (window as any)[key] = { v: 1 };
45
+ registerRunJSSafeWindowGlobals([key]);
46
+
47
+ const win: any = createSafeWindow();
48
+ expect(win[key]).toEqual({ v: 1 });
49
+
50
+ delete (window as any)[key];
51
+ });
52
+
24
53
  it('createSafeDocument exposes whitelisted methods and extras', () => {
25
54
  const doc: any = createSafeDocument({ bar: true });
26
55
  expect(typeof doc.createElement).toBe('function');
@@ -28,6 +57,24 @@ describe('safeGlobals', () => {
28
57
  expect(() => doc.cookie).toThrow(/not allowed/);
29
58
  });
30
59
 
60
+ it('createSafeDocument allows writing new props and reading them back', () => {
61
+ const doc: any = createSafeDocument();
62
+ doc.someLib = { ok: true };
63
+ expect(doc.someLib).toEqual({ ok: true });
64
+ expect('someLib' in doc).toBe(true);
65
+ });
66
+
67
+ it('createSafeDocument can access dynamically registered globals from real document', () => {
68
+ const key = '__nb_safe_document_global__';
69
+ (document as any)[key] = 123;
70
+ registerRunJSSafeDocumentGlobals([key]);
71
+
72
+ const doc: any = createSafeDocument();
73
+ expect(doc[key]).toBe(123);
74
+
75
+ delete (document as any)[key];
76
+ });
77
+
31
78
  it('createSafeNavigator exposes limited props and guards others', () => {
32
79
  const nav: any = createSafeNavigator();
33
80
  // clipboard object should always exist
@@ -88,6 +88,7 @@ function createMetaBaseProperties(field: CollectionField) {
88
88
  return {
89
89
  title: field.title || field.name,
90
90
  interface: field.interface,
91
+ options: field.options,
91
92
  uiSchema: field.uiSchema || {},
92
93
  };
93
94
  }
@@ -63,9 +63,15 @@ export { parsePathnameToViewParams, type ViewParam } from './parsePathnameToView
63
63
  // 安全全局对象(window/document)
64
64
  export { createSafeDocument, createSafeWindow, createSafeNavigator } from './safeGlobals';
65
65
 
66
+ // RunJS 代码兼容预处理({{ }})与 JSX 编译
67
+ export { prepareRunJsCode, preprocessRunJsTemplates } from './runjsTemplateCompat';
68
+
66
69
  // Ephemeral context helper(用于临时注入属性/方法,避免污染父级 ctx)
67
70
  export { createEphemeralContext } from './createEphemeralContext';
68
71
 
69
72
  // Filter helpers
70
73
  export { pruneFilter } from './pruneFilter';
71
74
  export { isBeforeRenderFlow } from './flows';
75
+
76
+ // Module URL resolver
77
+ export { resolveModuleUrl, isCssFile } from './resolveModuleUrl';
@@ -409,8 +409,13 @@ export async function preprocessExpression(expression: string, ctx: FlowContext)
409
409
  async function compileExpression<TModel extends FlowModel = FlowModel>(expression: string, ctx: FlowContext) {
410
410
  // 仅点号路径匹配:ctx.a.b.c(不支持括号/函数/索引),用于数组聚合取值
411
411
  const matchDotOnly = (expr: string): string | null => {
412
- const m = expr.trim().match(/^ctx\.([a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)$/);
413
- return m ? m[1] : null;
412
+ // 顶层变量名仍使用 JS 标识符规则(与 ctx.defineProperty 保持一致);
413
+ // 子路径允许包含 '-'(例如 formValues.oho-test.o2m-users)。
414
+ const m = expr
415
+ .trim()
416
+ .match(/^ctx\.([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\.([a-zA-Z_$][a-zA-Z0-9_$-]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$-]*)*))?$/);
417
+ if (!m) return null;
418
+ return m[2] ? `${m[1]}.${m[2]}` : m[1];
414
419
  };
415
420
 
416
421
  // 基于 getValuesByPath 的聚合取值:支持数组扁平化,仅支持 '.' 访问
@@ -423,6 +428,20 @@ async function compileExpression<TModel extends FlowModel = FlowModel>(expressio
423
428
  return getValuesByPath(base as object, segs.join('.'));
424
429
  };
425
430
 
431
+ const resolveInnerExpression = async (innerExpr: string): Promise<any> => {
432
+ const dotPath = matchDotOnly(innerExpr);
433
+ if (dotPath) {
434
+ const resolved = await resolveDotOnlyPath(dotPath);
435
+ // 当 dotPath 含 '-' 时可能与减号运算符存在歧义,例如:ctx.aa.bb-ctx.cc。
436
+ // 若按 path 解析未取到值,则回退到 JS 表达式解析,尽量保持兼容。
437
+ if (resolved === undefined && dotPath.includes('-')) {
438
+ return await processExpression(innerExpr, ctx);
439
+ }
440
+ return resolved;
441
+ }
442
+ return await processExpression(innerExpr, ctx);
443
+ };
444
+
426
445
  /**
427
446
  * 单个表达式模式匹配
428
447
  *
@@ -443,11 +462,7 @@ async function compileExpression<TModel extends FlowModel = FlowModel>(expressio
443
462
  const singleMatch = expression.match(/^\s*\{\{\s*([^{}]+?)\s*\}\}\s*$/);
444
463
  if (singleMatch) {
445
464
  const inner = singleMatch[1];
446
- const dotPath = matchDotOnly(inner);
447
- if (dotPath) {
448
- return await resolveDotOnlyPath(dotPath);
449
- }
450
- return await processExpression(inner, ctx);
465
+ return await resolveInnerExpression(inner);
451
466
  }
452
467
 
453
468
  /**
@@ -470,8 +485,7 @@ async function compileExpression<TModel extends FlowModel = FlowModel>(expressio
470
485
  let result = expression;
471
486
 
472
487
  for (const [fullMatch, innerExpr] of matches) {
473
- const dotPath = matchDotOnly(innerExpr);
474
- const value = dotPath ? await resolveDotOnlyPath(dotPath) : await processExpression(innerExpr, ctx);
488
+ const value = await resolveInnerExpression(innerExpr);
475
489
  if (value !== undefined) {
476
490
  const replacement = typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
477
491
  result = result.replace(fullMatch, replacement);
@@ -0,0 +1,91 @@
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
+ /**
11
+ * 解析模块 URL,将相对路径转换为完整的 CDN URL
12
+ *
13
+ * @param url - 模块地址(支持相对路径或完整 URL)
14
+ * @param options - 可选配置
15
+ * @param options.addSuffix - 是否添加 ESM_CDN_SUFFIX 后缀(如 `+esm`),默认为 `true`
16
+ * @returns 解析后的完整 URL
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * // 相对路径会被拼接上 CDN 前缀和后缀(默认添加)
21
+ * // 如果使用 esm.sh(默认),不需要后缀
22
+ * resolveModuleUrl('vue@3.4.0')
23
+ * // => 'https://esm.sh/vue@3.4.0'
24
+ *
25
+ * // 如果使用 jsdelivr,需要配置 ESM_CDN_SUFFIX='/+esm'
26
+ * // resolveModuleUrl('vue@3.4.0') => 'https://cdn.jsdelivr.net/npm/vue@3.4.0/+esm'
27
+ *
28
+ * // 不添加后缀(适用于 UMD 库或 CSS 文件)
29
+ * resolveModuleUrl('vue@3.4.0', { addSuffix: false })
30
+ * // => 'https://esm.sh/vue@3.4.0' (即使配置了 suffix 也不会添加)
31
+ *
32
+ * // 原始 URL(适用于 UMD 库)
33
+ * resolveModuleUrl('lodash@4.17.21/lodash.js', { raw: true })
34
+ * // => 'https://esm.sh/lodash@4.17.21/lodash.js?raw' (即使配置了 suffix 也不会添加)
35
+ *
36
+ * // 完整 URL 保持不变
37
+ * resolveModuleUrl('https://cdn.jsdelivr.net/npm/vue@3.4.0')
38
+ * // => 'https://cdn.jsdelivr.net/npm/vue@3.4.0'
39
+ * ```
40
+ */
41
+ export function resolveModuleUrl(url: string, options?: { addSuffix?: boolean; raw?: boolean }): string {
42
+ if (!url || typeof url !== 'string') {
43
+ throw new Error('invalid url');
44
+ }
45
+
46
+ const u = url.trim();
47
+
48
+ // 如果是完整 URL(http:// 或 https://),直接返回
49
+ if (u.startsWith('http://') || u.startsWith('https://')) {
50
+ return u;
51
+ }
52
+
53
+ // 相对路径:拼接 CDN 前缀和后缀
54
+ const ESM_CDN_BASE_URL = (window as any)['__esm_cdn_base_url__'] || 'https://esm.sh';
55
+ const ESM_CDN_SUFFIX = options?.addSuffix ? (window as any)['__esm_cdn_suffix__'] || '' : '';
56
+
57
+ // 移除 base URL 末尾的斜杠,移除相对路径开头的斜杠
58
+ const base = ESM_CDN_BASE_URL.replace(/\/$/, '');
59
+ const path = u.replace(/^\//, '');
60
+
61
+ if (options?.raw) {
62
+ const sep = path.includes('?') ? '&' : '?';
63
+ return `${base}/${path}${sep}raw`;
64
+ }
65
+
66
+ return `${base}/${path}${ESM_CDN_SUFFIX}`;
67
+ }
68
+
69
+ /**
70
+ * 判断 URL 是否为 CSS 文件
71
+ *
72
+ * @param url - 文件 URL(支持带 query 和 hash,如 `example.css?v=123`)
73
+ * @returns 如果是 CSS 文件返回 `true`,否则返回 `false`
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * isCssFile('style.css') // => true
78
+ * isCssFile('style.css?v=123') // => true
79
+ * isCssFile('style.css#section') // => true
80
+ * isCssFile('script.js') // => false
81
+ * ```
82
+ */
83
+ export function isCssFile(url: string): boolean {
84
+ if (!url || typeof url !== 'string') {
85
+ return false;
86
+ }
87
+
88
+ // 去掉 query 和 hash 后判断文件扩展名
89
+ const pathPart = url.split('?')[0].split('#')[0];
90
+ return pathPart.endsWith('.css');
91
+ }