@nocobase/flow-engine 3.0.0-alpha.7 → 3.0.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -247,6 +247,7 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
247
247
  }, [active, cascaderProps.disabled, currentPath]);
248
248
  const handleChange = (0, import_react.useCallback)(
249
249
  (selectedValues, selectedOptions) => {
250
+ var _a;
250
251
  const lastOption = selectedOptions == null ? void 0 : selectedOptions[selectedOptions.length - 1];
251
252
  if (!selectedValues || selectedValues.length === 0) {
252
253
  onChange == null ? void 0 : onChange("", lastOption == null ? void 0 : lastOption.meta);
@@ -256,6 +257,7 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
256
257
  const path = selectedValues.map(String);
257
258
  const pathString = path.join(".");
258
259
  const isLeaf = lastOption == null ? void 0 : lastOption.isLeaf;
260
+ const isSelectable = ((_a = lastOption == null ? void 0 : lastOption.meta) == null ? void 0 : _a.selectable) !== false;
259
261
  const now = Date.now();
260
262
  let formattedValue;
261
263
  if (customFormatPathToValue) {
@@ -267,12 +269,16 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
267
269
  formattedValue = (0, import_utils.formatPathToValue)(lastOption == null ? void 0 : lastOption.meta);
268
270
  }
269
271
  if (isLeaf) {
272
+ if (!isSelectable) {
273
+ setTempSelectedPath(path);
274
+ return;
275
+ }
270
276
  onChange == null ? void 0 : onChange(formattedValue, lastOption == null ? void 0 : lastOption.meta);
271
277
  setTempSelectedPath([]);
272
278
  return;
273
279
  }
274
280
  const lastSelected = lastSelectedRef.current;
275
- const isDoubleClick = !onlyLeafSelectable && (lastSelected == null ? void 0 : lastSelected.path) === pathString && now - lastSelected.time < 300;
281
+ const isDoubleClick = isSelectable && !onlyLeafSelectable && (lastSelected == null ? void 0 : lastSelected.path) === pathString && now - lastSelected.time < 300;
276
282
  if (isDoubleClick) {
277
283
  onChange == null ? void 0 : onChange(formattedValue, lastOption == null ? void 0 : lastOption.meta);
278
284
  lastSelectedRef.current = null;
@@ -27,6 +27,9 @@ import type { RecordRef } from './utils/serverContextParams';
27
27
  import { FlowView, FlowViewer } from './views/FlowView';
28
28
  import { type RunJSVersion } from './runjs-context/registry';
29
29
  type Getter<T = any> = (ctx: FlowContext) => T | Promise<T>;
30
+ export type ResolveJsonTemplateOptions = {
31
+ contractModelUid?: string | number | null;
32
+ };
30
33
  export type FlowContextDocRef = string | {
31
34
  url: string;
32
35
  title?: string;
@@ -77,6 +80,7 @@ export interface MetaTreeNode {
77
80
  hidden?: boolean | (() => boolean);
78
81
  disabled?: boolean | (() => boolean);
79
82
  disabledReason?: string | (() => string | undefined);
83
+ selectable?: boolean;
80
84
  children?: MetaTreeNode[] | (() => Promise<MetaTreeNode[]>);
81
85
  }
82
86
  export interface PropertyMeta {
@@ -343,7 +347,7 @@ declare class BaseFlowEngineContext extends FlowContext {
343
347
  * @deprecated use `resolveJsonTemplate` instead
344
348
  */
345
349
  renderJson: (template: JSONValue) => Promise<any>;
346
- resolveJsonTemplate: (template: JSONValue) => Promise<any>;
350
+ resolveJsonTemplate: (template: JSONValue, options?: ResolveJsonTemplateOptions) => Promise<any>;
347
351
  getVar: (path: string) => Promise<any>;
348
352
  request: (options: RequestOptions) => Promise<any>;
349
353
  runjs: (code: string, variables?: Record<string, any>, options?: JSRunnerOptions) => Promise<any>;
@@ -2383,7 +2383,7 @@ const _FlowEngineContext = class _FlowEngineContext extends BaseFlowEngineContex
2383
2383
  this.defineMethod("renderJson", function(template) {
2384
2384
  return this.resolveJsonTemplate(template);
2385
2385
  });
2386
- this.defineMethod("resolveJsonTemplate", async function(template) {
2386
+ const resolveJsonTemplate = /* @__PURE__ */ __name(async function(template, options) {
2387
2387
  var _a, _b, _c;
2388
2388
  const used = (0, import_utils.extractUsedVariablePaths)(template);
2389
2389
  const usedVarNames = Object.keys(used || {});
@@ -2516,7 +2516,12 @@ const _FlowEngineContext = class _FlowEngineContext extends BaseFlowEngineContex
2516
2516
  }
2517
2517
  if (this.api) {
2518
2518
  try {
2519
+ const contractRd = (0, import_params_resolvers.buildFlowModelResolveDescriptor)(
2520
+ this,
2521
+ options == null ? void 0 : options.contractModelUid
2522
+ );
2519
2523
  serverResolved = await (0, import_params_resolvers.enqueueVariablesResolve)(this, {
2524
+ ...contractRd ? { contractRd } : {},
2520
2525
  rd: (0, import_params_resolvers.buildFlowModelResolveDescriptor)(this, (_a = this.model) == null ? void 0 : _a.uid),
2521
2526
  template,
2522
2527
  contextParams: autoContextParams || {}
@@ -2528,7 +2533,8 @@ const _FlowEngineContext = class _FlowEngineContext extends BaseFlowEngineContex
2528
2533
  }
2529
2534
  }
2530
2535
  return (0, import_utils.resolveExpressions)(serverResolved, this);
2531
- });
2536
+ }, "resolveJsonTemplate");
2537
+ this.defineMethod("resolveJsonTemplate", resolveJsonTemplate);
2532
2538
  this.defineMethod(
2533
2539
  "getVar",
2534
2540
  async function(varPath) {
@@ -6,11 +6,33 @@
6
6
  * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
+ declare const PRESET_KEY_LIST: readonly ["today", "now", "yesterday", "tomorrow", "thisWeek", "lastWeek", "nextWeek", "thisMonth", "lastMonth", "nextMonth", "thisQuarter", "lastQuarter", "nextQuarter", "thisYear", "lastYear", "nextYear"];
10
+ export type CtxDatePreset = (typeof PRESET_KEY_LIST)[number];
11
+ export type CtxDateRelativeDirection = 'next' | 'past';
12
+ export type CtxDateRelativeUnit = 'day' | 'week' | 'month' | 'year';
13
+ export type CtxDateExpressionConfig = {
14
+ kind: 'exact';
15
+ value: string | [string, string];
16
+ format?: string;
17
+ } | {
18
+ kind: 'relative';
19
+ direction: CtxDateRelativeDirection;
20
+ amount: number;
21
+ unit: CtxDateRelativeUnit;
22
+ format?: string;
23
+ } | {
24
+ kind: 'preset';
25
+ preset: CtxDatePreset;
26
+ format?: string;
27
+ };
9
28
  export declare function isCtxDatePathPrefix(pathSegments: string[]): boolean;
10
29
  export declare function encodeBase64Url(input: string): string;
11
30
  export declare function decodeBase64Url(input: string): string | undefined;
12
31
  export declare function isCtxDateExpression(value: unknown): value is string;
13
32
  export declare function isCompleteCtxDatePath(pathSegments: string[]): boolean;
14
33
  export declare function parseCtxDateExpression(value: unknown): any;
34
+ export declare function parseCtxDateExpressionConfig(value: unknown): CtxDateExpressionConfig | undefined;
35
+ export declare function serializeCtxDateExpressionConfig(config: CtxDateExpressionConfig): string | undefined;
15
36
  export declare function serializeCtxDateValue(value: unknown): string | undefined;
16
37
  export declare function resolveCtxDatePath(pathSegments: string[]): any;
38
+ export {};
@@ -43,13 +43,15 @@ __export(dateVariable_exports, {
43
43
  isCtxDateExpression: () => isCtxDateExpression,
44
44
  isCtxDatePathPrefix: () => isCtxDatePathPrefix,
45
45
  parseCtxDateExpression: () => parseCtxDateExpression,
46
+ parseCtxDateExpressionConfig: () => parseCtxDateExpressionConfig,
46
47
  resolveCtxDatePath: () => resolveCtxDatePath,
48
+ serializeCtxDateExpressionConfig: () => serializeCtxDateExpressionConfig,
47
49
  serializeCtxDateValue: () => serializeCtxDateValue
48
50
  });
49
51
  module.exports = __toCommonJS(dateVariable_exports);
50
52
  var import_dayjs = __toESM(require("dayjs"));
51
53
  const CTX_DATE_REGEX = /^\{\{\s*ctx\.date(?:\.(.+?))?\s*\}\}$/;
52
- const PRESET_KEYS = /* @__PURE__ */ new Set([
54
+ const PRESET_KEY_LIST = [
53
55
  "today",
54
56
  "now",
55
57
  "yesterday",
@@ -66,9 +68,11 @@ const PRESET_KEYS = /* @__PURE__ */ new Set([
66
68
  "thisYear",
67
69
  "lastYear",
68
70
  "nextYear"
69
- ]);
71
+ ];
72
+ const PRESET_KEYS = new Set(PRESET_KEY_LIST);
70
73
  const RELATIVE_DIRECTIONS = /* @__PURE__ */ new Set(["next", "past"]);
71
74
  const RELATIVE_UNITS = /* @__PURE__ */ new Set(["day", "week", "month", "year"]);
75
+ const MAX_DATE_FORMAT_LENGTH = 128;
72
76
  function parseCtxDateSegments(value) {
73
77
  if (typeof value !== "string") return null;
74
78
  const trimmed = value.trim();
@@ -79,8 +83,7 @@ function parseCtxDateSegments(value) {
79
83
  return rawPath.split(".").map((seg) => seg.trim()).filter(Boolean);
80
84
  }
81
85
  __name(parseCtxDateSegments, "parseCtxDateSegments");
82
- function isCtxDatePathPrefix(pathSegments) {
83
- const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
86
+ function isBaseCtxDatePathPrefix(segments) {
84
87
  if (segments[0] !== "date") return false;
85
88
  if (segments.length === 1) return true;
86
89
  if (segments[1] === "preset") {
@@ -117,6 +120,34 @@ function isCtxDatePathPrefix(pathSegments) {
117
120
  }
118
121
  return false;
119
122
  }
123
+ __name(isBaseCtxDatePathPrefix, "isBaseCtxDatePathPrefix");
124
+ function decodeFormatToken(token) {
125
+ const raw = String(token || "");
126
+ if (!raw.startsWith("v")) return void 0;
127
+ const decoded = decodeBase64Url(raw.slice(1));
128
+ if (!decoded || decoded.length > MAX_DATE_FORMAT_LENGTH) return void 0;
129
+ return decoded;
130
+ }
131
+ __name(decodeFormatToken, "decodeFormatToken");
132
+ function splitFormattedDateSegments(segments) {
133
+ if (segments[0] !== "date") return null;
134
+ if (segments[1] !== "format") return { baseSegments: segments };
135
+ if (segments.length < 4) return null;
136
+ const format = decodeFormatToken(segments[2]);
137
+ if (!format) return null;
138
+ return { baseSegments: ["date", ...segments.slice(3)], format };
139
+ }
140
+ __name(splitFormattedDateSegments, "splitFormattedDateSegments");
141
+ function isCtxDatePathPrefix(pathSegments) {
142
+ const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
143
+ if (segments[0] !== "date") return false;
144
+ if (segments.length === 1) return true;
145
+ if (segments[1] !== "format") return isBaseCtxDatePathPrefix(segments);
146
+ if (segments.length === 2) return true;
147
+ if (segments.length === 3) return typeof decodeFormatToken(segments[2]) === "string";
148
+ const formatted = splitFormattedDateSegments(segments);
149
+ return formatted ? isBaseCtxDatePathPrefix(formatted.baseSegments) : false;
150
+ }
120
151
  __name(isCtxDatePathPrefix, "isCtxDatePathPrefix");
121
152
  function withDatePrefix(pathSegments) {
122
153
  if (pathSegments[0] === "date") {
@@ -225,26 +256,31 @@ __name(isCtxDateExpression, "isCtxDateExpression");
225
256
  function isCompleteCtxDatePath(pathSegments) {
226
257
  if (!isCtxDatePathPrefix(pathSegments)) return false;
227
258
  const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
228
- if (segments[0] !== "date") return false;
229
- if (segments[1] === "preset") {
230
- return segments.length === 3 && PRESET_KEYS.has(segments[2]);
259
+ const formatted = splitFormattedDateSegments(segments);
260
+ if (!formatted) return false;
261
+ const baseSegments = formatted.baseSegments;
262
+ if (baseSegments[1] === "preset") {
263
+ return baseSegments.length === 3 && PRESET_KEYS.has(baseSegments[2]);
231
264
  }
232
- if (segments[1] === "relative") {
233
- if (segments.length !== 5) return false;
234
- return RELATIVE_DIRECTIONS.has(segments[2]) && RELATIVE_UNITS.has(segments[3]) && typeof parseNumberToken(segments[4]) === "number";
265
+ if (baseSegments[1] === "relative") {
266
+ if (baseSegments.length !== 5) return false;
267
+ return RELATIVE_DIRECTIONS.has(baseSegments[2]) && RELATIVE_UNITS.has(baseSegments[3]) && typeof parseNumberToken(baseSegments[4]) === "number";
235
268
  }
236
- if (segments[1] === "exact" && segments[2] === "single" && segments[3] === "date") {
237
- return segments.length === 5 && /^v.+/.test(segments[4]);
269
+ if (baseSegments[1] === "exact" && baseSegments[2] === "single" && baseSegments[3] === "date") {
270
+ return baseSegments.length === 5 && /^v.+/.test(baseSegments[4]);
238
271
  }
239
- if (segments[1] === "exact" && segments[2] === "range" && segments[3] === "date") {
240
- return segments.length === 6 && /^v.+/.test(segments[4]) && /^v.+/.test(segments[5]);
272
+ if (baseSegments[1] === "exact" && baseSegments[2] === "range" && baseSegments[3] === "date") {
273
+ return baseSegments.length === 6 && /^v.+/.test(baseSegments[4]) && /^v.+/.test(baseSegments[5]);
241
274
  }
242
275
  return false;
243
276
  }
244
277
  __name(isCompleteCtxDatePath, "isCompleteCtxDatePath");
245
278
  function parseCtxDateExpression(value) {
246
279
  if (!isCtxDateExpression(value)) return void 0;
247
- const segments = withDatePrefix(parseCtxDateSegments(value) || []);
280
+ const rawSegments = withDatePrefix(parseCtxDateSegments(value) || []);
281
+ const formatted = splitFormattedDateSegments(rawSegments);
282
+ if (!formatted) return void 0;
283
+ const segments = formatted.baseSegments;
248
284
  if (segments[1] === "preset" && segments.length === 3 && PRESET_KEYS.has(segments[2])) {
249
285
  return { type: segments[2] };
250
286
  }
@@ -272,6 +308,60 @@ function parseCtxDateExpression(value) {
272
308
  return void 0;
273
309
  }
274
310
  __name(parseCtxDateExpression, "parseCtxDateExpression");
311
+ function parseCtxDateExpressionConfig(value) {
312
+ if (!isCtxDateExpression(value)) return void 0;
313
+ const segments = withDatePrefix(parseCtxDateSegments(value) || []);
314
+ const formatted = splitFormattedDateSegments(segments);
315
+ if (!formatted) return void 0;
316
+ const parsed = parseCtxDateExpression(value);
317
+ const formatConfig = formatted.format ? { format: formatted.format } : {};
318
+ if (typeof parsed === "string") {
319
+ return { kind: "exact", value: parsed, ...formatConfig };
320
+ }
321
+ if (Array.isArray(parsed) && parsed.length === 2 && typeof parsed[0] === "string" && typeof parsed[1] === "string") {
322
+ return { kind: "exact", value: [parsed[0], parsed[1]], ...formatConfig };
323
+ }
324
+ if (!parsed || typeof parsed !== "object") return void 0;
325
+ const typed = parsed;
326
+ if (typed.type === "past" || typed.type === "next") {
327
+ if (typeof typed.unit !== "string" || !RELATIVE_UNITS.has(typed.unit) || typeof typed.number !== "number") {
328
+ return void 0;
329
+ }
330
+ return {
331
+ kind: "relative",
332
+ direction: typed.type,
333
+ amount: typed.number,
334
+ unit: typed.unit,
335
+ ...formatConfig
336
+ };
337
+ }
338
+ if (typeof typed.type === "string" && PRESET_KEYS.has(typed.type)) {
339
+ return { kind: "preset", preset: typed.type, ...formatConfig };
340
+ }
341
+ return void 0;
342
+ }
343
+ __name(parseCtxDateExpressionConfig, "parseCtxDateExpressionConfig");
344
+ function serializeCtxDateExpressionConfig(config) {
345
+ let legacyValue;
346
+ if (config.kind === "preset") {
347
+ if (!PRESET_KEYS.has(config.preset)) return void 0;
348
+ legacyValue = { type: config.preset };
349
+ } else if (config.kind === "relative") {
350
+ if (!RELATIVE_DIRECTIONS.has(config.direction) || !RELATIVE_UNITS.has(config.unit)) return void 0;
351
+ const amount = Math.floor(Number(config.amount));
352
+ if (!Number.isFinite(amount) || amount <= 0) return void 0;
353
+ legacyValue = { type: config.direction, unit: config.unit, number: amount };
354
+ } else {
355
+ legacyValue = config.value;
356
+ }
357
+ const expression = serializeCtxDateValue(legacyValue);
358
+ if (!expression || !config.format) return expression;
359
+ const format = String(config.format);
360
+ if (!format.trim() || format.length > MAX_DATE_FORMAT_LENGTH) return void 0;
361
+ const segments = withDatePrefix(parseCtxDateSegments(expression) || []);
362
+ return toCtxDateExpression(["date", "format", `v${encodeBase64Url(format)}`, ...segments.slice(1)]);
363
+ }
364
+ __name(serializeCtxDateExpressionConfig, "serializeCtxDateExpressionConfig");
275
365
  function serializeCtxDateValue(value) {
276
366
  if (isCtxDateExpression(value)) {
277
367
  return String(value).trim();
@@ -317,8 +407,23 @@ function serializeCtxDateValue(value) {
317
407
  return void 0;
318
408
  }
319
409
  __name(serializeCtxDateValue, "serializeCtxDateValue");
410
+ function formatResolvedDateValue(value, format) {
411
+ const formatValue = /* @__PURE__ */ __name((item) => {
412
+ if (typeof item !== "string") return item;
413
+ const parsed = (0, import_dayjs.default)(item);
414
+ return parsed.isValid() ? parsed.format(format) : item;
415
+ }, "formatValue");
416
+ return Array.isArray(value) ? value.map(formatValue) : formatValue(value);
417
+ }
418
+ __name(formatResolvedDateValue, "formatResolvedDateValue");
320
419
  function resolveCtxDatePath(pathSegments) {
321
- const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
420
+ const rawSegments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
421
+ const formatted = splitFormattedDateSegments(rawSegments);
422
+ if (!formatted) return void 0;
423
+ if (formatted.format) {
424
+ return formatResolvedDateValue(resolveCtxDatePath(formatted.baseSegments), formatted.format);
425
+ }
426
+ const segments = formatted.baseSegments;
322
427
  if (segments[0] !== "date") return void 0;
323
428
  if (segments[1] === "preset" && segments.length === 3) {
324
429
  const key = segments[2];
@@ -375,6 +480,8 @@ __name(resolveCtxDatePath, "resolveCtxDatePath");
375
480
  isCtxDateExpression,
376
481
  isCtxDatePathPrefix,
377
482
  parseCtxDateExpression,
483
+ parseCtxDateExpressionConfig,
378
484
  resolveCtxDatePath,
485
+ serializeCtxDateExpressionConfig,
379
486
  serializeCtxDateValue
380
487
  });
@@ -21,7 +21,7 @@ export { extractPropertyPath, formatPathToVariable, isVariableExpression } from
21
21
  export { clearAutoFlowError, getAutoFlowError, setAutoFlowError, type AutoFlowError } from './autoFlowError';
22
22
  export { parsePathnameToViewParams, type ViewParam } from './parsePathnameToViewParams';
23
23
  export { createOpenViewRouteState, decodeOpenViewRouteState, encodeOpenViewRouteState, isOpenViewRouteStateToken, RUNJS_OPEN_VIEW_ROUTE_STATE, type OpenViewRouteMode, type OpenViewRouteSize, type OpenViewRouteState, } from './openViewRouteState';
24
- export { decodeBase64Url, encodeBase64Url, isCompleteCtxDatePath, isCtxDatePathPrefix, isCtxDateExpression, parseCtxDateExpression, resolveCtxDatePath, serializeCtxDateValue, } from './dateVariable';
24
+ export { decodeBase64Url, encodeBase64Url, isCompleteCtxDatePath, isCtxDatePathPrefix, isCtxDateExpression, parseCtxDateExpression, parseCtxDateExpressionConfig, resolveCtxDatePath, serializeCtxDateExpressionConfig, serializeCtxDateValue, type CtxDateExpressionConfig, type CtxDatePreset, type CtxDateRelativeDirection, type CtxDateRelativeUnit, } from './dateVariable';
25
25
  export { isRunJSValue, normalizeRunJSValue, extractUsedVariablePathsFromRunJS, type RunJSValue } from './runjsValue';
26
26
  export { resolveRunJSObjectValues } from './resolveRunJSObjectValues';
27
27
  export { prepareRunJsCode, preprocessRunJsTemplates } from './runjsTemplateCompat';
@@ -73,6 +73,7 @@ __export(utils_exports, {
73
73
  isVariableExpression: () => import_context.isVariableExpression,
74
74
  normalizeRunJSValue: () => import_runjsValue.normalizeRunJSValue,
75
75
  parseCtxDateExpression: () => import_dateVariable.parseCtxDateExpression,
76
+ parseCtxDateExpressionConfig: () => import_dateVariable.parseCtxDateExpressionConfig,
76
77
  parsePathnameToViewParams: () => import_parsePathnameToViewParams.parsePathnameToViewParams,
77
78
  prepareRunJsCode: () => import_runjsTemplateCompat.prepareRunJsCode,
78
79
  preprocessRunJsTemplates: () => import_runjsTemplateCompat.preprocessRunJsTemplates,
@@ -87,6 +88,7 @@ __export(utils_exports, {
87
88
  resolveStepDisabledInSettings: () => import_schema_utils.resolveStepDisabledInSettings,
88
89
  resolveStepUiSchema: () => import_schema_utils.resolveStepUiSchema,
89
90
  resolveUiMode: () => import_schema_utils.resolveUiMode,
91
+ serializeCtxDateExpressionConfig: () => import_dateVariable.serializeCtxDateExpressionConfig,
90
92
  serializeCtxDateValue: () => import_dateVariable.serializeCtxDateValue,
91
93
  setAutoFlowError: () => import_autoFlowError.setAutoFlowError,
92
94
  setupRuntimeContextSteps: () => import_setupRuntimeContextSteps.setupRuntimeContextSteps,
@@ -168,6 +170,7 @@ var import_randomId = require("./randomId");
168
170
  isVariableExpression,
169
171
  normalizeRunJSValue,
170
172
  parseCtxDateExpression,
173
+ parseCtxDateExpressionConfig,
171
174
  parsePathnameToViewParams,
172
175
  prepareRunJsCode,
173
176
  preprocessRunJsTemplates,
@@ -182,6 +185,7 @@ var import_randomId = require("./randomId");
182
185
  resolveStepDisabledInSettings,
183
186
  resolveStepUiSchema,
184
187
  resolveUiMode,
188
+ serializeCtxDateExpressionConfig,
185
189
  serializeCtxDateValue,
186
190
  setAutoFlowError,
187
191
  setupRuntimeContextSteps,
@@ -27,6 +27,7 @@ export type JSONValue = string | {
27
27
  [key: string]: JSONValue;
28
28
  } | JSONValue[];
29
29
  type BatchPayload = {
30
+ contractRd?: string;
30
31
  rd?: string;
31
32
  template: JSONValue;
32
33
  contextParams?: ServerContextParams | undefined;
@@ -146,6 +146,7 @@ function enqueueVariablesResolve(ctx, payload) {
146
146
  try {
147
147
  const batch = items.map((it) => ({
148
148
  id: it.id,
149
+ contractRd: it.payload.contractRd,
149
150
  rd: it.payload.rd,
150
151
  template: it.payload.template,
151
152
  contextParams: it.payload.contextParams || {}
@@ -28,6 +28,7 @@ interface PopupNodeResource {
28
28
  interface PopupNode {
29
29
  uid?: string;
30
30
  resource: PopupNodeResource;
31
+ sourceRecord?: unknown;
31
32
  parent?: PopupNode;
32
33
  }
33
34
  export declare function buildPopupRuntime(ctx: FlowContext, view: FlowView): Promise<PopupNode | undefined>;
@@ -428,10 +428,11 @@ function createPopupMeta(ctx, anchorView) {
428
428
  }
429
429
  __name(createPopupMeta, "createPopupMeta");
430
430
  async function buildPopupRuntime(ctx, view) {
431
- var _a;
431
+ var _a, _b, _c;
432
432
  const stack = getViewStack(view);
433
433
  const currentIndex = getAnchoredViewStackIndex(view, stack);
434
- 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;
435
436
  const hasOpener = Array.isArray(openerUids) && openerUids.length > 0;
436
437
  const hasStackPopup = currentIndex >= 1;
437
438
  const isPopup = hasStackPopup || hasOpener;
@@ -448,16 +449,17 @@ async function buildPopupRuntime(ctx, view) {
448
449
  associationName: args.associationName,
449
450
  filterByTk: args.filterByTk,
450
451
  sourceId: args.sourceId
451
- }
452
+ },
453
+ ...typeof sourceRecord !== "undefined" ? { sourceRecord } : {}
452
454
  };
453
455
  }
454
456
  const buildNode = /* @__PURE__ */ __name(async (idx) => {
455
- var _a2, _b, _c, _d, _e, _f;
457
+ var _a2, _b2, _c2, _d, _e, _f;
456
458
  if (idx < 0 || !((_a2 = stack[idx]) == null ? void 0 : _a2.viewUid)) return void 0;
457
459
  const viewUid = stack[idx].viewUid;
458
- 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);
459
461
  if (!model) {
460
- 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 }));
461
463
  }
462
464
  const p = ((_d = model == null ? void 0 : model.getStepParams) == null ? void 0 : _d.call(model, "popupSettings", "openView")) || {};
463
465
  const collectionName = p == null ? void 0 : p.collectionName;
@@ -477,16 +479,43 @@ async function buildPopupRuntime(ctx, view) {
477
479
  return node;
478
480
  }, "buildNode");
479
481
  const currentNode = await buildNode(currentIndex);
482
+ if (currentNode && typeof sourceRecord !== "undefined") {
483
+ currentNode.sourceRecord = sourceRecord;
484
+ }
480
485
  return currentNode;
481
486
  }
482
487
  __name(buildPopupRuntime, "buildPopupRuntime");
483
488
  function registerPopupVariable(ctx, view) {
484
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");
485
511
  ctx.defineProperty("popup", {
486
512
  get: /* @__PURE__ */ __name(async () => buildPopupRuntime(ctx, view), "get"),
487
513
  meta: createPopupMeta(ctx, view),
488
514
  resolveOnServer: /* @__PURE__ */ __name((p) => {
489
515
  try {
516
+ if (p === "sourceRecord" || p.startsWith("sourceRecord.")) {
517
+ return shouldResolveSourceRecordOnServer(p);
518
+ }
490
519
  return !!p && POPUP_SERVER_PATH_RE.test(p);
491
520
  } catch (_) {
492
521
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "3.0.0-alpha.7",
3
+ "version": "3.0.0-alpha.8",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "3.0.0-alpha.7",
12
- "@nocobase/shared": "3.0.0-alpha.7",
11
+ "@nocobase/sdk": "3.0.0-alpha.8",
12
+ "@nocobase/shared": "3.0.0-alpha.8",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "19cd28c710f3b3c614ebe2fd7d5a415d527ee559"
40
+ "gitHead": "ce017f3deb8b2414c6b13818042c4187270d1d8a"
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>;
@@ -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 = !onlyLeafSelectable && lastSelected?.path === pathString && now - lastSelected.time < 300;
341
+ const isDoubleClick =
342
+ isSelectable && !onlyLeafSelectable && lastSelected?.path === pathString && now - lastSelected.time < 300;
337
343
 
338
344
  if (isDoubleClick) {
339
345
  // 双击:选中非叶子节点
@@ -864,4 +864,39 @@ describe('FlowContextSelector', () => {
864
864
  // It should only expand the node, not select it
865
865
  expect(onChange).not.toHaveBeenCalled();
866
866
  });
867
+
868
+ it('should expand but never select a node marked selectable=false', async () => {
869
+ const onChange = vi.fn();
870
+ const flowContext = createTestFlowContext();
871
+ const metaTree = [
872
+ {
873
+ name: 'date',
874
+ title: 'Date',
875
+ type: 'date',
876
+ paths: ['date'],
877
+ selectable: false,
878
+ children: [{ name: 'today', title: 'Today', type: 'date', paths: ['date', 'today'] }],
879
+ },
880
+ ];
881
+
882
+ render(
883
+ <TestFlowContextWrapper context={flowContext}>
884
+ <FlowContextSelector metaTree={metaTree} onChange={onChange} />
885
+ </TestFlowContextWrapper>,
886
+ );
887
+
888
+ fireEvent.click(screen.getByRole('button'));
889
+ await waitFor(() => expect(screen.getByText('Date')).toBeInTheDocument());
890
+
891
+ fireEvent.click(screen.getByText('Date'));
892
+ fireEvent.click(screen.getByText('Date'));
893
+ expect(onChange).not.toHaveBeenCalled();
894
+
895
+ await waitFor(() => expect(screen.getByText('Today')).toBeInTheDocument());
896
+ fireEvent.click(screen.getByText('Today'));
897
+ expect(onChange).toHaveBeenCalledWith(
898
+ '{{ ctx.date.today }}',
899
+ expect.objectContaining({ paths: ['date', 'today'] }),
900
+ );
901
+ });
867
902
  });
@@ -165,6 +165,10 @@ function inferSelectsFromUsage(paths: string[] = []): { generatedAppends?: strin
165
165
 
166
166
  type Getter<T = any> = (ctx: FlowContext) => T | Promise<T>;
167
167
 
168
+ export type ResolveJsonTemplateOptions = {
169
+ contractModelUid?: string | number | null;
170
+ };
171
+
168
172
  export type FlowContextDocRef = string | { url: string; title?: string };
169
173
 
170
174
  export type FlowDeprecationDoc =
@@ -221,6 +225,8 @@ export interface MetaTreeNode {
221
225
  // 变量禁用状态与原因(用于变量选择器 UI 展示)
222
226
  disabled?: boolean | (() => boolean);
223
227
  disabledReason?: string | (() => string | undefined);
228
+ // 允许节点仅用于展开子级,而不能作为变量值被选中
229
+ selectable?: boolean;
224
230
  children?: MetaTreeNode[] | (() => Promise<MetaTreeNode[]>);
225
231
  }
226
232
 
@@ -3044,7 +3050,7 @@ class BaseFlowEngineContext extends FlowContext {
3044
3050
  * @deprecated use `resolveJsonTemplate` instead
3045
3051
  */
3046
3052
  declare renderJson: (template: JSONValue) => Promise<any>;
3047
- declare resolveJsonTemplate: (template: JSONValue) => Promise<any>;
3053
+ declare resolveJsonTemplate: (template: JSONValue, options?: ResolveJsonTemplateOptions) => Promise<any>;
3048
3054
  declare getVar: (path: string) => Promise<any>;
3049
3055
  declare request: (options: RequestOptions) => Promise<any>;
3050
3056
  declare runjs: (code: string, variables?: Record<string, any>, options?: JSRunnerOptions) => Promise<any>;
@@ -3227,7 +3233,11 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3227
3233
  this.defineMethod('renderJson', function (template: any) {
3228
3234
  return this.resolveJsonTemplate(template);
3229
3235
  });
3230
- this.defineMethod('resolveJsonTemplate', async function (this: BaseFlowEngineContext, template: any) {
3236
+ const resolveJsonTemplate = async function (
3237
+ this: BaseFlowEngineContext,
3238
+ template: any,
3239
+ options?: ResolveJsonTemplateOptions,
3240
+ ) {
3231
3241
  // 提取模板使用到的变量及其子路径
3232
3242
  const used = extractUsedVariablePaths(template);
3233
3243
  const usedVarNames = Object.keys(used || {});
@@ -3396,7 +3406,12 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3396
3406
 
3397
3407
  if (this.api) {
3398
3408
  try {
3409
+ const contractRd = buildFlowModelResolveDescriptor(
3410
+ this as FlowRuntimeContext<FlowModel>,
3411
+ options?.contractModelUid,
3412
+ );
3399
3413
  serverResolved = await enqueueVariablesResolve(this as FlowRuntimeContext<FlowModel>, {
3414
+ ...(contractRd ? { contractRd } : {}),
3400
3415
  rd: buildFlowModelResolveDescriptor(this as FlowRuntimeContext<FlowModel>, this.model?.uid),
3401
3416
  template,
3402
3417
  contextParams: autoContextParams || {},
@@ -3409,7 +3424,8 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3409
3424
  }
3410
3425
 
3411
3426
  return resolveExpressions(serverResolved, this);
3412
- });
3427
+ };
3428
+ this.defineMethod('resolveJsonTemplate', resolveJsonTemplate);
3413
3429
 
3414
3430
  // Helper: resolve a single ctx expression value via resolveJsonTemplate behavior.
3415
3431
  // Example: await ctx.getVar('ctx.record.id')
@@ -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
 
@@ -11,7 +11,7 @@ import dayjs from 'dayjs';
11
11
 
12
12
  const CTX_DATE_REGEX = /^\{\{\s*ctx\.date(?:\.(.+?))?\s*\}\}$/;
13
13
 
14
- const PRESET_KEYS = new Set([
14
+ const PRESET_KEY_LIST = [
15
15
  'today',
16
16
  'now',
17
17
  'yesterday',
@@ -28,10 +28,28 @@ const PRESET_KEYS = new Set([
28
28
  'thisYear',
29
29
  'lastYear',
30
30
  'nextYear',
31
- ]);
31
+ ] as const;
32
+
33
+ export type CtxDatePreset = (typeof PRESET_KEY_LIST)[number];
34
+ export type CtxDateRelativeDirection = 'next' | 'past';
35
+ export type CtxDateRelativeUnit = 'day' | 'week' | 'month' | 'year';
36
+
37
+ export type CtxDateExpressionConfig =
38
+ | { kind: 'exact'; value: string | [string, string]; format?: string }
39
+ | {
40
+ kind: 'relative';
41
+ direction: CtxDateRelativeDirection;
42
+ amount: number;
43
+ unit: CtxDateRelativeUnit;
44
+ format?: string;
45
+ }
46
+ | { kind: 'preset'; preset: CtxDatePreset; format?: string };
47
+
48
+ const PRESET_KEYS = new Set<string>(PRESET_KEY_LIST);
32
49
 
33
50
  const RELATIVE_DIRECTIONS = new Set(['next', 'past']);
34
51
  const RELATIVE_UNITS = new Set(['day', 'week', 'month', 'year']);
52
+ const MAX_DATE_FORMAT_LENGTH = 128;
35
53
 
36
54
  function parseCtxDateSegments(value: string): string[] | null {
37
55
  if (typeof value !== 'string') return null;
@@ -46,8 +64,7 @@ function parseCtxDateSegments(value: string): string[] | null {
46
64
  .filter(Boolean);
47
65
  }
48
66
 
49
- export function isCtxDatePathPrefix(pathSegments: string[]): boolean {
50
- const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
67
+ function isBaseCtxDatePathPrefix(segments: string[]): boolean {
51
68
  if (segments[0] !== 'date') return false;
52
69
  if (segments.length === 1) return true;
53
70
 
@@ -96,6 +113,36 @@ export function isCtxDatePathPrefix(pathSegments: string[]): boolean {
96
113
  return false;
97
114
  }
98
115
 
116
+ function decodeFormatToken(token: string): string | undefined {
117
+ const raw = String(token || '');
118
+ if (!raw.startsWith('v')) return undefined;
119
+ const decoded = decodeBase64Url(raw.slice(1));
120
+ if (!decoded || decoded.length > MAX_DATE_FORMAT_LENGTH) return undefined;
121
+ return decoded;
122
+ }
123
+
124
+ function splitFormattedDateSegments(segments: string[]): { baseSegments: string[]; format?: string } | null {
125
+ if (segments[0] !== 'date') return null;
126
+ if (segments[1] !== 'format') return { baseSegments: segments };
127
+ if (segments.length < 4) return null;
128
+
129
+ const format = decodeFormatToken(segments[2]);
130
+ if (!format) return null;
131
+ return { baseSegments: ['date', ...segments.slice(3)], format };
132
+ }
133
+
134
+ export function isCtxDatePathPrefix(pathSegments: string[]): boolean {
135
+ const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
136
+ if (segments[0] !== 'date') return false;
137
+ if (segments.length === 1) return true;
138
+ if (segments[1] !== 'format') return isBaseCtxDatePathPrefix(segments);
139
+ if (segments.length === 2) return true;
140
+ if (segments.length === 3) return typeof decodeFormatToken(segments[2]) === 'string';
141
+
142
+ const formatted = splitFormattedDateSegments(segments);
143
+ return formatted ? isBaseCtxDatePathPrefix(formatted.baseSegments) : false;
144
+ }
145
+
99
146
  function withDatePrefix(pathSegments: string[]): string[] {
100
147
  if (pathSegments[0] === 'date') {
101
148
  return pathSegments;
@@ -210,27 +257,29 @@ export function isCtxDateExpression(value: unknown): value is string {
210
257
  export function isCompleteCtxDatePath(pathSegments: string[]): boolean {
211
258
  if (!isCtxDatePathPrefix(pathSegments)) return false;
212
259
  const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
213
- if (segments[0] !== 'date') return false;
260
+ const formatted = splitFormattedDateSegments(segments);
261
+ if (!formatted) return false;
262
+ const baseSegments = formatted.baseSegments;
214
263
 
215
- if (segments[1] === 'preset') {
216
- return segments.length === 3 && PRESET_KEYS.has(segments[2]);
264
+ if (baseSegments[1] === 'preset') {
265
+ return baseSegments.length === 3 && PRESET_KEYS.has(baseSegments[2]);
217
266
  }
218
267
 
219
- if (segments[1] === 'relative') {
220
- if (segments.length !== 5) return false;
268
+ if (baseSegments[1] === 'relative') {
269
+ if (baseSegments.length !== 5) return false;
221
270
  return (
222
- RELATIVE_DIRECTIONS.has(segments[2]) &&
223
- RELATIVE_UNITS.has(segments[3]) &&
224
- typeof parseNumberToken(segments[4]) === 'number'
271
+ RELATIVE_DIRECTIONS.has(baseSegments[2]) &&
272
+ RELATIVE_UNITS.has(baseSegments[3]) &&
273
+ typeof parseNumberToken(baseSegments[4]) === 'number'
225
274
  );
226
275
  }
227
276
 
228
- if (segments[1] === 'exact' && segments[2] === 'single' && segments[3] === 'date') {
229
- return segments.length === 5 && /^v.+/.test(segments[4]);
277
+ if (baseSegments[1] === 'exact' && baseSegments[2] === 'single' && baseSegments[3] === 'date') {
278
+ return baseSegments.length === 5 && /^v.+/.test(baseSegments[4]);
230
279
  }
231
280
 
232
- if (segments[1] === 'exact' && segments[2] === 'range' && segments[3] === 'date') {
233
- return segments.length === 6 && /^v.+/.test(segments[4]) && /^v.+/.test(segments[5]);
281
+ if (baseSegments[1] === 'exact' && baseSegments[2] === 'range' && baseSegments[3] === 'date') {
282
+ return baseSegments.length === 6 && /^v.+/.test(baseSegments[4]) && /^v.+/.test(baseSegments[5]);
234
283
  }
235
284
 
236
285
  return false;
@@ -238,7 +287,10 @@ export function isCompleteCtxDatePath(pathSegments: string[]): boolean {
238
287
 
239
288
  export function parseCtxDateExpression(value: unknown): any {
240
289
  if (!isCtxDateExpression(value)) return undefined;
241
- const segments = withDatePrefix(parseCtxDateSegments(value as string) || []);
290
+ const rawSegments = withDatePrefix(parseCtxDateSegments(value as string) || []);
291
+ const formatted = splitFormattedDateSegments(rawSegments);
292
+ if (!formatted) return undefined;
293
+ const segments = formatted.baseSegments;
242
294
 
243
295
  if (segments[1] === 'preset' && segments.length === 3 && PRESET_KEYS.has(segments[2])) {
244
296
  return { type: segments[2] };
@@ -276,6 +328,66 @@ export function parseCtxDateExpression(value: unknown): any {
276
328
  return undefined;
277
329
  }
278
330
 
331
+ export function parseCtxDateExpressionConfig(value: unknown): CtxDateExpressionConfig | undefined {
332
+ if (!isCtxDateExpression(value)) return undefined;
333
+ const segments = withDatePrefix(parseCtxDateSegments(value) || []);
334
+ const formatted = splitFormattedDateSegments(segments);
335
+ if (!formatted) return undefined;
336
+
337
+ const parsed = parseCtxDateExpression(value);
338
+ const formatConfig = formatted.format ? { format: formatted.format } : {};
339
+ if (typeof parsed === 'string') {
340
+ return { kind: 'exact', value: parsed, ...formatConfig };
341
+ }
342
+ if (Array.isArray(parsed) && parsed.length === 2 && typeof parsed[0] === 'string' && typeof parsed[1] === 'string') {
343
+ return { kind: 'exact', value: [parsed[0], parsed[1]], ...formatConfig };
344
+ }
345
+
346
+ if (!parsed || typeof parsed !== 'object') return undefined;
347
+ const typed = parsed as { type?: unknown; unit?: unknown; number?: unknown };
348
+ if (typed.type === 'past' || typed.type === 'next') {
349
+ if (typeof typed.unit !== 'string' || !RELATIVE_UNITS.has(typed.unit) || typeof typed.number !== 'number') {
350
+ return undefined;
351
+ }
352
+ return {
353
+ kind: 'relative',
354
+ direction: typed.type,
355
+ amount: typed.number,
356
+ unit: typed.unit as CtxDateRelativeUnit,
357
+ ...formatConfig,
358
+ };
359
+ }
360
+
361
+ if (typeof typed.type === 'string' && PRESET_KEYS.has(typed.type)) {
362
+ return { kind: 'preset', preset: typed.type as CtxDatePreset, ...formatConfig };
363
+ }
364
+ return undefined;
365
+ }
366
+
367
+ export function serializeCtxDateExpressionConfig(config: CtxDateExpressionConfig): string | undefined {
368
+ let legacyValue: unknown;
369
+
370
+ if (config.kind === 'preset') {
371
+ if (!PRESET_KEYS.has(config.preset)) return undefined;
372
+ legacyValue = { type: config.preset };
373
+ } else if (config.kind === 'relative') {
374
+ if (!RELATIVE_DIRECTIONS.has(config.direction) || !RELATIVE_UNITS.has(config.unit)) return undefined;
375
+ const amount = Math.floor(Number(config.amount));
376
+ if (!Number.isFinite(amount) || amount <= 0) return undefined;
377
+ legacyValue = { type: config.direction, unit: config.unit, number: amount };
378
+ } else {
379
+ legacyValue = config.value;
380
+ }
381
+
382
+ const expression = serializeCtxDateValue(legacyValue);
383
+ if (!expression || !config.format) return expression;
384
+
385
+ const format = String(config.format);
386
+ if (!format.trim() || format.length > MAX_DATE_FORMAT_LENGTH) return undefined;
387
+ const segments = withDatePrefix(parseCtxDateSegments(expression) || []);
388
+ return toCtxDateExpression(['date', 'format', `v${encodeBase64Url(format)}`, ...segments.slice(1)]);
389
+ }
390
+
279
391
  export function serializeCtxDateValue(value: unknown): string | undefined {
280
392
  if (isCtxDateExpression(value)) {
281
393
  return String(value).trim();
@@ -327,8 +439,23 @@ export function serializeCtxDateValue(value: unknown): string | undefined {
327
439
  return undefined;
328
440
  }
329
441
 
442
+ function formatResolvedDateValue(value: unknown, format: string): unknown {
443
+ const formatValue = (item: unknown) => {
444
+ if (typeof item !== 'string') return item;
445
+ const parsed = dayjs(item);
446
+ return parsed.isValid() ? parsed.format(format) : item;
447
+ };
448
+ return Array.isArray(value) ? value.map(formatValue) : formatValue(value);
449
+ }
450
+
330
451
  export function resolveCtxDatePath(pathSegments: string[]): any {
331
- const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
452
+ const rawSegments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
453
+ const formatted = splitFormattedDateSegments(rawSegments);
454
+ if (!formatted) return undefined;
455
+ if (formatted.format) {
456
+ return formatResolvedDateValue(resolveCtxDatePath(formatted.baseSegments), formatted.format);
457
+ }
458
+ const segments = formatted.baseSegments;
332
459
  if (segments[0] !== 'date') return undefined;
333
460
 
334
461
  if (segments[1] === 'preset' && segments.length === 3) {
@@ -92,8 +92,14 @@ export {
92
92
  isCtxDatePathPrefix,
93
93
  isCtxDateExpression,
94
94
  parseCtxDateExpression,
95
+ parseCtxDateExpressionConfig,
95
96
  resolveCtxDatePath,
97
+ serializeCtxDateExpressionConfig,
96
98
  serializeCtxDateValue,
99
+ type CtxDateExpressionConfig,
100
+ type CtxDatePreset,
101
+ type CtxDateRelativeDirection,
102
+ type CtxDateRelativeUnit,
97
103
  } from './dateVariable';
98
104
 
99
105
  // RunJS value helpers
@@ -79,6 +79,7 @@ export type JSONValue = string | { [key: string]: JSONValue } | JSONValue[];
79
79
  // =========================
80
80
 
81
81
  type BatchPayload = {
82
+ contractRd?: string;
82
83
  rd?: string;
83
84
  template: JSONValue;
84
85
  contextParams?: ServerContextParams | undefined;
@@ -172,6 +173,7 @@ export function enqueueVariablesResolve(ctx: FlowRuntimeContext, payload: BatchP
172
173
  try {
173
174
  const batch = items.map((it) => ({
174
175
  id: it.id,
176
+ contractRd: it.payload.contractRd,
175
177
  rd: it.payload.rd,
176
178
  template: it.payload.template,
177
179
  contextParams: it.payload.contextParams || {},
@@ -475,12 +475,14 @@ interface PopupNodeResource {
475
475
  interface PopupNode {
476
476
  uid?: string;
477
477
  resource: PopupNodeResource;
478
+ sourceRecord?: unknown;
478
479
  parent?: PopupNode;
479
480
  }
480
481
 
481
482
  export async function buildPopupRuntime(ctx: FlowContext, view: FlowView): Promise<PopupNode | undefined> {
482
483
  const stack = getViewStack(view);
483
484
  const currentIndex = getAnchoredViewStackIndex(view, stack);
485
+ const sourceRecord = view?.inputArgs?.parentItem?.value;
484
486
 
485
487
  const openerUids = view?.inputArgs?.openerUids;
486
488
  const hasOpener = Array.isArray(openerUids) && openerUids.length > 0;
@@ -503,6 +505,7 @@ export async function buildPopupRuntime(ctx: FlowContext, view: FlowView): Promi
503
505
  filterByTk: args.filterByTk,
504
506
  sourceId: args.sourceId,
505
507
  },
508
+ ...(typeof sourceRecord !== 'undefined' ? { sourceRecord } : {}),
506
509
  };
507
510
  }
508
511
 
@@ -531,6 +534,9 @@ export async function buildPopupRuntime(ctx: FlowContext, view: FlowView): Promi
531
534
  return node;
532
535
  };
533
536
  const currentNode = await buildNode(currentIndex);
537
+ if (currentNode && typeof sourceRecord !== 'undefined') {
538
+ currentNode.sourceRecord = sourceRecord;
539
+ }
534
540
  return currentNode;
535
541
  }
536
542
 
@@ -542,6 +548,30 @@ export function registerPopupVariable(ctx: FlowContext, view: FlowView) {
542
548
  // - 任意层级 parent.parent... 下的 record / sourceRecord 及其子字段
543
549
  const POPUP_SERVER_PATH_RE =
544
550
  /^(?:record|sourceRecord)(?:\.|$)|^parent(?:\.parent)*(?:\.(?:record|sourceRecord))(?:\.|$)/;
551
+ const shouldResolveSourceRecordOnServer = (path: string): boolean => {
552
+ if (path !== 'sourceRecord' && !path.startsWith('sourceRecord.')) return false;
553
+
554
+ const parentItem = view?.inputArgs?.parentItem;
555
+ if (typeof parentItem?.value === 'undefined') return true;
556
+
557
+ const sourcePath = path === 'sourceRecord' ? '' : path.slice('sourceRecord.'.length);
558
+ if (!sourcePath) return false;
559
+
560
+ const parentItemResolver = view?.inputArgs?.parentItemResolver;
561
+ if (typeof parentItemResolver === 'function') {
562
+ return parentItemResolver(`value.${sourcePath}`);
563
+ }
564
+
565
+ const segments = sourcePath.split('.').filter(Boolean);
566
+ let current = parentItem.value;
567
+ for (const segment of segments) {
568
+ if (current === null || typeof current !== 'object' || !(segment in current)) {
569
+ return true;
570
+ }
571
+ current = current[segment];
572
+ }
573
+ return false;
574
+ };
545
575
  // 始终注册 popup 变量:
546
576
  // - 若当前视图无可推断记录,仅在元信息中不呈现 record 字段;
547
577
  // - 但仍可依据 navigation 推断并展示上级弹窗信息。
@@ -550,6 +580,9 @@ export function registerPopupVariable(ctx: FlowContext, view: FlowView) {
550
580
  meta: createPopupMeta(ctx, view),
551
581
  resolveOnServer: (p: string) => {
552
582
  try {
583
+ if (p === 'sourceRecord' || p.startsWith('sourceRecord.')) {
584
+ return shouldResolveSourceRecordOnServer(p);
585
+ }
553
586
  return !!p && POPUP_SERVER_PATH_RE.test(p);
554
587
  } catch (_) {
555
588
  return false;