@nocobase/flow-engine 2.3.0-alpha.1 → 2.3.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/acl/Acl.d.ts +2 -1
- package/lib/acl/Acl.js +28 -0
- package/lib/components/FlowContextSelector.js +7 -1
- package/lib/components/MobilePopup.js +14 -3
- package/lib/components/subModel/LazyDropdown.js +62 -33
- package/lib/flowContext.d.ts +12 -1
- package/lib/flowContext.js +47 -6
- package/lib/locale/en-US.json +2 -0
- package/lib/locale/index.d.ts +4 -0
- package/lib/locale/zh-CN.json +2 -0
- package/lib/resources/flowResource.js +1 -0
- package/lib/utils/associationObjectVariable.d.ts +10 -0
- package/lib/utils/associationObjectVariable.js +10 -7
- package/lib/utils/dateVariable.d.ts +22 -0
- package/lib/utils/dateVariable.js +123 -16
- package/lib/utils/dirtyAwareApiClient.d.ts +1 -0
- package/lib/utils/dirtyAwareApiClient.js +15 -2
- package/lib/utils/index.d.ts +3 -3
- package/lib/utils/index.js +8 -0
- package/lib/utils/params-resolvers.d.ts +3 -0
- package/lib/utils/params-resolvers.js +10 -0
- package/lib/utils/variablesParams.js +5 -0
- package/lib/views/createViewMeta.d.ts +1 -0
- package/lib/views/createViewMeta.js +53 -22
- package/package.json +4 -4
- package/src/__tests__/createViewMeta.popup.test.ts +84 -1
- package/src/__tests__/flowContext.test.ts +8 -0
- package/src/__tests__/objectVariable.test.ts +6 -1
- package/src/__tests__/runjsFormSubmit.test.ts +138 -0
- package/src/acl/Acl.tsx +36 -1
- package/src/acl/__tests__/Acl.test.tsx +70 -0
- package/src/components/FlowContextSelector.tsx +7 -1
- package/src/components/MobilePopup.tsx +16 -4
- package/src/components/__tests__/MobilePopup.test.tsx +42 -1
- package/src/components/subModel/LazyDropdown.tsx +71 -38
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/components/subModel/__tests__/LazyDropdown.test.tsx +202 -0
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +35 -0
- package/src/flowContext.ts +79 -6
- package/src/locale/__tests__/index.test.ts +21 -0
- package/src/locale/en-US.json +2 -0
- package/src/locale/zh-CN.json +2 -0
- package/src/resources/__tests__/flowResource.test.ts +3 -0
- package/src/resources/flowResource.ts +1 -0
- package/src/utils/__tests__/dateVariable.test.ts +57 -4
- package/src/utils/__tests__/variablesParams.test.ts +28 -1
- package/src/utils/associationObjectVariable.ts +9 -6
- package/src/utils/dateVariable.ts +145 -18
- package/src/utils/dirtyAwareApiClient.ts +25 -2
- package/src/utils/index.ts +17 -2
- package/src/utils/params-resolvers.ts +12 -0
- package/src/utils/variablesParams.ts +10 -0
- package/src/views/createViewMeta.ts +52 -18
|
@@ -33,6 +33,13 @@ __export(createViewMeta_exports, {
|
|
|
33
33
|
});
|
|
34
34
|
module.exports = __toCommonJS(createViewMeta_exports);
|
|
35
35
|
var import_variablesParams = require("../utils/variablesParams");
|
|
36
|
+
function buildPopupSourceRecordRef(ref) {
|
|
37
|
+
if ((ref == null ? void 0 : ref.sourceId) == null || ref.sourceId === "" || typeof ref.associationName !== "string") return void 0;
|
|
38
|
+
const collection = ref.associationName.split(".")[0];
|
|
39
|
+
if (!collection) return void 0;
|
|
40
|
+
return { collection, dataSourceKey: ref.dataSourceKey || "main", filterByTk: ref.sourceId };
|
|
41
|
+
}
|
|
42
|
+
__name(buildPopupSourceRecordRef, "buildPopupSourceRecordRef");
|
|
36
43
|
function isDefined(value) {
|
|
37
44
|
return value !== void 0 && value !== null;
|
|
38
45
|
}
|
|
@@ -306,9 +313,11 @@ function createPopupMeta(ctx, anchorView) {
|
|
|
306
313
|
let level = 1;
|
|
307
314
|
let parentRef = await getParentRecordRef(level, c);
|
|
308
315
|
while (parentRef) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
316
|
+
const parent = { record: parentRef };
|
|
317
|
+
const sourceRecord = buildPopupSourceRecordRef(parentRef);
|
|
318
|
+
if (sourceRecord) parent.sourceRecord = sourceRecord;
|
|
319
|
+
cur.parent = parent;
|
|
320
|
+
cur = parent;
|
|
312
321
|
level += 1;
|
|
313
322
|
parentRef = await getParentRecordRef(level, c);
|
|
314
323
|
}
|
|
@@ -317,19 +326,12 @@ function createPopupMeta(ctx, anchorView) {
|
|
|
317
326
|
(_b = (_a = c.logger) == null ? void 0 : _a.debug) == null ? void 0 : _b.call(_a, { err }, "[FlowEngine] buildVariablesParams: build parent-chain failed");
|
|
318
327
|
}
|
|
319
328
|
try {
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
params.sourceRecord = {
|
|
327
|
-
collection: parentCollectionName,
|
|
328
|
-
dataSourceKey: dsKey,
|
|
329
|
-
filterByTk: srcId
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
}
|
|
329
|
+
const sourceRecord = buildPopupSourceRecordRef({
|
|
330
|
+
sourceId: inputArgs == null ? void 0 : inputArgs.sourceId,
|
|
331
|
+
associationName: inputArgs == null ? void 0 : inputArgs.associationName,
|
|
332
|
+
dataSourceKey: inputArgs == null ? void 0 : inputArgs.dataSourceKey
|
|
333
|
+
});
|
|
334
|
+
if (sourceRecord) params.sourceRecord = sourceRecord;
|
|
333
335
|
} catch (err) {
|
|
334
336
|
(_d = (_c = c.logger) == null ? void 0 : _c.debug) == null ? void 0 : _d.call(_c, { err }, "[FlowEngine] buildVariablesParams: infer sourceRecord failed");
|
|
335
337
|
}
|
|
@@ -426,10 +428,11 @@ function createPopupMeta(ctx, anchorView) {
|
|
|
426
428
|
}
|
|
427
429
|
__name(createPopupMeta, "createPopupMeta");
|
|
428
430
|
async function buildPopupRuntime(ctx, view) {
|
|
429
|
-
var _a;
|
|
431
|
+
var _a, _b, _c;
|
|
430
432
|
const stack = getViewStack(view);
|
|
431
433
|
const currentIndex = getAnchoredViewStackIndex(view, stack);
|
|
432
|
-
const
|
|
434
|
+
const sourceRecord = (_b = (_a = view == null ? void 0 : view.inputArgs) == null ? void 0 : _a.parentItem) == null ? void 0 : _b.value;
|
|
435
|
+
const openerUids = (_c = view == null ? void 0 : view.inputArgs) == null ? void 0 : _c.openerUids;
|
|
433
436
|
const hasOpener = Array.isArray(openerUids) && openerUids.length > 0;
|
|
434
437
|
const hasStackPopup = currentIndex >= 1;
|
|
435
438
|
const isPopup = hasStackPopup || hasOpener;
|
|
@@ -446,16 +449,17 @@ async function buildPopupRuntime(ctx, view) {
|
|
|
446
449
|
associationName: args.associationName,
|
|
447
450
|
filterByTk: args.filterByTk,
|
|
448
451
|
sourceId: args.sourceId
|
|
449
|
-
}
|
|
452
|
+
},
|
|
453
|
+
...typeof sourceRecord !== "undefined" ? { sourceRecord } : {}
|
|
450
454
|
};
|
|
451
455
|
}
|
|
452
456
|
const buildNode = /* @__PURE__ */ __name(async (idx) => {
|
|
453
|
-
var _a2,
|
|
457
|
+
var _a2, _b2, _c2, _d, _e, _f;
|
|
454
458
|
if (idx < 0 || !((_a2 = stack[idx]) == null ? void 0 : _a2.viewUid)) return void 0;
|
|
455
459
|
const viewUid = stack[idx].viewUid;
|
|
456
|
-
let model = (
|
|
460
|
+
let model = (_b2 = ctx.engine) == null ? void 0 : _b2.getModel(viewUid, true);
|
|
457
461
|
if (!model) {
|
|
458
|
-
model = await ((
|
|
462
|
+
model = await ((_c2 = ctx.engine) == null ? void 0 : _c2.loadModel({ uid: viewUid }));
|
|
459
463
|
}
|
|
460
464
|
const p = ((_d = model == null ? void 0 : model.getStepParams) == null ? void 0 : _d.call(model, "popupSettings", "openView")) || {};
|
|
461
465
|
const collectionName = p == null ? void 0 : p.collectionName;
|
|
@@ -475,16 +479,43 @@ async function buildPopupRuntime(ctx, view) {
|
|
|
475
479
|
return node;
|
|
476
480
|
}, "buildNode");
|
|
477
481
|
const currentNode = await buildNode(currentIndex);
|
|
482
|
+
if (currentNode && typeof sourceRecord !== "undefined") {
|
|
483
|
+
currentNode.sourceRecord = sourceRecord;
|
|
484
|
+
}
|
|
478
485
|
return currentNode;
|
|
479
486
|
}
|
|
480
487
|
__name(buildPopupRuntime, "buildPopupRuntime");
|
|
481
488
|
function registerPopupVariable(ctx, view) {
|
|
482
489
|
const POPUP_SERVER_PATH_RE = /^(?:record|sourceRecord)(?:\.|$)|^parent(?:\.parent)*(?:\.(?:record|sourceRecord))(?:\.|$)/;
|
|
490
|
+
const shouldResolveSourceRecordOnServer = /* @__PURE__ */ __name((path) => {
|
|
491
|
+
var _a, _b;
|
|
492
|
+
if (path !== "sourceRecord" && !path.startsWith("sourceRecord.")) return false;
|
|
493
|
+
const parentItem = (_a = view == null ? void 0 : view.inputArgs) == null ? void 0 : _a.parentItem;
|
|
494
|
+
if (typeof (parentItem == null ? void 0 : parentItem.value) === "undefined") return true;
|
|
495
|
+
const sourcePath = path === "sourceRecord" ? "" : path.slice("sourceRecord.".length);
|
|
496
|
+
if (!sourcePath) return false;
|
|
497
|
+
const parentItemResolver = (_b = view == null ? void 0 : view.inputArgs) == null ? void 0 : _b.parentItemResolver;
|
|
498
|
+
if (typeof parentItemResolver === "function") {
|
|
499
|
+
return parentItemResolver(`value.${sourcePath}`);
|
|
500
|
+
}
|
|
501
|
+
const segments = sourcePath.split(".").filter(Boolean);
|
|
502
|
+
let current = parentItem.value;
|
|
503
|
+
for (const segment of segments) {
|
|
504
|
+
if (current === null || typeof current !== "object" || !(segment in current)) {
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
current = current[segment];
|
|
508
|
+
}
|
|
509
|
+
return false;
|
|
510
|
+
}, "shouldResolveSourceRecordOnServer");
|
|
483
511
|
ctx.defineProperty("popup", {
|
|
484
512
|
get: /* @__PURE__ */ __name(async () => buildPopupRuntime(ctx, view), "get"),
|
|
485
513
|
meta: createPopupMeta(ctx, view),
|
|
486
514
|
resolveOnServer: /* @__PURE__ */ __name((p) => {
|
|
487
515
|
try {
|
|
516
|
+
if (p === "sourceRecord" || p.startsWith("sourceRecord.")) {
|
|
517
|
+
return shouldResolveSourceRecordOnServer(p);
|
|
518
|
+
}
|
|
488
519
|
return !!p && POPUP_SERVER_PATH_RE.test(p);
|
|
489
520
|
} catch (_) {
|
|
490
521
|
return false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/flow-engine",
|
|
3
|
-
"version": "2.3.0-
|
|
3
|
+
"version": "2.3.0-beta.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@formily/antd-v5": "1.x",
|
|
10
10
|
"@formily/reactive": "2.x",
|
|
11
|
-
"@nocobase/sdk": "2.3.0-
|
|
12
|
-
"@nocobase/shared": "2.3.0-
|
|
11
|
+
"@nocobase/sdk": "2.3.0-beta.2",
|
|
12
|
+
"@nocobase/shared": "2.3.0-beta.2",
|
|
13
13
|
"ahooks": "^3.7.2",
|
|
14
14
|
"axios": "^1.7.0",
|
|
15
15
|
"dayjs": "^1.11.9",
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
],
|
|
38
38
|
"author": "NocoBase Team",
|
|
39
39
|
"license": "Apache-2.0",
|
|
40
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "2e5b43a39d3f3e7cff58cb5e5a36a8c8e599dfb5"
|
|
41
41
|
}
|
|
@@ -11,7 +11,7 @@ import { describe, it, expect, vi } from 'vitest';
|
|
|
11
11
|
import { FlowContext } from '../flowContext';
|
|
12
12
|
import { FlowEngine } from '../flowEngine';
|
|
13
13
|
import type { FlowView } from '../views/FlowView';
|
|
14
|
-
import { buildPopupRuntime, createPopupMeta } from '../views/createViewMeta';
|
|
14
|
+
import { buildPopupRuntime, createPopupMeta, registerPopupVariable } from '../views/createViewMeta';
|
|
15
15
|
|
|
16
16
|
describe('createPopupMeta - popup variables', () => {
|
|
17
17
|
function makeCtx() {
|
|
@@ -167,6 +167,7 @@ describe('createPopupMeta - popup variables', () => {
|
|
|
167
167
|
it('buildPopupRuntime anchors current popup even when the navigation stack already has a child popup', async () => {
|
|
168
168
|
const { engine, ctx } = makeCtx();
|
|
169
169
|
const parentView = makeNestedPopupView('parent-popup-uid', 13);
|
|
170
|
+
parentView.inputArgs.parentItem = { value: { id: 13, phone: '9999' } };
|
|
170
171
|
mockNestedPopupModels(engine);
|
|
171
172
|
|
|
172
173
|
const popup = await buildPopupRuntime(ctx, parentView);
|
|
@@ -179,6 +180,7 @@ describe('createPopupMeta - popup variables', () => {
|
|
|
179
180
|
filterByTk: 13,
|
|
180
181
|
sourceId: 13,
|
|
181
182
|
});
|
|
183
|
+
expect(popup?.sourceRecord).toEqual({ id: 13, phone: '9999' });
|
|
182
184
|
});
|
|
183
185
|
|
|
184
186
|
it('buildVariablesParams(record) keeps the parent view record when a child popup is open', async () => {
|
|
@@ -314,4 +316,85 @@ describe('createPopupMeta - popup variables', () => {
|
|
|
314
316
|
const props = typeof meta.properties === 'function' ? await (meta.properties as any)() : meta.properties || {};
|
|
315
317
|
expect(props.record).toBeUndefined();
|
|
316
318
|
});
|
|
319
|
+
|
|
320
|
+
it('uses the current parent item value for popup sourceRecord fields', async () => {
|
|
321
|
+
const { ctx } = makeCtx();
|
|
322
|
+
const parentItemResolver = vi.fn(() => false);
|
|
323
|
+
const anchorView: FlowView = {
|
|
324
|
+
type: 'dialog',
|
|
325
|
+
inputArgs: {
|
|
326
|
+
openerUids: ['opener-uid-1'],
|
|
327
|
+
viewUid: 'popup-uid',
|
|
328
|
+
dataSourceKey: 'main',
|
|
329
|
+
collectionName: 'roles',
|
|
330
|
+
associationName: 'users.roles',
|
|
331
|
+
sourceId: 1,
|
|
332
|
+
parentItem: { value: { id: 1, phone: '9999' } },
|
|
333
|
+
parentItemResolver,
|
|
334
|
+
},
|
|
335
|
+
Header: null,
|
|
336
|
+
Footer: null,
|
|
337
|
+
close: () => void 0,
|
|
338
|
+
update: () => void 0,
|
|
339
|
+
} as any;
|
|
340
|
+
|
|
341
|
+
registerPopupVariable(ctx, anchorView);
|
|
342
|
+
|
|
343
|
+
await expect(ctx.popup).resolves.toMatchObject({
|
|
344
|
+
sourceRecord: { id: 1, phone: '9999' },
|
|
345
|
+
});
|
|
346
|
+
expect(ctx.getPropertyOptions('popup')?.resolveOnServer?.('sourceRecord.phone')).toBe(false);
|
|
347
|
+
expect(parentItemResolver).toHaveBeenCalledWith('value.phone');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('resolves a popup sourceRecord field from the current parent item without a server request', async () => {
|
|
351
|
+
const engine = new FlowEngine();
|
|
352
|
+
const ctx = engine.context;
|
|
353
|
+
const anchorView: FlowView = {
|
|
354
|
+
type: 'dialog',
|
|
355
|
+
inputArgs: {
|
|
356
|
+
openerUids: ['opener-uid-1'],
|
|
357
|
+
viewUid: 'popup-uid',
|
|
358
|
+
dataSourceKey: 'main',
|
|
359
|
+
collectionName: 'roles',
|
|
360
|
+
associationName: 'users.roles',
|
|
361
|
+
sourceId: 1,
|
|
362
|
+
parentItem: { value: { id: 1, phone: '9999' } },
|
|
363
|
+
parentItemResolver: () => false,
|
|
364
|
+
},
|
|
365
|
+
Header: null,
|
|
366
|
+
Footer: null,
|
|
367
|
+
close: () => void 0,
|
|
368
|
+
update: () => void 0,
|
|
369
|
+
} as any;
|
|
370
|
+
|
|
371
|
+
registerPopupVariable(ctx, anchorView);
|
|
372
|
+
|
|
373
|
+
await expect(ctx.resolveJsonTemplate('{{ ctx.popup.sourceRecord.phone }}')).resolves.toBe('9999');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('keeps server resolution for popup sourceRecord association subpaths', async () => {
|
|
377
|
+
const { ctx } = makeCtx();
|
|
378
|
+
const anchorView: FlowView = {
|
|
379
|
+
type: 'dialog',
|
|
380
|
+
inputArgs: {
|
|
381
|
+
openerUids: ['opener-uid-1'],
|
|
382
|
+
viewUid: 'popup-uid',
|
|
383
|
+
dataSourceKey: 'main',
|
|
384
|
+
collectionName: 'roles',
|
|
385
|
+
associationName: 'users.roles',
|
|
386
|
+
sourceId: 1,
|
|
387
|
+
parentItem: { value: { id: 1, departmentId: 2 } },
|
|
388
|
+
parentItemResolver: (path: string) => path === 'value.department.title',
|
|
389
|
+
},
|
|
390
|
+
Header: null,
|
|
391
|
+
Footer: null,
|
|
392
|
+
close: () => void 0,
|
|
393
|
+
update: () => void 0,
|
|
394
|
+
} as any;
|
|
395
|
+
|
|
396
|
+
registerPopupVariable(ctx, anchorView);
|
|
397
|
+
|
|
398
|
+
expect(ctx.getPropertyOptions('popup')?.resolveOnServer?.('sourceRecord.department.title')).toBe(true);
|
|
399
|
+
});
|
|
317
400
|
});
|
|
@@ -16,6 +16,7 @@ import { RunJSContextRegistry } from '../runjs-context/registry';
|
|
|
16
16
|
import { setupRunJSContexts } from '../runjs-context/setup';
|
|
17
17
|
import { createViewScopedEngine } from '../ViewScopedFlowEngine';
|
|
18
18
|
import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
|
|
19
|
+
import { serializeCtxDateExpressionConfig } from '../utils/dateVariable';
|
|
19
20
|
|
|
20
21
|
describe('FlowContext properties and methods', () => {
|
|
21
22
|
it('should return static property value', () => {
|
|
@@ -2068,10 +2069,16 @@ describe('getPropertyMetaTree with deep delegate meta', () => {
|
|
|
2068
2069
|
describe('FlowContext resolveOnServer selective server resolution', () => {
|
|
2069
2070
|
it('resolves ctx.date expressions on client context', async () => {
|
|
2070
2071
|
const engine = new FlowEngine();
|
|
2072
|
+
const formattedToday = serializeCtxDateExpressionConfig({
|
|
2073
|
+
kind: 'preset',
|
|
2074
|
+
preset: 'today',
|
|
2075
|
+
format: 'YYYY/MM/DD',
|
|
2076
|
+
});
|
|
2071
2077
|
const out = await (engine.context as any).resolveJsonTemplate({
|
|
2072
2078
|
today: '{{ ctx.date.preset.today }}',
|
|
2073
2079
|
next12: '{{ ctx.date.relative.next.day.n12 }}',
|
|
2074
2080
|
now: '{{ ctx.date.preset.now }}',
|
|
2081
|
+
formattedToday,
|
|
2075
2082
|
});
|
|
2076
2083
|
|
|
2077
2084
|
expect(typeof out.today).toBe('string');
|
|
@@ -2080,6 +2087,7 @@ describe('FlowContext resolveOnServer selective server resolution', () => {
|
|
|
2080
2087
|
expect(out.next12).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
2081
2088
|
expect(typeof out.now).toBe('string');
|
|
2082
2089
|
expect(out.now.length).toBeGreaterThan(0);
|
|
2090
|
+
expect(out.formattedToday).toMatch(/^\d{4}\/\d{2}\/\d{2}$/);
|
|
2083
2091
|
});
|
|
2084
2092
|
|
|
2085
2093
|
it('does not call server by default (no resolveOnServer set)', async () => {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { generateFlowModelRdFromToken } from '@nocobase/utils/client';
|
|
11
12
|
import { FlowContext } from '../flowContext';
|
|
12
13
|
import { FlowEngine } from '../flowEngine';
|
|
13
14
|
import {
|
|
@@ -122,7 +123,10 @@ describe('objectVariable utilities', () => {
|
|
|
122
123
|
|
|
123
124
|
// Provide API stub to intercept variables:resolve
|
|
124
125
|
const calls: any[] = [];
|
|
126
|
+
const payload = Buffer.from(JSON.stringify({ userId: 1, signInTime: 'contract-owner-test' })).toString('base64url');
|
|
127
|
+
const token = `test.${payload}.sig`;
|
|
125
128
|
(ctx as any).api = {
|
|
129
|
+
auth: { token },
|
|
126
130
|
request: vi.fn(async ({ url, data, method }) => {
|
|
127
131
|
calls.push({ url, data, method });
|
|
128
132
|
const batch = (data?.values?.batch as any[]) || [];
|
|
@@ -146,13 +150,14 @@ describe('objectVariable utilities', () => {
|
|
|
146
150
|
});
|
|
147
151
|
|
|
148
152
|
const template = { x: '{{ ctx.obj.author.name }}' } as any;
|
|
149
|
-
await (ctx as any).resolveJsonTemplate(template);
|
|
153
|
+
await (ctx as any).resolveJsonTemplate(template, { contractModelUid: 'form-grid' });
|
|
150
154
|
|
|
151
155
|
// Assert variables:resolve was called with proper flattened contextParams
|
|
152
156
|
expect((ctx as any).api.request).toHaveBeenCalled();
|
|
153
157
|
const call = calls.find((c) => c.url === 'variables:resolve');
|
|
154
158
|
expect(call).toBeTruthy();
|
|
155
159
|
const batch0 = call.data?.values?.batch?.[0];
|
|
160
|
+
expect(batch0?.contractRd).toBe(generateFlowModelRdFromToken('form-grid', token));
|
|
156
161
|
expect(batch0?.contextParams).toBeTruthy();
|
|
157
162
|
// Flattened key should be 'obj.author'
|
|
158
163
|
const cp = batch0.contextParams as Record<string, any>;
|
|
@@ -0,0 +1,138 @@
|
|
|
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 } from '@nocobase/sdk';
|
|
12
|
+
import { FlowContext, FlowRunJSContext } from '../flowContext';
|
|
13
|
+
import { JSItemRunJSContext } from '../runjs-context/contexts/JSItemRunJSContext';
|
|
14
|
+
|
|
15
|
+
describe('FlowRunJSContext form submission', () => {
|
|
16
|
+
it('uses the form block RunJS submit capability without changing the native form', () => {
|
|
17
|
+
const nativeSubmit = vi.fn();
|
|
18
|
+
const getFieldsValue = vi.fn(() => ({ name: 'Alice' }));
|
|
19
|
+
const form = { submit: nativeSubmit, getFieldsValue };
|
|
20
|
+
const submitFromRunJs = vi.fn();
|
|
21
|
+
const delegate = new FlowContext();
|
|
22
|
+
delegate.defineProperty('form', { value: form });
|
|
23
|
+
delegate.defineProperty('blockModel', { value: { submitFromRunJs } });
|
|
24
|
+
|
|
25
|
+
const ctx = new JSItemRunJSContext(delegate);
|
|
26
|
+
|
|
27
|
+
expect(ctx.form.getFieldsValue()).toEqual({ name: 'Alice' });
|
|
28
|
+
ctx.form.submit();
|
|
29
|
+
expect(submitFromRunJs).toHaveBeenCalledOnce();
|
|
30
|
+
expect(nativeSubmit).not.toHaveBeenCalled();
|
|
31
|
+
expect(form.submit).toBe(nativeSubmit);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('keeps the native submit method when the block has no RunJS submit capability', () => {
|
|
35
|
+
const nativeSubmit = vi.fn();
|
|
36
|
+
const form = { submit: nativeSubmit };
|
|
37
|
+
const delegate = new FlowContext();
|
|
38
|
+
delegate.defineProperty('form', { value: form });
|
|
39
|
+
delegate.defineProperty('blockModel', { value: {} });
|
|
40
|
+
|
|
41
|
+
const ctx = new FlowRunJSContext(delegate);
|
|
42
|
+
|
|
43
|
+
ctx.form.submit();
|
|
44
|
+
expect(nativeSubmit).toHaveBeenCalledOnce();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('preserves nested form values and adds association paths to matching resource create calls', async () => {
|
|
48
|
+
const api = new APIClient();
|
|
49
|
+
const request = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { data: { id: 1 } } });
|
|
50
|
+
const values = { name: 'Alice', children: [{ name: 'Bob' }] };
|
|
51
|
+
const getFieldsValue = vi.fn(() => values);
|
|
52
|
+
const delegate = new FlowContext();
|
|
53
|
+
delegate.defineProperty('api', { value: api });
|
|
54
|
+
delegate.defineProperty('resource', {
|
|
55
|
+
value: {
|
|
56
|
+
getResourceName: () => 't1_user',
|
|
57
|
+
getDataSourceKey: () => 'main',
|
|
58
|
+
getUpdateAssociationValues: () => ['children'],
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
delegate.defineProperty('form', { value: { submit: vi.fn(), getFieldsValue } });
|
|
62
|
+
delegate.defineProperty('blockModel', { value: { submitFromRunJs: vi.fn() } });
|
|
63
|
+
const ctx = new FlowRunJSContext(delegate);
|
|
64
|
+
|
|
65
|
+
await ctx.api.resource('t1_user').create({ values: ctx.form.getFieldsValue(true) });
|
|
66
|
+
|
|
67
|
+
expect(getFieldsValue).toHaveBeenCalledWith(true);
|
|
68
|
+
expect(request).toHaveBeenCalledWith({
|
|
69
|
+
url: 't1_user:create',
|
|
70
|
+
method: 'post',
|
|
71
|
+
params: { updateAssociationValues: ['children'] },
|
|
72
|
+
data: values,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('keeps explicit association params and unrelated resource calls unchanged', async () => {
|
|
77
|
+
const api = new APIClient();
|
|
78
|
+
const request = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { data: { id: 1 } } });
|
|
79
|
+
const delegate = new FlowContext();
|
|
80
|
+
delegate.defineProperty('api', { value: api });
|
|
81
|
+
delegate.defineProperty('resource', {
|
|
82
|
+
value: {
|
|
83
|
+
getResourceName: () => 't1_user',
|
|
84
|
+
getDataSourceKey: () => 'main',
|
|
85
|
+
getUpdateAssociationValues: () => ['children'],
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
delegate.defineProperty('form', { value: { submit: vi.fn() } });
|
|
89
|
+
delegate.defineProperty('blockModel', { value: { submitFromRunJs: vi.fn() } });
|
|
90
|
+
const ctx = new FlowRunJSContext(delegate);
|
|
91
|
+
|
|
92
|
+
await ctx.api.resource('t1_user').create({ values: {}, updateAssociationValues: [] });
|
|
93
|
+
await ctx.api.resource('t1_user').create({ values: {}, updateAssociationValues: null });
|
|
94
|
+
await ctx.api.resource('t1_user').create({ values: {}, updateAssociationValues: undefined });
|
|
95
|
+
await ctx.api.resource('posts').create({ values: { title: 'Post' } });
|
|
96
|
+
await ctx.api.resource('t1_user', undefined, { 'X-Data-Source': 'external' }).create({ values: { name: 'Bob' } });
|
|
97
|
+
|
|
98
|
+
expect(request.mock.calls.map(([config]) => config.params)).toEqual([
|
|
99
|
+
{ updateAssociationValues: [] },
|
|
100
|
+
{ updateAssociationValues: null },
|
|
101
|
+
{ updateAssociationValues: undefined },
|
|
102
|
+
{},
|
|
103
|
+
{},
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('adds association paths only for the matching association source record', async () => {
|
|
108
|
+
const api = new APIClient();
|
|
109
|
+
const request = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { data: { id: 1 } } });
|
|
110
|
+
const delegate = new FlowContext();
|
|
111
|
+
delegate.defineProperty('api', { value: api });
|
|
112
|
+
delegate.defineProperty('resource', {
|
|
113
|
+
value: {
|
|
114
|
+
getResourceName: () => 'users.children',
|
|
115
|
+
getDataSourceKey: () => 'main',
|
|
116
|
+
getSourceId: () => 1,
|
|
117
|
+
getUpdateAssociationValues: () => ['toys'],
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
delegate.defineProperty('form', { value: { submit: vi.fn() } });
|
|
121
|
+
delegate.defineProperty('blockModel', { value: { submitFromRunJs: vi.fn() } });
|
|
122
|
+
const ctx = new FlowRunJSContext(delegate);
|
|
123
|
+
|
|
124
|
+
await ctx.api.resource('users.children', 1).create({ values: { name: 'same source' } });
|
|
125
|
+
await ctx.api.resource('users.children', 2).create({ values: { name: 'different source' } });
|
|
126
|
+
|
|
127
|
+
expect(request.mock.calls.map(([config]) => ({ url: config.url, params: config.params }))).toEqual([
|
|
128
|
+
{
|
|
129
|
+
url: 'users/1/children:create',
|
|
130
|
+
params: { updateAssociationValues: ['toys'] },
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
url: 'users/2/children:create',
|
|
134
|
+
params: {},
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/acl/Acl.tsx
CHANGED
|
@@ -16,7 +16,7 @@ interface CheckOptions {
|
|
|
16
16
|
actionName: string;
|
|
17
17
|
fields?: string[];
|
|
18
18
|
recordPkValue?: string | number;
|
|
19
|
-
allowedActions
|
|
19
|
+
allowedActions?: Record<string, Array<string | number>>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export class ACL {
|
|
@@ -149,6 +149,41 @@ export class ACL {
|
|
|
149
149
|
return allowed;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
can(options: CheckOptions): boolean {
|
|
153
|
+
const { allowAll } = this.data;
|
|
154
|
+
if (allowAll) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { actionName, allowedActions, recordPkValue } = options;
|
|
159
|
+
const hasRecordPkValue = recordPkValue !== undefined && recordPkValue !== null;
|
|
160
|
+
const recordPermission =
|
|
161
|
+
hasRecordPkValue && allowedActions ? this.verifyScope(actionName, recordPkValue, allowedActions) : null;
|
|
162
|
+
if (hasRecordPkValue && allowedActions && recordPermission !== true) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const params = this.parseAction(options);
|
|
167
|
+
if (!params) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (!_.isEmpty(params.filter) && recordPermission !== true) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (!options.fields?.length) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const allowedFields: string[] = []
|
|
178
|
+
.concat(params.whitelist || [])
|
|
179
|
+
.concat(params.fields || [])
|
|
180
|
+
.concat(params.appends || []);
|
|
181
|
+
if (!allowedFields.length) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return options.fields.every((field) => allowedFields.includes(field));
|
|
185
|
+
}
|
|
186
|
+
|
|
152
187
|
async aclCheck(options: CheckOptions): Promise<boolean> {
|
|
153
188
|
// await this.load();
|
|
154
189
|
const { allowAll } = this.data;
|
|
@@ -71,6 +71,76 @@ describe('ACL', () => {
|
|
|
71
71
|
expect(notOk).toBe(false);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
it('checks record update scope before field permission', () => {
|
|
75
|
+
const payload = {
|
|
76
|
+
data: {
|
|
77
|
+
allowAll: false,
|
|
78
|
+
actionAlias: {},
|
|
79
|
+
resources: ['posts'],
|
|
80
|
+
actions: { 'posts:update': { whitelist: ['title'] } },
|
|
81
|
+
strategy: { actions: [] },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const engine = makeEngine(payload);
|
|
85
|
+
const acl = new ACL(engine);
|
|
86
|
+
acl.setData(payload.data);
|
|
87
|
+
|
|
88
|
+
const options = {
|
|
89
|
+
dataSourceKey: 'main',
|
|
90
|
+
resourceName: 'posts',
|
|
91
|
+
actionName: 'update',
|
|
92
|
+
allowedActions: {
|
|
93
|
+
update: [1],
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
expect(acl.can({ ...options, recordPkValue: 1, fields: ['title'] })).toBe(true);
|
|
98
|
+
expect(acl.can({ ...options, recordPkValue: 2, fields: ['title'] })).toBe(false);
|
|
99
|
+
expect(acl.can({ ...options, recordPkValue: 1, fields: ['body'] })).toBe(false);
|
|
100
|
+
expect(acl.can({ ...options, recordPkValue: 0, fields: ['title'] })).toBe(false);
|
|
101
|
+
expect(
|
|
102
|
+
acl.can({
|
|
103
|
+
dataSourceKey: 'main',
|
|
104
|
+
resourceName: 'posts',
|
|
105
|
+
actionName: 'update',
|
|
106
|
+
fields: ['title'],
|
|
107
|
+
}),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('allows every field when a scoped action has no field restriction', () => {
|
|
112
|
+
const payload = {
|
|
113
|
+
data: {
|
|
114
|
+
allowAll: false,
|
|
115
|
+
actionAlias: {},
|
|
116
|
+
resources: ['posts'],
|
|
117
|
+
actions: {
|
|
118
|
+
'posts:update': {
|
|
119
|
+
filter: { createdById: '{{ ctx.state.currentUser.id }}' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
strategy: { actions: [] },
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const engine = makeEngine(payload);
|
|
126
|
+
const acl = new ACL(engine);
|
|
127
|
+
acl.setData(payload.data);
|
|
128
|
+
|
|
129
|
+
const options = {
|
|
130
|
+
dataSourceKey: 'main',
|
|
131
|
+
resourceName: 'posts',
|
|
132
|
+
actionName: 'update',
|
|
133
|
+
allowedActions: {
|
|
134
|
+
update: [1],
|
|
135
|
+
},
|
|
136
|
+
fields: ['title'],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
expect(acl.can({ ...options, recordPkValue: 1 })).toBe(true);
|
|
140
|
+
expect(acl.can({ ...options, recordPkValue: 2 })).toBe(false);
|
|
141
|
+
expect(acl.can({ ...options, allowedActions: undefined, recordPkValue: undefined })).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
74
144
|
it('reloads permissions when auth token changes', async () => {
|
|
75
145
|
const payload1 = {
|
|
76
146
|
data: {
|
|
@@ -311,6 +311,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
311
311
|
const path = selectedValues.map(String);
|
|
312
312
|
const pathString = path.join('.');
|
|
313
313
|
const isLeaf = lastOption?.isLeaf;
|
|
314
|
+
const isSelectable = lastOption?.meta?.selectable !== false;
|
|
314
315
|
const now = Date.now();
|
|
315
316
|
|
|
316
317
|
// 使用自定义格式化函数或默认函数
|
|
@@ -325,6 +326,10 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
325
326
|
}
|
|
326
327
|
|
|
327
328
|
if (isLeaf) {
|
|
329
|
+
if (!isSelectable) {
|
|
330
|
+
setTempSelectedPath(path);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
328
333
|
onChange?.(formattedValue, lastOption?.meta);
|
|
329
334
|
// 选中叶子节点后,可清空内部临时路径(外部 value 将驱动级联)
|
|
330
335
|
setTempSelectedPath([]);
|
|
@@ -333,7 +338,8 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
333
338
|
|
|
334
339
|
// 非叶子节点:检查双击
|
|
335
340
|
const lastSelected = lastSelectedRef.current;
|
|
336
|
-
const isDoubleClick =
|
|
341
|
+
const isDoubleClick =
|
|
342
|
+
isSelectable && !onlyLeafSelectable && lastSelected?.path === pathString && now - lastSelected.time < 300;
|
|
337
343
|
|
|
338
344
|
if (isDoubleClick) {
|
|
339
345
|
// 双击:选中非叶子节点
|
|
@@ -26,26 +26,38 @@ interface MobilePopupProps {
|
|
|
26
26
|
footer?: ReactNode;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const getMobilePopupMaxHeight = () => {
|
|
30
|
+
if (typeof CSS !== 'undefined' && CSS.supports?.('height', '100dvh')) {
|
|
31
|
+
return 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return 'calc(100vh - var(--nb-mobile-page-header-height, 46px))';
|
|
35
|
+
};
|
|
36
|
+
|
|
29
37
|
export const MobilePopup: FC<MobilePopupProps> = (props) => {
|
|
30
38
|
const { title, visible, onClose: closePopup, children, minHeight, className, footer } = props;
|
|
31
39
|
const { t } = useTranslation();
|
|
32
40
|
const { componentCls, hashId } = useMobileActionDrawerStyle();
|
|
33
41
|
|
|
34
42
|
const bodyStyles = (props as MobilePopupProps & { styles?: { body?: React.CSSProperties } }).styles?.body;
|
|
43
|
+
const defaultMaxHeight = getMobilePopupMaxHeight();
|
|
35
44
|
const popupStyle = useMemo(() => {
|
|
36
45
|
return {
|
|
37
46
|
minHeight: bodyStyles?.minHeight ?? minHeight,
|
|
38
47
|
height: bodyStyles?.height,
|
|
39
|
-
maxHeight: bodyStyles?.maxHeight,
|
|
48
|
+
maxHeight: bodyStyles?.maxHeight ?? defaultMaxHeight,
|
|
40
49
|
};
|
|
41
|
-
}, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, minHeight]);
|
|
50
|
+
}, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, defaultMaxHeight, minHeight]);
|
|
42
51
|
|
|
43
|
-
const bodyStyle = useMemo(() => {
|
|
52
|
+
const bodyStyle = useMemo<React.CSSProperties>(() => {
|
|
44
53
|
return {
|
|
45
54
|
padding: 0,
|
|
55
|
+
maxHeight: defaultMaxHeight,
|
|
56
|
+
overflowY: 'auto',
|
|
57
|
+
overflowX: 'hidden',
|
|
46
58
|
...bodyStyles,
|
|
47
59
|
};
|
|
48
|
-
}, [bodyStyles]);
|
|
60
|
+
}, [bodyStyles, defaultMaxHeight]);
|
|
49
61
|
|
|
50
62
|
const handleCloseKeyDown = useCallback(
|
|
51
63
|
(event: React.KeyboardEvent<HTMLSpanElement>) => {
|