@nocobase/flow-engine 2.1.11 → 2.2.0-alpha.2

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 (67) hide show
  1. package/lib/JSRunner.d.ts +1 -0
  2. package/lib/JSRunner.js +110 -20
  3. package/lib/components/FlowContextSelector.js +24 -3
  4. package/lib/components/variables/VariableHybridInput.d.ts +8 -0
  5. package/lib/components/variables/VariableHybridInput.js +128 -12
  6. package/lib/components/variables/types.d.ts +8 -0
  7. package/lib/flowContext.d.ts +1 -1
  8. package/lib/flowContext.js +33 -7
  9. package/lib/flowI18n.js +3 -3
  10. package/lib/locale/en-US.json +1 -0
  11. package/lib/locale/index.d.ts +2 -0
  12. package/lib/locale/zh-CN.json +1 -0
  13. package/lib/resources/apiResource.js +2 -1
  14. package/lib/resources/baseRecordResource.js +6 -17
  15. package/lib/resources/multiRecordResource.js +13 -3
  16. package/lib/resources/singleRecordResource.js +7 -2
  17. package/lib/runjs-context/helpers.js +12 -5
  18. package/lib/utils/dataSourceDirty.d.ts +20 -0
  19. package/lib/utils/dataSourceDirty.js +139 -0
  20. package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
  21. package/lib/utils/dirtyAwareApiClient.js +378 -0
  22. package/lib/utils/index.d.ts +1 -1
  23. package/lib/utils/index.js +11 -11
  24. package/lib/utils/openViewRouteState.d.ts +28 -0
  25. package/lib/utils/openViewRouteState.js +125 -0
  26. package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
  27. package/lib/utils/parsePathnameToViewParams.js +18 -1
  28. package/lib/utils/resolveRunJSObjectValues.js +3 -2
  29. package/lib/utils/runjsModuleLoader.js +0 -30
  30. package/lib/views/ViewNavigation.js +5 -0
  31. package/package.json +4 -4
  32. package/src/JSRunner.ts +112 -25
  33. package/src/__tests__/JSRunner.test.ts +4 -5
  34. package/src/__tests__/flowContext.test.ts +88 -0
  35. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
  36. package/src/__tests__/flowI18n.test.ts +11 -0
  37. package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
  38. package/src/components/FlowContextSelector.tsx +33 -2
  39. package/src/components/variables/VariableHybridInput.tsx +166 -9
  40. package/src/components/variables/__tests__/VariableHybridInput.test.tsx +178 -0
  41. package/src/components/variables/types.ts +8 -0
  42. package/src/flowContext.ts +43 -8
  43. package/src/flowI18n.ts +8 -3
  44. package/src/locale/en-US.json +1 -0
  45. package/src/locale/zh-CN.json +1 -0
  46. package/src/resources/apiResource.ts +2 -1
  47. package/src/resources/baseRecordResource.ts +6 -23
  48. package/src/resources/multiRecordResource.ts +13 -3
  49. package/src/resources/singleRecordResource.ts +6 -1
  50. package/src/runjs-context/helpers.ts +12 -6
  51. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
  52. package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
  53. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
  54. package/src/utils/dataSourceDirty.ts +126 -0
  55. package/src/utils/dirtyAwareApiClient.ts +430 -0
  56. package/src/utils/index.ts +10 -9
  57. package/src/utils/openViewRouteState.ts +107 -0
  58. package/src/utils/parsePathnameToViewParams.ts +23 -1
  59. package/src/utils/resolveRunJSObjectValues.ts +5 -2
  60. package/src/utils/runjsModuleLoader.ts +0 -32
  61. package/src/views/ViewNavigation.ts +6 -1
  62. package/src/views/__tests__/ViewNavigation.test.ts +15 -0
  63. package/lib/utils/safeGlobals.d.ts +0 -28
  64. package/lib/utils/safeGlobals.js +0 -367
  65. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
  66. package/src/utils/__tests__/safeGlobals.test.ts +0 -106
  67. package/src/utils/safeGlobals.ts +0 -406
@@ -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.1.11",
3
+ "version": "2.2.0-alpha.2",
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.1.11",
12
- "@nocobase/shared": "2.1.11",
11
+ "@nocobase/sdk": "2.2.0-alpha.2",
12
+ "@nocobase/shared": "2.2.0-alpha.2",
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": "1ccf891d837e21089f65f84b892407b34a0a0cb9"
40
+ "gitHead": "9dd5224bb8f6c1ac12d766b9f175bdc29bddd845"
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', () => {
@@ -1429,6 +1431,92 @@ describe('FlowEngine context', () => {
1429
1431
  expect(engine.context.appName).toBe('NocoBase');
1430
1432
  });
1431
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
+
1432
1520
  it('ctx.sql should resolve template variables from caller context in delegate chain', async () => {
1433
1521
  const engine = new FlowEngine();
1434
1522
  const request = vi.fn(async () => ({ data: { data: [] } }));
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest';
11
11
  import { FlowEngine } from '../flowEngine';
12
12
  import { MultiRecordResource } from '../resources/multiRecordResource';
13
13
  import { SingleRecordResource } from '../resources/singleRecordResource';
14
+ import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
14
15
 
15
16
  describe('FlowEngine dataSource dirty registry', () => {
16
17
  it('tracks versions per dataSourceKey + resourceName', () => {
@@ -60,4 +61,54 @@ describe('FlowEngine dataSource dirty registry', () => {
60
61
  // plus root collection (safety)
61
62
  expect(markSpy).toHaveBeenCalledWith('main', 'users');
62
63
  });
64
+
65
+ it('marks dirty once for record write helpers when using the dirty-aware context api', async () => {
66
+ const engine = new FlowEngine();
67
+ const request = vi.fn(async () => ({ data: { data: { id: 1 }, meta: {} } }));
68
+ const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
69
+ engine.context.defineProperty('api', {
70
+ value: {
71
+ auth: { locale: 'zh-CN' },
72
+ request,
73
+ resource: vi.fn(),
74
+ },
75
+ });
76
+ engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
77
+
78
+ const multi = engine.createResource(MultiRecordResource);
79
+ multi.setDataSourceKey('main').setResourceName('posts');
80
+ await multi.create({ title: 't' } as any, { refresh: false });
81
+
82
+ expect(request).toHaveBeenCalledTimes(1);
83
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
84
+ expect(dirtyEvents).toEqual([{ dataSourceKey: 'main', resourceNames: ['posts'] }]);
85
+
86
+ const single = engine.createResource(SingleRecordResource);
87
+ single.setDataSourceKey('main').setResourceName('posts').setFilterByTk(1);
88
+ await single.save({ title: 'u' } as any, { refresh: false });
89
+
90
+ expect(request).toHaveBeenCalledTimes(2);
91
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(2);
92
+ expect(dirtyEvents).toEqual([
93
+ { dataSourceKey: 'main', resourceNames: ['posts'] },
94
+ { dataSourceKey: 'main', resourceNames: ['posts'] },
95
+ ]);
96
+ });
97
+
98
+ it('still marks dirty for direct runAction writes', async () => {
99
+ const engine = new FlowEngine();
100
+ engine.context.defineProperty('api', {
101
+ value: {
102
+ auth: { locale: 'zh-CN' },
103
+ request: vi.fn(async () => ({ data: { data: { id: 1 }, meta: {} } })),
104
+ resource: vi.fn(),
105
+ },
106
+ });
107
+
108
+ const multi = engine.createResource(MultiRecordResource);
109
+ multi.setDataSourceKey('main').setResourceName('posts');
110
+ await multi.runAction('create', { data: { title: 't' } });
111
+
112
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
113
+ });
63
114
  });
@@ -17,6 +17,17 @@ describe('FlowI18n', () => {
17
17
  expect(i18n.translate("{{ t('Hello') }}")).toBe('你好');
18
18
  });
19
19
 
20
+ it('keeps embedded quotes of a different type inside the key', () => {
21
+ // A single-quoted key whose text contains double quotes (and vice versa) must not be truncated at the first inner
22
+ // quote.
23
+ const key = 'Unlike "Post-action event", it listens for data changes.';
24
+ const table: Record<string, string> = { [key]: '与“操作后事件”不同,它监听数据变动。' };
25
+ const i18n = new FlowI18n({ i18n: { t: (k: string) => table[k] ?? k } });
26
+
27
+ expect(i18n.translate(`{{t('${key}', { ns: "workflow" })}}`)).toBe(table[key]);
28
+ expect(i18n.translate(`{{t("It's here", { ns: "workflow" })}}`)).toBe("It's here");
29
+ });
30
+
20
31
  it('template compile ignores malformed options', () => {
21
32
  const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
22
33
  const i18n = new FlowI18n({ i18n: { t: (k: string) => k } });
@@ -8,9 +8,9 @@
8
8
  */
9
9
 
10
10
  import { describe, it, expect, beforeAll, vi } from 'vitest';
11
- import { FlowEngineContext, FlowRunJSContext } from '../flowContext';
11
+ import { FlowContext, FlowEngineContext, FlowRunJSContext } from '../flowContext';
12
+ import { RUNJS_OPEN_VIEW_ROUTE_STATE } from '../utils/openViewRouteState';
12
13
  import { FlowEngine } from '../flowEngine';
13
- import { FlowContext } from '../flowContext';
14
14
  import { setupRunJSContexts } from '../runjs-context/setup';
15
15
  import { createJSRunnerWithVersion } from '..';
16
16
  import { RunJSContextRegistry } from '../runjs-context/registry';
@@ -267,6 +267,19 @@ describe('RunJS Runtime Features', () => {
267
267
  expect(runCtx.libs.dayjs).toBeDefined();
268
268
  expect(runCtx.libs.antdIcons).toBeDefined();
269
269
  });
270
+
271
+ it('should mark ctx.openView calls with route state only when RunJS passes display overrides', async () => {
272
+ const parentCtx = new FlowContext();
273
+ const openView = vi.fn(async () => undefined);
274
+ parentCtx.defineMethod('openView', openView);
275
+
276
+ const runCtx = new FlowRunJSContext(parentCtx);
277
+ await (runCtx as any).openView('popup', { mode: 'dialog', size: 'large' });
278
+ await (runCtx as any).openView('popup', { filterByTk: 1 });
279
+
280
+ expect(openView.mock.calls[0][1][RUNJS_OPEN_VIEW_ROUTE_STATE]).toEqual({ mode: 'dialog', size: 'large' });
281
+ expect(Object.prototype.hasOwnProperty.call(openView.mock.calls[1][1], RUNJS_OPEN_VIEW_ROUTE_STATE)).toBe(false);
282
+ });
270
283
  });
271
284
 
272
285
  describe('Actual code execution', () => {
@@ -92,6 +92,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
92
92
  open,
93
93
  onlyLeafSelectable = false,
94
94
  ignoreFieldNames,
95
+ dropdownFooter,
95
96
  ...cascaderProps
96
97
  }) => {
97
98
  const { token } = theme.useToken();
@@ -360,12 +361,41 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
360
361
  [cascaderOnDropdownVisibleChange, open],
361
362
  );
362
363
 
364
+ // Footer hint at the bottom of the dropdown. Defaults to the "double click to choose entire object" hint whenever
365
+ // non-leaf selection is allowed (double-clicking a non-leaf selects the whole object). Callers can override with
366
+ // their own node, or pass `null` to hide it.
367
+ const footerNode = useMemo(() => {
368
+ if (dropdownFooter !== undefined) {
369
+ return dropdownFooter;
370
+ }
371
+ if (onlyLeafSelectable) {
372
+ return null;
373
+ }
374
+ return (
375
+ <div
376
+ className={css`
377
+ padding: 6px 12px;
378
+ color: ${token.colorTextDescription};
379
+ border-top: 1px solid ${token.colorSplit};
380
+ font-size: ${token.fontSizeSM}px;
381
+ `}
382
+ >
383
+ {flowCtx.t('Double click to choose entire object')}
384
+ </div>
385
+ );
386
+ }, [dropdownFooter, onlyLeafSelectable, token, flowCtx]);
387
+
363
388
  const renderDropdown = useCallback(
364
389
  (menu: React.ReactElement) => {
365
390
  const cascaderMenuNode = cascaderDropdownRender ? cascaderDropdownRender(menu) : menu;
366
391
  const cascaderMenu = React.isValidElement(cascaderMenuNode) ? cascaderMenuNode : <>{cascaderMenuNode}</>;
367
392
  if (!isSearchEnabled || children === null) {
368
- return cascaderMenu;
393
+ return (
394
+ <>
395
+ {cascaderMenu}
396
+ {footerNode}
397
+ </>
398
+ );
369
399
  }
370
400
 
371
401
  return (
@@ -381,10 +411,11 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
381
411
  />
382
412
  </div>
383
413
  {cascaderMenu}
414
+ {footerNode}
384
415
  </>
385
416
  );
386
417
  },
387
- [cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText],
418
+ [cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText, footerNode],
388
419
  );
389
420
 
390
421
  const inlinePlaceholder =