@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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
var __defProp = Object.defineProperty;
|
|
11
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
12
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
13
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
14
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
15
|
+
var __export = (target, all) => {
|
|
16
|
+
for (var name in all)
|
|
17
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
18
|
+
};
|
|
19
|
+
var __copyProps = (to, from, except, desc) => {
|
|
20
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
21
|
+
for (let key of __getOwnPropNames(from))
|
|
22
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
23
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
24
|
+
}
|
|
25
|
+
return to;
|
|
26
|
+
};
|
|
27
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
28
|
+
var openViewRouteState_exports = {};
|
|
29
|
+
__export(openViewRouteState_exports, {
|
|
30
|
+
RUNJS_OPEN_VIEW_ROUTE_STATE: () => RUNJS_OPEN_VIEW_ROUTE_STATE,
|
|
31
|
+
createOpenViewRouteState: () => createOpenViewRouteState,
|
|
32
|
+
decodeOpenViewRouteState: () => decodeOpenViewRouteState,
|
|
33
|
+
encodeOpenViewRouteState: () => encodeOpenViewRouteState,
|
|
34
|
+
isOpenViewRouteStateToken: () => isOpenViewRouteStateToken
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(openViewRouteState_exports);
|
|
37
|
+
const OPEN_VIEW_ROUTE_MODES = ["drawer", "dialog", "embed"];
|
|
38
|
+
const OPEN_VIEW_ROUTE_SIZES = ["small", "medium", "large"];
|
|
39
|
+
const OPEN_VIEW_ROUTE_STATE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
40
|
+
const OPEN_VIEW_ROUTE_STATE_TOKEN_LENGTH = 8;
|
|
41
|
+
const RUNJS_OPEN_VIEW_ROUTE_STATE = Symbol.for("nocobase.runjs.openViewRouteState");
|
|
42
|
+
function isOpenViewRouteMode(value) {
|
|
43
|
+
return typeof value === "string" && OPEN_VIEW_ROUTE_MODES.includes(value);
|
|
44
|
+
}
|
|
45
|
+
__name(isOpenViewRouteMode, "isOpenViewRouteMode");
|
|
46
|
+
function isOpenViewRouteSize(value) {
|
|
47
|
+
return typeof value === "string" && OPEN_VIEW_ROUTE_SIZES.includes(value);
|
|
48
|
+
}
|
|
49
|
+
__name(isOpenViewRouteSize, "isOpenViewRouteSize");
|
|
50
|
+
function createOpenViewRouteState(input) {
|
|
51
|
+
const state = {};
|
|
52
|
+
if (isOpenViewRouteMode(input == null ? void 0 : input.mode)) {
|
|
53
|
+
state.mode = input.mode;
|
|
54
|
+
}
|
|
55
|
+
if (isOpenViewRouteSize(input == null ? void 0 : input.size)) {
|
|
56
|
+
state.size = input.size;
|
|
57
|
+
}
|
|
58
|
+
return state.mode || state.size ? state : void 0;
|
|
59
|
+
}
|
|
60
|
+
__name(createOpenViewRouteState, "createOpenViewRouteState");
|
|
61
|
+
function hashString(value) {
|
|
62
|
+
let hash = 2166136261;
|
|
63
|
+
for (let i = 0; i < value.length; i++) {
|
|
64
|
+
hash ^= value.charCodeAt(i);
|
|
65
|
+
hash = Math.imul(hash, 16777619);
|
|
66
|
+
}
|
|
67
|
+
return hash >>> 0;
|
|
68
|
+
}
|
|
69
|
+
__name(hashString, "hashString");
|
|
70
|
+
function stateToCode(state) {
|
|
71
|
+
const modeIndex = state.mode ? OPEN_VIEW_ROUTE_MODES.indexOf(state.mode) + 1 : 0;
|
|
72
|
+
const sizeIndex = state.size ? OPEN_VIEW_ROUTE_SIZES.indexOf(state.size) + 1 : 0;
|
|
73
|
+
const code = modeIndex * 4 + sizeIndex;
|
|
74
|
+
return code > 0 ? code : void 0;
|
|
75
|
+
}
|
|
76
|
+
__name(stateToCode, "stateToCode");
|
|
77
|
+
function codeToState(code) {
|
|
78
|
+
const modeIndex = Math.floor(code / 4);
|
|
79
|
+
const sizeIndex = code % 4;
|
|
80
|
+
return createOpenViewRouteState({
|
|
81
|
+
mode: modeIndex ? OPEN_VIEW_ROUTE_MODES[modeIndex - 1] : void 0,
|
|
82
|
+
size: sizeIndex ? OPEN_VIEW_ROUTE_SIZES[sizeIndex - 1] : void 0
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
__name(codeToState, "codeToState");
|
|
86
|
+
function tokenForCode(viewUid, code) {
|
|
87
|
+
let seed = hashString(`${viewUid}:${code}`);
|
|
88
|
+
let token = "";
|
|
89
|
+
for (let i = 0; i < OPEN_VIEW_ROUTE_STATE_TOKEN_LENGTH; i++) {
|
|
90
|
+
seed = Math.imul(seed ^ code + i * 17, 16777619) >>> 0;
|
|
91
|
+
token += OPEN_VIEW_ROUTE_STATE_TOKEN_ALPHABET[seed % OPEN_VIEW_ROUTE_STATE_TOKEN_ALPHABET.length];
|
|
92
|
+
}
|
|
93
|
+
return token;
|
|
94
|
+
}
|
|
95
|
+
__name(tokenForCode, "tokenForCode");
|
|
96
|
+
function isOpenViewRouteStateToken(value) {
|
|
97
|
+
return typeof value === "string" && value.length === OPEN_VIEW_ROUTE_STATE_TOKEN_LENGTH && [...value].every((char) => OPEN_VIEW_ROUTE_STATE_TOKEN_ALPHABET.includes(char));
|
|
98
|
+
}
|
|
99
|
+
__name(isOpenViewRouteStateToken, "isOpenViewRouteStateToken");
|
|
100
|
+
function encodeOpenViewRouteState(viewUid, input) {
|
|
101
|
+
const state = createOpenViewRouteState(input);
|
|
102
|
+
const code = state ? stateToCode(state) : void 0;
|
|
103
|
+
return code ? tokenForCode(viewUid, code) : void 0;
|
|
104
|
+
}
|
|
105
|
+
__name(encodeOpenViewRouteState, "encodeOpenViewRouteState");
|
|
106
|
+
function decodeOpenViewRouteState(viewUid, token) {
|
|
107
|
+
if (!isOpenViewRouteStateToken(token)) {
|
|
108
|
+
return void 0;
|
|
109
|
+
}
|
|
110
|
+
for (let code = 1; code < 16; code++) {
|
|
111
|
+
if (tokenForCode(viewUid, code) === token) {
|
|
112
|
+
return codeToState(code);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return void 0;
|
|
116
|
+
}
|
|
117
|
+
__name(decodeOpenViewRouteState, "decodeOpenViewRouteState");
|
|
118
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
119
|
+
0 && (module.exports = {
|
|
120
|
+
RUNJS_OPEN_VIEW_ROUTE_STATE,
|
|
121
|
+
createOpenViewRouteState,
|
|
122
|
+
decodeOpenViewRouteState,
|
|
123
|
+
encodeOpenViewRouteState,
|
|
124
|
+
isOpenViewRouteStateToken
|
|
125
|
+
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
|
+
import { type OpenViewRouteState } from './openViewRouteState';
|
|
9
10
|
export interface ViewParam {
|
|
10
11
|
/** 视图唯一标识符,一般为某个 Model 实例的 uid */
|
|
11
12
|
viewUid: string;
|
|
@@ -15,6 +16,8 @@ export interface ViewParam {
|
|
|
15
16
|
filterByTk?: string | Record<string, string | number>;
|
|
16
17
|
/** source Id */
|
|
17
18
|
sourceId?: string;
|
|
19
|
+
/** RunJS ctx.openView runtime display overrides decoded from URL. */
|
|
20
|
+
openViewRouteState?: OpenViewRouteState;
|
|
18
21
|
}
|
|
19
22
|
export interface ParsePathnameToViewParamsOptions {
|
|
20
23
|
rootPrefix?: string;
|
|
@@ -30,6 +30,7 @@ __export(parsePathnameToViewParams_exports, {
|
|
|
30
30
|
parsePathnameToViewParams: () => parsePathnameToViewParams
|
|
31
31
|
});
|
|
32
32
|
module.exports = __toCommonJS(parsePathnameToViewParams_exports);
|
|
33
|
+
var import_openViewRouteState = require("./openViewRouteState");
|
|
33
34
|
const normalizePathname = /* @__PURE__ */ __name((pathname) => {
|
|
34
35
|
if (!pathname || pathname === "/") {
|
|
35
36
|
return "/";
|
|
@@ -77,7 +78,23 @@ const parsePathnameToViewParams = /* @__PURE__ */ __name((pathname, options = {}
|
|
|
77
78
|
} else {
|
|
78
79
|
break;
|
|
79
80
|
}
|
|
80
|
-
} else if (currentView
|
|
81
|
+
} else if (currentView) {
|
|
82
|
+
if (segment === "opts") {
|
|
83
|
+
if (i + 1 < segments.length) {
|
|
84
|
+
const routeState = (0, import_openViewRouteState.decodeOpenViewRouteState)(currentView.viewUid, segments[i + 1]);
|
|
85
|
+
if (routeState) {
|
|
86
|
+
currentView.openViewRouteState = routeState;
|
|
87
|
+
}
|
|
88
|
+
i += 2;
|
|
89
|
+
} else {
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (i + 1 >= segments.length) {
|
|
95
|
+
i++;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
81
98
|
const rawValue = segments[i + 1];
|
|
82
99
|
let decoded = rawValue;
|
|
83
100
|
try {
|
|
@@ -32,6 +32,7 @@ __export(ViewNavigation_exports, {
|
|
|
32
32
|
});
|
|
33
33
|
module.exports = __toCommonJS(ViewNavigation_exports);
|
|
34
34
|
var import_reactive = require("../reactive");
|
|
35
|
+
var import_utils = require("../utils");
|
|
35
36
|
function encodeFilterByTk(val) {
|
|
36
37
|
if (val === void 0 || val === null) return "";
|
|
37
38
|
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
@@ -63,6 +64,10 @@ function generatePathnameFromViewParams(viewParams, options = {}) {
|
|
|
63
64
|
segments.push("view");
|
|
64
65
|
}
|
|
65
66
|
segments.push(viewParam.viewUid);
|
|
67
|
+
const openViewRouteStateToken = (0, import_utils.encodeOpenViewRouteState)(viewParam.viewUid, viewParam.openViewRouteState);
|
|
68
|
+
if (openViewRouteStateToken) {
|
|
69
|
+
segments.push("opts", openViewRouteStateToken);
|
|
70
|
+
}
|
|
66
71
|
if (viewParam.tabUid) {
|
|
67
72
|
segments.push("tab", viewParam.tabUid);
|
|
68
73
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/flow-engine",
|
|
3
|
-
"version": "2.2.0-beta.
|
|
3
|
+
"version": "2.2.0-beta.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@formily/antd-v5": "1.x",
|
|
10
10
|
"@formily/reactive": "2.x",
|
|
11
|
-
"@nocobase/sdk": "2.2.0-beta.
|
|
12
|
-
"@nocobase/shared": "2.2.0-beta.
|
|
11
|
+
"@nocobase/sdk": "2.2.0-beta.8",
|
|
12
|
+
"@nocobase/shared": "2.2.0-beta.8",
|
|
13
13
|
"ahooks": "^3.7.2",
|
|
14
14
|
"axios": "^1.7.0",
|
|
15
15
|
"dayjs": "^1.11.9",
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
],
|
|
38
38
|
"author": "NocoBase Team",
|
|
39
39
|
"license": "Apache-2.0",
|
|
40
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "fa2502c1e9faf6d74b3f51b42dbc6546638d46af"
|
|
41
41
|
}
|
|
@@ -14,6 +14,8 @@ import { FlowEngine } from '../flowEngine';
|
|
|
14
14
|
import { FlowModel } from '../models/flowModel';
|
|
15
15
|
import { RunJSContextRegistry } from '../runjs-context/registry';
|
|
16
16
|
import { setupRunJSContexts } from '../runjs-context/setup';
|
|
17
|
+
import { createViewScopedEngine } from '../ViewScopedFlowEngine';
|
|
18
|
+
import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
|
|
17
19
|
|
|
18
20
|
describe('FlowContext properties and methods', () => {
|
|
19
21
|
it('should return static property value', () => {
|
|
@@ -177,6 +179,49 @@ describe('FlowContext properties and methods', () => {
|
|
|
177
179
|
});
|
|
178
180
|
});
|
|
179
181
|
|
|
182
|
+
it('should expose current role as a top-level variable', async () => {
|
|
183
|
+
const engine = new FlowEngine();
|
|
184
|
+
const ctx = engine.context;
|
|
185
|
+
ctx.defineProperty('api', { value: { auth: { role: 'admin' } } });
|
|
186
|
+
|
|
187
|
+
expect(ctx.role).toBe('admin');
|
|
188
|
+
await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toBe('admin');
|
|
189
|
+
|
|
190
|
+
const roleNode = ctx.getPropertyMetaTree().find((node) => node.name === 'role');
|
|
191
|
+
expect(roleNode).toMatchObject({
|
|
192
|
+
name: 'role',
|
|
193
|
+
title: '{{t("Current role")}}',
|
|
194
|
+
paths: ['role'],
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should expose actual role names for union role mode', async () => {
|
|
199
|
+
const engine = new FlowEngine();
|
|
200
|
+
const ctx = engine.context;
|
|
201
|
+
ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
|
|
202
|
+
ctx.defineProperty('user', {
|
|
203
|
+
value: {
|
|
204
|
+
roles: [
|
|
205
|
+
{ name: 'admin', title: 'Admin' },
|
|
206
|
+
{ name: 'member', title: 'Member' },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(ctx.role).toEqual(['admin', 'member']);
|
|
212
|
+
await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual(['admin', 'member']);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should expose an empty role list for union role mode without user roles', async () => {
|
|
216
|
+
const engine = new FlowEngine();
|
|
217
|
+
const ctx = engine.context;
|
|
218
|
+
ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
|
|
219
|
+
ctx.defineProperty('user', { value: {} });
|
|
220
|
+
|
|
221
|
+
expect(ctx.role).toEqual([]);
|
|
222
|
+
await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
180
225
|
it('should throw sync error in get', () => {
|
|
181
226
|
const ctx = new FlowContext();
|
|
182
227
|
ctx.defineProperty('error', {
|
|
@@ -1386,6 +1431,92 @@ describe('FlowEngine context', () => {
|
|
|
1386
1431
|
expect(engine.context.appName).toBe('NocoBase');
|
|
1387
1432
|
});
|
|
1388
1433
|
|
|
1434
|
+
it('ctx.api should return a dirty-aware wrapper for static api properties', async () => {
|
|
1435
|
+
const engine = new FlowEngine();
|
|
1436
|
+
const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
|
|
1437
|
+
const api = {
|
|
1438
|
+
auth: { locale: 'zh-CN' },
|
|
1439
|
+
request: vi.fn(async () => ({ data: { ok: true } })),
|
|
1440
|
+
resource: vi.fn(() => ({ update })),
|
|
1441
|
+
};
|
|
1442
|
+
engine.context.defineProperty('api', { value: api });
|
|
1443
|
+
|
|
1444
|
+
await engine.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
|
|
1445
|
+
|
|
1446
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
1447
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
1448
|
+
expect(engine.context.api).toBe(engine.context.api);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it('ctx.api should stay dirty-aware when resolved from a scoped context delegate', async () => {
|
|
1452
|
+
const root = new FlowEngine();
|
|
1453
|
+
const scoped = createViewScopedEngine(root);
|
|
1454
|
+
const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
|
|
1455
|
+
root.context.defineProperty('api', {
|
|
1456
|
+
value: {
|
|
1457
|
+
auth: { locale: 'zh-CN' },
|
|
1458
|
+
request: vi.fn(async () => ({ data: { ok: true } })),
|
|
1459
|
+
resource: vi.fn(() => ({ update })),
|
|
1460
|
+
},
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
await scoped.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
|
|
1464
|
+
|
|
1465
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
1466
|
+
expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
1467
|
+
expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
it('ctx.request should use the dirty-aware api wrapper', async () => {
|
|
1471
|
+
const engine = new FlowEngine();
|
|
1472
|
+
const request = vi.fn(async () => ({ data: { ok: true } }));
|
|
1473
|
+
engine.context.defineProperty('api', {
|
|
1474
|
+
value: {
|
|
1475
|
+
auth: { locale: 'zh-CN' },
|
|
1476
|
+
request,
|
|
1477
|
+
resource: vi.fn(),
|
|
1478
|
+
},
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
await engine.context.request({
|
|
1482
|
+
resource: 'posts',
|
|
1483
|
+
action: 'update',
|
|
1484
|
+
headers: { 'X-Data-Source': 'analytics' },
|
|
1485
|
+
params: { filterByTk: 1 },
|
|
1486
|
+
} as any);
|
|
1487
|
+
|
|
1488
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
1489
|
+
expect(engine.getDataSourceDirtyVersion('analytics', 'posts')).toBe(1);
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
it('ctx.request should use the caller context when resolved through a scoped delegate', async () => {
|
|
1493
|
+
const root = new FlowEngine();
|
|
1494
|
+
const scoped = createViewScopedEngine(root);
|
|
1495
|
+
const callerCtx = new FlowContext();
|
|
1496
|
+
const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
|
|
1497
|
+
const request = vi.fn(async () => ({ data: { ok: true } }));
|
|
1498
|
+
root.context.defineProperty('api', {
|
|
1499
|
+
value: {
|
|
1500
|
+
auth: { locale: 'zh-CN' },
|
|
1501
|
+
request,
|
|
1502
|
+
resource: vi.fn(),
|
|
1503
|
+
},
|
|
1504
|
+
});
|
|
1505
|
+
callerCtx.addDelegate(scoped.context);
|
|
1506
|
+
scoped.context.engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
|
|
1507
|
+
|
|
1508
|
+
await callerCtx.request({
|
|
1509
|
+
resource: 'posts',
|
|
1510
|
+
action: 'update',
|
|
1511
|
+
params: { filterByTk: 1 },
|
|
1512
|
+
} as any);
|
|
1513
|
+
|
|
1514
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
1515
|
+
expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
1516
|
+
expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
1517
|
+
expect(dirtyEvents).toEqual([{ dataSourceKey: 'main', resourceNames: ['posts'] }]);
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1389
1520
|
it('ctx.sql should resolve template variables from caller context in delegate chain', async () => {
|
|
1390
1521
|
const engine = new FlowEngine();
|
|
1391
1522
|
const request = vi.fn(async () => ({ data: { data: [] } }));
|
|
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
11
11
|
import { FlowEngine } from '../flowEngine';
|
|
12
12
|
import { MultiRecordResource } from '../resources/multiRecordResource';
|
|
13
13
|
import { SingleRecordResource } from '../resources/singleRecordResource';
|
|
14
|
+
import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
|
|
14
15
|
|
|
15
16
|
describe('FlowEngine dataSource dirty registry', () => {
|
|
16
17
|
it('tracks versions per dataSourceKey + resourceName', () => {
|
|
@@ -60,4 +61,54 @@ describe('FlowEngine dataSource dirty registry', () => {
|
|
|
60
61
|
// plus root collection (safety)
|
|
61
62
|
expect(markSpy).toHaveBeenCalledWith('main', 'users');
|
|
62
63
|
});
|
|
64
|
+
|
|
65
|
+
it('marks dirty once for record write helpers when using the dirty-aware context api', async () => {
|
|
66
|
+
const engine = new FlowEngine();
|
|
67
|
+
const request = vi.fn(async () => ({ data: { data: { id: 1 }, meta: {} } }));
|
|
68
|
+
const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
|
|
69
|
+
engine.context.defineProperty('api', {
|
|
70
|
+
value: {
|
|
71
|
+
auth: { locale: 'zh-CN' },
|
|
72
|
+
request,
|
|
73
|
+
resource: vi.fn(),
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
|
|
77
|
+
|
|
78
|
+
const multi = engine.createResource(MultiRecordResource);
|
|
79
|
+
multi.setDataSourceKey('main').setResourceName('posts');
|
|
80
|
+
await multi.create({ title: 't' } as any, { refresh: false });
|
|
81
|
+
|
|
82
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
84
|
+
expect(dirtyEvents).toEqual([{ dataSourceKey: 'main', resourceNames: ['posts'] }]);
|
|
85
|
+
|
|
86
|
+
const single = engine.createResource(SingleRecordResource);
|
|
87
|
+
single.setDataSourceKey('main').setResourceName('posts').setFilterByTk(1);
|
|
88
|
+
await single.save({ title: 'u' } as any, { refresh: false });
|
|
89
|
+
|
|
90
|
+
expect(request).toHaveBeenCalledTimes(2);
|
|
91
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(2);
|
|
92
|
+
expect(dirtyEvents).toEqual([
|
|
93
|
+
{ dataSourceKey: 'main', resourceNames: ['posts'] },
|
|
94
|
+
{ dataSourceKey: 'main', resourceNames: ['posts'] },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('still marks dirty for direct runAction writes', async () => {
|
|
99
|
+
const engine = new FlowEngine();
|
|
100
|
+
engine.context.defineProperty('api', {
|
|
101
|
+
value: {
|
|
102
|
+
auth: { locale: 'zh-CN' },
|
|
103
|
+
request: vi.fn(async () => ({ data: { data: { id: 1 }, meta: {} } })),
|
|
104
|
+
resource: vi.fn(),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const multi = engine.createResource(MultiRecordResource);
|
|
109
|
+
multi.setDataSourceKey('main').setResourceName('posts');
|
|
110
|
+
await multi.runAction('create', { data: { title: 't' } });
|
|
111
|
+
|
|
112
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
113
|
+
});
|
|
63
114
|
});
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
11
|
-
import { FlowEngineContext, FlowRunJSContext } from '../flowContext';
|
|
11
|
+
import { FlowContext, FlowEngineContext, FlowRunJSContext } from '../flowContext';
|
|
12
|
+
import { RUNJS_OPEN_VIEW_ROUTE_STATE } from '../utils/openViewRouteState';
|
|
12
13
|
import { FlowEngine } from '../flowEngine';
|
|
13
|
-
import { FlowContext } from '../flowContext';
|
|
14
14
|
import { setupRunJSContexts } from '../runjs-context/setup';
|
|
15
15
|
import { createJSRunnerWithVersion } from '..';
|
|
16
16
|
import { RunJSContextRegistry } from '../runjs-context/registry';
|
|
@@ -267,6 +267,19 @@ describe('RunJS Runtime Features', () => {
|
|
|
267
267
|
expect(runCtx.libs.dayjs).toBeDefined();
|
|
268
268
|
expect(runCtx.libs.antdIcons).toBeDefined();
|
|
269
269
|
});
|
|
270
|
+
|
|
271
|
+
it('should mark ctx.openView calls with route state only when RunJS passes display overrides', async () => {
|
|
272
|
+
const parentCtx = new FlowContext();
|
|
273
|
+
const openView = vi.fn(async () => undefined);
|
|
274
|
+
parentCtx.defineMethod('openView', openView);
|
|
275
|
+
|
|
276
|
+
const runCtx = new FlowRunJSContext(parentCtx);
|
|
277
|
+
await (runCtx as any).openView('popup', { mode: 'dialog', size: 'large' });
|
|
278
|
+
await (runCtx as any).openView('popup', { filterByTk: 1 });
|
|
279
|
+
|
|
280
|
+
expect(openView.mock.calls[0][1][RUNJS_OPEN_VIEW_ROUTE_STATE]).toEqual({ mode: 'dialog', size: 'large' });
|
|
281
|
+
expect(Object.prototype.hasOwnProperty.call(openView.mock.calls[1][1], RUNJS_OPEN_VIEW_ROUTE_STATE)).toBe(false);
|
|
282
|
+
});
|
|
270
283
|
});
|
|
271
284
|
|
|
272
285
|
describe('Actual code execution', () => {
|
|
@@ -11,7 +11,7 @@ import type { CSSInterpolation, CSSObject } from '@ant-design/cssinjs';
|
|
|
11
11
|
import { useStyleRegister } from '@ant-design/cssinjs';
|
|
12
12
|
import { merge } from '@formily/shared';
|
|
13
13
|
import type { ComponentTokenMap } from 'antd/es/theme/interface';
|
|
14
|
-
import {
|
|
14
|
+
import { useContext, useRef } from 'react';
|
|
15
15
|
import { ConfigProvider, theme } from 'antd';
|
|
16
16
|
|
|
17
17
|
const usePrefixCls = (
|
|
@@ -83,6 +83,10 @@ type UseComponentStyleResult = {
|
|
|
83
83
|
componentCls: string;
|
|
84
84
|
rootPrefixCls: string;
|
|
85
85
|
};
|
|
86
|
+
type WrapSSRCache = {
|
|
87
|
+
deps: unknown[];
|
|
88
|
+
wrapSSR: ReturnType<typeof useStyleRegister>;
|
|
89
|
+
};
|
|
86
90
|
|
|
87
91
|
const genStyleHook = <ComponentName extends OverrideComponent>(
|
|
88
92
|
component: ComponentName,
|
|
@@ -122,9 +126,18 @@ const genStyleHook = <ComponentName extends OverrideComponent>(
|
|
|
122
126
|
|
|
123
127
|
// useStyleRegister 有 BUG,会导致重复渲染,所以这里做了一层缓存
|
|
124
128
|
// 等 https://github.com/ant-design/cssinjs/pull/176 合并后,可以去掉这层缓存
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
const wrapSSRDeps = [theme, token, hashId, prefixCls, iconPrefixCls, rootPrefixCls, props];
|
|
130
|
+
const wrapSSRCacheRef = useRef<WrapSSRCache>();
|
|
131
|
+
const currentCache = wrapSSRCacheRef.current;
|
|
132
|
+
let memoizedWrapSSR: ReturnType<typeof useStyleRegister> = currentCache?.wrapSSR || wrapSSR;
|
|
133
|
+
|
|
134
|
+
if (!currentCache || wrapSSRDeps.some((dep, index) => dep !== currentCache.deps[index])) {
|
|
135
|
+
wrapSSRCacheRef.current = {
|
|
136
|
+
deps: wrapSSRDeps,
|
|
137
|
+
wrapSSR,
|
|
138
|
+
};
|
|
139
|
+
memoizedWrapSSR = wrapSSR;
|
|
140
|
+
}
|
|
128
141
|
|
|
129
142
|
return {
|
|
130
143
|
wrapSSR: memoizedWrapSSR,
|
|
@@ -140,7 +153,7 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
|
|
|
140
153
|
return {
|
|
141
154
|
[componentCls]: {
|
|
142
155
|
'.nb-mobile-action-drawer-header': {
|
|
143
|
-
height: 'var(--nb-mobile-page-header-height)',
|
|
156
|
+
height: 'var(--nb-mobile-page-header-height, 46px)',
|
|
144
157
|
display: 'flex',
|
|
145
158
|
alignItems: 'center',
|
|
146
159
|
justifyContent: 'space-between',
|
|
@@ -171,7 +184,10 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
|
|
|
171
184
|
'.nb-mobile-action-drawer-body': {
|
|
172
185
|
borderTopLeftRadius: 8,
|
|
173
186
|
borderTopRightRadius: 8,
|
|
174
|
-
maxHeight: 'calc(
|
|
187
|
+
maxHeight: 'calc(100vh - var(--nb-mobile-page-header-height, 46px))',
|
|
188
|
+
'@supports (height: 100dvh)': {
|
|
189
|
+
maxHeight: 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))',
|
|
190
|
+
},
|
|
175
191
|
overflowY: 'auto',
|
|
176
192
|
overflowX: 'hidden',
|
|
177
193
|
backgroundColor: token.colorBgLayout,
|
|
@@ -0,0 +1,103 @@
|
|
|
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 React, { CSSProperties, ReactNode } from 'react';
|
|
11
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
12
|
+
import { ConfigProvider } from 'antd';
|
|
13
|
+
import { vi } from 'vitest';
|
|
14
|
+
import { MobilePopup } from '../MobilePopup';
|
|
15
|
+
import { useMobileActionDrawerStyle } from '../MobilePopup.style';
|
|
16
|
+
|
|
17
|
+
vi.mock('antd-mobile', () => ({
|
|
18
|
+
Popup: ({
|
|
19
|
+
bodyClassName,
|
|
20
|
+
bodyStyle,
|
|
21
|
+
children,
|
|
22
|
+
className,
|
|
23
|
+
visible,
|
|
24
|
+
}: {
|
|
25
|
+
bodyClassName?: string;
|
|
26
|
+
bodyStyle?: CSSProperties;
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
visible?: boolean;
|
|
30
|
+
}) =>
|
|
31
|
+
visible ? (
|
|
32
|
+
<div className={className} data-testid="popup-root">
|
|
33
|
+
<div className={`adm-popup-body ${bodyClassName || ''}`} data-testid="popup-body" style={bodyStyle}>
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
) : null,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('antd-mobile-icons', () => ({
|
|
41
|
+
CloseOutline: () => <span data-testid="close-outline" />,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock('react-i18next', () => ({
|
|
45
|
+
useTranslation: () => ({
|
|
46
|
+
t: (key: string) => key,
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
function StyleProbe() {
|
|
51
|
+
const { componentCls, hashId } = useMobileActionDrawerStyle();
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={`${componentCls} ${hashId}`}>
|
|
55
|
+
<div className="nb-mobile-action-drawer-header" data-testid="drawer-header" />
|
|
56
|
+
<div className="nb-mobile-action-drawer-body" data-testid="drawer-body" />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getInjectedStyles() {
|
|
62
|
+
return Array.from(document.querySelectorAll('style'))
|
|
63
|
+
.map((style) => style.textContent || '')
|
|
64
|
+
.join('\n')
|
|
65
|
+
.replace(/\s+/g, '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('MobilePopup styles', () => {
|
|
69
|
+
it('mounts the action drawer class on the real popup body', async () => {
|
|
70
|
+
render(
|
|
71
|
+
<MobilePopup visible title="Edit" onClose={vi.fn()}>
|
|
72
|
+
<div style={{ height: 1200 }}>Long content</div>
|
|
73
|
+
</MobilePopup>,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const popupBody = await screen.findByTestId('popup-body');
|
|
77
|
+
|
|
78
|
+
expect(screen.getByTestId('popup-root')).toHaveClass('ant-nb-mobile-action-drawer');
|
|
79
|
+
expect(popupBody).toHaveClass('adm-popup-body');
|
|
80
|
+
expect(popupBody).toHaveClass('nb-mobile-action-drawer-body');
|
|
81
|
+
expect(popupBody).toHaveStyle({ padding: '0px' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('limits the mobile action drawer body by viewport height', async () => {
|
|
85
|
+
render(
|
|
86
|
+
<ConfigProvider>
|
|
87
|
+
<StyleProbe />
|
|
88
|
+
</ConfigProvider>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(getInjectedStyles()).toContain('.nb-mobile-action-drawer-body');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const styles = getInjectedStyles();
|
|
96
|
+
|
|
97
|
+
expect(styles).toContain('height:var(--nb-mobile-page-header-height,46px)');
|
|
98
|
+
expect(styles).toContain('max-height:calc(100vh-var(--nb-mobile-page-header-height,46px))');
|
|
99
|
+
expect(styles).toContain('max-height:calc(100dvh-var(--nb-mobile-page-header-height,46px))');
|
|
100
|
+
expect(styles).toContain('overflow-y:auto');
|
|
101
|
+
expect(styles).not.toContain('max-height:calc(100%-var(--nb-mobile-page-header-height))');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -35,6 +35,10 @@ type ToolbarDragAnchorDetail = {
|
|
|
35
35
|
point: ToolbarDragAnchorPoint | null;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
const getToolbarModelUidFromNode = (node: HTMLElement | null): string | null => {
|
|
39
|
+
return node?.closest<HTMLElement>('.nb-toolbar-container[data-model-uid]')?.getAttribute('data-model-uid') || null;
|
|
40
|
+
};
|
|
41
|
+
|
|
38
42
|
export const resolveOverlayAnchorTransform = ({
|
|
39
43
|
activeId,
|
|
40
44
|
active,
|
|
@@ -62,7 +66,7 @@ export const resolveOverlayAnchorTransform = ({
|
|
|
62
66
|
const resolveDraggableHostNode = (activatorNode: HTMLElement | null) => {
|
|
63
67
|
const ownerDocument = activatorNode?.ownerDocument;
|
|
64
68
|
const floatToolbarContainer = activatorNode?.closest<HTMLElement>('.nb-toolbar-container[data-model-uid]');
|
|
65
|
-
const toolbarModelUid =
|
|
69
|
+
const toolbarModelUid = getToolbarModelUidFromNode(activatorNode);
|
|
66
70
|
|
|
67
71
|
if (!ownerDocument || !toolbarModelUid) {
|
|
68
72
|
return activatorNode;
|
|
@@ -95,6 +99,7 @@ export const DragHandler: FC<{ model: FlowModel; children?: React.ReactNode }> =
|
|
|
95
99
|
const dragHandlerRef = useRef<HTMLSpanElement | null>(null);
|
|
96
100
|
const draggableNodeRef = useRef<HTMLElement | null>(null);
|
|
97
101
|
const pointerPressCleanupRef = useRef<(() => void) | null>(null);
|
|
102
|
+
const toolbarDragModelUidRef = useRef<string | null>(null);
|
|
98
103
|
const isDraggingRef = useRef(isDragging);
|
|
99
104
|
const isPointerPressActiveRef = useRef(false);
|
|
100
105
|
const isToolbarDragActiveRef = useRef(false);
|
|
@@ -126,9 +131,13 @@ export const DragHandler: FC<{ model: FlowModel; children?: React.ReactNode }> =
|
|
|
126
131
|
return;
|
|
127
132
|
}
|
|
128
133
|
|
|
134
|
+
const toolbarModelUid =
|
|
135
|
+
getToolbarModelUidFromNode(dragHandlerRef.current) || toolbarDragModelUidRef.current || model.uid;
|
|
136
|
+
toolbarDragModelUidRef.current = active ? toolbarModelUid : null;
|
|
137
|
+
|
|
129
138
|
ownerDocument.dispatchEvent(
|
|
130
139
|
new CustomEvent(TOOLBAR_DRAG_ACTIVITY_EVENT, {
|
|
131
|
-
detail: { active, modelUid:
|
|
140
|
+
detail: { active, modelUid: toolbarModelUid },
|
|
132
141
|
}),
|
|
133
142
|
);
|
|
134
143
|
},
|