@nocobase/flow-engine 2.2.0-beta.1 → 2.2.0-beta.10

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 (85) hide show
  1. package/lib/JSRunner.d.ts +1 -0
  2. package/lib/JSRunner.js +110 -20
  3. package/lib/components/FieldModelRenderer.js +44 -31
  4. package/lib/components/MobilePopup.js +28 -10
  5. package/lib/components/MobilePopup.style.js +27 -6
  6. package/lib/components/dnd/index.js +9 -2
  7. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
  8. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
  9. package/lib/flowContext.d.ts +1 -1
  10. package/lib/flowContext.js +97 -42
  11. package/lib/flowEngine.js +6 -0
  12. package/lib/flowSettings.d.ts +5 -1
  13. package/lib/flowSettings.js +70 -0
  14. package/lib/locale/en-US.json +1 -0
  15. package/lib/locale/index.d.ts +2 -0
  16. package/lib/locale/zh-CN.json +1 -0
  17. package/lib/resources/apiResource.js +2 -1
  18. package/lib/resources/baseRecordResource.js +6 -17
  19. package/lib/resources/multiRecordResource.js +13 -3
  20. package/lib/resources/singleRecordResource.js +7 -2
  21. package/lib/runjs-context/helpers.js +12 -5
  22. package/lib/types.d.ts +12 -0
  23. package/lib/utils/dataSourceDirty.d.ts +20 -0
  24. package/lib/utils/dataSourceDirty.js +139 -0
  25. package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
  26. package/lib/utils/dirtyAwareApiClient.js +378 -0
  27. package/lib/utils/index.d.ts +1 -1
  28. package/lib/utils/index.js +11 -11
  29. package/lib/utils/loadedPageCache.d.ts +1 -0
  30. package/lib/utils/loadedPageCache.js +6 -0
  31. package/lib/utils/openViewRouteState.d.ts +28 -0
  32. package/lib/utils/openViewRouteState.js +125 -0
  33. package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
  34. package/lib/utils/parsePathnameToViewParams.js +18 -1
  35. package/lib/utils/resolveRunJSObjectValues.js +3 -2
  36. package/lib/utils/runjsModuleLoader.js +0 -30
  37. package/lib/views/ViewNavigation.js +5 -0
  38. package/package.json +4 -4
  39. package/src/JSRunner.ts +112 -25
  40. package/src/__tests__/JSRunner.test.ts +4 -5
  41. package/src/__tests__/flowContext.test.ts +154 -0
  42. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
  43. package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
  44. package/src/__tests__/flowSettings.test.ts +72 -0
  45. package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
  46. package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
  47. package/src/components/FieldModelRenderer.tsx +50 -36
  48. package/src/components/MobilePopup.style.ts +34 -7
  49. package/src/components/MobilePopup.tsx +30 -10
  50. package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
  51. package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
  52. package/src/components/__tests__/MobilePopup.test.tsx +109 -0
  53. package/src/components/dnd/index.tsx +11 -2
  54. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
  55. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
  56. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
  57. package/src/flowContext.ts +104 -36
  58. package/src/flowEngine.ts +6 -0
  59. package/src/flowSettings.ts +85 -1
  60. package/src/locale/en-US.json +1 -0
  61. package/src/locale/zh-CN.json +1 -0
  62. package/src/resources/apiResource.ts +2 -1
  63. package/src/resources/baseRecordResource.ts +6 -23
  64. package/src/resources/multiRecordResource.ts +13 -3
  65. package/src/resources/singleRecordResource.ts +6 -1
  66. package/src/runjs-context/helpers.ts +12 -6
  67. package/src/types.ts +14 -0
  68. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
  69. package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
  70. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
  71. package/src/utils/dataSourceDirty.ts +126 -0
  72. package/src/utils/dirtyAwareApiClient.ts +430 -0
  73. package/src/utils/index.ts +10 -9
  74. package/src/utils/loadedPageCache.ts +7 -0
  75. package/src/utils/openViewRouteState.ts +107 -0
  76. package/src/utils/parsePathnameToViewParams.ts +23 -1
  77. package/src/utils/resolveRunJSObjectValues.ts +5 -2
  78. package/src/utils/runjsModuleLoader.ts +0 -32
  79. package/src/views/ViewNavigation.ts +6 -1
  80. package/src/views/__tests__/ViewNavigation.test.ts +15 -0
  81. package/lib/utils/safeGlobals.d.ts +0 -28
  82. package/lib/utils/safeGlobals.js +0 -367
  83. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
  84. package/src/utils/__tests__/safeGlobals.test.ts +0 -106
  85. package/src/utils/safeGlobals.ts +0 -406
@@ -0,0 +1,125 @@
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
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
15
+ var __export = (target, all) => {
16
+ for (var name in all)
17
+ __defProp(target, name, { get: all[name], enumerable: true });
18
+ };
19
+ var __copyProps = (to, from, except, desc) => {
20
+ if (from && typeof from === "object" || typeof from === "function") {
21
+ for (let key of __getOwnPropNames(from))
22
+ if (!__hasOwnProp.call(to, key) && key !== except)
23
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
24
+ }
25
+ return to;
26
+ };
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+ var openViewRouteState_exports = {};
29
+ __export(openViewRouteState_exports, {
30
+ RUNJS_OPEN_VIEW_ROUTE_STATE: () => RUNJS_OPEN_VIEW_ROUTE_STATE,
31
+ createOpenViewRouteState: () => createOpenViewRouteState,
32
+ decodeOpenViewRouteState: () => decodeOpenViewRouteState,
33
+ encodeOpenViewRouteState: () => encodeOpenViewRouteState,
34
+ isOpenViewRouteStateToken: () => isOpenViewRouteStateToken
35
+ });
36
+ module.exports = __toCommonJS(openViewRouteState_exports);
37
+ const OPEN_VIEW_ROUTE_MODES = ["drawer", "dialog", "embed"];
38
+ const OPEN_VIEW_ROUTE_SIZES = ["small", "medium", "large"];
39
+ const OPEN_VIEW_ROUTE_STATE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
40
+ const OPEN_VIEW_ROUTE_STATE_TOKEN_LENGTH = 8;
41
+ const RUNJS_OPEN_VIEW_ROUTE_STATE = Symbol.for("nocobase.runjs.openViewRouteState");
42
+ function isOpenViewRouteMode(value) {
43
+ return typeof value === "string" && OPEN_VIEW_ROUTE_MODES.includes(value);
44
+ }
45
+ __name(isOpenViewRouteMode, "isOpenViewRouteMode");
46
+ function isOpenViewRouteSize(value) {
47
+ return typeof value === "string" && OPEN_VIEW_ROUTE_SIZES.includes(value);
48
+ }
49
+ __name(isOpenViewRouteSize, "isOpenViewRouteSize");
50
+ function createOpenViewRouteState(input) {
51
+ const state = {};
52
+ if (isOpenViewRouteMode(input == null ? void 0 : input.mode)) {
53
+ state.mode = input.mode;
54
+ }
55
+ if (isOpenViewRouteSize(input == null ? void 0 : input.size)) {
56
+ state.size = input.size;
57
+ }
58
+ return state.mode || state.size ? state : void 0;
59
+ }
60
+ __name(createOpenViewRouteState, "createOpenViewRouteState");
61
+ function hashString(value) {
62
+ let hash = 2166136261;
63
+ for (let i = 0; i < value.length; i++) {
64
+ hash ^= value.charCodeAt(i);
65
+ hash = Math.imul(hash, 16777619);
66
+ }
67
+ return hash >>> 0;
68
+ }
69
+ __name(hashString, "hashString");
70
+ function stateToCode(state) {
71
+ const modeIndex = state.mode ? OPEN_VIEW_ROUTE_MODES.indexOf(state.mode) + 1 : 0;
72
+ const sizeIndex = state.size ? OPEN_VIEW_ROUTE_SIZES.indexOf(state.size) + 1 : 0;
73
+ const code = modeIndex * 4 + sizeIndex;
74
+ return code > 0 ? code : void 0;
75
+ }
76
+ __name(stateToCode, "stateToCode");
77
+ function codeToState(code) {
78
+ const modeIndex = Math.floor(code / 4);
79
+ const sizeIndex = code % 4;
80
+ return createOpenViewRouteState({
81
+ mode: modeIndex ? OPEN_VIEW_ROUTE_MODES[modeIndex - 1] : void 0,
82
+ size: sizeIndex ? OPEN_VIEW_ROUTE_SIZES[sizeIndex - 1] : void 0
83
+ });
84
+ }
85
+ __name(codeToState, "codeToState");
86
+ function tokenForCode(viewUid, code) {
87
+ let seed = hashString(`${viewUid}:${code}`);
88
+ let token = "";
89
+ for (let i = 0; i < OPEN_VIEW_ROUTE_STATE_TOKEN_LENGTH; i++) {
90
+ seed = Math.imul(seed ^ code + i * 17, 16777619) >>> 0;
91
+ token += OPEN_VIEW_ROUTE_STATE_TOKEN_ALPHABET[seed % OPEN_VIEW_ROUTE_STATE_TOKEN_ALPHABET.length];
92
+ }
93
+ return token;
94
+ }
95
+ __name(tokenForCode, "tokenForCode");
96
+ function isOpenViewRouteStateToken(value) {
97
+ return typeof value === "string" && value.length === OPEN_VIEW_ROUTE_STATE_TOKEN_LENGTH && [...value].every((char) => OPEN_VIEW_ROUTE_STATE_TOKEN_ALPHABET.includes(char));
98
+ }
99
+ __name(isOpenViewRouteStateToken, "isOpenViewRouteStateToken");
100
+ function encodeOpenViewRouteState(viewUid, input) {
101
+ const state = createOpenViewRouteState(input);
102
+ const code = state ? stateToCode(state) : void 0;
103
+ return code ? tokenForCode(viewUid, code) : void 0;
104
+ }
105
+ __name(encodeOpenViewRouteState, "encodeOpenViewRouteState");
106
+ function decodeOpenViewRouteState(viewUid, token) {
107
+ if (!isOpenViewRouteStateToken(token)) {
108
+ return void 0;
109
+ }
110
+ for (let code = 1; code < 16; code++) {
111
+ if (tokenForCode(viewUid, code) === token) {
112
+ return codeToState(code);
113
+ }
114
+ }
115
+ return void 0;
116
+ }
117
+ __name(decodeOpenViewRouteState, "decodeOpenViewRouteState");
118
+ // Annotate the CommonJS export names for ESM import in node:
119
+ 0 && (module.exports = {
120
+ RUNJS_OPEN_VIEW_ROUTE_STATE,
121
+ createOpenViewRouteState,
122
+ decodeOpenViewRouteState,
123
+ encodeOpenViewRouteState,
124
+ isOpenViewRouteStateToken
125
+ });
@@ -6,6 +6,7 @@
6
6
  * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
+ import { type OpenViewRouteState } from './openViewRouteState';
9
10
  export interface ViewParam {
10
11
  /** 视图唯一标识符,一般为某个 Model 实例的 uid */
11
12
  viewUid: string;
@@ -15,6 +16,8 @@ export interface ViewParam {
15
16
  filterByTk?: string | Record<string, string | number>;
16
17
  /** source Id */
17
18
  sourceId?: string;
19
+ /** RunJS ctx.openView runtime display overrides decoded from URL. */
20
+ openViewRouteState?: OpenViewRouteState;
18
21
  }
19
22
  export interface ParsePathnameToViewParamsOptions {
20
23
  rootPrefix?: string;
@@ -30,6 +30,7 @@ __export(parsePathnameToViewParams_exports, {
30
30
  parsePathnameToViewParams: () => parsePathnameToViewParams
31
31
  });
32
32
  module.exports = __toCommonJS(parsePathnameToViewParams_exports);
33
+ var import_openViewRouteState = require("./openViewRouteState");
33
34
  const normalizePathname = /* @__PURE__ */ __name((pathname) => {
34
35
  if (!pathname || pathname === "/") {
35
36
  return "/";
@@ -77,7 +78,23 @@ const parsePathnameToViewParams = /* @__PURE__ */ __name((pathname, options = {}
77
78
  } else {
78
79
  break;
79
80
  }
80
- } else if (currentView && i + 1 < segments.length) {
81
+ } else if (currentView) {
82
+ if (segment === "opts") {
83
+ if (i + 1 < segments.length) {
84
+ const routeState = (0, import_openViewRouteState.decodeOpenViewRouteState)(currentView.viewUid, segments[i + 1]);
85
+ if (routeState) {
86
+ currentView.openViewRouteState = routeState;
87
+ }
88
+ i += 2;
89
+ } else {
90
+ i++;
91
+ }
92
+ continue;
93
+ }
94
+ if (i + 1 >= segments.length) {
95
+ i++;
96
+ continue;
97
+ }
81
98
  const rawValue = segments[i + 1];
82
99
  let decoded = rawValue;
83
100
  try {
@@ -31,8 +31,8 @@ __export(resolveRunJSObjectValues_exports, {
31
31
  });
32
32
  module.exports = __toCommonJS(resolveRunJSObjectValues_exports);
33
33
  var import_runjsValue = require("./runjsValue");
34
- var import_safeGlobals = require("./safeGlobals");
35
34
  async function resolveRunJSObjectValues(ctx, raw) {
35
+ var _a;
36
36
  const out = {};
37
37
  if (!raw || typeof raw !== "object") return out;
38
38
  if (Array.isArray(raw)) return out;
@@ -41,7 +41,8 @@ async function resolveRunJSObjectValues(ctx, raw) {
41
41
  if ((0, import_runjsValue.isRunJSValue)(value)) {
42
42
  const { code, version } = (0, import_runjsValue.normalizeRunJSValue)(value);
43
43
  if (!code.trim()) continue;
44
- const ret = await (0, import_safeGlobals.runjsWithSafeGlobals)(ctx, code, { version });
44
+ const runjsCtx = ctx;
45
+ const ret = await ((_a = runjsCtx == null ? void 0 : runjsCtx.runjs) == null ? void 0 : _a.call(runjsCtx, code, void 0, { version }));
45
46
  if (!(ret == null ? void 0 : ret.success)) {
46
47
  throw new Error(`RunJS execution failed for "${key}"`);
47
48
  }
@@ -34,27 +34,6 @@ __export(runjsModuleLoader_exports, {
34
34
  module.exports = __toCommonJS(runjsModuleLoader_exports);
35
35
  var import_runjsLibs = require("../runjsLibs");
36
36
  var import_resolveModuleUrl = require("./resolveModuleUrl");
37
- var import_safeGlobals = require("./safeGlobals");
38
- function snapshotOwnKeys(obj) {
39
- try {
40
- if (!obj || typeof obj !== "object" && typeof obj !== "function") return [];
41
- return Object.getOwnPropertyNames(obj);
42
- } catch (_) {
43
- return [];
44
- }
45
- }
46
- __name(snapshotOwnKeys, "snapshotOwnKeys");
47
- function diffAddedKeys(afterKeys, beforeKeys) {
48
- if (!afterKeys.length) return [];
49
- if (!beforeKeys.length) return [...afterKeys];
50
- const beforeSet = new Set(beforeKeys);
51
- const added = [];
52
- for (const k of afterKeys) {
53
- if (!beforeSet.has(k)) added.push(k);
54
- }
55
- return added;
56
- }
57
- __name(diffAddedKeys, "diffAddedKeys");
58
37
  async function withRunjsModuleLoadLock(task) {
59
38
  const g = globalThis;
60
39
  g.__nocobaseRunjsModuleLoadLock = (g.__nocobaseRunjsModuleLoadLock || Promise.resolve()).catch(() => {
@@ -222,8 +201,6 @@ async function prefetchEsmModule(url, options) {
222
201
  __name(prefetchEsmModule, "prefetchEsmModule");
223
202
  async function runjsRequireAsync(requirejs, url) {
224
203
  return await withRunjsModuleLoadLock(async () => {
225
- const beforeWinKeys = typeof window !== "undefined" ? snapshotOwnKeys(window) : [];
226
- const beforeDocKeys = typeof document !== "undefined" ? snapshotOwnKeys(document) : [];
227
204
  let result;
228
205
  let error;
229
206
  try {
@@ -242,13 +219,6 @@ async function runjsRequireAsync(requirejs, url) {
242
219
  });
243
220
  } catch (e) {
244
221
  error = e;
245
- } finally {
246
- const afterWinKeys = typeof window !== "undefined" ? snapshotOwnKeys(window) : [];
247
- const afterDocKeys = typeof document !== "undefined" ? snapshotOwnKeys(document) : [];
248
- const addedWinKeys = diffAddedKeys(afterWinKeys, beforeWinKeys);
249
- const addedDocKeys = diffAddedKeys(afterDocKeys, beforeDocKeys);
250
- (0, import_safeGlobals.registerRunJSSafeWindowGlobals)(addedWinKeys);
251
- (0, import_safeGlobals.registerRunJSSafeDocumentGlobals)(addedDocKeys);
252
222
  }
253
223
  if (error) throw error;
254
224
  return result;
@@ -32,6 +32,7 @@ __export(ViewNavigation_exports, {
32
32
  });
33
33
  module.exports = __toCommonJS(ViewNavigation_exports);
34
34
  var import_reactive = require("../reactive");
35
+ var import_utils = require("../utils");
35
36
  function encodeFilterByTk(val) {
36
37
  if (val === void 0 || val === null) return "";
37
38
  if (val && typeof val === "object" && !Array.isArray(val)) {
@@ -63,6 +64,10 @@ function generatePathnameFromViewParams(viewParams, options = {}) {
63
64
  segments.push("view");
64
65
  }
65
66
  segments.push(viewParam.viewUid);
67
+ const openViewRouteStateToken = (0, import_utils.encodeOpenViewRouteState)(viewParam.viewUid, viewParam.openViewRouteState);
68
+ if (openViewRouteStateToken) {
69
+ segments.push("opts", openViewRouteStateToken);
70
+ }
66
71
  if (viewParam.tabUid) {
67
72
  segments.push("tab", viewParam.tabUid);
68
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.2.0-beta.1",
3
+ "version": "2.2.0-beta.10",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.2.0-beta.1",
12
- "@nocobase/shared": "2.2.0-beta.1",
11
+ "@nocobase/sdk": "2.2.0-beta.10",
12
+ "@nocobase/shared": "2.2.0-beta.10",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "f82fa9d0c3aa8e00e53dd94e404a312483b4866b"
40
+ "gitHead": "d572beec24de46df948f28db72b25303a63bf62d"
41
41
  }
package/src/JSRunner.ts CHANGED
@@ -39,6 +39,117 @@ export function shouldPreprocessRunJSTemplates(
39
39
  return options?.version !== 'v2';
40
40
  }
41
41
 
42
+ const RUNJS_BROWSER_GLOBAL_NAMES = [
43
+ 'fetch',
44
+ 'localStorage',
45
+ 'sessionStorage',
46
+ 'XMLHttpRequest',
47
+ 'WebSocket',
48
+ 'Worker',
49
+ 'SharedWorker',
50
+ 'ServiceWorker',
51
+ 'BroadcastChannel',
52
+ 'EventSource',
53
+ 'indexedDB',
54
+ 'caches',
55
+ 'Function',
56
+ 'eval',
57
+ 'globalThis',
58
+ 'Intl',
59
+ 'Blob',
60
+ 'URL',
61
+ 'location',
62
+ ] as const;
63
+
64
+ export const RUNJS_ALLOWED_BARE_GLOBAL_NAMES = [
65
+ 'ctx',
66
+ 'console',
67
+ 'window',
68
+ 'document',
69
+ 'navigator',
70
+ 'setTimeout',
71
+ 'clearTimeout',
72
+ 'setInterval',
73
+ 'clearInterval',
74
+ 'Array',
75
+ 'ArrayBuffer',
76
+ 'BigInt',
77
+ 'BigInt64Array',
78
+ 'BigUint64Array',
79
+ 'Boolean',
80
+ 'DataView',
81
+ 'Date',
82
+ 'Error',
83
+ 'EvalError',
84
+ 'FinalizationRegistry',
85
+ 'Float32Array',
86
+ 'Float64Array',
87
+ 'Int8Array',
88
+ 'Int16Array',
89
+ 'Int32Array',
90
+ 'Map',
91
+ 'Math',
92
+ 'Number',
93
+ 'Object',
94
+ 'Promise',
95
+ 'Proxy',
96
+ 'RangeError',
97
+ 'ReferenceError',
98
+ 'Reflect',
99
+ 'RegExp',
100
+ 'Set',
101
+ 'String',
102
+ 'Symbol',
103
+ 'SyntaxError',
104
+ 'TypeError',
105
+ 'URIError',
106
+ 'Uint8Array',
107
+ 'Uint8ClampedArray',
108
+ 'Uint16Array',
109
+ 'Uint32Array',
110
+ 'WeakMap',
111
+ 'WeakRef',
112
+ 'WeakSet',
113
+ 'JSON',
114
+ 'decodeURI',
115
+ 'decodeURIComponent',
116
+ 'encodeURI',
117
+ 'encodeURIComponent',
118
+ 'isFinite',
119
+ 'isNaN',
120
+ 'parseFloat',
121
+ 'parseInt',
122
+ 'undefined',
123
+ 'NaN',
124
+ 'Infinity',
125
+ ...RUNJS_BROWSER_GLOBAL_NAMES,
126
+ ] as const;
127
+
128
+ function collectRunJSBrowserGlobals(providedGlobals: Record<string, unknown> = {}) {
129
+ const windowGlobal = providedGlobals.window;
130
+ if (!windowGlobal || typeof windowGlobal !== 'object') {
131
+ return {};
132
+ }
133
+
134
+ const windowRecord = windowGlobal as Record<string, unknown>;
135
+ const globals: Record<string, unknown> = {};
136
+ RUNJS_BROWSER_GLOBAL_NAMES.forEach((name) => {
137
+ if (Object.prototype.hasOwnProperty.call(providedGlobals, name)) {
138
+ return;
139
+ }
140
+ try {
141
+ const value = windowRecord[name];
142
+ if (typeof value === 'undefined') {
143
+ return;
144
+ }
145
+ globals[name] = name === 'fetch' && typeof value === 'function' ? value.bind(windowGlobal) : value;
146
+ } catch {
147
+ // Ignore browser globals that cannot be read in the current environment.
148
+ }
149
+ });
150
+ return globals;
151
+ }
152
+
42
153
  // Heuristic: detect likely bare `{{ctx.xxx}}` usage in executable positions (not quoted string literals).
43
154
  const BARE_CTX_TEMPLATE_RE = /(^|[=(:,[\s)])(\{\{\s*(ctx(?:\.|\[|\?\.)[^}]*)\s*\}\})/m;
44
155
 
@@ -98,31 +209,7 @@ export class JSRunner {
98
209
  };
99
210
 
100
211
  const providedGlobals = options.globals || {};
101
- const liftedGlobals: Record<string, any> = {};
102
-
103
- // Auto-lift selected globals from safe window into top-level sandbox globals
104
- // so user code can access them directly (e.g. `new Blob(...)`).
105
- if (!Object.prototype.hasOwnProperty.call(providedGlobals, 'Blob')) {
106
- try {
107
- const blobCtor = (providedGlobals as any).window?.Blob;
108
- if (typeof blobCtor !== 'undefined') {
109
- liftedGlobals.Blob = blobCtor;
110
- }
111
- } catch {
112
- // ignore when window proxy blocks property access
113
- }
114
- }
115
-
116
- if (!Object.prototype.hasOwnProperty.call(providedGlobals, 'URL')) {
117
- try {
118
- const urlCtor = (providedGlobals as any).window?.URL;
119
- if (typeof urlCtor !== 'undefined') {
120
- liftedGlobals.URL = urlCtor;
121
- }
122
- } catch {
123
- // ignore when window proxy blocks property access
124
- }
125
- }
212
+ const liftedGlobals = collectRunJSBrowserGlobals(providedGlobals);
126
213
 
127
214
  this.globals = {
128
215
  console,
@@ -9,7 +9,6 @@
9
9
 
10
10
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
11
  import { JSRunner, shouldPreprocessRunJSTemplates } from '../JSRunner';
12
- import { createSafeWindow } from '../utils';
13
12
 
14
13
  describe('JSRunner', () => {
15
14
  let originalSearch: string;
@@ -68,7 +67,7 @@ describe('JSRunner', () => {
68
67
 
69
68
  const runner = new JSRunner({
70
69
  globals: {
71
- window: createSafeWindow(),
70
+ window,
72
71
  },
73
72
  });
74
73
 
@@ -84,7 +83,7 @@ describe('JSRunner', () => {
84
83
 
85
84
  const runner = new JSRunner({
86
85
  globals: {
87
- window: createSafeWindow(),
86
+ window,
88
87
  Blob: explicitBlob,
89
88
  },
90
89
  });
@@ -97,7 +96,7 @@ describe('JSRunner', () => {
97
96
  it('auto-lifts URL from injected window to top-level globals', async () => {
98
97
  const runner = new JSRunner({
99
98
  globals: {
100
- window: createSafeWindow(),
99
+ window,
101
100
  },
102
101
  });
103
102
 
@@ -114,7 +113,7 @@ describe('JSRunner', () => {
114
113
 
115
114
  const runner = new JSRunner({
116
115
  globals: {
117
- window: createSafeWindow(),
116
+ window,
118
117
  URL: explicitURL,
119
118
  },
120
119
  });
@@ -14,6 +14,8 @@ import { FlowEngine } from '../flowEngine';
14
14
  import { FlowModel } from '../models/flowModel';
15
15
  import { RunJSContextRegistry } from '../runjs-context/registry';
16
16
  import { setupRunJSContexts } from '../runjs-context/setup';
17
+ import { createViewScopedEngine } from '../ViewScopedFlowEngine';
18
+ import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
17
19
 
18
20
  describe('FlowContext properties and methods', () => {
19
21
  it('should return static property value', () => {
@@ -177,6 +179,49 @@ describe('FlowContext properties and methods', () => {
177
179
  });
178
180
  });
179
181
 
182
+ it('should expose current role as a top-level variable', async () => {
183
+ const engine = new FlowEngine();
184
+ const ctx = engine.context;
185
+ ctx.defineProperty('api', { value: { auth: { role: 'admin' } } });
186
+
187
+ expect(ctx.role).toBe('admin');
188
+ await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toBe('admin');
189
+
190
+ const roleNode = ctx.getPropertyMetaTree().find((node) => node.name === 'role');
191
+ expect(roleNode).toMatchObject({
192
+ name: 'role',
193
+ title: '{{t("Current role")}}',
194
+ paths: ['role'],
195
+ });
196
+ });
197
+
198
+ it('should expose actual role names for union role mode', async () => {
199
+ const engine = new FlowEngine();
200
+ const ctx = engine.context;
201
+ ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
202
+ ctx.defineProperty('user', {
203
+ value: {
204
+ roles: [
205
+ { name: 'admin', title: 'Admin' },
206
+ { name: 'member', title: 'Member' },
207
+ ],
208
+ },
209
+ });
210
+
211
+ expect(ctx.role).toEqual(['admin', 'member']);
212
+ await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual(['admin', 'member']);
213
+ });
214
+
215
+ it('should expose an empty role list for union role mode without user roles', async () => {
216
+ const engine = new FlowEngine();
217
+ const ctx = engine.context;
218
+ ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
219
+ ctx.defineProperty('user', { value: {} });
220
+
221
+ expect(ctx.role).toEqual([]);
222
+ await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual([]);
223
+ });
224
+
180
225
  it('should throw sync error in get', () => {
181
226
  const ctx = new FlowContext();
182
227
  ctx.defineProperty('error', {
@@ -1386,6 +1431,92 @@ describe('FlowEngine context', () => {
1386
1431
  expect(engine.context.appName).toBe('NocoBase');
1387
1432
  });
1388
1433
 
1434
+ it('ctx.api should return a dirty-aware wrapper for static api properties', async () => {
1435
+ const engine = new FlowEngine();
1436
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
1437
+ const api = {
1438
+ auth: { locale: 'zh-CN' },
1439
+ request: vi.fn(async () => ({ data: { ok: true } })),
1440
+ resource: vi.fn(() => ({ update })),
1441
+ };
1442
+ engine.context.defineProperty('api', { value: api });
1443
+
1444
+ await engine.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
1445
+
1446
+ expect(update).toHaveBeenCalledTimes(1);
1447
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1448
+ expect(engine.context.api).toBe(engine.context.api);
1449
+ });
1450
+
1451
+ it('ctx.api should stay dirty-aware when resolved from a scoped context delegate', async () => {
1452
+ const root = new FlowEngine();
1453
+ const scoped = createViewScopedEngine(root);
1454
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
1455
+ root.context.defineProperty('api', {
1456
+ value: {
1457
+ auth: { locale: 'zh-CN' },
1458
+ request: vi.fn(async () => ({ data: { ok: true } })),
1459
+ resource: vi.fn(() => ({ update })),
1460
+ },
1461
+ });
1462
+
1463
+ await scoped.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
1464
+
1465
+ expect(update).toHaveBeenCalledTimes(1);
1466
+ expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1467
+ expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1468
+ });
1469
+
1470
+ it('ctx.request should use the dirty-aware api wrapper', async () => {
1471
+ const engine = new FlowEngine();
1472
+ const request = vi.fn(async () => ({ data: { ok: true } }));
1473
+ engine.context.defineProperty('api', {
1474
+ value: {
1475
+ auth: { locale: 'zh-CN' },
1476
+ request,
1477
+ resource: vi.fn(),
1478
+ },
1479
+ });
1480
+
1481
+ await engine.context.request({
1482
+ resource: 'posts',
1483
+ action: 'update',
1484
+ headers: { 'X-Data-Source': 'analytics' },
1485
+ params: { filterByTk: 1 },
1486
+ } as any);
1487
+
1488
+ expect(request).toHaveBeenCalledTimes(1);
1489
+ expect(engine.getDataSourceDirtyVersion('analytics', 'posts')).toBe(1);
1490
+ });
1491
+
1492
+ it('ctx.request should use the caller context when resolved through a scoped delegate', async () => {
1493
+ const root = new FlowEngine();
1494
+ const scoped = createViewScopedEngine(root);
1495
+ const callerCtx = new FlowContext();
1496
+ const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
1497
+ const request = vi.fn(async () => ({ data: { ok: true } }));
1498
+ root.context.defineProperty('api', {
1499
+ value: {
1500
+ auth: { locale: 'zh-CN' },
1501
+ request,
1502
+ resource: vi.fn(),
1503
+ },
1504
+ });
1505
+ callerCtx.addDelegate(scoped.context);
1506
+ scoped.context.engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
1507
+
1508
+ await callerCtx.request({
1509
+ resource: 'posts',
1510
+ action: 'update',
1511
+ params: { filterByTk: 1 },
1512
+ } as any);
1513
+
1514
+ expect(request).toHaveBeenCalledTimes(1);
1515
+ expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1516
+ expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1517
+ expect(dirtyEvents).toEqual([{ dataSourceKey: 'main', resourceNames: ['posts'] }]);
1518
+ });
1519
+
1389
1520
  it('ctx.sql should resolve template variables from caller context in delegate chain', async () => {
1390
1521
  const engine = new FlowEngine();
1391
1522
  const request = vi.fn(async () => ({ data: { data: [] } }));
@@ -1476,6 +1607,29 @@ describe('FlowEngine context', () => {
1476
1607
  await expect((engine.context as any).getVar('foo.bar')).rejects.toThrow();
1477
1608
  });
1478
1609
 
1610
+ it('model.context.getVar should resolve the latest ctx.auth.user after user changes', async () => {
1611
+ const engine = new FlowEngine();
1612
+ engine.context.defineProperty('api', {
1613
+ value: { auth: { role: 'admin', locale: 'en-US', token: 'token-1' } },
1614
+ });
1615
+ engine.context.defineProperty('user', { value: { id: 1, nickname: 'Alice' } });
1616
+
1617
+ class AuthUserModel extends FlowModel {}
1618
+ engine.registerModels({ AuthUserModel });
1619
+ const model = engine.createModel({ use: 'AuthUserModel' });
1620
+
1621
+ expect(await model.context.getVar('ctx.auth.user.id')).toBe(1);
1622
+ expect(await model.context.getVar('ctx.auth.token')).toBe('token-1');
1623
+
1624
+ engine.context.api.auth.role = 'member';
1625
+ engine.context.api.auth.token = 'token-2';
1626
+ engine.context.defineProperty('user', { value: { id: 2, nickname: 'Bob' } });
1627
+
1628
+ expect(await model.context.getVar('ctx.auth.user.id')).toBe(2);
1629
+ expect(await model.context.getVar('ctx.auth.roleName')).toBe('member');
1630
+ expect(await model.context.getVar('ctx.auth.token')).toBe('token-2');
1631
+ });
1632
+
1479
1633
  it('engine.context.runAction should resolve action from engine.getAction', async () => {
1480
1634
  const engine = new FlowEngine();
1481
1635