@nocobase/flow-engine 2.3.0-alpha.1 → 2.3.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/lib/acl/Acl.d.ts +2 -1
  2. package/lib/acl/Acl.js +28 -0
  3. package/lib/components/FlowContextSelector.js +7 -1
  4. package/lib/components/MobilePopup.js +14 -3
  5. package/lib/components/subModel/LazyDropdown.js +62 -33
  6. package/lib/flowContext.d.ts +12 -1
  7. package/lib/flowContext.js +47 -6
  8. package/lib/locale/en-US.json +2 -0
  9. package/lib/locale/index.d.ts +4 -0
  10. package/lib/locale/zh-CN.json +2 -0
  11. package/lib/resources/flowResource.js +1 -0
  12. package/lib/utils/associationObjectVariable.d.ts +10 -0
  13. package/lib/utils/associationObjectVariable.js +10 -7
  14. package/lib/utils/dateVariable.d.ts +22 -0
  15. package/lib/utils/dateVariable.js +123 -16
  16. package/lib/utils/dirtyAwareApiClient.d.ts +1 -0
  17. package/lib/utils/dirtyAwareApiClient.js +15 -2
  18. package/lib/utils/index.d.ts +3 -3
  19. package/lib/utils/index.js +8 -0
  20. package/lib/utils/params-resolvers.d.ts +3 -0
  21. package/lib/utils/params-resolvers.js +10 -0
  22. package/lib/utils/variablesParams.js +5 -0
  23. package/lib/views/createViewMeta.d.ts +1 -0
  24. package/lib/views/createViewMeta.js +53 -22
  25. package/package.json +4 -4
  26. package/src/__tests__/createViewMeta.popup.test.ts +84 -1
  27. package/src/__tests__/flowContext.test.ts +8 -0
  28. package/src/__tests__/objectVariable.test.ts +6 -1
  29. package/src/__tests__/runjsFormSubmit.test.ts +138 -0
  30. package/src/acl/Acl.tsx +36 -1
  31. package/src/acl/__tests__/Acl.test.tsx +70 -0
  32. package/src/components/FlowContextSelector.tsx +7 -1
  33. package/src/components/MobilePopup.tsx +16 -4
  34. package/src/components/__tests__/MobilePopup.test.tsx +42 -1
  35. package/src/components/subModel/LazyDropdown.tsx +71 -38
  36. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
  37. package/src/components/subModel/__tests__/LazyDropdown.test.tsx +202 -0
  38. package/src/components/variables/__tests__/FlowContextSelector.test.tsx +35 -0
  39. package/src/flowContext.ts +79 -6
  40. package/src/locale/__tests__/index.test.ts +21 -0
  41. package/src/locale/en-US.json +2 -0
  42. package/src/locale/zh-CN.json +2 -0
  43. package/src/resources/__tests__/flowResource.test.ts +3 -0
  44. package/src/resources/flowResource.ts +1 -0
  45. package/src/utils/__tests__/dateVariable.test.ts +57 -4
  46. package/src/utils/__tests__/variablesParams.test.ts +28 -1
  47. package/src/utils/associationObjectVariable.ts +9 -6
  48. package/src/utils/dateVariable.ts +145 -18
  49. package/src/utils/dirtyAwareApiClient.ts +25 -2
  50. package/src/utils/index.ts +17 -2
  51. package/src/utils/params-resolvers.ts +12 -0
  52. package/src/utils/variablesParams.ts +10 -0
  53. package/src/views/createViewMeta.ts +52 -18
@@ -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
- import { getDirtyAwareApiClient } from './utils/dirtyAwareApiClient';
57
- import { inferRecordRef } from './utils/variablesParams';
56
+ import { getDirtyAwareApiClient, PREPARE_CONTEXT_RESOURCE_ACTION_PARAMS } from './utils/dirtyAwareApiClient';
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';
@@ -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 || {});
@@ -3316,6 +3326,15 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3316
3326
  const inputFromMeta = await collectFromMeta();
3317
3327
  const autoInput = { ...inputFromMeta } as Record<string, any>;
3318
3328
 
3329
+ const viewPaths = serverVarPaths.view || [];
3330
+ if (
3331
+ !autoInput.view &&
3332
+ viewPaths.some((path) => path === 'record' || path.startsWith('record.') || path.startsWith('record['))
3333
+ ) {
3334
+ const recordRef = inferViewRecordRef(this);
3335
+ if (recordRef) autoInput.view = { record: recordRef };
3336
+ }
3337
+
3319
3338
  // Special-case: formValues
3320
3339
  // If server needs to resolve some formValues paths but meta params only cover association anchors
3321
3340
  // (e.g. formValues.customer) and some top-level paths are missing (e.g. formValues.status),
@@ -3387,7 +3406,13 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3387
3406
 
3388
3407
  if (this.api) {
3389
3408
  try {
3409
+ const contractRd = buildFlowModelResolveDescriptor(
3410
+ this as FlowRuntimeContext<FlowModel>,
3411
+ options?.contractModelUid,
3412
+ );
3390
3413
  serverResolved = await enqueueVariablesResolve(this as FlowRuntimeContext<FlowModel>, {
3414
+ ...(contractRd ? { contractRd } : {}),
3415
+ rd: buildFlowModelResolveDescriptor(this as FlowRuntimeContext<FlowModel>, this.model?.uid),
3391
3416
  template,
3392
3417
  contextParams: autoContextParams || {},
3393
3418
  });
@@ -3399,7 +3424,8 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3399
3424
  }
3400
3425
 
3401
3426
  return resolveExpressions(serverResolved, this);
3402
- });
3427
+ };
3428
+ this.defineMethod('resolveJsonTemplate', resolveJsonTemplate);
3403
3429
 
3404
3430
  // Helper: resolve a single ctx expression value via resolveJsonTemplate behavior.
3405
3431
  // Example: await ctx.getVar('ctx.record.id')
@@ -4582,9 +4608,56 @@ function __mergeRunJSDocMeta(base: any, patch: any): RunJSDocMeta {
4582
4608
  return out as RunJSDocMeta;
4583
4609
  }
4584
4610
  export class FlowRunJSContext extends FlowContext {
4611
+ [PREPARE_CONTEXT_RESOURCE_ACTION_PARAMS](
4612
+ action: { actionName: string; dataSourceKey?: string; resourceName: string; resourceOf?: unknown },
4613
+ params: Record<string, unknown> | undefined,
4614
+ ) {
4615
+ if (
4616
+ action.actionName.toLowerCase() !== 'create' ||
4617
+ !params ||
4618
+ Array.isArray(params) ||
4619
+ Object.prototype.hasOwnProperty.call(params, 'updateAssociationValues') ||
4620
+ !this.form ||
4621
+ typeof this.blockModel?.submitFromRunJs !== 'function'
4622
+ ) {
4623
+ return params;
4624
+ }
4625
+
4626
+ const resource = this.resource;
4627
+ const currentResourceName = resource?.getResourceName?.();
4628
+ const currentDataSourceKey = resource?.getDataSourceKey?.() || 'main';
4629
+ if (action.resourceName !== currentResourceName || (action.dataSourceKey || 'main') !== currentDataSourceKey) {
4630
+ return params;
4631
+ }
4632
+
4633
+ const currentSourceId = resource?.getSourceId?.();
4634
+ if (
4635
+ currentResourceName?.includes('.') &&
4636
+ currentSourceId !== null &&
4637
+ typeof currentSourceId !== 'undefined' &&
4638
+ String(action.resourceOf ?? '') !== String(currentSourceId)
4639
+ ) {
4640
+ return params;
4641
+ }
4642
+
4643
+ const updateAssociationValues = resource?.getUpdateAssociationValues?.();
4644
+ if (!Array.isArray(updateAssociationValues) || updateAssociationValues.length === 0) {
4645
+ return params;
4646
+ }
4647
+
4648
+ return {
4649
+ ...params,
4650
+ updateAssociationValues: [...updateAssociationValues],
4651
+ };
4652
+ }
4653
+
4585
4654
  constructor(delegate: FlowContext) {
4586
4655
  super();
4587
4656
  this.addDelegate(delegate);
4657
+ const submit = delegate.blockModel?.submitFromRunJs?.bind(delegate.blockModel);
4658
+ if (delegate.form && submit) {
4659
+ this.defineProperty('form', { value: { ...delegate.form, submit } });
4660
+ }
4588
4661
  this.defineProperty('React', { value: React });
4589
4662
  this.defineProperty('antd', { value: antd });
4590
4663
  this.defineProperty('dayjs', {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+
12
+ import { getFlowEngineTranslation } from '../index';
13
+
14
+ describe('flow engine locale', () => {
15
+ it('translates the default secondary confirmation text into Chinese', () => {
16
+ expect(getFlowEngineTranslation('Please Confirm', 'zh-CN')).toBe('请确认');
17
+ expect(getFlowEngineTranslation('Are you sure you want to perform the action?', 'zh-CN')).toBe(
18
+ '确定要执行此操作吗?',
19
+ );
20
+ });
21
+ });
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "Add": "Add",
3
+ "Are you sure you want to perform the action?": "Are you sure you want to perform the action?",
3
4
  "Are you sure you want to delete this item? This action cannot be undone.": "Are you sure you want to delete this item? This action cannot be undone.",
4
5
  "Are you sure to convert this template block to copy mode?": "Are you sure you want to convert this template block to copy mode?",
5
6
  "Array index out of bounds": "Array index {{index}} out of bounds for '{{subKey}}'",
@@ -53,6 +54,7 @@
53
54
  "Other blocks": "Other blocks",
54
55
  "Parent not found, cannot replace block": "Parent not found, cannot replace block",
55
56
  "Previous step": "Previous step",
57
+ "Please Confirm": "Please Confirm",
56
58
  "Replace current block with template?": "Replace current block with template?",
57
59
  "Replaced with template block": "Replaced with template block",
58
60
  "Render failed": "Render failed",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "Add": "添加",
3
+ "Are you sure you want to perform the action?": "确定要执行此操作吗?",
3
4
  "Are you sure you want to delete this item? This action cannot be undone.": "确定要删除此项吗?此操作不可撤销。",
4
5
  "Are you sure to convert this template block to copy mode?": "确定将该模板区块转换为复制模式吗?",
5
6
  "Array index out of bounds": "数组索引 {{index}} 超出 '{{subKey}}' 的边界",
@@ -59,6 +60,7 @@
59
60
  "OK": "确定",
60
61
  "Other blocks": "其他区块",
61
62
  "Previous step": "上一步",
63
+ "Please Confirm": "请确认",
62
64
  "Render failed": "渲染失败",
63
65
  "Response record": "响应结果记录",
64
66
  "Step configuration": "步骤配置",
@@ -79,6 +79,9 @@ describe('FlowResource - error handling', () => {
79
79
  expect(r.getError()).toBeNull();
80
80
 
81
81
  const err = new ResourceError({ response: { data: { error: { message: 'boom', code: 'X' } } } });
82
+ expect(err.data).toEqual({ message: 'boom', code: 'X' });
83
+ expect(err.message).toBe('boom');
84
+ expect(err.code).toBe('X');
82
85
  const ret = r.setError(err);
83
86
  expect(ret).toBe(r);
84
87
  expect(r.error).toBe(err);
@@ -47,6 +47,7 @@ export class ResourceError extends Error {
47
47
  constructor(error) {
48
48
  const data = toErrMessages(error).shift();
49
49
  super(data.message);
50
+ this.data = data;
50
51
  this.name = 'ResponseError';
51
52
  }
52
53
 
@@ -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,
@@ -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) {