@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
@@ -12,7 +12,7 @@ import _ from 'lodash';
12
12
  import { APIResource } from './apiResource';
13
13
  import { FilterItem } from './filterItem';
14
14
  import { ResourceError } from './flowResource';
15
- import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
15
+ import { markDataSourceDirty } from '../utils/dataSourceDirty';
16
16
 
17
17
  export abstract class BaseRecordResource<TData = any> extends APIResource<TData> {
18
18
  protected resourceName: string;
@@ -142,28 +142,11 @@ export abstract class BaseRecordResource<TData = any> extends APIResource<TData>
142
142
  * Used to coordinate "refresh on active" across view stacks.
143
143
  */
144
144
  protected markDataSourceDirty(resourceName?: string) {
145
- const engine = this.context.engine;
146
- if (!engine) return;
147
-
148
- const dataSourceKey = this.getDataSourceKey() || 'main';
149
- const resName = resourceName || this.getResourceName();
150
- if (!resName) return;
151
-
152
- const affectedResourceNames = new Set<string>([String(resName)]);
153
- // Optional safety: association resources like "users.profile" may impact parent collection views.
154
- if (typeof resName === 'string' && resName.includes('.')) {
155
- affectedResourceNames.add(resName.split('.')[0]);
156
- }
157
-
158
- for (const name of affectedResourceNames) {
159
- engine.markDataSourceDirty(dataSourceKey, name);
160
- }
161
-
162
- // Signal current view to re-evaluate dirty blocks (e.g., same-view sibling refresh).
163
- // This is emitted on the *current* engine emitter (view-scoped) so it won't affect other views.
164
- engine.emitter?.emit?.(DATA_SOURCE_DIRTY_EVENT, {
165
- dataSourceKey,
166
- resourceNames: Array.from(affectedResourceNames),
145
+ markDataSourceDirty({
146
+ engine: this.context.engine,
147
+ dataSourceKey: this.getDataSourceKey(),
148
+ resourceName: resourceName || this.getResourceName(),
149
+ includePreviousEngines: true,
167
150
  });
168
151
  }
169
152
 
@@ -10,6 +10,7 @@
10
10
  import { observable } from '@formily/reactive';
11
11
  import { AxiosRequestConfig } from 'axios';
12
12
  import _ from 'lodash';
13
+ import { SKIP_DATA_SOURCE_DIRTY } from '../utils/dirtyAwareApiClient';
13
14
  import { BaseRecordResource } from './baseRecordResource';
14
15
 
15
16
  export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDataItem[]> {
@@ -113,7 +114,10 @@ export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDa
113
114
 
114
115
  async create(data: TDataItem, options?: AxiosRequestConfig & { refresh?: boolean }): Promise<void> {
115
116
  const config = this.mergeRequestConfig({ data }, this.createActionOptions, options);
116
- const res = await this.runAction('create', config);
117
+ const res = await this.runAction('create', {
118
+ ...config,
119
+ [SKIP_DATA_SOURCE_DIRTY]: true,
120
+ });
117
121
  this.markDataSourceDirty();
118
122
  this.emit('saved', data);
119
123
  if (options?.refresh !== false) {
@@ -146,7 +150,10 @@ export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDa
146
150
  this.updateActionOptions,
147
151
  options,
148
152
  );
149
- await this.runAction('update', config);
153
+ await this.runAction('update', {
154
+ ...config,
155
+ [SKIP_DATA_SOURCE_DIRTY]: true,
156
+ });
150
157
  this.markDataSourceDirty();
151
158
  this.emit('saved', data);
152
159
  await this.refresh();
@@ -172,7 +179,10 @@ export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDa
172
179
  },
173
180
  options,
174
181
  );
175
- await this.runAction('destroy', config);
182
+ await this.runAction('destroy', {
183
+ ...config,
184
+ [SKIP_DATA_SOURCE_DIRTY]: true,
185
+ });
176
186
  this.markDataSourceDirty();
177
187
  const currentPage = this.getPage();
178
188
  const lastPage = Math.ceil((this.getCount() - _.castArray(filterByTk).length) / this.getPageSize());
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { AxiosRequestConfig } from 'axios';
11
11
  import _ from 'lodash';
12
+ import { SKIP_DATA_SOURCE_DIRTY } from '../utils/dirtyAwareApiClient';
12
13
  import { BaseRecordResource } from './baseRecordResource';
13
14
 
14
15
  export class SingleRecordResource<TData = any> extends BaseRecordResource<TData> {
@@ -43,6 +44,7 @@ export class SingleRecordResource<TData = any> extends BaseRecordResource<TData>
43
44
  const res = await this.runAction(actionName, {
44
45
  ...config,
45
46
  data: result,
47
+ [SKIP_DATA_SOURCE_DIRTY]: true,
46
48
  });
47
49
  // Mark as dirty before emitting/refreshing so other views can refresh when activated.
48
50
  this.markDataSourceDirty();
@@ -62,7 +64,10 @@ export class SingleRecordResource<TData = any> extends BaseRecordResource<TData>
62
64
  },
63
65
  options,
64
66
  );
65
- await this.runAction('destroy', config);
67
+ await this.runAction('destroy', {
68
+ ...config,
69
+ [SKIP_DATA_SOURCE_DIRTY]: true,
70
+ });
66
71
  this.markDataSourceDirty();
67
72
  this.setData(null);
68
73
  }
@@ -46,12 +46,17 @@ export function createJSRunnerWithVersion(this: FlowContext, options?: JSRunnerO
46
46
  doc = {};
47
47
  }
48
48
  const deprecatedCtx = createRunJSDeprecationProxy(runCtx, { doc });
49
- const globals: Record<string, any> = { ctx: deprecatedCtx, ...(options?.globals || {}) };
50
- // 对字段/区块类上下文,默认注入 window/document 以支持在沙箱中访问 DOM API
51
- if (modelClass === 'JSFieldModel' || modelClass === 'JSBlockModel') {
52
- if (typeof window !== 'undefined') globals.window = window as any;
53
- if (typeof document !== 'undefined') globals.document = document as any;
49
+ const browserGlobals: Record<string, any> = {};
50
+ if (typeof window !== 'undefined') {
51
+ browserGlobals.window = window;
52
+ if (typeof navigator !== 'undefined') {
53
+ browserGlobals.navigator = navigator;
54
+ }
54
55
  }
56
+ if (typeof document !== 'undefined') {
57
+ browserGlobals.document = document;
58
+ }
59
+ const globals: Record<string, any> = { ctx: deprecatedCtx, ...browserGlobals, ...(options?.globals || {}) };
55
60
  // 透传 JSRunnerOptions 其余配置(如 timeoutMs)
56
61
  const { timeoutMs } = options || {};
57
62
  return new JSRunner({ globals, timeoutMs });
@@ -59,7 +64,8 @@ export function createJSRunnerWithVersion(this: FlowContext, options?: JSRunnerO
59
64
 
60
65
  export function getRunJSScenesForModel(modelClass: string, version: RunJSVersion = 'v1'): string[] {
61
66
  const meta = RunJSContextRegistry.getMeta(version, modelClass);
62
- return Array.isArray(meta?.scenes) ? [...meta!.scenes!] : [];
67
+ const scenes = meta?.scenes;
68
+ return Array.isArray(scenes) ? [...scenes] : [];
63
69
  }
64
70
 
65
71
  export function getRunJSScenesForContext(ctx: FlowContext, { version = 'v1' as RunJSVersion } = {}): string[] {
@@ -0,0 +1,392 @@
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 } from '../../flowContext';
12
+ import { FlowEngine } from '../../flowEngine';
13
+ import { createViewScopedEngine } from '../../ViewScopedFlowEngine';
14
+ import { DATA_SOURCE_DIRTY_EVENT } from '../../views/viewEvents';
15
+ import { getDirtyAwareApiClient, SKIP_DATA_SOURCE_DIRTY } from '../dirtyAwareApiClient';
16
+
17
+ type TestRequestOptions = {
18
+ url?: string;
19
+ resource?: string;
20
+ action?: string;
21
+ headers?: Record<string, string>;
22
+ params?: unknown;
23
+ };
24
+
25
+ type TestResource = Record<string, (...args: unknown[]) => Promise<unknown>>;
26
+
27
+ type TestApi = {
28
+ auth: { locale: string };
29
+ request: (config: TestRequestOptions) => Promise<unknown>;
30
+ resource: (name: string, of?: unknown, headers?: Record<string, string>, cancel?: boolean) => TestResource;
31
+ };
32
+
33
+ function getWrappedApi(engine: FlowEngine, api: TestApi): TestApi {
34
+ return getDirtyAwareApiClient(api, engine.context) as TestApi;
35
+ }
36
+
37
+ describe('dirtyAwareApiClient', () => {
38
+ it('should return non-api values as-is', () => {
39
+ const context = new FlowContext();
40
+ const value = { request: vi.fn() };
41
+
42
+ expect(getDirtyAwareApiClient(value, context)).toBe(value);
43
+ });
44
+
45
+ it('should mark the resource dirty after mutating resource actions succeed', async () => {
46
+ const engine = new FlowEngine();
47
+ const list = vi.fn(async () => ({ data: { data: [] } }));
48
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
49
+ const api: TestApi = {
50
+ auth: { locale: 'zh-CN' },
51
+ request: vi.fn(async () => ({ data: { ok: true } })),
52
+ resource: vi.fn(() => ({ list, update })),
53
+ };
54
+ const wrappedApi = getWrappedApi(engine, api);
55
+
56
+ await wrappedApi.resource('posts').list();
57
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(0);
58
+
59
+ await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
60
+
61
+ expect(update).toHaveBeenCalledTimes(1);
62
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
63
+ expect(getDirtyAwareApiClient(api, engine.context)).toBe(wrappedApi);
64
+ });
65
+
66
+ it('should not double-wrap an already dirty-aware api', async () => {
67
+ const engine = new FlowEngine();
68
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
69
+ const api: TestApi = {
70
+ auth: { locale: 'zh-CN' },
71
+ request: vi.fn(async () => ({ data: { ok: true } })),
72
+ resource: vi.fn(() => ({ update })),
73
+ };
74
+
75
+ const wrappedApi = getWrappedApi(engine, api);
76
+ const wrappedAgain = getDirtyAwareApiClient(wrappedApi, engine.context) as TestApi;
77
+ await wrappedAgain.resource('posts').update({ filterByTk: 1 });
78
+
79
+ expect(wrappedAgain).toBe(wrappedApi);
80
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
81
+ });
82
+
83
+ it('should not mark dirty for read actions', async () => {
84
+ const nonMutatingActions = [
85
+ 'get',
86
+ 'getSystemSettings',
87
+ 'list',
88
+ 'listByUser',
89
+ 'query',
90
+ 'count',
91
+ 'check',
92
+ 'preview',
93
+ 'test',
94
+ 'find',
95
+ 'exists',
96
+ 'aggregate',
97
+ 'listMine',
98
+ 'parents',
99
+ 'children',
100
+ 'search',
101
+ 'send',
102
+ 'testConnection',
103
+ 'refresh',
104
+ 'run',
105
+ 'runById',
106
+ 'unknownCustomAction',
107
+ ];
108
+
109
+ for (const actionName of nonMutatingActions) {
110
+ const engine = new FlowEngine();
111
+ const api: TestApi = {
112
+ auth: { locale: 'zh-CN' },
113
+ request: vi.fn(async () => ({ data: { ok: true } })),
114
+ resource: vi.fn(() => ({
115
+ [actionName]: vi.fn(async () => ({ data: { data: [] } })),
116
+ })),
117
+ };
118
+
119
+ await getWrappedApi(engine, api).resource('posts')[actionName]();
120
+
121
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(0);
122
+ }
123
+ });
124
+
125
+ it('should mark dirty for known mutating action variants', async () => {
126
+ const mutatingActions = [
127
+ 'create',
128
+ 'execute',
129
+ 'updateOrCreate',
130
+ 'firstOrCreate',
131
+ 'setFields',
132
+ 'updateProfile',
133
+ 'saveAsTemplate',
134
+ 'remove/abc',
135
+ ];
136
+
137
+ for (const actionName of mutatingActions) {
138
+ const engine = new FlowEngine();
139
+ const api: TestApi = {
140
+ auth: { locale: 'zh-CN' },
141
+ request: vi.fn(async () => ({ data: { ok: true } })),
142
+ resource: vi.fn(() => ({
143
+ [actionName]: vi.fn(async () => ({ data: { ok: true } })),
144
+ })),
145
+ };
146
+
147
+ await getWrappedApi(engine, api).resource('posts')[actionName]();
148
+
149
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
150
+ }
151
+ });
152
+
153
+ it('should not mark dirty when a mutating action fails', async () => {
154
+ const engine = new FlowEngine();
155
+ const update = vi.fn(async () => {
156
+ throw new Error('update failed');
157
+ });
158
+ const api: TestApi = {
159
+ auth: { locale: 'zh-CN' },
160
+ request: vi.fn(async () => ({ data: { ok: true } })),
161
+ resource: vi.fn(() => ({ update })),
162
+ };
163
+
164
+ await expect(getWrappedApi(engine, api).resource('posts').update({ filterByTk: 1 })).rejects.toThrow(
165
+ 'update failed',
166
+ );
167
+
168
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(0);
169
+ });
170
+
171
+ it('should mark association resource and parent collection dirty', async () => {
172
+ const engine = new FlowEngine();
173
+ const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
174
+ engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
175
+ const add = vi.fn(async () => ({ data: { data: null } }));
176
+ const api: TestApi = {
177
+ auth: { locale: 'zh-CN' },
178
+ request: vi.fn(async () => ({ data: { ok: true } })),
179
+ resource: vi.fn(() => ({ add })),
180
+ };
181
+
182
+ await getWrappedApi(engine, api)
183
+ .resource('users.roles', 1, { 'x-data-source': 'external' })
184
+ .add({ values: [1, 2] });
185
+
186
+ expect(engine.getDataSourceDirtyVersion('external', 'users.roles')).toBe(1);
187
+ expect(engine.getDataSourceDirtyVersion('external', 'users')).toBe(1);
188
+ expect(dirtyEvents).toEqual([{ dataSourceKey: 'external', resourceNames: ['users.roles', 'users'] }]);
189
+ });
190
+
191
+ it('should mark resource-action request mutations dirty after success', async () => {
192
+ const engine = new FlowEngine();
193
+ const request = vi.fn(async () => ({ data: { ok: true } }));
194
+ const api: TestApi = {
195
+ auth: { locale: 'zh-CN' },
196
+ request,
197
+ resource: vi.fn(),
198
+ };
199
+
200
+ await getWrappedApi(engine, api).request({
201
+ resource: 'posts',
202
+ action: 'update',
203
+ headers: { 'X-Data-Source': 'analytics' },
204
+ params: { filterByTk: 1 },
205
+ });
206
+
207
+ expect(request).toHaveBeenCalledTimes(1);
208
+ expect(engine.getDataSourceDirtyVersion('analytics', 'posts')).toBe(1);
209
+ });
210
+
211
+ it('should mark URL-form resource mutations dirty after success', async () => {
212
+ const engine = new FlowEngine();
213
+ const request = vi.fn(async () => ({ data: { ok: true } }));
214
+ const api: TestApi = {
215
+ auth: { locale: 'zh-CN' },
216
+ request,
217
+ resource: vi.fn(),
218
+ };
219
+ const wrappedApi = getWrappedApi(engine, api);
220
+
221
+ await wrappedApi.request({ url: 'posts:update' });
222
+ await wrappedApi.request({
223
+ url: '/api/posts:update?filterByTk=1',
224
+ headers: { 'x-data-source': 'external' },
225
+ });
226
+ await wrappedApi.request({
227
+ url: '/api/posts/1/tags:set',
228
+ headers: { 'X-Data-Source': 'analytics' },
229
+ });
230
+
231
+ expect(request).toHaveBeenCalledTimes(3);
232
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
233
+ expect(engine.getDataSourceDirtyVersion('external', 'posts')).toBe(1);
234
+ expect(engine.getDataSourceDirtyVersion('analytics', 'posts.tags')).toBe(1);
235
+ expect(engine.getDataSourceDirtyVersion('analytics', 'posts')).toBe(1);
236
+ });
237
+
238
+ it('should resolve data source resource URLs to the nested data source target', async () => {
239
+ const engine = new FlowEngine();
240
+ const request = vi.fn(async () => ({ data: { ok: true } }));
241
+ const api: TestApi = {
242
+ auth: { locale: 'zh-CN' },
243
+ request,
244
+ resource: vi.fn(),
245
+ };
246
+
247
+ await getWrappedApi(engine, api).request({
248
+ url: 'dataSources/external/collections:update',
249
+ });
250
+
251
+ expect(request).toHaveBeenCalledTimes(1);
252
+ expect(engine.getDataSourceDirtyVersion('external', 'collections')).toBe(1);
253
+ expect(engine.getDataSourceDirtyVersion('main', 'dataSources.collections')).toBe(0);
254
+ expect(engine.getDataSourceDirtyVersion('main', 'dataSources')).toBe(0);
255
+ });
256
+
257
+ it('should resolve dataSources resourceOf requests to the nested data source target', async () => {
258
+ const engine = new FlowEngine();
259
+ const request = vi.fn(async () => ({ data: { ok: true } }));
260
+ const update = vi.fn(async () => ({ data: { ok: true } }));
261
+ const api: TestApi = {
262
+ auth: { locale: 'zh-CN' },
263
+ request,
264
+ resource: vi.fn(() => ({ update })),
265
+ };
266
+ const wrappedApi = getWrappedApi(engine, api);
267
+
268
+ await wrappedApi.request({
269
+ resource: 'dataSources.collections',
270
+ resourceOf: 'external',
271
+ action: 'update',
272
+ } as TestRequestOptions & { resourceOf: string });
273
+ await wrappedApi.resource('dataSources.roles', 'external').update({ values: { allow: true } });
274
+ await wrappedApi.resource('dataSources/external/roles').update({ values: { allow: false } });
275
+
276
+ expect(engine.getDataSourceDirtyVersion('external', 'collections')).toBe(1);
277
+ expect(engine.getDataSourceDirtyVersion('external', 'roles')).toBe(2);
278
+ expect(engine.getDataSourceDirtyVersion('main', 'dataSources.collections')).toBe(0);
279
+ expect(engine.getDataSourceDirtyVersion('main', 'dataSources.roles')).toBe(0);
280
+ });
281
+
282
+ it('should skip dirty marking and strip the internal skip flag from raw requests', async () => {
283
+ const engine = new FlowEngine();
284
+ const request = vi.fn(async () => ({ data: { ok: true } }));
285
+ const api: TestApi = {
286
+ auth: { locale: 'zh-CN' },
287
+ request,
288
+ resource: vi.fn(),
289
+ };
290
+
291
+ await getWrappedApi(engine, api).request({
292
+ url: 'posts:update',
293
+ [SKIP_DATA_SOURCE_DIRTY]: true,
294
+ } as TestRequestOptions & { [SKIP_DATA_SOURCE_DIRTY]: boolean });
295
+
296
+ expect(request).toHaveBeenCalledTimes(1);
297
+ expect(request.mock.calls[0][0]).not.toHaveProperty(SKIP_DATA_SOURCE_DIRTY);
298
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(0);
299
+ });
300
+
301
+ it('should strip configured API base from URL-form mutations', async () => {
302
+ const engine = new FlowEngine();
303
+ const request = vi.fn(async () => ({ data: { ok: true } }));
304
+ const api: TestApi = {
305
+ auth: { locale: 'zh-CN' },
306
+ request,
307
+ resource: vi.fn(),
308
+ };
309
+ engine.context.defineProperty('app', {
310
+ value: {
311
+ getApiUrl(pathname = '') {
312
+ return `https://app.example.com/foo/api/${pathname.replace(/^\//, '')}`;
313
+ },
314
+ },
315
+ });
316
+ const wrappedApi = getWrappedApi(engine, api);
317
+
318
+ await wrappedApi.request({ url: '/foo/api/posts:update' });
319
+ await wrappedApi.request({
320
+ url: 'https://app.example.com/foo/api/users/1/roles:set',
321
+ headers: { 'x-data-source': 'external' },
322
+ });
323
+
324
+ expect(request).toHaveBeenCalledTimes(2);
325
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
326
+ expect(engine.getDataSourceDirtyVersion('main', 'foo.api.posts')).toBe(0);
327
+ expect(engine.getDataSourceDirtyVersion('external', 'users.roles')).toBe(1);
328
+ expect(engine.getDataSourceDirtyVersion('external', 'users')).toBe(1);
329
+ });
330
+
331
+ it('should not mark URL-form resource dirty for reads, failed mutations, or external URLs', async () => {
332
+ const engine = new FlowEngine();
333
+ const request = vi
334
+ .fn()
335
+ .mockResolvedValueOnce({ data: { data: [] } })
336
+ .mockResolvedValueOnce({ data: { data: [] } })
337
+ .mockRejectedValueOnce(new Error('request failed'))
338
+ .mockResolvedValueOnce({ data: { ok: true } })
339
+ .mockResolvedValueOnce({ data: { ok: true } });
340
+ const api: TestApi = {
341
+ auth: { locale: 'zh-CN' },
342
+ request,
343
+ resource: vi.fn(),
344
+ };
345
+ const wrappedApi = getWrappedApi(engine, api);
346
+
347
+ await wrappedApi.request({ url: 'posts:list' });
348
+ await wrappedApi.request({ url: '/api/posts:parents' });
349
+ await expect(wrappedApi.request({ url: '/api/posts:update' })).rejects.toThrow('request failed');
350
+ await wrappedApi.request({ url: 'https://example.com/api/posts:update' });
351
+ await wrappedApi.request({ url: '//example.com/api/posts:update' });
352
+
353
+ expect(request).toHaveBeenCalledTimes(5);
354
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(0);
355
+ });
356
+
357
+ it('should mark opener engine dirty when called from a scoped view context', async () => {
358
+ const root = new FlowEngine();
359
+ const scoped = createViewScopedEngine(root);
360
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
361
+ const api: TestApi = {
362
+ auth: { locale: 'zh-CN' },
363
+ request: vi.fn(async () => ({ data: { ok: true } })),
364
+ resource: vi.fn(() => ({ update })),
365
+ };
366
+
367
+ await getWrappedApi(scoped, api)
368
+ .resource('posts')
369
+ .update({ filterByTk: 1, values: { title: 't' } });
370
+
371
+ expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
372
+ expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
373
+ });
374
+
375
+ it('should not double-mark when the context exposes a scoped engine proxy', async () => {
376
+ const root = new FlowEngine();
377
+ const scoped = createViewScopedEngine(root);
378
+ const context = new FlowContext();
379
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
380
+ const api: TestApi = {
381
+ auth: { locale: 'zh-CN' },
382
+ request: vi.fn(async () => ({ data: { ok: true } })),
383
+ resource: vi.fn(() => ({ update })),
384
+ };
385
+ context.defineProperty('engine', { value: scoped });
386
+
387
+ await (getDirtyAwareApiClient(api, context) as TestApi).resource('posts').update({ filterByTk: 1 });
388
+
389
+ expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
390
+ expect(scoped.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
391
+ });
392
+ });
@@ -0,0 +1,40 @@
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 { decodeOpenViewRouteState, encodeOpenViewRouteState, isOpenViewRouteStateToken } from '../openViewRouteState';
11
+
12
+ describe('openViewRouteState', () => {
13
+ it('encodes route state as an 8-letter view-bound token', () => {
14
+ const token = encodeOpenViewRouteState('popup', { mode: 'dialog', size: 'large' });
15
+
16
+ expect(token).toMatch(/^[A-Za-z]{8}$/);
17
+ expect(isOpenViewRouteStateToken(token)).toBe(true);
18
+ expect(decodeOpenViewRouteState('popup', token)).toEqual({ mode: 'dialog', size: 'large' });
19
+ expect(decodeOpenViewRouteState('other-popup', token)).toBeUndefined();
20
+ });
21
+
22
+ it('can encode mode without leaking a default size', () => {
23
+ const token = encodeOpenViewRouteState('popup', { mode: 'embed' });
24
+
25
+ expect(token).toMatch(/^[A-Za-z]{8}$/);
26
+ expect(decodeOpenViewRouteState('popup', token)).toEqual({ mode: 'embed' });
27
+ });
28
+
29
+ it('generates distinct tokens for supported route state combinations', () => {
30
+ const modes = [undefined, 'drawer', 'dialog', 'embed'] as const;
31
+ const sizes = [undefined, 'small', 'medium', 'large'] as const;
32
+ const tokens = modes.flatMap((mode) =>
33
+ sizes
34
+ .map((size) => encodeOpenViewRouteState('popup', { mode, size }))
35
+ .filter((token): token is string => !!token),
36
+ );
37
+
38
+ expect(new Set(tokens).size).toBe(tokens.length);
39
+ });
40
+ });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { parsePathnameToViewParams } from '../parsePathnameToViewParams';
11
+ import { encodeOpenViewRouteState } from '../openViewRouteState';
11
12
 
12
13
  describe('parsePathnameToViewParams', () => {
13
14
  test('should return single view param for basic admin path', () => {
@@ -48,6 +49,41 @@ describe('parsePathnameToViewParams', () => {
48
49
  expect(result).toEqual([{ viewUid: 'xxx' }, { viewUid: 'yyy', filterByTk: '1', sourceId: '1' }]);
49
50
  });
50
51
 
52
+ test('should parse RunJS openView route state without losing route params', () => {
53
+ const token = encodeOpenViewRouteState('yyy', { mode: 'dialog', size: 'large' });
54
+ if (!token) {
55
+ throw new Error('Expected openView route state token.');
56
+ }
57
+ const result = parsePathnameToViewParams(`/admin/xxx/view/yyy/opts/${token}/filterbytk/1/sourceid/2`);
58
+
59
+ expect(result).toEqual([
60
+ { viewUid: 'xxx' },
61
+ {
62
+ viewUid: 'yyy',
63
+ openViewRouteState: { mode: 'dialog', size: 'large' },
64
+ filterByTk: '1',
65
+ sourceId: '2',
66
+ },
67
+ ]);
68
+ });
69
+
70
+ test('should ignore invalid RunJS openView opts and keep following params', () => {
71
+ const result = parsePathnameToViewParams('/admin/xxx/view/yyy/opts/AbCdEfGh/filterbytk/1');
72
+
73
+ expect(result).toEqual([{ viewUid: 'xxx' }, { viewUid: 'yyy', filterByTk: '1' }]);
74
+ });
75
+
76
+ test('should not parse bare RunJS openView route state token segments', () => {
77
+ const token = encodeOpenViewRouteState('yyy', { mode: 'dialog', size: 'large' });
78
+ if (!token) {
79
+ throw new Error('Expected openView route state token.');
80
+ }
81
+ const result = parsePathnameToViewParams(`/admin/xxx/view/yyy/${token}/filterbytk/1`);
82
+
83
+ expect(result[1]).toMatchObject({ viewUid: 'yyy' });
84
+ expect(result[1]?.openViewRouteState).toBeUndefined();
85
+ });
86
+
51
87
  test('should handle multiple views with different filterByTk and sourceId', () => {
52
88
  const result = parsePathnameToViewParams(
53
89
  '/admin/xxx/view/yyy/filterbytk/1/sourceid/1/view/zzz/filterbytk/2/sourceid/2',