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

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 +632 -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 +713 -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 +742 -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,713 @@
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 { APIClient, type IResource, type RequestOptions } from '@nocobase/sdk';
12
+ import { FlowContext } from '../../flowContext';
13
+ import { FlowEngine } from '../../flowEngine';
14
+ import { createViewScopedEngine } from '../../ViewScopedFlowEngine';
15
+ import { DATA_SOURCE_DIRTY_EVENT } from '../../views/viewEvents';
16
+ import { getDirtyAwareApiClient, SKIP_DATA_SOURCE_DIRTY } from '../dirtyAwareApiClient';
17
+
18
+ type TestRequestOptions = {
19
+ url?: string;
20
+ resource?: string;
21
+ action?: string;
22
+ headers?: Record<string, string>;
23
+ params?: unknown;
24
+ };
25
+
26
+ type TestResource = Record<string, (...args: unknown[]) => Promise<unknown>>;
27
+
28
+ type TestApi = {
29
+ auth: { locale: string };
30
+ request: (config: TestRequestOptions) => Promise<unknown>;
31
+ resource: (name: string, of?: unknown, headers?: Record<string, string>, cancel?: boolean) => TestResource;
32
+ };
33
+
34
+ function getWrappedApi(engine: FlowEngine, api: TestApi): TestApi {
35
+ return getDirtyAwareApiClient(api, engine.context) as TestApi;
36
+ }
37
+
38
+ describe('dirtyAwareApiClient', () => {
39
+ it('should return non-api values as-is', () => {
40
+ const context = new FlowContext();
41
+ const value = { request: vi.fn() };
42
+
43
+ expect(getDirtyAwareApiClient(value, context)).toBe(value);
44
+ });
45
+
46
+ it('should mark the resource dirty after mutating resource actions succeed', async () => {
47
+ const engine = new FlowEngine();
48
+ const list = vi.fn(async () => ({ data: { data: [] } }));
49
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
50
+ const api: TestApi = {
51
+ auth: { locale: 'zh-CN' },
52
+ request: vi.fn(async () => ({ data: { ok: true } })),
53
+ resource: vi.fn(() => ({ list, update })),
54
+ };
55
+ const wrappedApi = getWrappedApi(engine, api);
56
+
57
+ await wrappedApi.resource('posts').list();
58
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(0);
59
+
60
+ await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
61
+
62
+ expect(update).toHaveBeenCalledTimes(1);
63
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
64
+ expect(getDirtyAwareApiClient(api, engine.context)).toBe(wrappedApi);
65
+ });
66
+
67
+ it('should not double-wrap an already dirty-aware api', async () => {
68
+ const engine = new FlowEngine();
69
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
70
+ const api: TestApi = {
71
+ auth: { locale: 'zh-CN' },
72
+ request: vi.fn(async () => ({ data: { ok: true } })),
73
+ resource: vi.fn(() => ({ update })),
74
+ };
75
+
76
+ const wrappedApi = getWrappedApi(engine, api);
77
+ const wrappedAgain = getDirtyAwareApiClient(wrappedApi, engine.context) as TestApi;
78
+ await wrappedAgain.resource('posts').update({ filterByTk: 1 });
79
+
80
+ expect(wrappedAgain).toBe(wrappedApi);
81
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
82
+ });
83
+
84
+ it('should keep request overrides local to the dirty-aware proxy', async () => {
85
+ const engine = new FlowEngine();
86
+ const request = vi.fn(async () => ({ data: { ok: true } }));
87
+ const api: TestApi = {
88
+ auth: { locale: 'zh-CN' },
89
+ request,
90
+ resource: vi.fn(),
91
+ };
92
+ const originalRequest = api.request;
93
+ const wrappedApi = getWrappedApi(engine, api);
94
+ const delegatedRequest = wrappedApi.request.bind(wrappedApi);
95
+ const requestOverride = vi.fn((config: TestRequestOptions) => delegatedRequest(config));
96
+
97
+ wrappedApi.request = requestOverride;
98
+
99
+ await wrappedApi.request({ url: 'posts:list' });
100
+
101
+ expect(requestOverride).toHaveBeenCalledTimes(1);
102
+ expect(request).toHaveBeenCalledTimes(1);
103
+ expect(api.request).toBe(originalRequest);
104
+ });
105
+
106
+ it('should keep resource overrides local to the dirty-aware proxy', async () => {
107
+ const engine = new FlowEngine();
108
+ const update = vi.fn(async () => ({ data: { ok: true } }));
109
+ const api: TestApi = {
110
+ auth: { locale: 'zh-CN' },
111
+ request: vi.fn(async () => ({ data: { ok: true } })),
112
+ resource: vi.fn(() => ({ update })),
113
+ };
114
+ const originalResource = api.resource;
115
+ const wrappedApi = getWrappedApi(engine, api);
116
+ const delegatedResource = wrappedApi.resource.bind(wrappedApi);
117
+ const resourceOverride = vi.fn((...args: Parameters<TestApi['resource']>) => delegatedResource(...args));
118
+
119
+ wrappedApi.resource = resourceOverride;
120
+
121
+ await wrappedApi.resource('posts').update({ filterByTk: 1 });
122
+
123
+ expect(resourceOverride).toHaveBeenCalledTimes(1);
124
+ expect(update).toHaveBeenCalledTimes(1);
125
+ expect(api.resource).toBe(originalResource);
126
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
127
+ });
128
+
129
+ it('should route resource actions through the local request override without double-marking', async () => {
130
+ const engine = new FlowEngine();
131
+ const api = new APIClient();
132
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
133
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
134
+ const delegatedRequest = wrappedApi.request.bind(wrappedApi);
135
+ const requestOverride = vi.fn((config: Parameters<APIClient['request']>[0]) => delegatedRequest(config));
136
+
137
+ wrappedApi.request = requestOverride as APIClient['request'];
138
+
139
+ await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
140
+
141
+ expect(requestOverride).toHaveBeenCalledTimes(1);
142
+ expect(transport).toHaveBeenCalledTimes(1);
143
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
144
+ });
145
+
146
+ it('should invoke custom request methods on the original api instance', async () => {
147
+ class StatefulRequestAPIClient extends APIClient {
148
+ #privateRequestCalls = 0;
149
+ publicRequestCalls = 0;
150
+
151
+ get privateRequestCalls() {
152
+ return this.#privateRequestCalls;
153
+ }
154
+
155
+ override request<T, R, D>(config: Parameters<APIClient['request']>[0]): Promise<R> {
156
+ this.#privateRequestCalls += 1;
157
+ this.publicRequestCalls += 1;
158
+ return super.request<T, R, D>(config);
159
+ }
160
+ }
161
+
162
+ const engine = new FlowEngine();
163
+ const api = new StatefulRequestAPIClient();
164
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
165
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
166
+
167
+ await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
168
+
169
+ expect(transport).toHaveBeenCalledTimes(1);
170
+ expect(api.privateRequestCalls).toBe(1);
171
+ expect(api.publicRequestCalls).toBe(1);
172
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
173
+ });
174
+
175
+ it('should invoke custom resource methods on the original api instance', async () => {
176
+ class StatefulResourceAPIClient extends APIClient {
177
+ #privateResourceCalls = 0;
178
+ publicResourceCalls = 0;
179
+
180
+ get privateResourceCalls() {
181
+ return this.#privateResourceCalls;
182
+ }
183
+
184
+ override resource(...args: Parameters<APIClient['resource']>): IResource {
185
+ this.#privateResourceCalls += 1;
186
+ this.publicResourceCalls += 1;
187
+ return super.resource(...args);
188
+ }
189
+ }
190
+
191
+ const engine = new FlowEngine();
192
+ const api = new StatefulResourceAPIClient();
193
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
194
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
195
+
196
+ await wrappedApi.request({
197
+ resource: 'posts',
198
+ action: 'update',
199
+ params: { filterByTk: 1, values: { title: 'updated' } },
200
+ });
201
+
202
+ expect(transport).toHaveBeenCalledTimes(1);
203
+ expect(api.privateResourceCalls).toBe(1);
204
+ expect(api.publicResourceCalls).toBe(1);
205
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
206
+ });
207
+
208
+ it('should keep one dirty mark when the request override rebuilds the config', async () => {
209
+ const engine = new FlowEngine();
210
+ const api = new APIClient();
211
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
212
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
213
+ const delegatedRequest = wrappedApi.request.bind(wrappedApi);
214
+ const requestOverride = vi.fn((config: Parameters<APIClient['request']>[0]) => {
215
+ const { url, method, headers, params, data } = config as RequestOptions;
216
+ return delegatedRequest({ url, method, headers, params, data });
217
+ });
218
+
219
+ wrappedApi.request = requestOverride as APIClient['request'];
220
+
221
+ await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
222
+
223
+ expect(requestOverride).toHaveBeenCalledTimes(1);
224
+ expect(transport).toHaveBeenCalledTimes(1);
225
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
226
+ });
227
+
228
+ it('should keep nested mutations from a request override independent', async () => {
229
+ const engine = new FlowEngine();
230
+ const api = new APIClient();
231
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
232
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
233
+ const delegatedRequest = wrappedApi.request.bind(wrappedApi);
234
+ const requestOverride = vi.fn(async (config: Parameters<APIClient['request']>[0]) => {
235
+ await delegatedRequest({ url: 'comments:create', method: 'post' });
236
+ return delegatedRequest(config);
237
+ });
238
+
239
+ wrappedApi.request = requestOverride as APIClient['request'];
240
+
241
+ await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
242
+
243
+ expect(requestOverride).toHaveBeenCalledTimes(1);
244
+ expect(transport).toHaveBeenCalledTimes(2);
245
+ expect(engine.getDataSourceDirtyVersion('main', 'comments')).toBe(1);
246
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
247
+ });
248
+
249
+ it('should route structured requests through the local resource override without double-marking', async () => {
250
+ const engine = new FlowEngine();
251
+ const api = new APIClient();
252
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
253
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
254
+ const delegatedResource = wrappedApi.resource.bind(wrappedApi);
255
+ const resourceOverride = vi.fn((...args: Parameters<APIClient['resource']>) => delegatedResource(...args));
256
+
257
+ wrappedApi.resource = resourceOverride;
258
+
259
+ await wrappedApi.request({
260
+ resource: 'posts',
261
+ action: 'update',
262
+ params: { filterByTk: 1, values: { title: 'updated' } },
263
+ });
264
+
265
+ expect(resourceOverride).toHaveBeenCalledTimes(1);
266
+ expect(transport).toHaveBeenCalledTimes(1);
267
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
268
+ });
269
+
270
+ it('should keep one dirty mark when the resource override delegates lazily', async () => {
271
+ const engine = new FlowEngine();
272
+ const api = new APIClient();
273
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
274
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
275
+ const delegatedResource = wrappedApi.resource.bind(wrappedApi);
276
+ const resourceOverride = vi.fn(
277
+ (...args: Parameters<APIClient['resource']>): IResource => ({
278
+ update: async (...actionArgs: Parameters<IResource['update']>) => {
279
+ await Promise.resolve();
280
+ return delegatedResource(...args).update(...actionArgs);
281
+ },
282
+ }),
283
+ );
284
+
285
+ wrappedApi.resource = resourceOverride;
286
+
287
+ await wrappedApi.request({
288
+ resource: 'posts',
289
+ action: 'update',
290
+ params: { filterByTk: 1, values: { title: 'updated' } },
291
+ });
292
+
293
+ expect(resourceOverride).toHaveBeenCalledTimes(1);
294
+ expect(transport).toHaveBeenCalledTimes(1);
295
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
296
+ });
297
+
298
+ it('should keep nested mutations from a resource override independent', async () => {
299
+ const engine = new FlowEngine();
300
+ const api = new APIClient();
301
+ const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
302
+ const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
303
+ const delegatedResource = wrappedApi.resource.bind(wrappedApi);
304
+ let nestedMutation: Promise<unknown> | undefined;
305
+ const resourceOverride = vi.fn((...args: Parameters<APIClient['resource']>) => {
306
+ nestedMutation = delegatedResource('comments').create({ values: { body: 'nested' } });
307
+ return delegatedResource(...args);
308
+ });
309
+
310
+ wrappedApi.resource = resourceOverride;
311
+
312
+ await wrappedApi.request({
313
+ resource: 'posts',
314
+ action: 'update',
315
+ params: { filterByTk: 1, values: { title: 'updated' } },
316
+ });
317
+ await nestedMutation;
318
+
319
+ expect(resourceOverride).toHaveBeenCalledTimes(1);
320
+ expect(transport).toHaveBeenCalledTimes(2);
321
+ expect(engine.getDataSourceDirtyVersion('main', 'comments')).toBe(1);
322
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
323
+ });
324
+
325
+ it('should clear local request and resource overrides when deleted', () => {
326
+ const engine = new FlowEngine();
327
+ const api: TestApi = {
328
+ auth: { locale: 'zh-CN' },
329
+ request: vi.fn(async () => ({ data: { ok: true } })),
330
+ resource: vi.fn(() => ({})),
331
+ };
332
+ const wrappedApi = getWrappedApi(engine, api);
333
+ const defaultRequest = wrappedApi.request;
334
+ const defaultResource = wrappedApi.resource;
335
+
336
+ wrappedApi.request = vi.fn();
337
+ wrappedApi.resource = vi.fn();
338
+
339
+ expect(Reflect.deleteProperty(wrappedApi, 'request')).toBe(true);
340
+ expect(Reflect.deleteProperty(wrappedApi, 'resource')).toBe(true);
341
+ expect(wrappedApi.request).toBe(defaultRequest);
342
+ expect(wrappedApi.resource).toBe(defaultResource);
343
+ });
344
+
345
+ it('should reject request and resource descriptor overrides without mutating the raw api', () => {
346
+ const engine = new FlowEngine();
347
+ const api: TestApi = {
348
+ auth: { locale: 'zh-CN' },
349
+ request: vi.fn(async () => ({ data: { ok: true } })),
350
+ resource: vi.fn(() => ({})),
351
+ };
352
+ const wrappedApi = getWrappedApi(engine, api);
353
+ const originalRequest = api.request;
354
+ const originalResource = api.resource;
355
+
356
+ expect(Reflect.defineProperty(wrappedApi, 'request', { value: vi.fn() })).toBe(false);
357
+ expect(Reflect.defineProperty(wrappedApi, 'resource', { value: vi.fn() })).toBe(false);
358
+ expect(() => Object.defineProperty(wrappedApi, 'request', { value: vi.fn() })).toThrow(TypeError);
359
+ expect(() => Object.defineProperty(wrappedApi, 'resource', { value: vi.fn() })).toThrow(TypeError);
360
+ expect(api.request).toBe(originalRequest);
361
+ expect(api.resource).toBe(originalResource);
362
+ });
363
+
364
+ it('should reject local overrides for own methods on a non-extensible raw api', () => {
365
+ const engine = new FlowEngine();
366
+ const api: TestApi = {
367
+ auth: { locale: 'zh-CN' },
368
+ request: vi.fn(async () => ({ data: { ok: true } })),
369
+ resource: vi.fn(() => ({})),
370
+ };
371
+ const wrappedApi = getWrappedApi(engine, api);
372
+ const defaultMethods = {
373
+ request: wrappedApi.request,
374
+ resource: wrappedApi.resource,
375
+ };
376
+
377
+ Object.preventExtensions(api);
378
+
379
+ for (const methodName of ['request', 'resource'] as const) {
380
+ expect(Reflect.set(wrappedApi, methodName, vi.fn())).toBe(false);
381
+ expect(wrappedApi[methodName]).toBe(defaultMethods[methodName]);
382
+ expect(Reflect.deleteProperty(wrappedApi, methodName)).toBe(false);
383
+ }
384
+ });
385
+
386
+ it('should keep overrides isolated between flow contexts', () => {
387
+ const api: TestApi = {
388
+ auth: { locale: 'zh-CN' },
389
+ request: vi.fn(async () => ({ data: { ok: true } })),
390
+ resource: vi.fn(() => ({})),
391
+ };
392
+ const firstContextApi = getDirtyAwareApiClient(api, new FlowContext()) as TestApi;
393
+ const secondContextApi = getDirtyAwareApiClient(api, new FlowContext()) as TestApi;
394
+ const secondRequest = secondContextApi.request;
395
+ const requestOverride = vi.fn();
396
+
397
+ firstContextApi.request = requestOverride;
398
+
399
+ expect(firstContextApi.request).toBe(requestOverride);
400
+ expect(secondContextApi.request).toBe(secondRequest);
401
+ expect(api.request).not.toBe(requestOverride);
402
+ });
403
+
404
+ it('should not mark dirty for read actions', async () => {
405
+ const nonMutatingActions = [
406
+ 'get',
407
+ 'getSystemSettings',
408
+ 'list',
409
+ 'listByUser',
410
+ 'query',
411
+ 'count',
412
+ 'check',
413
+ 'preview',
414
+ 'test',
415
+ 'find',
416
+ 'exists',
417
+ 'aggregate',
418
+ 'listMine',
419
+ 'parents',
420
+ 'children',
421
+ 'search',
422
+ 'send',
423
+ 'testConnection',
424
+ 'refresh',
425
+ 'run',
426
+ 'runById',
427
+ 'unknownCustomAction',
428
+ ];
429
+
430
+ for (const actionName of nonMutatingActions) {
431
+ const engine = new FlowEngine();
432
+ const api: TestApi = {
433
+ auth: { locale: 'zh-CN' },
434
+ request: vi.fn(async () => ({ data: { ok: true } })),
435
+ resource: vi.fn(() => ({
436
+ [actionName]: vi.fn(async () => ({ data: { data: [] } })),
437
+ })),
438
+ };
439
+
440
+ await getWrappedApi(engine, api).resource('posts')[actionName]();
441
+
442
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(0);
443
+ }
444
+ });
445
+
446
+ it('should mark dirty for known mutating action variants', async () => {
447
+ const mutatingActions = [
448
+ 'create',
449
+ 'execute',
450
+ 'updateOrCreate',
451
+ 'firstOrCreate',
452
+ 'setFields',
453
+ 'updateProfile',
454
+ 'saveAsTemplate',
455
+ 'remove/abc',
456
+ ];
457
+
458
+ for (const actionName of mutatingActions) {
459
+ const engine = new FlowEngine();
460
+ const api: TestApi = {
461
+ auth: { locale: 'zh-CN' },
462
+ request: vi.fn(async () => ({ data: { ok: true } })),
463
+ resource: vi.fn(() => ({
464
+ [actionName]: vi.fn(async () => ({ data: { ok: true } })),
465
+ })),
466
+ };
467
+
468
+ await getWrappedApi(engine, api).resource('posts')[actionName]();
469
+
470
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
471
+ }
472
+ });
473
+
474
+ it('should not mark dirty when a mutating action fails', async () => {
475
+ const engine = new FlowEngine();
476
+ const update = vi.fn(async () => {
477
+ throw new Error('update failed');
478
+ });
479
+ const api: TestApi = {
480
+ auth: { locale: 'zh-CN' },
481
+ request: vi.fn(async () => ({ data: { ok: true } })),
482
+ resource: vi.fn(() => ({ update })),
483
+ };
484
+
485
+ await expect(getWrappedApi(engine, api).resource('posts').update({ filterByTk: 1 })).rejects.toThrow(
486
+ 'update failed',
487
+ );
488
+
489
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(0);
490
+ });
491
+
492
+ it('should mark association resource and parent collection dirty', async () => {
493
+ const engine = new FlowEngine();
494
+ const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
495
+ engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
496
+ const add = vi.fn(async () => ({ data: { data: null } }));
497
+ const api: TestApi = {
498
+ auth: { locale: 'zh-CN' },
499
+ request: vi.fn(async () => ({ data: { ok: true } })),
500
+ resource: vi.fn(() => ({ add })),
501
+ };
502
+
503
+ await getWrappedApi(engine, api)
504
+ .resource('users.roles', 1, { 'x-data-source': 'external' })
505
+ .add({ values: [1, 2] });
506
+
507
+ expect(engine.getDataSourceDirtyVersion('external', 'users.roles')).toBe(1);
508
+ expect(engine.getDataSourceDirtyVersion('external', 'users')).toBe(1);
509
+ expect(dirtyEvents).toEqual([{ dataSourceKey: 'external', resourceNames: ['users.roles', 'users'] }]);
510
+ });
511
+
512
+ it('should mark resource-action request mutations dirty after success', async () => {
513
+ const engine = new FlowEngine();
514
+ const request = vi.fn(async () => ({ data: { ok: true } }));
515
+ const api: TestApi = {
516
+ auth: { locale: 'zh-CN' },
517
+ request,
518
+ resource: vi.fn(),
519
+ };
520
+
521
+ await getWrappedApi(engine, api).request({
522
+ resource: 'posts',
523
+ action: 'update',
524
+ headers: { 'X-Data-Source': 'analytics' },
525
+ params: { filterByTk: 1 },
526
+ });
527
+
528
+ expect(request).toHaveBeenCalledTimes(1);
529
+ expect(engine.getDataSourceDirtyVersion('analytics', 'posts')).toBe(1);
530
+ });
531
+
532
+ it('should mark URL-form resource mutations dirty after success', async () => {
533
+ const engine = new FlowEngine();
534
+ const request = vi.fn(async () => ({ data: { ok: true } }));
535
+ const api: TestApi = {
536
+ auth: { locale: 'zh-CN' },
537
+ request,
538
+ resource: vi.fn(),
539
+ };
540
+ const wrappedApi = getWrappedApi(engine, api);
541
+
542
+ await wrappedApi.request({ url: 'posts:update' });
543
+ await wrappedApi.request({
544
+ url: '/api/posts:update?filterByTk=1',
545
+ headers: { 'x-data-source': 'external' },
546
+ });
547
+ await wrappedApi.request({
548
+ url: '/api/posts/1/tags:set',
549
+ headers: { 'X-Data-Source': 'analytics' },
550
+ });
551
+
552
+ expect(request).toHaveBeenCalledTimes(3);
553
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
554
+ expect(engine.getDataSourceDirtyVersion('external', 'posts')).toBe(1);
555
+ expect(engine.getDataSourceDirtyVersion('analytics', 'posts.tags')).toBe(1);
556
+ expect(engine.getDataSourceDirtyVersion('analytics', 'posts')).toBe(1);
557
+ });
558
+
559
+ it('should resolve data source resource URLs to the nested data source target', async () => {
560
+ const engine = new FlowEngine();
561
+ const request = vi.fn(async () => ({ data: { ok: true } }));
562
+ const api: TestApi = {
563
+ auth: { locale: 'zh-CN' },
564
+ request,
565
+ resource: vi.fn(),
566
+ };
567
+
568
+ await getWrappedApi(engine, api).request({
569
+ url: 'dataSources/external/collections:update',
570
+ });
571
+
572
+ expect(request).toHaveBeenCalledTimes(1);
573
+ expect(engine.getDataSourceDirtyVersion('external', 'collections')).toBe(1);
574
+ expect(engine.getDataSourceDirtyVersion('main', 'dataSources.collections')).toBe(0);
575
+ expect(engine.getDataSourceDirtyVersion('main', 'dataSources')).toBe(0);
576
+ });
577
+
578
+ it('should resolve dataSources resourceOf requests to the nested data source target', async () => {
579
+ const engine = new FlowEngine();
580
+ const request = vi.fn(async () => ({ data: { ok: true } }));
581
+ const update = vi.fn(async () => ({ data: { ok: true } }));
582
+ const api: TestApi = {
583
+ auth: { locale: 'zh-CN' },
584
+ request,
585
+ resource: vi.fn(() => ({ update })),
586
+ };
587
+ const wrappedApi = getWrappedApi(engine, api);
588
+
589
+ await wrappedApi.request({
590
+ resource: 'dataSources.collections',
591
+ resourceOf: 'external',
592
+ action: 'update',
593
+ } as TestRequestOptions & { resourceOf: string });
594
+ await wrappedApi.resource('dataSources.roles', 'external').update({ values: { allow: true } });
595
+ await wrappedApi.resource('dataSources/external/roles').update({ values: { allow: false } });
596
+
597
+ expect(engine.getDataSourceDirtyVersion('external', 'collections')).toBe(1);
598
+ expect(engine.getDataSourceDirtyVersion('external', 'roles')).toBe(2);
599
+ expect(engine.getDataSourceDirtyVersion('main', 'dataSources.collections')).toBe(0);
600
+ expect(engine.getDataSourceDirtyVersion('main', 'dataSources.roles')).toBe(0);
601
+ });
602
+
603
+ it('should skip dirty marking and strip the internal skip flag from raw requests', async () => {
604
+ const engine = new FlowEngine();
605
+ const request = vi.fn(async () => ({ data: { ok: true } }));
606
+ const api: TestApi = {
607
+ auth: { locale: 'zh-CN' },
608
+ request,
609
+ resource: vi.fn(),
610
+ };
611
+
612
+ await getWrappedApi(engine, api).request({
613
+ url: 'posts:update',
614
+ [SKIP_DATA_SOURCE_DIRTY]: true,
615
+ } as TestRequestOptions & { [SKIP_DATA_SOURCE_DIRTY]: boolean });
616
+
617
+ expect(request).toHaveBeenCalledTimes(1);
618
+ expect(request.mock.calls[0][0]).not.toHaveProperty(SKIP_DATA_SOURCE_DIRTY);
619
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(0);
620
+ });
621
+
622
+ it('should strip configured API base from URL-form mutations', async () => {
623
+ const engine = new FlowEngine();
624
+ const request = vi.fn(async () => ({ data: { ok: true } }));
625
+ const api: TestApi = {
626
+ auth: { locale: 'zh-CN' },
627
+ request,
628
+ resource: vi.fn(),
629
+ };
630
+ engine.context.defineProperty('app', {
631
+ value: {
632
+ getApiUrl(pathname = '') {
633
+ return `https://app.example.com/foo/api/${pathname.replace(/^\//, '')}`;
634
+ },
635
+ },
636
+ });
637
+ const wrappedApi = getWrappedApi(engine, api);
638
+
639
+ await wrappedApi.request({ url: '/foo/api/posts:update' });
640
+ await wrappedApi.request({
641
+ url: 'https://app.example.com/foo/api/users/1/roles:set',
642
+ headers: { 'x-data-source': 'external' },
643
+ });
644
+
645
+ expect(request).toHaveBeenCalledTimes(2);
646
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
647
+ expect(engine.getDataSourceDirtyVersion('main', 'foo.api.posts')).toBe(0);
648
+ expect(engine.getDataSourceDirtyVersion('external', 'users.roles')).toBe(1);
649
+ expect(engine.getDataSourceDirtyVersion('external', 'users')).toBe(1);
650
+ });
651
+
652
+ it('should not mark URL-form resource dirty for reads, failed mutations, or external URLs', async () => {
653
+ const engine = new FlowEngine();
654
+ const request = vi
655
+ .fn()
656
+ .mockResolvedValueOnce({ data: { data: [] } })
657
+ .mockResolvedValueOnce({ data: { data: [] } })
658
+ .mockRejectedValueOnce(new Error('request failed'))
659
+ .mockResolvedValueOnce({ data: { ok: true } })
660
+ .mockResolvedValueOnce({ data: { ok: true } });
661
+ const api: TestApi = {
662
+ auth: { locale: 'zh-CN' },
663
+ request,
664
+ resource: vi.fn(),
665
+ };
666
+ const wrappedApi = getWrappedApi(engine, api);
667
+
668
+ await wrappedApi.request({ url: 'posts:list' });
669
+ await wrappedApi.request({ url: '/api/posts:parents' });
670
+ await expect(wrappedApi.request({ url: '/api/posts:update' })).rejects.toThrow('request failed');
671
+ await wrappedApi.request({ url: 'https://example.com/api/posts:update' });
672
+ await wrappedApi.request({ url: '//example.com/api/posts:update' });
673
+
674
+ expect(request).toHaveBeenCalledTimes(5);
675
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(0);
676
+ });
677
+
678
+ it('should mark opener engine dirty when called from a scoped view context', async () => {
679
+ const root = new FlowEngine();
680
+ const scoped = createViewScopedEngine(root);
681
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
682
+ const api: TestApi = {
683
+ auth: { locale: 'zh-CN' },
684
+ request: vi.fn(async () => ({ data: { ok: true } })),
685
+ resource: vi.fn(() => ({ update })),
686
+ };
687
+
688
+ await getWrappedApi(scoped, api)
689
+ .resource('posts')
690
+ .update({ filterByTk: 1, values: { title: 't' } });
691
+
692
+ expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
693
+ expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
694
+ });
695
+
696
+ it('should not double-mark when the context exposes a scoped engine proxy', async () => {
697
+ const root = new FlowEngine();
698
+ const scoped = createViewScopedEngine(root);
699
+ const context = new FlowContext();
700
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
701
+ const api: TestApi = {
702
+ auth: { locale: 'zh-CN' },
703
+ request: vi.fn(async () => ({ data: { ok: true } })),
704
+ resource: vi.fn(() => ({ update })),
705
+ };
706
+ context.defineProperty('engine', { value: scoped });
707
+
708
+ await (getDirtyAwareApiClient(api, context) as TestApi).resource('posts').update({ filterByTk: 1 });
709
+
710
+ expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
711
+ expect(scoped.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
712
+ });
713
+ });