@nocobase/flow-engine 2.1.35 → 2.1.37
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/FlowContextSelector.js +7 -1
- package/lib/flowContext.d.ts +1 -0
- package/lib/flowContext.js +10 -4
- 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/index.d.ts +3 -3
- package/lib/utils/index.js +8 -0
- package/lib/utils/params-resolvers.d.ts +2 -0
- package/lib/utils/params-resolvers.js +9 -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/components/FlowContextSelector.tsx +7 -1
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +39 -3
- package/src/flowContext.ts +14 -2
- 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/index.ts +17 -2
- package/src/utils/params-resolvers.ts +10 -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.1.
|
|
3
|
+
"version": "2.1.37",
|
|
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.1.
|
|
12
|
-
"@nocobase/shared": "2.1.
|
|
11
|
+
"@nocobase/sdk": "2.1.37",
|
|
12
|
+
"@nocobase/shared": "2.1.37",
|
|
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": "a0c3201363ae028058845af0176ee902a9eeb3e1"
|
|
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 () => {
|
|
@@ -286,6 +286,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
286
286
|
const path = selectedValues.map(String);
|
|
287
287
|
const pathString = path.join('.');
|
|
288
288
|
const isLeaf = lastOption?.isLeaf;
|
|
289
|
+
const isSelectable = lastOption?.meta?.selectable !== false;
|
|
289
290
|
const now = Date.now();
|
|
290
291
|
|
|
291
292
|
// 使用自定义格式化函数或默认函数
|
|
@@ -300,6 +301,10 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
300
301
|
}
|
|
301
302
|
|
|
302
303
|
if (isLeaf) {
|
|
304
|
+
if (!isSelectable) {
|
|
305
|
+
setTempSelectedPath(path);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
303
308
|
onChange?.(formattedValue, lastOption?.meta);
|
|
304
309
|
// 选中叶子节点后,可清空内部临时路径(外部 value 将驱动级联)
|
|
305
310
|
setTempSelectedPath([]);
|
|
@@ -308,7 +313,8 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
308
313
|
|
|
309
314
|
// 非叶子节点:检查双击
|
|
310
315
|
const lastSelected = lastSelectedRef.current;
|
|
311
|
-
const isDoubleClick =
|
|
316
|
+
const isDoubleClick =
|
|
317
|
+
isSelectable && !onlyLeafSelectable && lastSelected?.path === pathString && now - lastSelected.time < 300;
|
|
312
318
|
|
|
313
319
|
if (isDoubleClick) {
|
|
314
320
|
// 双击:选中非叶子节点
|
|
@@ -405,10 +405,11 @@ describe('FlowContextSelector', () => {
|
|
|
405
405
|
|
|
406
406
|
const clearIcon = document.querySelector('.ant-input-clear-icon') as HTMLElement | null;
|
|
407
407
|
expect(clearIcon).toBeInTheDocument();
|
|
408
|
+
if (!clearIcon) throw new Error('Expected clear icon to be rendered');
|
|
408
409
|
|
|
409
|
-
fireEvent.mouseDown(clearIcon
|
|
410
|
-
fireEvent.mouseUp(clearIcon
|
|
411
|
-
fireEvent.click(clearIcon
|
|
410
|
+
fireEvent.mouseDown(clearIcon);
|
|
411
|
+
fireEvent.mouseUp(clearIcon);
|
|
412
|
+
fireEvent.click(clearIcon);
|
|
412
413
|
|
|
413
414
|
await waitFor(() => {
|
|
414
415
|
expect(onChange).toHaveBeenCalledWith('', undefined);
|
|
@@ -807,4 +808,39 @@ describe('FlowContextSelector', () => {
|
|
|
807
808
|
// It should only expand the node, not select it
|
|
808
809
|
expect(onChange).not.toHaveBeenCalled();
|
|
809
810
|
});
|
|
811
|
+
|
|
812
|
+
it('should expand but never select a node marked selectable=false', async () => {
|
|
813
|
+
const onChange = vi.fn();
|
|
814
|
+
const flowContext = createTestFlowContext();
|
|
815
|
+
const metaTree = [
|
|
816
|
+
{
|
|
817
|
+
name: 'date',
|
|
818
|
+
title: 'Date',
|
|
819
|
+
type: 'date',
|
|
820
|
+
paths: ['date'],
|
|
821
|
+
selectable: false,
|
|
822
|
+
children: [{ name: 'today', title: 'Today', type: 'date', paths: ['date', 'today'] }],
|
|
823
|
+
},
|
|
824
|
+
];
|
|
825
|
+
|
|
826
|
+
render(
|
|
827
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
828
|
+
<FlowContextSelector metaTree={metaTree} onChange={onChange} />
|
|
829
|
+
</TestFlowContextWrapper>,
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
fireEvent.click(screen.getByRole('button'));
|
|
833
|
+
await waitFor(() => expect(screen.getByText('Date')).toBeInTheDocument());
|
|
834
|
+
|
|
835
|
+
fireEvent.click(screen.getByText('Date'));
|
|
836
|
+
fireEvent.click(screen.getByText('Date'));
|
|
837
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
838
|
+
|
|
839
|
+
await waitFor(() => expect(screen.getByText('Today')).toBeInTheDocument());
|
|
840
|
+
fireEvent.click(screen.getByText('Today'));
|
|
841
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
842
|
+
'{{ ctx.date.today }}',
|
|
843
|
+
expect.objectContaining({ paths: ['date', 'today'] }),
|
|
844
|
+
);
|
|
845
|
+
});
|
|
810
846
|
});
|
package/src/flowContext.ts
CHANGED
|
@@ -50,11 +50,11 @@ import {
|
|
|
50
50
|
resolveModuleUrl,
|
|
51
51
|
} from './utils';
|
|
52
52
|
import { FlowExitAllException } from './utils/exceptions';
|
|
53
|
-
import { enqueueVariablesResolve, JSONValue } from './utils/params-resolvers';
|
|
53
|
+
import { buildFlowModelResolveDescriptor, enqueueVariablesResolve, JSONValue } from './utils/params-resolvers';
|
|
54
54
|
import type { RecordRef } from './utils/serverContextParams';
|
|
55
55
|
import { buildServerContextParams as _buildServerContextParams } from './utils/serverContextParams';
|
|
56
56
|
import { getDirtyAwareApiClient } from './utils/dirtyAwareApiClient';
|
|
57
|
-
import { inferRecordRef } from './utils/variablesParams';
|
|
57
|
+
import { inferRecordRef, inferViewRecordRef } from './utils/variablesParams';
|
|
58
58
|
import { FlowView, FlowViewer } from './views/FlowView';
|
|
59
59
|
import { RunJSContextRegistry, getModelClassName, type RunJSVersion } from './runjs-context/registry';
|
|
60
60
|
import { createEphemeralContext } from './utils/createEphemeralContext';
|
|
@@ -221,6 +221,8 @@ export interface MetaTreeNode {
|
|
|
221
221
|
// 变量禁用状态与原因(用于变量选择器 UI 展示)
|
|
222
222
|
disabled?: boolean | (() => boolean);
|
|
223
223
|
disabledReason?: string | (() => string | undefined);
|
|
224
|
+
// 允许节点仅用于展开子级,而不能作为变量值被选中
|
|
225
|
+
selectable?: boolean;
|
|
224
226
|
children?: MetaTreeNode[] | (() => Promise<MetaTreeNode[]>);
|
|
225
227
|
}
|
|
226
228
|
|
|
@@ -3316,6 +3318,15 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3316
3318
|
const inputFromMeta = await collectFromMeta();
|
|
3317
3319
|
const autoInput = { ...inputFromMeta } as Record<string, any>;
|
|
3318
3320
|
|
|
3321
|
+
const viewPaths = serverVarPaths.view || [];
|
|
3322
|
+
if (
|
|
3323
|
+
!autoInput.view &&
|
|
3324
|
+
viewPaths.some((path) => path === 'record' || path.startsWith('record.') || path.startsWith('record['))
|
|
3325
|
+
) {
|
|
3326
|
+
const recordRef = inferViewRecordRef(this);
|
|
3327
|
+
if (recordRef) autoInput.view = { record: recordRef };
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3319
3330
|
// Special-case: formValues
|
|
3320
3331
|
// If server needs to resolve some formValues paths but meta params only cover association anchors
|
|
3321
3332
|
// (e.g. formValues.customer) and some top-level paths are missing (e.g. formValues.status),
|
|
@@ -3388,6 +3399,7 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3388
3399
|
if (this.api) {
|
|
3389
3400
|
try {
|
|
3390
3401
|
serverResolved = await enqueueVariablesResolve(this as FlowRuntimeContext<FlowModel>, {
|
|
3402
|
+
rd: buildFlowModelResolveDescriptor(this as FlowRuntimeContext<FlowModel>, this.model?.uid),
|
|
3391
3403
|
template,
|
|
3392
3404
|
contextParams: autoContextParams || {},
|
|
3393
3405
|
});
|
|
@@ -12,9 +12,12 @@ import {
|
|
|
12
12
|
decodeBase64Url,
|
|
13
13
|
encodeBase64Url,
|
|
14
14
|
isCompleteCtxDatePath,
|
|
15
|
+
isCtxDatePathPrefix,
|
|
15
16
|
isCtxDateExpression,
|
|
16
17
|
parseCtxDateExpression,
|
|
18
|
+
parseCtxDateExpressionConfig,
|
|
17
19
|
resolveCtxDatePath,
|
|
20
|
+
serializeCtxDateExpressionConfig,
|
|
18
21
|
serializeCtxDateValue,
|
|
19
22
|
} from '../dateVariable';
|
|
20
23
|
|
|
@@ -54,13 +57,60 @@ describe('dateVariable utils', () => {
|
|
|
54
57
|
number: 2,
|
|
55
58
|
});
|
|
56
59
|
|
|
57
|
-
const singleExpr = serializeCtxDateValue('2026-02-12')
|
|
60
|
+
const singleExpr = serializeCtxDateValue('2026-02-12');
|
|
61
|
+
if (!singleExpr) throw new Error('Expected exact date expression');
|
|
58
62
|
expect(parseCtxDateExpression(singleExpr)).toBe('2026-02-12');
|
|
59
63
|
|
|
60
|
-
const rangeExpr = serializeCtxDateValue(['2026-02-12', '2026-02-20'])
|
|
64
|
+
const rangeExpr = serializeCtxDateValue(['2026-02-12', '2026-02-20']);
|
|
65
|
+
if (!rangeExpr) throw new Error('Expected exact date range expression');
|
|
61
66
|
expect(parseCtxDateExpression(rangeExpr)).toEqual(['2026-02-12', '2026-02-20']);
|
|
62
67
|
});
|
|
63
68
|
|
|
69
|
+
it('serializes, parses and resolves formatted expressions', () => {
|
|
70
|
+
const expression = serializeCtxDateExpressionConfig({
|
|
71
|
+
kind: 'preset',
|
|
72
|
+
preset: 'today',
|
|
73
|
+
format: 'YYYY/MM/DD',
|
|
74
|
+
});
|
|
75
|
+
if (!expression) throw new Error('Expected formatted date expression');
|
|
76
|
+
|
|
77
|
+
expect(expression).toMatch(/^\{\{ ctx\.date\.format\.v[A-Za-z0-9_-]+\.preset\.today \}\}$/);
|
|
78
|
+
expect(parseCtxDateExpressionConfig(expression)).toEqual({
|
|
79
|
+
kind: 'preset',
|
|
80
|
+
preset: 'today',
|
|
81
|
+
format: 'YYYY/MM/DD',
|
|
82
|
+
});
|
|
83
|
+
// Keep the legacy parser contract for filter-form consumers.
|
|
84
|
+
expect(parseCtxDateExpression(expression)).toEqual({ type: 'today' });
|
|
85
|
+
|
|
86
|
+
const path = expression.replace('{{ ctx.', '').replace(' }}', '').split('.');
|
|
87
|
+
expect(resolveCtxDatePath(path)).toMatch(/^\d{4}\/\d{2}\/\d{2}$/);
|
|
88
|
+
expect(isCompleteCtxDatePath(path)).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('preserves significant whitespace in a custom Format', () => {
|
|
92
|
+
const expression = serializeCtxDateExpressionConfig({
|
|
93
|
+
kind: 'preset',
|
|
94
|
+
preset: 'today',
|
|
95
|
+
format: 'YYYY-MM-DD ',
|
|
96
|
+
});
|
|
97
|
+
if (!expression) throw new Error('Expected formatted date expression');
|
|
98
|
+
|
|
99
|
+
expect(parseCtxDateExpressionConfig(expression)?.format).toBe('YYYY-MM-DD ');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('formats exact ranges element by element', () => {
|
|
103
|
+
const expression = serializeCtxDateExpressionConfig({
|
|
104
|
+
kind: 'exact',
|
|
105
|
+
value: ['2026-02-12', '2026-02-20'],
|
|
106
|
+
format: 'YYYYMMDD',
|
|
107
|
+
});
|
|
108
|
+
if (!expression) throw new Error('Expected formatted date range expression');
|
|
109
|
+
const path = expression.replace('{{ ctx.', '').replace(' }}', '').split('.');
|
|
110
|
+
|
|
111
|
+
expect(resolveCtxDatePath(path)).toEqual(['20260212', '20260220']);
|
|
112
|
+
});
|
|
113
|
+
|
|
64
114
|
it('resolves preset/relative/exact path', () => {
|
|
65
115
|
expect(typeof resolveCtxDatePath(['date', 'preset', 'now'])).toBe('string');
|
|
66
116
|
|
|
@@ -72,11 +122,13 @@ describe('dateVariable utils', () => {
|
|
|
72
122
|
expect(typeof rel).toBe('string');
|
|
73
123
|
expect(rel).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
74
124
|
|
|
75
|
-
const singleExpr = serializeCtxDateValue('2026-02-12')
|
|
125
|
+
const singleExpr = serializeCtxDateValue('2026-02-12');
|
|
126
|
+
if (!singleExpr) throw new Error('Expected exact date expression');
|
|
76
127
|
const token = singleExpr.replace('{{ ctx.date.exact.single.date.', '').replace(' }}', '');
|
|
77
128
|
expect(resolveCtxDatePath(['date', 'exact', 'single', 'date', token])).toBe('2026-02-12');
|
|
78
129
|
|
|
79
|
-
const rangeExpr = serializeCtxDateValue(['2026-02-12', '2026-02-20'])
|
|
130
|
+
const rangeExpr = serializeCtxDateValue(['2026-02-12', '2026-02-20']);
|
|
131
|
+
if (!rangeExpr) throw new Error('Expected exact date range expression');
|
|
80
132
|
const parts = rangeExpr.replace('{{ ctx.date.exact.range.date.', '').replace(' }}', '').split('.');
|
|
81
133
|
expect(resolveCtxDatePath(['date', 'exact', 'range', 'date', parts[0], parts[1]])).toEqual([
|
|
82
134
|
'2026-02-12',
|
|
@@ -90,6 +142,7 @@ describe('dateVariable utils', () => {
|
|
|
90
142
|
expect(isCompleteCtxDatePath(['date', 'exact', 'single', 'date', 'vabc'])).toBe(true);
|
|
91
143
|
expect(isCompleteCtxDatePath(['date', 'exact', 'range', 'date', 'vabc', 'vdef'])).toBe(true);
|
|
92
144
|
expect(isCompleteCtxDatePath(['date', 'relative', 'next', 'day'])).toBe(false);
|
|
145
|
+
expect(isCtxDatePathPrefix(['date', 'format'])).toBe(true);
|
|
93
146
|
expect(isCompleteCtxDatePath(['user', 'name'])).toBe(false);
|
|
94
147
|
});
|
|
95
148
|
|
|
@@ -34,7 +34,8 @@ describe('variablesParams helpers', () => {
|
|
|
34
34
|
|
|
35
35
|
it('inferRecordRef fallback to collection.getFilterByTK when resource has no filterByTk', () => {
|
|
36
36
|
const engine = new FlowEngine();
|
|
37
|
-
const ds = engine.context.dataSourceManager.getDataSource('main')
|
|
37
|
+
const ds = engine.context.dataSourceManager.getDataSource('main');
|
|
38
|
+
if (!ds) throw new Error('main data source is required');
|
|
38
39
|
ds.addCollection({
|
|
39
40
|
name: 'users',
|
|
40
41
|
filterTargetKey: 'id',
|
|
@@ -108,6 +109,32 @@ describe('variablesParams helpers', () => {
|
|
|
108
109
|
});
|
|
109
110
|
});
|
|
110
111
|
|
|
112
|
+
it('collectContextParamsForTemplate infers view.record when its meta has no descriptor', async () => {
|
|
113
|
+
const ctx: any = {
|
|
114
|
+
getPropertyOptions: () => undefined,
|
|
115
|
+
view: {
|
|
116
|
+
inputArgs: {
|
|
117
|
+
collectionName: 'posts',
|
|
118
|
+
dataSourceKey: 'main',
|
|
119
|
+
filterByTk: 3,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const res = await collectContextParamsForTemplate(ctx, {
|
|
125
|
+
recordId: '{{ ctx.view.record.id }}',
|
|
126
|
+
viewType: '{{ ctx.view.type }}',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(res).toEqual({
|
|
130
|
+
'view.record': {
|
|
131
|
+
collection: 'posts',
|
|
132
|
+
dataSourceKey: 'main',
|
|
133
|
+
filterByTk: 3,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
111
138
|
it('createRecordResolveOnServerWithLocal: no local record => always use server', () => {
|
|
112
139
|
const resolver = createRecordResolveOnServerWithLocal(
|
|
113
140
|
() => ({ name: 'posts', dataSourceKey: 'main' }) as any,
|
|
@@ -51,13 +51,14 @@ function findFieldByName(collection: Collection | null | undefined, name?: strin
|
|
|
51
51
|
* @param primaryKey 主键字段名
|
|
52
52
|
* @returns 解析出的主键值,无法解析时返回 undefined
|
|
53
53
|
*/
|
|
54
|
-
function
|
|
54
|
+
export function getAssociationFilterByTk(value: unknown, primaryKey: string | string[]) {
|
|
55
55
|
if (value == null) return undefined;
|
|
56
56
|
if (Array.isArray(primaryKey)) {
|
|
57
57
|
if (typeof value !== 'object' || !value) return undefined;
|
|
58
|
-
const
|
|
58
|
+
const record = value as Record<string, unknown>;
|
|
59
|
+
const out: Record<string, unknown> = {};
|
|
59
60
|
for (const k of primaryKey) {
|
|
60
|
-
const v =
|
|
61
|
+
const v = record[k];
|
|
61
62
|
if (typeof v === 'undefined' || v === null) return undefined;
|
|
62
63
|
out[k] = v;
|
|
63
64
|
}
|
|
@@ -65,7 +66,7 @@ function toFilterByTk(value: unknown, primaryKey: string | string[]) {
|
|
|
65
66
|
}
|
|
66
67
|
if (typeof value === 'string' || typeof value === 'number') return value;
|
|
67
68
|
if (typeof value === 'object') {
|
|
68
|
-
return (value as
|
|
69
|
+
return (value as Record<string, unknown>)[primaryKey];
|
|
69
70
|
}
|
|
70
71
|
return undefined;
|
|
71
72
|
}
|
|
@@ -149,7 +150,9 @@ export function createAssociationAwareObjectMetaFactory(
|
|
|
149
150
|
if (associationValue == null) continue;
|
|
150
151
|
|
|
151
152
|
if (Array.isArray(associationValue)) {
|
|
152
|
-
const ids = associationValue
|
|
153
|
+
const ids = associationValue
|
|
154
|
+
.map((item) => getAssociationFilterByTk(item, primaryKey))
|
|
155
|
+
.filter((v) => v != null);
|
|
153
156
|
if (ids.length) {
|
|
154
157
|
params[name] = {
|
|
155
158
|
collection: target,
|
|
@@ -158,7 +161,7 @@ export function createAssociationAwareObjectMetaFactory(
|
|
|
158
161
|
};
|
|
159
162
|
}
|
|
160
163
|
} else {
|
|
161
|
-
const id =
|
|
164
|
+
const id = getAssociationFilterByTk(associationValue, primaryKey);
|
|
162
165
|
if (id != null) {
|
|
163
166
|
params[name] = {
|
|
164
167
|
collection: target,
|