@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.
@@ -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
- if (!cur.parent) cur.parent = {};
310
- cur.parent.record = parentRef;
311
- cur = cur.parent;
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 srcId = inputArgs == null ? void 0 : inputArgs.sourceId;
321
- const assoc = inputArgs == null ? void 0 : inputArgs.associationName;
322
- const dsKey = (inputArgs == null ? void 0 : inputArgs.dataSourceKey) || "main";
323
- if (srcId != null && srcId !== "" && assoc && typeof assoc === "string") {
324
- const parentCollectionName = String(assoc).split(".")[0];
325
- if (parentCollectionName) {
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 openerUids = (_a = view == null ? void 0 : view.inputArgs) == null ? void 0 : _a.openerUids;
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, _b, _c, _d, _e, _f;
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 = (_b = ctx.engine) == null ? void 0 : _b.getModel(viewUid, true);
460
+ let model = (_b2 = ctx.engine) == null ? void 0 : _b2.getModel(viewUid, true);
457
461
  if (!model) {
458
- model = await ((_c = ctx.engine) == null ? void 0 : _c.loadModel({ uid: viewUid }));
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.35",
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.35",
12
- "@nocobase/shared": "2.1.35",
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": "d844133f9bd61a3506a3be1af25a1cb06dafbc6f"
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 = !onlyLeafSelectable && lastSelected?.path === pathString && now - lastSelected.time < 300;
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
  });
@@ -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 toFilterByTk(value: unknown, primaryKey: string | string[]) {
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 out: Record<string, any> = {};
58
+ const record = value as Record<string, unknown>;
59
+ const out: Record<string, unknown> = {};
59
60
  for (const k of primaryKey) {
60
- const v = (value as any)[k];
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 any)[primaryKey];
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.map((item) => toFilterByTk(item, primaryKey)).filter((v) => v != null);
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 = toFilterByTk(associationValue, primaryKey);
164
+ const id = getAssociationFilterByTk(associationValue, primaryKey);
162
165
  if (id != null) {
163
166
  params[name] = {
164
167
  collection: target,