@nocobase/flow-engine 2.2.0-beta.6 → 2.2.0-beta.8
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.
- package/lib/components/MobilePopup.style.js +16 -5
- package/lib/components/dnd/index.js +9 -2
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
- package/lib/flowContext.d.ts +1 -1
- package/lib/flowContext.js +32 -8
- package/lib/locale/en-US.json +1 -0
- package/lib/locale/index.d.ts +2 -0
- package/lib/locale/zh-CN.json +1 -0
- package/lib/resources/apiResource.js +2 -1
- package/lib/resources/baseRecordResource.js +6 -17
- package/lib/resources/multiRecordResource.js +13 -3
- package/lib/resources/singleRecordResource.js +7 -2
- package/lib/utils/dataSourceDirty.d.ts +20 -0
- package/lib/utils/dataSourceDirty.js +139 -0
- package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
- package/lib/utils/dirtyAwareApiClient.js +378 -0
- package/lib/utils/index.d.ts +1 -0
- package/lib/utils/index.js +11 -0
- package/lib/utils/openViewRouteState.d.ts +28 -0
- package/lib/utils/openViewRouteState.js +125 -0
- package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
- package/lib/utils/parsePathnameToViewParams.js +18 -1
- package/lib/views/ViewNavigation.js +5 -0
- package/package.json +4 -4
- package/src/__tests__/flowContext.test.ts +131 -0
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
- package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
- package/src/components/MobilePopup.style.ts +22 -6
- package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
- package/src/components/dnd/index.tsx +11 -2
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
- package/src/flowContext.ts +45 -8
- package/src/locale/en-US.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/resources/apiResource.ts +2 -1
- package/src/resources/baseRecordResource.ts +6 -23
- package/src/resources/multiRecordResource.ts +13 -3
- package/src/resources/singleRecordResource.ts +6 -1
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
- package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
- package/src/utils/dataSourceDirty.ts +126 -0
- package/src/utils/dirtyAwareApiClient.ts +430 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/openViewRouteState.ts +107 -0
- package/src/utils/parsePathnameToViewParams.ts +23 -1
- package/src/views/ViewNavigation.ts +6 -1
- package/src/views/__tests__/ViewNavigation.test.ts +15 -0
package/src/flowContext.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { ISchema } from '@formily/json-schema';
|
|
11
11
|
import { observable } from '@formily/reactive';
|
|
12
|
-
import { APIClient, RequestOptions } from '@nocobase/sdk';
|
|
12
|
+
import type { APIClient, RequestOptions } from '@nocobase/sdk';
|
|
13
13
|
import type { Router } from '@remix-run/router';
|
|
14
14
|
import axios from 'axios';
|
|
15
15
|
import { MessageInstance } from 'antd/es/message/interface';
|
|
@@ -39,9 +39,11 @@ import {
|
|
|
39
39
|
extractUsedVariablePaths,
|
|
40
40
|
FlowExitException,
|
|
41
41
|
FLOW_ENGINE_NAMESPACE,
|
|
42
|
+
createOpenViewRouteState,
|
|
42
43
|
isCtxDatePathPrefix,
|
|
43
44
|
isCssFile,
|
|
44
45
|
prepareRunJsCode,
|
|
46
|
+
RUNJS_OPEN_VIEW_ROUTE_STATE,
|
|
45
47
|
resolveCtxDatePath,
|
|
46
48
|
resolveDefaultParams,
|
|
47
49
|
resolveExpressions,
|
|
@@ -51,6 +53,7 @@ import { FlowExitAllException } from './utils/exceptions';
|
|
|
51
53
|
import { enqueueVariablesResolve, JSONValue } from './utils/params-resolvers';
|
|
52
54
|
import type { RecordRef } from './utils/serverContextParams';
|
|
53
55
|
import { buildServerContextParams as _buildServerContextParams } from './utils/serverContextParams';
|
|
56
|
+
import { getDirtyAwareApiClient } from './utils/dirtyAwareApiClient';
|
|
54
57
|
import { inferRecordRef } from './utils/variablesParams';
|
|
55
58
|
import { FlowView, FlowViewer } from './views/FlowView';
|
|
56
59
|
import { RunJSContextRegistry, getModelClassName, type RunJSVersion } from './runjs-context/registry';
|
|
@@ -2909,19 +2912,20 @@ export class FlowContext {
|
|
|
2909
2912
|
|
|
2910
2913
|
// 静态值
|
|
2911
2914
|
if ('value' in options) {
|
|
2912
|
-
return options.value;
|
|
2915
|
+
return key === 'api' ? getDirtyAwareApiClient(options.value, currentContext) : options.value;
|
|
2913
2916
|
}
|
|
2914
2917
|
|
|
2915
2918
|
// get 方法
|
|
2916
2919
|
if (options.get) {
|
|
2917
2920
|
if (options.cache === false) {
|
|
2918
|
-
|
|
2921
|
+
const value = options.get(currentContext);
|
|
2922
|
+
return key === 'api' ? getDirtyAwareApiClient(value, currentContext) : value;
|
|
2919
2923
|
}
|
|
2920
2924
|
|
|
2921
2925
|
const cacheKey = options.observable ? '_observableCache' : '_cache';
|
|
2922
2926
|
|
|
2923
2927
|
if (key in this[cacheKey]) {
|
|
2924
|
-
return this[cacheKey][key];
|
|
2928
|
+
return key === 'api' ? getDirtyAwareApiClient(this[cacheKey][key], currentContext) : this[cacheKey][key];
|
|
2925
2929
|
}
|
|
2926
2930
|
|
|
2927
2931
|
if (this._pending[key]) return this._pending[key];
|
|
@@ -2939,7 +2943,7 @@ export class FlowContext {
|
|
|
2939
2943
|
(v) => {
|
|
2940
2944
|
this[cacheKey][key] = v;
|
|
2941
2945
|
delete this._pending[key];
|
|
2942
|
-
return v;
|
|
2946
|
+
return key === 'api' ? getDirtyAwareApiClient(v, currentContext) : v;
|
|
2943
2947
|
},
|
|
2944
2948
|
(err) => {
|
|
2945
2949
|
delete this._pending[key];
|
|
@@ -2951,7 +2955,7 @@ export class FlowContext {
|
|
|
2951
2955
|
|
|
2952
2956
|
// sync 直接缓存
|
|
2953
2957
|
this[cacheKey][key] = result;
|
|
2954
|
-
return result;
|
|
2958
|
+
return key === 'api' ? getDirtyAwareApiClient(result, currentContext) : result;
|
|
2955
2959
|
}
|
|
2956
2960
|
|
|
2957
2961
|
return undefined;
|
|
@@ -3074,7 +3078,7 @@ class BaseFlowEngineContext extends FlowContext {
|
|
|
3074
3078
|
this.defineMethod('getModel', (modelName: string, searchInPreviousEngines?: boolean) => {
|
|
3075
3079
|
return this.engine.getModel(modelName, searchInPreviousEngines);
|
|
3076
3080
|
});
|
|
3077
|
-
this.defineMethod('request', (options: RequestOptions)
|
|
3081
|
+
this.defineMethod('request', function (this: FlowContext, options: RequestOptions) {
|
|
3078
3082
|
const app = this.app as { getApiUrl?: (pathname?: string) => string } | undefined;
|
|
3079
3083
|
if (typeof options?.url === 'string' && shouldBypassApiClient(options.url, app)) {
|
|
3080
3084
|
return axios.request(options);
|
|
@@ -3429,7 +3433,19 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3429
3433
|
}),
|
|
3430
3434
|
});
|
|
3431
3435
|
this.defineProperty('role', {
|
|
3432
|
-
get: () =>
|
|
3436
|
+
get: () => {
|
|
3437
|
+
const currentRole = this.api?.auth?.role;
|
|
3438
|
+
if (currentRole !== '__union__') {
|
|
3439
|
+
return currentRole;
|
|
3440
|
+
}
|
|
3441
|
+
const roles = this.user?.roles;
|
|
3442
|
+
if (!Array.isArray(roles)) {
|
|
3443
|
+
return [];
|
|
3444
|
+
}
|
|
3445
|
+
return roles
|
|
3446
|
+
.map((role: { name?: string }) => role?.name)
|
|
3447
|
+
.filter((name: string | undefined): name is string => !!name);
|
|
3448
|
+
},
|
|
3433
3449
|
cache: false,
|
|
3434
3450
|
// 注意:使用惰性 meta 工厂,避免在 i18n 尚未注入时提前求值导致无法翻译
|
|
3435
3451
|
meta: Object.assign(() => ({ type: 'string', title: this.t('Current role'), sort: 990 }), {
|
|
@@ -4588,6 +4604,27 @@ export class FlowRunJSContext extends FlowContext {
|
|
|
4588
4604
|
this.defineProperty('ReactDOM', { value: ReactDOMShim });
|
|
4589
4605
|
|
|
4590
4606
|
setupRunJSLibs(this);
|
|
4607
|
+
this.defineMethod('openView', async function (uid: string, options?: Record<PropertyKey, unknown>) {
|
|
4608
|
+
const delegateOpenView = (
|
|
4609
|
+
delegate as FlowContext & {
|
|
4610
|
+
openView?: (uid: string, options?: Record<PropertyKey, unknown>) => Promise<unknown>;
|
|
4611
|
+
}
|
|
4612
|
+
).openView;
|
|
4613
|
+
|
|
4614
|
+
if (typeof delegateOpenView !== 'function') {
|
|
4615
|
+
throw new Error('ctx.openView is not available in current context.');
|
|
4616
|
+
}
|
|
4617
|
+
|
|
4618
|
+
const routeState = createOpenViewRouteState(options);
|
|
4619
|
+
if (!routeState) {
|
|
4620
|
+
return delegateOpenView(uid, options);
|
|
4621
|
+
}
|
|
4622
|
+
|
|
4623
|
+
return delegateOpenView(uid, {
|
|
4624
|
+
...(options || {}),
|
|
4625
|
+
[RUNJS_OPEN_VIEW_ROUTE_STATE]: routeState,
|
|
4626
|
+
});
|
|
4627
|
+
});
|
|
4591
4628
|
|
|
4592
4629
|
// Convenience: ctx.render(<App />[, container])
|
|
4593
4630
|
// - container defaults to ctx.element if available
|
package/src/locale/en-US.json
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"Replace current block with template?": "Replace current block with template?",
|
|
57
57
|
"Replaced with template block": "Replaced with template block",
|
|
58
58
|
"Render failed": "Render failed",
|
|
59
|
+
"Response record": "Response record",
|
|
59
60
|
"Step configuration": "Step configuration",
|
|
60
61
|
"Step parameter configuration": "Step parameter configuration",
|
|
61
62
|
"Step with key {{stepKey}} not found": "Step with key {{stepKey}} not found",
|
package/src/locale/zh-CN.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { APIClient } from '@nocobase/sdk';
|
|
11
11
|
import { FlowContext } from '../flowContext';
|
|
12
|
+
import { getDirtyAwareApiClient } from '../utils/dirtyAwareApiClient';
|
|
12
13
|
import { FlowResource, ResourceError } from './flowResource';
|
|
13
14
|
|
|
14
15
|
export class APIResource<TData = any> extends FlowResource<TData> {
|
|
@@ -33,7 +34,7 @@ export class APIResource<TData = any> extends FlowResource<TData> {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
setAPIClient(api: APIClient) {
|
|
36
|
-
this.api = api;
|
|
37
|
+
this.api = getDirtyAwareApiClient(api, this.context) as APIClient;
|
|
37
38
|
return this;
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -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 {
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
}
|
|
@@ -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
|
+
});
|