@nocobase/client-v2 2.1.0-beta.38 → 2.1.0-beta.41

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 (137) hide show
  1. package/es/Application.d.ts +6 -0
  2. package/es/collection-field-interface/CollectionFieldInterface.d.ts +51 -15
  3. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +82 -3
  4. package/es/collection-manager/field-configure.d.ts +80 -0
  5. package/es/collection-manager/field-validation.d.ts +43 -0
  6. package/es/collection-manager/filter-operators/index.d.ts +46 -0
  7. package/es/collection-manager/filter-operators/operators.d.ts +30 -0
  8. package/es/collection-manager/interfaces/checkbox.d.ts +1 -41
  9. package/es/collection-manager/interfaces/checkboxGroup.d.ts +12 -44
  10. package/es/collection-manager/interfaces/collection.d.ts +12 -51
  11. package/es/collection-manager/interfaces/color.d.ts +1 -16
  12. package/es/collection-manager/interfaces/createdAt.d.ts +1 -44
  13. package/es/collection-manager/interfaces/createdBy.d.ts +0 -4
  14. package/es/collection-manager/interfaces/dateOnly.d.ts +7 -44
  15. package/es/collection-manager/interfaces/datetime.d.ts +1 -44
  16. package/es/collection-manager/interfaces/datetimeNoTz.d.ts +1 -44
  17. package/es/collection-manager/interfaces/email.d.ts +1 -29
  18. package/es/collection-manager/interfaces/id.d.ts +1 -16
  19. package/es/collection-manager/interfaces/index.d.ts +2 -3
  20. package/es/collection-manager/interfaces/input.d.ts +1 -102
  21. package/es/collection-manager/interfaces/integer.d.ts +1 -95
  22. package/es/collection-manager/interfaces/json.d.ts +16 -7
  23. package/es/collection-manager/interfaces/m2m.d.ts +11 -19
  24. package/es/collection-manager/interfaces/m2o.d.ts +11 -19
  25. package/es/collection-manager/interfaces/markdown.d.ts +1 -63
  26. package/es/collection-manager/interfaces/multipleSelect.d.ts +12 -44
  27. package/es/collection-manager/interfaces/nanoid.d.ts +1 -34
  28. package/es/collection-manager/interfaces/number.d.ts +1 -87
  29. package/es/collection-manager/interfaces/o2m.d.ts +12 -24
  30. package/es/collection-manager/interfaces/obo.d.ts +207 -0
  31. package/es/collection-manager/interfaces/oho.d.ts +207 -0
  32. package/es/collection-manager/interfaces/password.d.ts +1 -56
  33. package/es/collection-manager/interfaces/percent.d.ts +1 -84
  34. package/es/collection-manager/interfaces/phone.d.ts +1 -25
  35. package/es/collection-manager/interfaces/properties/index.d.ts +0 -28
  36. package/es/collection-manager/interfaces/radioGroup.d.ts +1 -29
  37. package/es/collection-manager/interfaces/richText.d.ts +1 -63
  38. package/es/collection-manager/interfaces/select.d.ts +12 -44
  39. package/es/collection-manager/interfaces/snowflake-id.d.ts +1 -34
  40. package/es/collection-manager/interfaces/tableoid.d.ts +1 -10
  41. package/es/collection-manager/interfaces/textarea.d.ts +1 -51
  42. package/es/collection-manager/interfaces/time.d.ts +1 -16
  43. package/es/collection-manager/interfaces/types.d.ts +3 -12
  44. package/es/collection-manager/interfaces/unixTimestamp.d.ts +1 -44
  45. package/es/collection-manager/interfaces/updatedAt.d.ts +1 -44
  46. package/es/collection-manager/interfaces/updatedBy.d.ts +0 -4
  47. package/es/collection-manager/interfaces/url.d.ts +1 -20
  48. package/es/collection-manager/interfaces/uuid.d.ts +1 -34
  49. package/es/collection-manager/template-fields.d.ts +53 -0
  50. package/es/components/form/VariableInput.d.ts +53 -2
  51. package/es/components/form/filter/CollectionFilter.d.ts +9 -1
  52. package/es/components/form/filter/CollectionFilterItem.d.ts +10 -2
  53. package/es/flow/components/filter/useFilterOptions.d.ts +13 -1
  54. package/es/flow/models/blocks/assign-form/assignFieldValuesFlow.d.ts +84 -0
  55. package/es/flow/models/blocks/assign-form/index.d.ts +1 -0
  56. package/es/flow/models/blocks/form/FormActionModel.d.ts +9 -2
  57. package/es/index.d.ts +4 -0
  58. package/es/index.mjs +105 -142
  59. package/lib/index.js +106 -143
  60. package/package.json +7 -7
  61. package/src/Application.tsx +24 -0
  62. package/src/collection-field-interface/CollectionFieldInterface.ts +71 -77
  63. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +201 -4
  64. package/src/collection-manager/field-configure.ts +548 -0
  65. package/src/collection-manager/field-validation.ts +195 -0
  66. package/src/collection-manager/filter-operators/index.ts +176 -0
  67. package/src/collection-manager/{interfaces/properties → filter-operators}/operators.ts +24 -13
  68. package/src/collection-manager/interfaces/checkbox.ts +2 -9
  69. package/src/collection-manager/interfaces/checkboxGroup.ts +2 -10
  70. package/src/collection-manager/interfaces/collection.ts +2 -15
  71. package/src/collection-manager/interfaces/color.ts +2 -2
  72. package/src/collection-manager/interfaces/createdAt.ts +2 -2
  73. package/src/collection-manager/interfaces/createdBy.ts +1 -12
  74. package/src/collection-manager/interfaces/dateOnly.ts +8 -2
  75. package/src/collection-manager/interfaces/datetime.ts +2 -2
  76. package/src/collection-manager/interfaces/datetimeNoTz.ts +2 -2
  77. package/src/collection-manager/interfaces/email.ts +2 -9
  78. package/src/collection-manager/interfaces/id.ts +1 -2
  79. package/src/collection-manager/interfaces/index.ts +2 -3
  80. package/src/collection-manager/interfaces/input.ts +2 -133
  81. package/src/collection-manager/interfaces/integer.ts +2 -71
  82. package/src/collection-manager/interfaces/json.tsx +17 -11
  83. package/src/collection-manager/interfaces/m2m.tsx +0 -21
  84. package/src/collection-manager/interfaces/m2o.tsx +0 -22
  85. package/src/collection-manager/interfaces/markdown.ts +2 -51
  86. package/src/collection-manager/interfaces/multipleSelect.ts +2 -14
  87. package/src/collection-manager/interfaces/nanoid.ts +2 -2
  88. package/src/collection-manager/interfaces/number.ts +2 -85
  89. package/src/collection-manager/interfaces/o2m.tsx +1 -22
  90. package/src/collection-manager/interfaces/obo.tsx +145 -0
  91. package/src/collection-manager/interfaces/oho.tsx +145 -0
  92. package/src/collection-manager/interfaces/password.ts +2 -44
  93. package/src/collection-manager/interfaces/percent.ts +2 -74
  94. package/src/collection-manager/interfaces/phone.ts +2 -2
  95. package/src/collection-manager/interfaces/properties/index.ts +0 -133
  96. package/src/collection-manager/interfaces/radioGroup.ts +2 -2
  97. package/src/collection-manager/interfaces/richText.ts +2 -51
  98. package/src/collection-manager/interfaces/select.ts +2 -14
  99. package/src/collection-manager/interfaces/snowflake-id.ts +2 -2
  100. package/src/collection-manager/interfaces/tableoid.ts +1 -2
  101. package/src/collection-manager/interfaces/textarea.ts +2 -51
  102. package/src/collection-manager/interfaces/time.ts +2 -2
  103. package/src/collection-manager/interfaces/types.ts +4 -12
  104. package/src/collection-manager/interfaces/unixTimestamp.tsx +2 -2
  105. package/src/collection-manager/interfaces/updatedAt.ts +2 -2
  106. package/src/collection-manager/interfaces/updatedBy.ts +1 -12
  107. package/src/collection-manager/interfaces/url.ts +2 -4
  108. package/src/collection-manager/interfaces/uuid.ts +2 -2
  109. package/src/collection-manager/template-fields.ts +109 -0
  110. package/src/components/README.md +1 -0
  111. package/src/components/README.zh-CN.md +1 -0
  112. package/src/components/form/VariableInput.tsx +101 -28
  113. package/src/components/form/__tests__/VariableInput.test.ts +85 -0
  114. package/src/components/form/filter/CollectionFilter.tsx +11 -1
  115. package/src/components/form/filter/CollectionFilterItem.tsx +12 -4
  116. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +42 -0
  117. package/src/components/form/filter/useFilterActionProps.ts +7 -4
  118. package/src/flow/components/filter/useFilterOptions.ts +27 -5
  119. package/src/flow/models/actions/UpdateRecordActionModel.tsx +14 -95
  120. package/src/flow/models/actions/UpdateRecordActionUtils.ts +4 -7
  121. package/src/flow/models/actions/__tests__/AssignFormRefill.test.ts +26 -1
  122. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +63 -2
  123. package/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx +206 -0
  124. package/src/flow/models/blocks/assign-form/index.ts +1 -0
  125. package/src/flow/models/blocks/form/FormActionModel.tsx +30 -3
  126. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +71 -0
  127. package/src/flow/models/blocks/form/submitHandler.ts +8 -1
  128. package/src/flow/models/fields/CollectionSelectorFieldModel.tsx +8 -2
  129. package/src/flow/models/fields/DisplayEnumFieldModel.tsx +8 -2
  130. package/src/index.ts +4 -0
  131. package/es/collection-manager/interfaces/linkTo.d.ts +0 -90
  132. package/es/collection-manager/interfaces/o2o.d.ts +0 -621
  133. package/es/collection-manager/interfaces/properties/operators.d.ts +0 -294
  134. package/es/collection-manager/interfaces/subTable.d.ts +0 -172
  135. package/src/collection-manager/interfaces/linkTo.ts +0 -120
  136. package/src/collection-manager/interfaces/o2o.tsx +0 -561
  137. package/src/collection-manager/interfaces/subTable.ts +0 -218
@@ -164,6 +164,7 @@ Key props:
164
164
  - `namespaces`: restrict the picker to specific top-level namespaces. Omit to expose every registered top-level property
165
165
  - `extraNodes`: static leaves appended after the namespace-filtered nodes. Use for variables that only make sense in the current page (e.g. `$resetLink`)
166
166
  - `converters`: override the default path ↔ string converters. `EnvVariableInput` uses this hook to lock its output to `$env`
167
+ - `delimiters`: token pair wrapping the stored variable reference. Defaults to `['{{', '}}']` (Handlebars HTML-escaped). Pass `['{{{', '}}}']` for fields rendered as HTML where escaping would corrupt the variable value — e.g. the in-app message body
167
168
  - `value` / `onChange` / `placeholder` / `disabled`: standard controlled-input props
168
169
 
169
170
  Under the hood `VariableInput` wraps `VariableHybridInput` (inline pills), `VariableTextArea` wraps `TextAreaWithContextSelector` (textarea + variable button). Both share the same MetaTree.
@@ -164,6 +164,7 @@ import { VariableInput, VariableTextArea } from '@nocobase/client-v2';
164
164
  - `namespaces`:限定可选的顶层命名空间。不传就用 `flowEngine.context` 里全部已注册的
165
165
  - `extraNodes`:在命名空间过滤后追加几条静态变量(用于 `$resetLink` 这类只在当前页面有意义的局部变量)
166
166
  - `converters`:覆盖默认的 path ↔ string 转换器。`EnvVariableInput` 就是用这个钩子把输出锁定到 `$env`
167
+ - `delimiters`:变量在存储字符串里使用的开闭分隔符,默认 `['{{', '}}']`(对应 Handlebars 的 HTML 转义形式)。若字段最终以 HTML 渲染、转义会破坏变量内容(如站内信正文),传 `['{{{', '}}}']` 走 Handlebars 的原样输出形式
167
168
  - `value` / `onChange` / `placeholder` / `disabled`:标准受控字段属性
168
169
 
169
170
  底层共用 `VariableHybridInput`(`VariableInput`)和 `TextAreaWithContextSelector`(`VariableTextArea`),用同一套 MetaTree 数据。
@@ -25,28 +25,75 @@ import { TextAreaWithContextSelector } from '../../flow/components/TextAreaWithC
25
25
  * stored values round-trip to a labelled pill instead of falling back to a
26
26
  * raw `{{…}}` literal.
27
27
  */
28
- const VARIABLE_EXPR_RE = /^\{\{\s*(.+?)\s*\}\}$/;
29
-
30
- export function parseVariablePath(value?: string): string[] | undefined {
31
- if (typeof value !== 'string') return undefined;
32
- const match = value.trim().match(VARIABLE_EXPR_RE);
33
- if (!match) return undefined;
34
- let pathString = match[1];
35
- // Backwards-compat: accept the legacy `ctx.` prefix so values produced by
36
- // pre-fix versions of the picker still resolve to a labelled pill.
37
- if (pathString === 'ctx') return [];
38
- if (pathString.startsWith('ctx.')) pathString = pathString.slice(4);
39
- return pathString.split('.');
28
+
29
+ /**
30
+ * Variable delimiters: opening + closing tokens. Default `['{{', '}}']`
31
+ * matches NocoBase server template convention (Handlebars HTML-escaped
32
+ * output). Pass `['{{{', '}}}']` to switch to Handlebars' raw/unescaped
33
+ * form — required for fields whose content is rendered as HTML (e.g.
34
+ * in-app message body) so the variable expansion bypasses HTML escaping.
35
+ *
36
+ * Restrict to literal-token pairs, since the regex builder escapes them
37
+ * verbatim.
38
+ */
39
+ export type VariableDelimiters = readonly [string, string];
40
+
41
+ const DEFAULT_DELIMITERS: VariableDelimiters = ['{{', '}}'];
42
+
43
+ function escapeForRegExp(s: string): string {
44
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
45
  }
41
46
 
42
- export function formatVariablePath(meta?: MetaTreeNode): string | undefined {
43
- const paths = meta?.paths || [];
44
- if (paths.length === 0) return undefined;
45
- // No inner spaces — matches the v1 storage shape exactly so round-trips
46
- // through the API stay byte-stable.
47
- return `{{${paths.join('.')}}}`;
47
+ /**
48
+ * Factory: returns a `parseValueToPath` bound to the given delimiters.
49
+ * Anchored (`^…$`) only treats the whole input as a single variable
50
+ * reference, matching the v1 single-line picker behaviour.
51
+ */
52
+ export function makeParseVariablePath(delimiters: VariableDelimiters = DEFAULT_DELIMITERS) {
53
+ const [open, close] = delimiters;
54
+ const re = new RegExp(`^${escapeForRegExp(open)}\\s*(.+?)\\s*${escapeForRegExp(close)}$`);
55
+ return (value?: string): string[] | undefined => {
56
+ if (typeof value !== 'string') return undefined;
57
+ const match = value.trim().match(re);
58
+ if (!match) return undefined;
59
+ let pathString = match[1];
60
+ // Backwards-compat: accept the legacy `ctx.` prefix so values produced by
61
+ // pre-fix versions of the picker still resolve to a labelled pill.
62
+ if (pathString === 'ctx') return [];
63
+ if (pathString.startsWith('ctx.')) pathString = pathString.slice(4);
64
+ return pathString.split('.');
65
+ };
48
66
  }
49
67
 
68
+ /**
69
+ * Factory: returns a `formatPathToValue` bound to the given delimiters.
70
+ * No inner spaces — matches the v1 storage shape exactly so round-trips
71
+ * through the API stay byte-stable.
72
+ */
73
+ export function makeFormatVariablePath(delimiters: VariableDelimiters = DEFAULT_DELIMITERS) {
74
+ const [open, close] = delimiters;
75
+ return (meta?: MetaTreeNode): string | undefined => {
76
+ const paths = meta?.paths || [];
77
+ if (paths.length === 0) return undefined;
78
+ return `${open}${paths.join('.')}${close}`;
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Factory: returns a global regex matching every occurrence of the
84
+ * variable token within a longer string. Used by `VariableHybridInput`
85
+ * to render embedded variables as pills.
86
+ */
87
+ export function makeVariableRegExp(delimiters: VariableDelimiters = DEFAULT_DELIMITERS): RegExp {
88
+ const [open, close] = delimiters;
89
+ return new RegExp(`${escapeForRegExp(open)}\\s*([^{}]+?)\\s*${escapeForRegExp(close)}`, 'g');
90
+ }
91
+
92
+ // Default exports — `{{ ... }}` for the most common case. Kept named so
93
+ // existing callers don't need to switch to the factory.
94
+ export const parseVariablePath = makeParseVariablePath();
95
+ export const formatVariablePath = makeFormatVariablePath();
96
+
50
97
  const META_TREE_CACHE_PREFIX = '@nocobase/client-v2:VariableInput:metaTree';
51
98
 
52
99
  /**
@@ -123,8 +170,22 @@ export interface VariableInputProps {
123
170
  * Override the converters used by the underlying `VariableHybridInput`.
124
171
  * Mostly useful when the caller wants to constrain `formatPathToValue` to a
125
172
  * specific namespace (see `EnvVariableInput` for that pattern).
173
+ *
174
+ * Takes precedence over `delimiters` when both are set on the same field
175
+ * (an explicit converter wins over the delimiter-derived one).
126
176
  */
127
177
  converters?: VariableHybridInputConverters;
178
+ /**
179
+ * Token pair wrapping variable references in the stored string. Defaults
180
+ * to `['{{', '}}']` — the standard NocoBase server-template form,
181
+ * HTML-escaped by Handlebars. Pass `['{{{', '}}}']` for fields rendered
182
+ * as HTML where escaping would corrupt the variable value (e.g. the
183
+ * in-app message body).
184
+ *
185
+ * Ignored when `converters` is also supplied — caller-provided converters
186
+ * win.
187
+ */
188
+ delimiters?: VariableDelimiters;
128
189
  className?: string;
129
190
  style?: React.CSSProperties;
130
191
  }
@@ -136,16 +197,24 @@ export interface VariableInputProps {
136
197
  * line of mixed literal+variable content is appropriate.
137
198
  */
138
199
  export function VariableInput(props: VariableInputProps) {
139
- const { namespaces, extraNodes, converters, ...rest } = props;
200
+ const { namespaces, extraNodes, converters, delimiters, ...rest } = props;
140
201
  const metaTree = useFilteredMetaTree({ namespaces, extraNodes });
141
- const mergedConverters = useMemo<VariableHybridInputConverters>(
142
- () => ({
143
- formatPathToValue: formatVariablePath,
144
- parseValueToPath: parseVariablePath,
202
+ const mergedConverters = useMemo<VariableHybridInputConverters>(() => {
203
+ // Default delimiters → reuse the pre-built singletons.
204
+ if (!delimiters) {
205
+ return {
206
+ formatPathToValue: formatVariablePath,
207
+ parseValueToPath: parseVariablePath,
208
+ ...converters,
209
+ };
210
+ }
211
+ return {
212
+ formatPathToValue: makeFormatVariablePath(delimiters),
213
+ parseValueToPath: makeParseVariablePath(delimiters),
214
+ variableRegExp: makeVariableRegExp(delimiters),
145
215
  ...converters,
146
- }),
147
- [converters],
148
- );
216
+ };
217
+ }, [converters, delimiters]);
149
218
  return <VariableHybridInput {...rest} converters={mergedConverters} metaTree={metaTree} />;
150
219
  }
151
220
 
@@ -161,9 +230,13 @@ export interface VariableTextAreaProps extends Omit<VariableInputProps, 'convert
161
230
  * is desirable (the server expands them at render time).
162
231
  */
163
232
  export function VariableTextArea(props: VariableTextAreaProps) {
164
- const { namespaces, extraNodes, rows, maxRows, style, ...rest } = props;
233
+ const { namespaces, extraNodes, rows, maxRows, style, delimiters, ...rest } = props;
165
234
  const metaTree = useFilteredMetaTree({ namespaces, extraNodes });
166
235
  const metaTreeGetter = useMemo(() => () => metaTree, [metaTree]);
236
+ const formatPathToValue = useMemo(
237
+ () => (delimiters ? makeFormatVariablePath(delimiters) : formatVariablePath),
238
+ [delimiters],
239
+ );
167
240
  return (
168
241
  <TextAreaWithContextSelector
169
242
  {...rest}
@@ -171,7 +244,7 @@ export function VariableTextArea(props: VariableTextAreaProps) {
171
244
  maxRows={maxRows}
172
245
  style={style}
173
246
  metaTree={metaTreeGetter}
174
- formatPathToValue={(meta) => formatVariablePath(meta) ?? ''}
247
+ formatPathToValue={(meta) => formatPathToValue(meta) ?? ''}
175
248
  />
176
249
  );
177
250
  }
@@ -0,0 +1,85 @@
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
+ import {
12
+ formatVariablePath,
13
+ makeFormatVariablePath,
14
+ makeParseVariablePath,
15
+ makeVariableRegExp,
16
+ parseVariablePath,
17
+ } from '../VariableInput';
18
+
19
+ describe('VariableInput delimiter helpers', () => {
20
+ describe('default delimiters {{ }}', () => {
21
+ it('formats meta nodes into `{{x.y}}` with no inner spaces', () => {
22
+ expect(formatVariablePath({ paths: ['$user', 'id'] } as any)).toBe('{{$user.id}}');
23
+ });
24
+
25
+ it('parses `{{x.y}}` into a path array', () => {
26
+ expect(parseVariablePath('{{ $user.id }}')).toEqual(['$user', 'id']);
27
+ expect(parseVariablePath('{{$user.id}}')).toEqual(['$user', 'id']);
28
+ });
29
+
30
+ it('strips a legacy `ctx.` prefix for backwards-compat', () => {
31
+ expect(parseVariablePath('{{ctx.$user.id}}')).toEqual(['$user', 'id']);
32
+ expect(parseVariablePath('{{ctx}}')).toEqual([]);
33
+ });
34
+
35
+ it('returns undefined for plain text', () => {
36
+ expect(parseVariablePath('hello world')).toBeUndefined();
37
+ expect(parseVariablePath('')).toBeUndefined();
38
+ expect(parseVariablePath(undefined)).toBeUndefined();
39
+ });
40
+ });
41
+
42
+ describe('triple-brace delimiters {{{ }}}', () => {
43
+ const format = makeFormatVariablePath(['{{{', '}}}']);
44
+ const parse = makeParseVariablePath(['{{{', '}}}']);
45
+
46
+ it('formats meta nodes into `{{{x.y}}}`', () => {
47
+ expect(format({ paths: ['$user', 'id'] } as any)).toBe('{{{$user.id}}}');
48
+ });
49
+
50
+ it('parses `{{{x.y}}}` into a path array', () => {
51
+ expect(parse('{{{$user.id}}}')).toEqual(['$user', 'id']);
52
+ expect(parse('{{{ $user.id }}}')).toEqual(['$user', 'id']);
53
+ });
54
+
55
+ it('does not match the double-brace form', () => {
56
+ expect(parse('{{$user.id}}')).toBeUndefined();
57
+ });
58
+ });
59
+
60
+ describe('makeVariableRegExp', () => {
61
+ it('matches every occurrence in a longer string under default delimiters', () => {
62
+ const re = makeVariableRegExp();
63
+ const matches = Array.from('hello {{ $user.id }} world {{ $env.X }}'.matchAll(re)).map((m) => m[1]);
64
+ expect(matches).toEqual(['$user.id', '$env.X']);
65
+ });
66
+
67
+ it('matches triple-brace occurrences when configured', () => {
68
+ const re = makeVariableRegExp(['{{{', '}}}']);
69
+ const matches = Array.from('hi {{{$user.id}}} :)'.matchAll(re)).map((m) => m[1]);
70
+ expect(matches).toEqual(['$user.id']);
71
+ });
72
+
73
+ it('does not match double-brace occurrences under triple-brace config', () => {
74
+ const re = makeVariableRegExp(['{{{', '}}}']);
75
+ const matches = Array.from('plain {{ x }} text'.matchAll(re));
76
+ expect(matches).toHaveLength(0);
77
+ });
78
+ });
79
+
80
+ it('handles empty paths consistently', () => {
81
+ expect(formatVariablePath({ paths: [] } as any)).toBeUndefined();
82
+ expect(formatVariablePath(undefined)).toBeUndefined();
83
+ expect(makeFormatVariablePath(['{{{', '}}}'])({ paths: [] } as any)).toBeUndefined();
84
+ });
85
+ });
@@ -25,7 +25,15 @@ export interface CollectionFilterProps {
25
25
  t?: (key: string, options?: Record<string, any>) => string;
26
26
  /** Whitelist of root-level field names to expose. */
27
27
  filterableFieldNames?: string[];
28
- /** Bypass the `filterableFieldNames` whitelist. */
28
+ /**
29
+ * Blacklist of root-level field names to drop. Mirrors v1's `nonfilterable: [...]` on `Filter.Action`. When both `filterableFieldNames` and this prop are supplied, both apply (final = whitelist ∩ ¬blacklist).
30
+ */
31
+ nonfilterableFieldNames?: string[];
32
+ /**
33
+ * Bypass the `filterableFieldNames` whitelist.
34
+ *
35
+ * Legacy escape hatch — prefer adjusting `filterableFieldNames` / `nonfilterableFieldNames` instead.
36
+ */
29
37
  noIgnore?: boolean;
30
38
  /** Override the trigger button's label. Defaults to `t('Filter')`, or the v1-style `t('{{count}} filter items', { count })` when conditions are present. */
31
39
  buttonText?: React.ReactNode;
@@ -50,6 +58,7 @@ export const CollectionFilter: FC<CollectionFilterProps> = (props) => {
50
58
  onChange,
51
59
  t = identity,
52
60
  filterableFieldNames,
61
+ nonfilterableFieldNames,
53
62
  noIgnore,
54
63
  buttonText,
55
64
  showCount = true,
@@ -63,6 +72,7 @@ export const CollectionFilter: FC<CollectionFilterProps> = (props) => {
63
72
  const filterAction = useFilterActionProps({
64
73
  collection,
65
74
  filterableFieldNames,
75
+ nonfilterableFieldNames,
66
76
  noIgnore,
67
77
  t,
68
78
  onApply: (filter: CompiledFilter, action: FilterApplyAction) => {
@@ -44,7 +44,15 @@ export interface CollectionFilterItemProps {
44
44
  collection: Collection;
45
45
  /** Whitelist of field names to expose; empty/undefined means all filterable fields. */
46
46
  filterableFieldNames?: string[];
47
- /** Bypass the `filterableFieldNames` whitelist (matches the legacy FilterItem `noIgnore`). */
47
+ /**
48
+ * Blacklist of field names to drop. Mirrors v1's `nonfilterable: [...]` on `Filter.Action`. When both whitelist and blacklist are supplied, both apply (final = whitelist ∩ ¬blacklist).
49
+ */
50
+ nonfilterableFieldNames?: string[];
51
+ /**
52
+ * Bypass the `filterableFieldNames` whitelist (matches the legacy FilterItem `noIgnore`).
53
+ *
54
+ * Legacy escape hatch — prefer adjusting `filterableFieldNames` / `nonfilterableFieldNames` instead.
55
+ */
48
56
  noIgnore?: boolean;
49
57
  /** Translator; defaults to identity so callers can omit it. */
50
58
  t?: (key: string) => string;
@@ -84,10 +92,10 @@ const toCascaderOptions = (options: FilterOption[]): CascaderOption[] =>
84
92
  */
85
93
  export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
86
94
  (props) => {
87
- const { collection, filterableFieldNames, noIgnore = false, t = identity } = props;
95
+ const { collection, filterableFieldNames, nonfilterableFieldNames, noIgnore = false, t = identity } = props;
88
96
  const { path: leftValue, operator, value: rightValue } = props.value;
89
97
 
90
- const options = useFilterOptions(collection, { filterableFieldNames, noIgnore, t });
98
+ const options = useFilterOptions(collection, { filterableFieldNames, nonfilterableFieldNames, noIgnore, t });
91
99
 
92
100
  const cascaderOptions = useMemo(() => toCascaderOptions(options), [options]);
93
101
 
@@ -164,7 +172,7 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
164
172
  */
165
173
  export function createCollectionFilterItem(
166
174
  collection: Collection,
167
- bound?: Pick<CollectionFilterItemProps, 'filterableFieldNames' | 'noIgnore' | 't'>,
175
+ bound?: Pick<CollectionFilterItemProps, 'filterableFieldNames' | 'nonfilterableFieldNames' | 'noIgnore' | 't'>,
168
176
  ) {
169
177
  const Component: FC<{ value: CollectionFilterItemValue }> = (props) => (
170
178
  <CollectionFilterItem {...bound} value={props.value} collection={collection} />
@@ -136,6 +136,48 @@ describe('CollectionFilterItem', () => {
136
136
  expect(screen.queryByText('Lock reason')).not.toBeInTheDocument();
137
137
  });
138
138
 
139
+ it('honours nonfilterableFieldNames blacklist', async () => {
140
+ const value = observable({ path: '', operator: '', value: '' });
141
+ const collection = buildStubCollection([
142
+ { name: 'username', title: 'Username' },
143
+ { name: 'lockReason', title: 'Lock reason' },
144
+ ]);
145
+
146
+ const { container } = render(
147
+ <CollectionFilterItem value={value} collection={collection} nonfilterableFieldNames={['lockReason']} />,
148
+ );
149
+
150
+ openFieldCascader(container);
151
+ expect(await screen.findByText('Username')).toBeInTheDocument();
152
+ expect(screen.queryByText('Lock reason')).not.toBeInTheDocument();
153
+ });
154
+
155
+ it('applies both whitelist and blacklist (final = whitelist minus blacklist)', async () => {
156
+ const value = observable({ path: '', operator: '', value: '' });
157
+ const collection = buildStubCollection([
158
+ { name: 'username', title: 'Username' },
159
+ { name: 'nickname', title: 'Nickname' },
160
+ { name: 'lockReason', title: 'Lock reason' },
161
+ ]);
162
+
163
+ // Whitelist permits username + nickname; blacklist then subtracts nickname. Result: only username should appear.
164
+ const { container } = render(
165
+ <CollectionFilterItem
166
+ value={value}
167
+ collection={collection}
168
+ filterableFieldNames={['username', 'nickname']}
169
+ nonfilterableFieldNames={['nickname']}
170
+ />,
171
+ );
172
+
173
+ openFieldCascader(container);
174
+ expect(await screen.findByText('Username')).toBeInTheDocument();
175
+ // nickname is whitelisted but also blacklisted — blacklist wins for that field.
176
+ expect(screen.queryByText('Nickname')).not.toBeInTheDocument();
177
+ // lockReason isn't in the whitelist either, so it stays hidden.
178
+ expect(screen.queryByText('Lock reason')).not.toBeInTheDocument();
179
+ });
180
+
139
181
  it('writes through value.value on input change', () => {
140
182
  const value = observable({ path: 'username', operator: '$eq', value: '' });
141
183
  const collection = buildStubCollection([{ name: 'username', title: 'Username' }]);
@@ -152,7 +152,7 @@ export interface UseFilterActionPropsResult {
152
152
  * ```
153
153
  */
154
154
  export function useFilterActionProps(args: UseFilterActionPropsArgs): UseFilterActionPropsResult {
155
- const { collection, onApply, filterableFieldNames, noIgnore, t } = args;
155
+ const { collection, onApply, filterableFieldNames, nonfilterableFieldNames, noIgnore, t } = args;
156
156
 
157
157
  // Held in a ref so the group object identity is stable for the lifetime of the host component — `<FilterContent>` mutates this object directly (push/splice on `items`, swap `logic`), and a fresh observable on every render would reset that internal state.
158
158
  const valueRef = useRef<FilterGroupValue>();
@@ -161,11 +161,14 @@ export function useFilterActionProps(args: UseFilterActionPropsArgs): UseFilterA
161
161
  }
162
162
  const value = valueRef.current;
163
163
 
164
- const options = useFilterOptions(collection, { filterableFieldNames, noIgnore, t });
164
+ const options = useFilterOptions(collection, { filterableFieldNames, nonfilterableFieldNames, noIgnore, t });
165
165
 
166
166
  const FilterItem = useMemo(
167
- () => (collection ? createCollectionFilterItem(collection, { filterableFieldNames, noIgnore, t }) : undefined),
168
- [collection, filterableFieldNames, noIgnore, t],
167
+ () =>
168
+ collection
169
+ ? createCollectionFilterItem(collection, { filterableFieldNames, nonfilterableFieldNames, noIgnore, t })
170
+ : undefined,
171
+ [collection, filterableFieldNames, nonfilterableFieldNames, noIgnore, t],
169
172
  );
170
173
 
171
174
  const onSubmit = useMemoizedFn(() => {
@@ -39,10 +39,22 @@ export type FilterOption = {
39
39
 
40
40
  export interface UseFilterOptionsArgs {
41
41
  /**
42
+ * **Preferred way to restrict fields**, alongside `nonfilterableFieldNames`.
43
+ *
42
44
  * Whitelist of root-level field names to expose. Empty/undefined means "all filterable fields". The whitelist applies only at depth 1 — nested fields under an allowed association field are always included, matching the legacy v1 `Filter.Action` behaviour.
43
45
  */
44
46
  filterableFieldNames?: string[];
45
- /** Bypass the `filterableFieldNames` whitelist (mirrors v1 `noIgnore`). */
47
+ /**
48
+ * **Preferred way to restrict fields**, alongside `filterableFieldNames`.
49
+ *
50
+ * Blacklist of root-level field names to drop. Mirrors v1's `nonfilterable: [...]` schema prop on `Filter.Action`. Applies at depth 1 only, same as the whitelist. When both `filterableFieldNames` and `nonfilterableFieldNames` are provided, both apply: the final field set is `(whitelist or all) minus blacklist`.
51
+ */
52
+ nonfilterableFieldNames?: string[];
53
+ /**
54
+ * Bypass the `filterableFieldNames` whitelist (mirrors v1 `noIgnore`).
55
+ *
56
+ * Legacy escape hatch from v1 schemas — prefer adjusting `filterableFieldNames` / `nonfilterableFieldNames` instead. Kept only for parity with existing v1 schemas that already set `noIgnore`.
57
+ */
46
58
  noIgnore?: boolean;
47
59
  /** Translator used for field/operator labels. Defaults to identity. */
48
60
  t?: (key: string) => string;
@@ -58,14 +70,24 @@ const identity = (s: string) => s;
58
70
  * - the whitelist applies at depth 1 only, so capping the root field list (e.g. to `['lockedTs', 'unlockTs', 'user']`) doesn't accidentally hide the nested `user.username` / `user.nickname` leaves.
59
71
  */
60
72
  export function useFilterOptions(collection: Collection | undefined, args: UseFilterOptionsArgs = {}): FilterOption[] {
61
- const { filterableFieldNames, noIgnore = false, t = identity } = args;
73
+ const { filterableFieldNames, nonfilterableFieldNames, noIgnore = false, t = identity } = args;
62
74
 
63
75
  const fields = useMemo(() => collection?.getFields() || [], [collection]);
64
76
 
65
77
  const ignoreFieldsNames = useMemo(() => {
66
- if (noIgnore || !filterableFieldNames?.length) return [];
67
- return fields.map((f) => f.name).filter((n) => !filterableFieldNames.includes(n));
68
- }, [fields, filterableFieldNames, noIgnore]);
78
+ // Whitelist contribution: every field not in `filterableFieldNames`.
79
+ // Skipped when `noIgnore` is set, or when no whitelist was provided.
80
+ const whitelistIgnored =
81
+ noIgnore || !filterableFieldNames?.length
82
+ ? []
83
+ : fields.map((f) => f.name).filter((n) => !filterableFieldNames.includes(n));
84
+ // Blacklist contribution: explicit names. Always applied, even with
85
+ // `noIgnore` (the blacklist's whole job is to subtract specific fields).
86
+ const blacklistIgnored = nonfilterableFieldNames ?? [];
87
+ if (!blacklistIgnored.length) return whitelistIgnored;
88
+ // Union the two so the final ignore set is `whitelist-derived ∪ blacklist`.
89
+ return Array.from(new Set([...whitelistIgnored, ...blacklistIgnored]));
90
+ }, [fields, filterableFieldNames, nonfilterableFieldNames, noIgnore]);
69
91
 
70
92
  return useMemo(
71
93
  () =>
@@ -7,75 +7,22 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { tExpr, FlowModelRenderer, useFlowEngine, useFlowSettingsContext } from '@nocobase/flow-engine';
10
+ import { tExpr, useFlowSettingsContext } from '@nocobase/flow-engine';
11
11
  import { Alert, ButtonProps } from 'antd';
12
- import React, { useEffect, useRef } from 'react';
12
+ import React from 'react';
13
13
  import { AxiosRequestConfig } from 'axios';
14
14
  import { ActionModel, ActionSceneEnum } from '../base/ActionModel';
15
- import { CollectionActionModel } from '../base/CollectionActionModel';
16
- import { RecordActionModel } from '../base/RecordActionModel';
17
15
  import { AssignFormModel } from '../blocks/assign-form/AssignFormModel';
16
+ import {
17
+ createAssignFieldValuesStep,
18
+ createAssignFormSubModelOptions,
19
+ getAssignFieldValuesDefaultParams,
20
+ } from '../blocks/assign-form/assignFieldValuesFlow';
18
21
  import { applyUpdateRecordAction } from './UpdateRecordActionUtils';
19
22
  // import { RemoteFlowModelRenderer } from '../../FlowPage';
20
23
 
21
24
  const SETTINGS_FLOW_KEY = 'assignSettings';
22
25
 
23
- // 配置态编辑器:渲染 assignForm 子模型
24
- function AssignFieldsEditor() {
25
- const { model, blockModel } = useFlowSettingsContext();
26
- const action: any = model;
27
- const engine = useFlowEngine();
28
- const initializedRef = useRef(false);
29
- const [formModel, setFormModel] = React.useState<AssignFormModel | null>(null);
30
-
31
- // 初始化回填
32
- useEffect(() => {
33
- let cancelled = false;
34
- (async () => {
35
- const loaded = (await engine.loadOrCreateModel(
36
- {
37
- parentId: action.uid,
38
- subKey: 'assignForm',
39
- use: 'AssignFormModel',
40
- },
41
- { skipSave: !model.context.flowSettingsEnabled },
42
- )) as AssignFormModel;
43
- if (cancelled) return;
44
- setFormModel(loaded);
45
- action.assignFormUid = (loaded as any)?.uid || action.assignFormUid;
46
- })();
47
- return () => {
48
- cancelled = true;
49
- };
50
- }, [action, engine]);
51
-
52
- // 初始化回填(在子模型加载完成后)
53
- useEffect(() => {
54
- if (initializedRef.current) return;
55
- if (!formModel) return;
56
- const prev = action.getStepParams?.(SETTINGS_FLOW_KEY, 'assignFieldValues') || {};
57
- // 注入资源上下文:与所在表格区块一致
58
- const coll = blockModel?.collection || action?.context?.collection;
59
- const dsKey = coll?.dataSourceKey;
60
- const collName = coll?.name;
61
- if (dsKey && collName) {
62
- formModel.setStepParams('resourceSettings', 'init', {
63
- dataSourceKey: dsKey,
64
- collectionName: collName,
65
- });
66
- }
67
- formModel.setInitialAssignedValues(prev?.assignedValues || {});
68
- // 批量配置态:移除 ctx.record(Action 为区块级,不具备单条记录上下文)
69
- const isBulk = action instanceof CollectionActionModel && !(action instanceof RecordActionModel);
70
- if (isBulk && (formModel as any)?.context?.defineProperty) {
71
- formModel.context.defineProperty('record', { get: () => undefined, cache: false });
72
- }
73
- initializedRef.current = true;
74
- }, [action, blockModel?.collection, formModel]);
75
-
76
- return formModel ? <FlowModelRenderer model={formModel} showFlowSettings={false} /> : null;
77
- }
78
-
79
26
  function Info() {
80
27
  const ctx = useFlowSettingsContext();
81
28
  return (
@@ -124,16 +71,9 @@ UpdateRecordActionModel.define({
124
71
  sort: 50,
125
72
  // 使用函数型 createModelOptions,从父级上下文提取资源信息,直接注入到子模型的 resourceSettings.init
126
73
  createModelOptions: (ctx) => {
127
- const dsKey = ctx.collection.dataSourceKey;
128
- const collName = ctx.collection.name;
129
- const init = dsKey && collName ? { dataSourceKey: dsKey, collectionName: collName } : undefined;
130
74
  return {
131
75
  subModels: {
132
- assignForm: {
133
- async: true,
134
- use: 'AssignFormModel',
135
- stepParams: { resourceSettings: { init } },
136
- },
76
+ assignForm: createAssignFormSubModelOptions(ctx),
137
77
  },
138
78
  };
139
79
  },
@@ -155,32 +95,12 @@ UpdateRecordActionModel.registerFlow({
155
95
  content: tExpr('Are you sure you want to perform the Update record action?'),
156
96
  },
157
97
  },
158
- assignFieldValues: {
98
+ assignFieldValues: createAssignFieldValuesStep({
99
+ settingsFlowKey: SETTINGS_FLOW_KEY,
159
100
  title: tExpr('Field values'),
160
- uiSchema() {
161
- return {
162
- tip: {
163
- 'x-decorator': 'FormItem',
164
- 'x-component': () => <Info />,
165
- },
166
- editor: {
167
- 'x-decorator': 'FormItem',
168
- 'x-component': () => <AssignFieldsEditor />,
169
- },
170
- };
171
- },
172
- async beforeParamsSave(ctx) {
173
- const m = ctx.model as UpdateRecordActionModel;
174
- // 跨视图栈按 uid 定位到设置面板中的真实 AssignForm 实例
175
- const form: AssignFormModel = m?.assignFormUid && ctx.engine.getModel?.(m.assignFormUid, true);
176
- if (!form) return;
177
- await form?.form?.validateFields?.();
178
- const assignedValues = form?.getAssignedValues?.() || {};
179
- const grid = form?.subModels?.grid;
180
- const items = grid?.subModels?.items || [];
181
- ctx.model.setStepParams(SETTINGS_FLOW_KEY, 'assignFieldValues', { assignedValues });
182
- },
183
- },
101
+ tipComponent: Info,
102
+ validateBeforeSave: true,
103
+ }),
184
104
  },
185
105
  });
186
106
 
@@ -190,8 +110,7 @@ UpdateRecordActionModel.registerFlow({
190
110
  steps: {
191
111
  apply: {
192
112
  async defaultParams(ctx) {
193
- const step = ctx.model.getStepParams(SETTINGS_FLOW_KEY, 'assignFieldValues') || {};
194
- return { assignedValues: step?.assignedValues || {} };
113
+ return getAssignFieldValuesDefaultParams(ctx, SETTINGS_FLOW_KEY);
195
114
  },
196
115
  async handler(ctx, params) {
197
116
  await applyUpdateRecordAction(ctx, params, { settingsFlowKey: SETTINGS_FLOW_KEY });
@@ -7,8 +7,9 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { MultiRecordResource, SingleRecordResource, resolveRunJSObjectValues } from '@nocobase/flow-engine';
10
+ import { MultiRecordResource, SingleRecordResource } from '@nocobase/flow-engine';
11
11
  import { dispatchEventDeep } from '../../utils';
12
+ import { resolveAssignFieldValues } from '../blocks/assign-form/assignFieldValuesFlow';
12
13
 
13
14
  export async function refreshLinkageRulesAfterUpdate(ctx: any) {
14
15
  const blockModel = ctx?.blockModel || ctx?.model?.context?.blockModel || ctx?.model;
@@ -59,12 +60,8 @@ export async function applyUpdateRecordAction(
59
60
  const confirmParams = savedConfirm && typeof savedConfirm === 'object' ? savedConfirm : { enable: false };
60
61
  await ctx.runAction('confirm', confirmParams);
61
62
 
62
- let assignedValues: Record<string, any> = {};
63
- try {
64
- assignedValues = await resolveRunJSObjectValues(ctx, params?.assignedValues);
65
- } catch (error) {
66
- console.error('[UpdateRecordAction] RunJS execution failed', error);
67
- ctx.message.error(ctx.t('RunJS execution failed'));
63
+ const assignedValues = await resolveAssignFieldValues(ctx, params?.assignedValues, 'UpdateRecordAction');
64
+ if (!assignedValues) {
68
65
  return;
69
66
  }
70
67