@nocobase/client-v2 2.1.0-beta.41 → 2.1.0-beta.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/components/form/TypedVariableInput.d.ts +75 -0
- package/es/components/form/index.d.ts +1 -0
- package/es/flow/models/blocks/assign-form/assignFieldValuesFlow.d.ts +8 -0
- package/es/index.mjs +70 -70
- package/lib/index.js +91 -91
- package/package.json +7 -7
- package/src/components/README.md +48 -0
- package/src/components/README.zh-CN.md +48 -0
- package/src/components/form/TypedVariableInput.tsx +441 -0
- package/src/components/form/__tests__/TypedVariableInput.test.tsx +152 -0
- package/src/components/form/index.tsx +1 -0
- package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +100 -0
- package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +23 -1
- package/src/flow/actions/linkageRules.tsx +5 -4
- package/src/flow/actions/linkageRulesRefresh.tsx +2 -3
- package/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts +272 -27
- package/src/flow/components/code-editor/runjsCompletions.ts +490 -9
- package/src/flow/models/actions/__tests__/AssignFormRefill.test.ts +90 -0
- package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +1 -3
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -4
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +216 -0
- package/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx +142 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.1.0-beta.
|
|
3
|
+
"version": "2.1.0-beta.43",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "es/index.mjs",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"@formily/antd-v5": "1.2.3",
|
|
28
28
|
"@formily/react": "^2.2.27",
|
|
29
29
|
"@formily/shared": "^2.2.27",
|
|
30
|
-
"@nocobase/evaluators": "2.1.0-beta.
|
|
31
|
-
"@nocobase/flow-engine": "2.1.0-beta.
|
|
32
|
-
"@nocobase/sdk": "2.1.0-beta.
|
|
33
|
-
"@nocobase/shared": "2.1.0-beta.
|
|
34
|
-
"@nocobase/utils": "2.1.0-beta.
|
|
30
|
+
"@nocobase/evaluators": "2.1.0-beta.43",
|
|
31
|
+
"@nocobase/flow-engine": "2.1.0-beta.43",
|
|
32
|
+
"@nocobase/sdk": "2.1.0-beta.43",
|
|
33
|
+
"@nocobase/shared": "2.1.0-beta.43",
|
|
34
|
+
"@nocobase/utils": "2.1.0-beta.43",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -45,5 +45,5 @@
|
|
|
45
45
|
"react-i18next": "^11.15.1",
|
|
46
46
|
"react-router-dom": "^6.30.1"
|
|
47
47
|
},
|
|
48
|
-
"gitHead": "
|
|
48
|
+
"gitHead": "6d7750e2373bf2451d246de88cc1f62491685e18"
|
|
49
49
|
}
|
package/src/components/README.md
CHANGED
|
@@ -169,6 +169,54 @@ Key props:
|
|
|
169
169
|
|
|
170
170
|
Under the hood `VariableInput` wraps `VariableHybridInput` (inline pills), `VariableTextArea` wraps `TextAreaWithContextSelector` (textarea + variable button). Both share the same MetaTree.
|
|
171
171
|
|
|
172
|
+
#### TypedVariableInput
|
|
173
|
+
|
|
174
|
+
Typed-constant + variable hybrid input. Ported from v1 `Variable.Input`'s `useTypedConstant` pattern: an italic `x` button on the right triggers a Cascader switcher `[Null | Constant<types> | Variable<…namespaces>]`; the left side renders the matching editor (`Input` / `InputNumber` / `Select(True/False)` / `DatePicker`) or a pill carrying the variable path.
|
|
175
|
+
|
|
176
|
+
Reach for this when a field **accepts both** a typed literal **and** a variable reference. The canonical example is `plugin-notification-email`'s SMTP `port` and `secure` fields: users can type a numeric port / boolean flag, or pass `{{ $env.SMTP_PORT }}` to read from environment variables.
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
import { TypedVariableInput } from '@nocobase/client-v2';
|
|
180
|
+
|
|
181
|
+
// Port — numeric constant + $env variable
|
|
182
|
+
<Form.Item name={['options', 'port']} label={t('Port')} initialValue={465}>
|
|
183
|
+
<TypedVariableInput
|
|
184
|
+
types={[['number', { min: 1, max: 65535, step: 1 }]]}
|
|
185
|
+
namespaces={['$env']}
|
|
186
|
+
/>
|
|
187
|
+
</Form.Item>
|
|
188
|
+
|
|
189
|
+
// Secure mode — boolean constant + $env variable
|
|
190
|
+
<Form.Item name={['options', 'secure']} label={t('Secure')} initialValue={true}>
|
|
191
|
+
<TypedVariableInput types={['boolean']} namespaces={['$env']} />
|
|
192
|
+
</Form.Item>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Key props:
|
|
196
|
+
|
|
197
|
+
- `types`: allowed constant types. Shape mirrors v1 `useTypedConstant` — pass bare type names (`['number', 'boolean']`) or `[type, editorProps]` tuples (`[['number', { min, max, step }]]`) to forward props to the underlying antd editor. Defaults to `['string', 'number', 'boolean', 'date']`. **Even when only one type is allowed, the `Constant` entry still expands into a typed submenu** (Number / Boolean / Date / String) — matches v1 so users can see what type the constant is
|
|
198
|
+
- `namespaces`: restrict the variable picker to specific top-level namespaces (e.g. `['$env']`). Omit to expose every namespace registered on `flowEngine.context`
|
|
199
|
+
- `extraNodes`: static leaves appended after the namespace-filtered nodes
|
|
200
|
+
- `nullable`: whether to expose the `Null` switcher entry. Default `true`. Combined with `Form.Item.rules={[{ required: true }]}`, the user can explicitly clear the field but submission is still blocked by validation — mirrors v1's "Null + required" pairing
|
|
201
|
+
- `delimiters`: variable-token delimiters, default `['{{', '}}']` — same as `VariableInput`
|
|
202
|
+
- `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`: standard controlled-input props
|
|
203
|
+
|
|
204
|
+
Value shape:
|
|
205
|
+
|
|
206
|
+
- Constant: stored as the native type (`number` / `boolean` / `Date` / `string`)
|
|
207
|
+
- Variable: a string like `'{{ $env.SMTP_PORT }}'`
|
|
208
|
+
- Null: `null`
|
|
209
|
+
|
|
210
|
+
When **not** to use it:
|
|
211
|
+
|
|
212
|
+
- **Pure literal fields** (users will never pass a variable) → use the antd primitive directly (`InputNumber` / `Select` / `DatePicker` / `Input`) and skip the Cascader column overhead
|
|
213
|
+
- **Pure variable fields** (users will never pass a literal) → use `EnvVariableInput` (`$env`-only, with optional password masking) or `VariableInput` (general-purpose)
|
|
214
|
+
|
|
215
|
+
Capabilities skipped (present in v1, not yet ported to v2):
|
|
216
|
+
|
|
217
|
+
- `object` constant type (JSON editor) — v2 has no inline "JSON editor + Cascader switcher" yet; add when there's a concrete caller
|
|
218
|
+
- Async `loadChildren` cascading — most MetaTree namespaces are already eagerly resolved by `useFilteredMetaTree`, so this hasn't been needed
|
|
219
|
+
|
|
172
220
|
#### FileSizeInput
|
|
173
221
|
|
|
174
222
|
A byte-valued size input paired with a unit selector (Byte / KB / MB / GB). The persisted value is always in bytes; the displayed number is derived from the picked unit.
|
|
@@ -169,6 +169,54 @@ import { VariableInput, VariableTextArea } from '@nocobase/client-v2';
|
|
|
169
169
|
|
|
170
170
|
底层共用 `VariableHybridInput`(`VariableInput`)和 `TextAreaWithContextSelector`(`VariableTextArea`),用同一套 MetaTree 数据。
|
|
171
171
|
|
|
172
|
+
#### TypedVariableInput
|
|
173
|
+
|
|
174
|
+
类型化常量 + 变量混合输入器。移植 v1 `Variable.Input` 的 `useTypedConstant` 形态:右侧斜体 `x` 按钮触发 Cascader 切换 `[空值 | 常量<types> | 变量和密钥<…namespaces>]`,左侧根据当前模式渲染对应编辑器(`Input` / `InputNumber` / `Select(True/False)` / `DatePicker`)或一颗带变量路径的 pill。
|
|
175
|
+
|
|
176
|
+
用于字段**同时接受**字面量**和**变量引用的场景。最典型的就是 `plugin-notification-email` 的 SMTP `port` 和 `secure`:可以填具体数字 / 布尔值,也可以填 `{{ $env.SMTP_PORT }}` 走环境变量。
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
import { TypedVariableInput } from '@nocobase/client-v2';
|
|
180
|
+
|
|
181
|
+
// 端口:数字常量 + $env 变量
|
|
182
|
+
<Form.Item name={['options', 'port']} label={t('端口')} initialValue={465}>
|
|
183
|
+
<TypedVariableInput
|
|
184
|
+
types={[['number', { min: 1, max: 65535, step: 1 }]]}
|
|
185
|
+
namespaces={['$env']}
|
|
186
|
+
/>
|
|
187
|
+
</Form.Item>
|
|
188
|
+
|
|
189
|
+
// 安全模式:布尔常量 + $env 变量
|
|
190
|
+
<Form.Item name={['options', 'secure']} label={t('安全模式')} initialValue={true}>
|
|
191
|
+
<TypedVariableInput types={['boolean']} namespaces={['$env']} />
|
|
192
|
+
</Form.Item>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
主要属性:
|
|
196
|
+
|
|
197
|
+
- `types`:允许的常量类型。形态对齐 v1 `useTypedConstant`,可以传裸类型名 `['number', 'boolean']`,也可以传 `[type, editorProps]` 元组 `[['number', { min, max, step }]]` 把 props 透传给底层 antd 编辑器。默认 `['string', 'number', 'boolean', 'date']`。**即使只允许一种类型,「常量」入口也会展开二级菜单**(数字 / 逻辑值 / 日期 / 字符串)——跟 v1 一致,让用户能直观看到当前常量是什么类型
|
|
198
|
+
- `namespaces`:限定变量 picker 可选的顶层命名空间(如 `['$env']`)。不传就用 `flowEngine.context` 里所有已注册命名空间
|
|
199
|
+
- `extraNodes`:在命名空间过滤后追加几条静态变量节点
|
|
200
|
+
- `nullable`:是否暴露「空值」入口,默认 `true`。配合 `Form.Item.rules={[{ required: true }]}` 可以让用户能手动清空、但提交时会被校验拦截——跟 v1 的「空值 + required」组合一致
|
|
201
|
+
- `delimiters`:变量 token 开闭分隔符,默认 `['{{', '}}']`,跟 `VariableInput` 一致
|
|
202
|
+
- `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`:标准受控字段属性
|
|
203
|
+
|
|
204
|
+
值的形态:
|
|
205
|
+
|
|
206
|
+
- 常量:原生类型直接存(`number` / `boolean` / `Date` / `string`)
|
|
207
|
+
- 变量:字符串 `'{{ $env.SMTP_PORT }}'`
|
|
208
|
+
- 空值:`null`
|
|
209
|
+
|
|
210
|
+
什么时候**不该用**:
|
|
211
|
+
|
|
212
|
+
- **纯字面量字段**(用户不会想填变量)→ 直接用 antd `InputNumber` / `Select` / `DatePicker` / `Input`,省掉 Cascader 那一格的视觉开销
|
|
213
|
+
- **纯变量字段**(用户不会想填字面量)→ 用 `EnvVariableInput`(`$env` 专用,带 password mask)或 `VariableInput`(更通用)
|
|
214
|
+
|
|
215
|
+
跳过的能力(v1 有但 v2 还没补):
|
|
216
|
+
|
|
217
|
+
- `object` 类型(JSON 编辑器)——v2 还没对应的「内联 JSON 编辑器 + Cascader 切换」组件,等真有需求再补
|
|
218
|
+
- 异步 `loadChildren` 分支——大多数命名空间的 MetaTree 已经由 `useFilteredMetaTree` 提前展平,没遇到刚需
|
|
219
|
+
|
|
172
220
|
#### FileSizeInput
|
|
173
221
|
|
|
174
222
|
文件大小输入器。值统一存字节数,UI 上配一个单位选择器(Byte / KB / MB / GB)。
|
|
@@ -0,0 +1,441 @@
|
|
|
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 { CloseCircleFilled } from '@ant-design/icons';
|
|
11
|
+
import {
|
|
12
|
+
buildContextSelectorItems,
|
|
13
|
+
useFlowContext,
|
|
14
|
+
type ContextSelectorItem,
|
|
15
|
+
type MetaTreeNode,
|
|
16
|
+
} from '@nocobase/flow-engine';
|
|
17
|
+
import { Button, Cascader, DatePicker, Input, InputNumber, Select, Space, Tag, theme, type CascaderProps } from 'antd';
|
|
18
|
+
import dayjs from 'dayjs';
|
|
19
|
+
import React, { useCallback, useMemo } from 'react';
|
|
20
|
+
import {
|
|
21
|
+
makeFormatVariablePath,
|
|
22
|
+
makeParseVariablePath,
|
|
23
|
+
useFilteredMetaTree,
|
|
24
|
+
type VariableDelimiters,
|
|
25
|
+
} from './VariableInput';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Constant types this input can edit. Subset of v1 `Variable.Input`
|
|
29
|
+
* `useTypedConstant` — drops `'object'` (no v2 JSON editor yet) and `'null'`
|
|
30
|
+
* (handled by the dedicated `nullable` prop).
|
|
31
|
+
*/
|
|
32
|
+
export type TypedConstantType = 'string' | 'number' | 'boolean' | 'date';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One allowed constant type. Either a bare type name (`'number'`) or a
|
|
36
|
+
* `[type, editorProps]` pair (`['number', { min: 1, max: 65535 }]`) where
|
|
37
|
+
* `editorProps` is forwarded to the antd editor for that type — same
|
|
38
|
+
* shape as v1 `useTypedConstant`.
|
|
39
|
+
*/
|
|
40
|
+
export type TypedConstantSpec = TypedConstantType | [TypedConstantType, Record<string, unknown>];
|
|
41
|
+
|
|
42
|
+
export interface TypedVariableInputProps {
|
|
43
|
+
/**
|
|
44
|
+
* Stored value. A `{{ ... }}` string is treated as a variable reference;
|
|
45
|
+
* anything else is a constant of the inferred type.
|
|
46
|
+
*/
|
|
47
|
+
value?: unknown;
|
|
48
|
+
onChange?: (next: unknown) => void;
|
|
49
|
+
/**
|
|
50
|
+
* Allowed constant types. The `Constant` switcher entry always exposes a
|
|
51
|
+
* typed submenu (matching v1 `Variable.Input`) so users can see what type
|
|
52
|
+
* the constant is, even when only one type is permitted. Default: all four
|
|
53
|
+
* supported types.
|
|
54
|
+
*/
|
|
55
|
+
types?: TypedConstantSpec[];
|
|
56
|
+
/**
|
|
57
|
+
* Restrict the variable picker to specific top-level meta-tree namespaces
|
|
58
|
+
* (e.g. `['$env']`). When omitted, every registered top-level property is
|
|
59
|
+
* exposed — matching `VariableInput`'s default behaviour.
|
|
60
|
+
*/
|
|
61
|
+
namespaces?: string[];
|
|
62
|
+
/** Additional leaves appended to the picker after the namespace-filtered nodes. */
|
|
63
|
+
extraNodes?: MetaTreeNode[];
|
|
64
|
+
/**
|
|
65
|
+
* When true (default), the switcher exposes a `Null` option that resets the
|
|
66
|
+
* value to `null`. When false, the value is constrained to one of the
|
|
67
|
+
* allowed types or a variable reference.
|
|
68
|
+
*/
|
|
69
|
+
nullable?: boolean;
|
|
70
|
+
/** Variable-token delimiters. Default `['{{', '}}']` — see `VariableInput`. */
|
|
71
|
+
delimiters?: VariableDelimiters;
|
|
72
|
+
disabled?: boolean;
|
|
73
|
+
placeholder?: string;
|
|
74
|
+
style?: React.CSSProperties;
|
|
75
|
+
className?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DEFAULT_TYPES: TypedConstantSpec[] = ['string', 'number', 'boolean', 'date'];
|
|
79
|
+
|
|
80
|
+
const TYPE_LABEL_KEYS: Record<TypedConstantType, string> = {
|
|
81
|
+
string: 'String',
|
|
82
|
+
number: 'Number',
|
|
83
|
+
boolean: 'Boolean',
|
|
84
|
+
date: 'Date',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type NormalizedType = { type: TypedConstantType; props: Record<string, unknown> };
|
|
88
|
+
|
|
89
|
+
function normalizeTypes(types: TypedConstantSpec[]): NormalizedType[] {
|
|
90
|
+
return types.map((spec) =>
|
|
91
|
+
Array.isArray(spec) ? { type: spec[0], props: spec[1] ?? {} } : { type: spec, props: {} },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function defaultValueFor(type: TypedConstantType): unknown {
|
|
96
|
+
switch (type) {
|
|
97
|
+
case 'string':
|
|
98
|
+
return '';
|
|
99
|
+
case 'number':
|
|
100
|
+
return 0;
|
|
101
|
+
case 'boolean':
|
|
102
|
+
return false;
|
|
103
|
+
case 'date': {
|
|
104
|
+
const now = new Date();
|
|
105
|
+
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
|
106
|
+
}
|
|
107
|
+
default:
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type DetectedMode = { mode: 'null' | 'variable' | TypedConstantType; variablePath?: string[] };
|
|
113
|
+
|
|
114
|
+
function detectMode(value: unknown, parseVariablePath: (v?: string) => string[] | undefined): DetectedMode {
|
|
115
|
+
if (value == null) return { mode: 'null' };
|
|
116
|
+
if (typeof value === 'string') {
|
|
117
|
+
const path = parseVariablePath(value);
|
|
118
|
+
if (path && path.length > 0) return { mode: 'variable', variablePath: path };
|
|
119
|
+
return { mode: 'string' };
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === 'number') return { mode: 'number' };
|
|
122
|
+
if (typeof value === 'boolean') return { mode: 'boolean' };
|
|
123
|
+
if (value instanceof Date) return { mode: 'date' };
|
|
124
|
+
return { mode: 'string' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const NULL_KEY = '__null__';
|
|
128
|
+
const CONST_KEY = '__const__';
|
|
129
|
+
|
|
130
|
+
interface SwitcherOption {
|
|
131
|
+
value: string;
|
|
132
|
+
label: React.ReactNode;
|
|
133
|
+
isLeaf?: boolean;
|
|
134
|
+
children?: SwitcherOption[];
|
|
135
|
+
meta?: MetaTreeNode;
|
|
136
|
+
paths?: string[];
|
|
137
|
+
disabled?: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function fromContextItem(item: ContextSelectorItem): SwitcherOption {
|
|
141
|
+
return {
|
|
142
|
+
value: item.value,
|
|
143
|
+
label: item.label,
|
|
144
|
+
isLeaf: item.isLeaf,
|
|
145
|
+
meta: item.meta,
|
|
146
|
+
paths: item.paths,
|
|
147
|
+
disabled: item.disabled,
|
|
148
|
+
children: item.children?.map(fromContextItem),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveVariableLabels(path: string[], metaTree: MetaTreeNode[]): string[] {
|
|
153
|
+
const labels: string[] = [];
|
|
154
|
+
let nodes: MetaTreeNode[] | undefined = metaTree;
|
|
155
|
+
for (const segment of path) {
|
|
156
|
+
if (!nodes) break;
|
|
157
|
+
const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
|
|
158
|
+
if (!matched) {
|
|
159
|
+
labels.push(segment);
|
|
160
|
+
nodes = undefined;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
labels.push(typeof matched.title === 'string' && matched.title ? matched.title : matched.name);
|
|
164
|
+
nodes = Array.isArray(matched.children) ? matched.children : undefined;
|
|
165
|
+
}
|
|
166
|
+
return labels;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderConstantEditor(
|
|
170
|
+
type: TypedConstantType,
|
|
171
|
+
value: unknown,
|
|
172
|
+
onChange: ((next: unknown) => void) | undefined,
|
|
173
|
+
options: {
|
|
174
|
+
typedProps: Record<string, unknown>;
|
|
175
|
+
disabled?: boolean;
|
|
176
|
+
placeholder?: string;
|
|
177
|
+
t: (text: string) => string;
|
|
178
|
+
},
|
|
179
|
+
): React.ReactNode {
|
|
180
|
+
const { typedProps, disabled, placeholder, t } = options;
|
|
181
|
+
switch (type) {
|
|
182
|
+
case 'string':
|
|
183
|
+
return (
|
|
184
|
+
<Input
|
|
185
|
+
value={typeof value === 'string' ? value : ''}
|
|
186
|
+
onChange={(ev) => onChange?.(ev.target.value)}
|
|
187
|
+
disabled={disabled}
|
|
188
|
+
placeholder={placeholder}
|
|
189
|
+
{...typedProps}
|
|
190
|
+
/>
|
|
191
|
+
);
|
|
192
|
+
case 'number':
|
|
193
|
+
return (
|
|
194
|
+
<InputNumber
|
|
195
|
+
value={typeof value === 'number' ? value : null}
|
|
196
|
+
onChange={(next) => onChange?.(next)}
|
|
197
|
+
disabled={disabled}
|
|
198
|
+
placeholder={placeholder}
|
|
199
|
+
style={{ width: '100%' }}
|
|
200
|
+
{...typedProps}
|
|
201
|
+
/>
|
|
202
|
+
);
|
|
203
|
+
case 'boolean':
|
|
204
|
+
return (
|
|
205
|
+
<Select
|
|
206
|
+
value={typeof value === 'boolean' ? value : undefined}
|
|
207
|
+
onChange={(next) => onChange?.(next)}
|
|
208
|
+
disabled={disabled}
|
|
209
|
+
placeholder={placeholder ?? t('Select')}
|
|
210
|
+
options={[
|
|
211
|
+
{ value: true, label: t('True') },
|
|
212
|
+
{ value: false, label: t('False') },
|
|
213
|
+
]}
|
|
214
|
+
style={{ width: '100%' }}
|
|
215
|
+
{...typedProps}
|
|
216
|
+
/>
|
|
217
|
+
);
|
|
218
|
+
case 'date': {
|
|
219
|
+
const parsed = value instanceof Date ? dayjs(value) : null;
|
|
220
|
+
return (
|
|
221
|
+
<DatePicker
|
|
222
|
+
value={parsed}
|
|
223
|
+
onChange={(next) => onChange?.(next ? next.toDate() : null)}
|
|
224
|
+
disabled={disabled}
|
|
225
|
+
showTime
|
|
226
|
+
allowClear={false}
|
|
227
|
+
style={{ width: '100%' }}
|
|
228
|
+
{...typedProps}
|
|
229
|
+
/>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
default:
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Constant-or-variable input. Port of v1 `Variable.Input` typed-constant
|
|
239
|
+
* Cascader pattern (the `[Null | Constant<type> | Variable<…namespaces>]`
|
|
240
|
+
* switcher). Use this when a field can accept either a typed literal
|
|
241
|
+
* (number, boolean, date, string) or a variable reference like
|
|
242
|
+
* `{{ $env.SMTP_PORT }}` — see `plugin-notification-email`'s port / secure
|
|
243
|
+
* fields for the canonical example.
|
|
244
|
+
*
|
|
245
|
+
* Pure literal fields should keep using the antd primitive
|
|
246
|
+
* (`InputNumber`, `Select`, `DatePicker`). Pure variable fields should keep
|
|
247
|
+
* using `EnvVariableInput` / `VariableInput`. This component carries the
|
|
248
|
+
* Cascader switcher overhead, so reach for it only when the field
|
|
249
|
+
* genuinely accepts both shapes.
|
|
250
|
+
*/
|
|
251
|
+
export function TypedVariableInput(props: TypedVariableInputProps) {
|
|
252
|
+
const {
|
|
253
|
+
value,
|
|
254
|
+
onChange,
|
|
255
|
+
types = DEFAULT_TYPES,
|
|
256
|
+
namespaces,
|
|
257
|
+
extraNodes,
|
|
258
|
+
nullable = true,
|
|
259
|
+
delimiters,
|
|
260
|
+
disabled,
|
|
261
|
+
placeholder,
|
|
262
|
+
style,
|
|
263
|
+
className,
|
|
264
|
+
} = props;
|
|
265
|
+
const ctx = useFlowContext();
|
|
266
|
+
const t = useCallback((text: string): string => (typeof ctx?.t === 'function' ? ctx.t(text) : text), [ctx]);
|
|
267
|
+
const { token } = theme.useToken();
|
|
268
|
+
|
|
269
|
+
const metaTree = useFilteredMetaTree({ namespaces, extraNodes });
|
|
270
|
+
|
|
271
|
+
const parseVariablePath = useMemo(() => makeParseVariablePath(delimiters), [delimiters]);
|
|
272
|
+
const formatVariablePath = useMemo(() => makeFormatVariablePath(delimiters), [delimiters]);
|
|
273
|
+
|
|
274
|
+
const normalizedTypes = useMemo(() => normalizeTypes(types), [types]);
|
|
275
|
+
const detected = useMemo(() => detectMode(value, parseVariablePath), [value, parseVariablePath]);
|
|
276
|
+
const variableItems = useMemo(() => buildContextSelectorItems(metaTree).map(fromContextItem), [metaTree]);
|
|
277
|
+
|
|
278
|
+
const switcherOptions = useMemo<SwitcherOption[]>(() => {
|
|
279
|
+
const items: SwitcherOption[] = [];
|
|
280
|
+
if (nullable) {
|
|
281
|
+
items.push({ value: NULL_KEY, label: t('Null'), isLeaf: true });
|
|
282
|
+
}
|
|
283
|
+
// Always render Constant with a typed submenu — even when only one type is
|
|
284
|
+
// allowed. Matches v1 `Variable.Input`, where clicking 常量 reveals 数字 /
|
|
285
|
+
// 逻辑值 / etc. so the user can see what type the constant actually is.
|
|
286
|
+
if (normalizedTypes.length > 0) {
|
|
287
|
+
items.push({
|
|
288
|
+
value: CONST_KEY,
|
|
289
|
+
label: t('Constant'),
|
|
290
|
+
children: normalizedTypes.map(({ type }) => ({
|
|
291
|
+
value: type,
|
|
292
|
+
label: t(TYPE_LABEL_KEYS[type]),
|
|
293
|
+
isLeaf: true,
|
|
294
|
+
})),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
items.push(...variableItems);
|
|
298
|
+
return items;
|
|
299
|
+
}, [nullable, normalizedTypes, variableItems, t]);
|
|
300
|
+
|
|
301
|
+
const onSwitcherChange = useCallback<NonNullable<CascaderProps<SwitcherOption>['onChange']>>(
|
|
302
|
+
(path, selectedOptions) => {
|
|
303
|
+
const head = path?.[0];
|
|
304
|
+
if (head === NULL_KEY) {
|
|
305
|
+
onChange?.(null);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (head === CONST_KEY) {
|
|
309
|
+
// Cascader always emits the second segment now that we render typed
|
|
310
|
+
// children even for single-type configs. Fall back to the first
|
|
311
|
+
// allowed type so picking only the parent still does something
|
|
312
|
+
// sensible (`changeOnSelect` lets this fire).
|
|
313
|
+
const targetType = (path[1] as TypedConstantType | undefined) ?? normalizedTypes[0]?.type;
|
|
314
|
+
if (!targetType) return;
|
|
315
|
+
if (detected.mode === targetType) return;
|
|
316
|
+
onChange?.(defaultValueFor(targetType));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const leaf = selectedOptions?.[selectedOptions.length - 1] as SwitcherOption | undefined;
|
|
320
|
+
const meta = leaf?.meta;
|
|
321
|
+
if (!meta) return;
|
|
322
|
+
const formatted = formatVariablePath(meta);
|
|
323
|
+
if (formatted != null) onChange?.(formatted);
|
|
324
|
+
},
|
|
325
|
+
[onChange, normalizedTypes, detected.mode, formatVariablePath],
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const onClearVariable = useCallback(() => {
|
|
329
|
+
if (nullable) {
|
|
330
|
+
onChange?.(null);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const first = normalizedTypes[0];
|
|
334
|
+
if (first) onChange?.(defaultValueFor(first.type));
|
|
335
|
+
}, [nullable, normalizedTypes, onChange]);
|
|
336
|
+
|
|
337
|
+
const constantTypeForRendering: TypedConstantType = useMemo(() => {
|
|
338
|
+
const m = detected.mode;
|
|
339
|
+
if (m === 'string' || m === 'number' || m === 'boolean' || m === 'date') return m;
|
|
340
|
+
return normalizedTypes[0]?.type ?? 'string';
|
|
341
|
+
}, [detected.mode, normalizedTypes]);
|
|
342
|
+
|
|
343
|
+
const typedProps = useMemo(
|
|
344
|
+
() => normalizedTypes.find((nt) => nt.type === constantTypeForRendering)?.props ?? {},
|
|
345
|
+
[normalizedTypes, constantTypeForRendering],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const isVariable = detected.mode === 'variable';
|
|
349
|
+
const isNull = detected.mode === 'null';
|
|
350
|
+
|
|
351
|
+
const variableLabels = useMemo(
|
|
352
|
+
() => (isVariable && detected.variablePath ? resolveVariableLabels(detected.variablePath, metaTree) : []),
|
|
353
|
+
[isVariable, detected.variablePath, metaTree],
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<Space.Compact style={{ display: 'flex', width: '100%', ...style }} className={className}>
|
|
358
|
+
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
|
359
|
+
{isVariable ? (
|
|
360
|
+
<div
|
|
361
|
+
role="button"
|
|
362
|
+
aria-label="variable-tag"
|
|
363
|
+
style={{
|
|
364
|
+
display: 'flex',
|
|
365
|
+
alignItems: 'center',
|
|
366
|
+
gap: token.marginXXS,
|
|
367
|
+
padding: `0 ${token.paddingSM}px`,
|
|
368
|
+
minHeight: token.controlHeight,
|
|
369
|
+
border: `1px solid ${token.colorBorder}`,
|
|
370
|
+
borderRadius: token.borderRadius,
|
|
371
|
+
borderTopRightRadius: 0,
|
|
372
|
+
borderBottomRightRadius: 0,
|
|
373
|
+
background: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
|
|
374
|
+
overflow: 'hidden',
|
|
375
|
+
}}
|
|
376
|
+
>
|
|
377
|
+
<Tag
|
|
378
|
+
color="blue"
|
|
379
|
+
style={{
|
|
380
|
+
marginInlineEnd: 0,
|
|
381
|
+
maxWidth: '100%',
|
|
382
|
+
overflow: 'hidden',
|
|
383
|
+
textOverflow: 'ellipsis',
|
|
384
|
+
whiteSpace: 'nowrap',
|
|
385
|
+
}}
|
|
386
|
+
>
|
|
387
|
+
{variableLabels.map((label, index) => (
|
|
388
|
+
<React.Fragment key={`${label}-${index}`}>
|
|
389
|
+
{index ? ' / ' : ''}
|
|
390
|
+
{label}
|
|
391
|
+
</React.Fragment>
|
|
392
|
+
))}
|
|
393
|
+
</Tag>
|
|
394
|
+
{!disabled ? (
|
|
395
|
+
<Button
|
|
396
|
+
type="text"
|
|
397
|
+
size="small"
|
|
398
|
+
aria-label="icon-close"
|
|
399
|
+
onClick={onClearVariable}
|
|
400
|
+
icon={<CloseCircleFilled style={{ color: token.colorTextTertiary }} />}
|
|
401
|
+
/>
|
|
402
|
+
) : null}
|
|
403
|
+
</div>
|
|
404
|
+
) : isNull ? (
|
|
405
|
+
// v1 used the `placeholder` slot (not `value`) so the antd default
|
|
406
|
+
// placeholder colour applies — keeps the field looking visibly
|
|
407
|
+
// empty/inactive rather than holding a real text value.
|
|
408
|
+
<Input placeholder={`<${t('Null')}>`} readOnly disabled={disabled} style={{ width: '100%' }} />
|
|
409
|
+
) : (
|
|
410
|
+
renderConstantEditor(constantTypeForRendering, value, onChange, {
|
|
411
|
+
typedProps,
|
|
412
|
+
disabled,
|
|
413
|
+
placeholder,
|
|
414
|
+
t,
|
|
415
|
+
})
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
<Cascader<SwitcherOption>
|
|
419
|
+
options={switcherOptions}
|
|
420
|
+
onChange={onSwitcherChange}
|
|
421
|
+
disabled={disabled}
|
|
422
|
+
changeOnSelect
|
|
423
|
+
>
|
|
424
|
+
<Button
|
|
425
|
+
aria-label="variable-switcher"
|
|
426
|
+
disabled={disabled}
|
|
427
|
+
type={isVariable ? 'primary' : 'default'}
|
|
428
|
+
style={{
|
|
429
|
+
flexShrink: 0,
|
|
430
|
+
fontStyle: 'italic',
|
|
431
|
+
fontFamily: '"New York", "Times New Roman", Times, serif',
|
|
432
|
+
}}
|
|
433
|
+
>
|
|
434
|
+
x
|
|
435
|
+
</Button>
|
|
436
|
+
</Cascader>
|
|
437
|
+
</Space.Compact>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export default TypedVariableInput;
|