@nocobase/flow-engine 2.2.0-alpha.7 → 2.2.0-alpha.9

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.
@@ -125,16 +125,19 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
125
125
  [onChange],
126
126
  );
127
127
 
128
+ const resolvedPath = useMemo(() => {
129
+ return resolvePathFromValue?.(innerValue);
130
+ }, [innerValue, resolvePathFromValue]);
131
+
128
132
  const resolvedMetaTreeNode = useMemo(() => {
129
133
  if (currentMetaTreeNode) return currentMetaTreeNode;
130
134
  if (Array.isArray(resolvedMetaTree)) {
131
- const path = resolvePathFromValue?.(innerValue);
132
- if (path) {
133
- return findMetaTreeNodeByPath(resolvedMetaTree, path);
135
+ if (resolvedPath) {
136
+ return findMetaTreeNodeByPath(resolvedMetaTree, resolvedPath);
134
137
  }
135
138
  }
136
139
  return null;
137
- }, [currentMetaTreeNode, innerValue, resolvedMetaTree, resolvePathFromValue]);
140
+ }, [currentMetaTreeNode, resolvedMetaTree, resolvedPath]);
138
141
 
139
142
  // 当 value 存在但 currentMetaTreeNode 还未恢复,尝试按路径逐级加载(支持 children 为函数的场景)
140
143
  useEffect(() => {
@@ -197,9 +200,25 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
197
200
  const ValueComponent = useMemo(() => {
198
201
  const Component = renderInputComponent?.(resolvedMetaTreeNode);
199
202
  const CustomComponent = resolvedMetaTreeNode?.render;
200
- const finalComponent = isVariableValue(innerValue) ? VariableTag : Component || CustomComponent || Input;
203
+ // Some domains (workflow) persist variables as `{{$context...}}` rather than the core `{{ ctx... }}` form.
204
+ // When a custom converter recognizes such a value as a variable path, render VariableTag even if the path no longer
205
+ // exists in the current meta tree. VariableTag will then show either the resolved label or the parsing-failed state.
206
+ const shouldRenderVariableTag =
207
+ isVariableValue(innerValue) ||
208
+ (Array.isArray(resolvedPath) && resolvedPath.length > 0 && !Component && !CustomComponent);
209
+ const finalComponent = shouldRenderVariableTag ? VariableTag : Component || CustomComponent || Input;
201
210
  return finalComponent;
202
- }, [renderInputComponent, resolvedMetaTreeNode, innerValue]);
211
+ }, [renderInputComponent, resolvedMetaTreeNode, innerValue, resolvedPath]);
212
+
213
+ const isVariableActive = useMemo(() => {
214
+ return (
215
+ isVariableValue(innerValue) ||
216
+ (Array.isArray(resolvedPath) &&
217
+ resolvedPath.length > 0 &&
218
+ !renderInputComponent?.(resolvedMetaTreeNode) &&
219
+ !resolvedMetaTreeNode?.render)
220
+ );
221
+ }, [innerValue, renderInputComponent, resolvedMetaTreeNode, resolvedPath]);
203
222
 
204
223
  useEffect(() => {
205
224
  if (!resolvedMetaTreeNode) return;
@@ -303,8 +322,11 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
303
322
  return {
304
323
  ...baseProps,
305
324
  onClear: handleClear,
325
+ disabled,
326
+ allowCustomTagInput: false,
306
327
  metaTreeNode: resolvedMetaTreeNode,
307
328
  metaTree,
329
+ resolvedPath,
308
330
  style: stableProps.style,
309
331
  };
310
332
  }
@@ -327,6 +349,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
327
349
  disabled,
328
350
  handleClear,
329
351
  resolvedMetaTreeNode,
352
+ resolvedPath,
330
353
  metaTree,
331
354
  ValueComponent,
332
355
  stableProps,
@@ -356,7 +379,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
356
379
  <FlowContextSelector
357
380
  metaTree={resolvedMetaTree}
358
381
  value={innerValue}
359
- active={isVariableValue(innerValue)}
382
+ active={isVariableActive}
360
383
  disabled={disabled}
361
384
  onChange={handleVariableSelect}
362
385
  parseValueToPath={resolvePathFromValue}
@@ -17,41 +17,46 @@ import { useRequest } from 'ahooks';
17
17
  import { useFlowContext } from '../../FlowContextProvider';
18
18
  import type { MetaTreeNode } from '../../flowContext';
19
19
 
20
+ const VARIABLE_PARSING_FAILED_TEXT = 'Variable parsing failed';
21
+
20
22
  const VariableTagComponent: React.FC<VariableTagProps> = ({
21
23
  value,
22
24
  onClear,
25
+ disabled = false,
26
+ allowCustomTagInput = true,
23
27
  className,
24
28
  style,
25
29
  metaTreeNode,
26
30
  metaTree,
31
+ resolvedPath,
27
32
  }) => {
28
33
  const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
29
34
  const ctx = useFlowContext();
30
35
 
31
- const { data: displayedValue } = useRequest(
36
+ const { data: displayState } = useRequest(
32
37
  async () => {
33
- const resolveLabelFromPath = async (rawPath?: (string | number)[]): Promise<string | null> => {
34
- if (!rawPath) return null;
35
- if (!Array.isArray(rawPath)) return null;
36
- if (!Array.isArray(resolvedMetaTree)) return null;
38
+ const resolveLabelFromPath = async (
39
+ rawPath?: (string | number)[],
40
+ ): Promise<{ label: string | null; resolved: boolean; attempted: boolean }> => {
41
+ if (!rawPath) return { label: null, resolved: false, attempted: false };
42
+ if (!Array.isArray(rawPath)) return { label: null, resolved: false, attempted: false };
43
+ if (!Array.isArray(resolvedMetaTree)) return { label: null, resolved: false, attempted: false };
37
44
 
38
45
  // 兼容 metaTree 为子树:顶层不含首段时,裁剪首段
39
46
  const topNames = new Set((resolvedMetaTree || []).map((n: any) => String(n?.name)));
40
47
  const path = !topNames.has(String(rawPath[0])) ? rawPath.slice(1) : rawPath;
41
- if (!path.length) return '';
48
+ if (!path.length) return { label: '', resolved: true, attempted: true };
42
49
 
43
50
  let nodes: MetaTreeNode[] | undefined = resolvedMetaTree as MetaTreeNode[];
44
51
  const titleChain: string[] = [];
45
- let matchedCount = 0;
46
52
 
47
53
  for (let i = 0; i < path.length; i++) {
48
- if (!nodes) break;
54
+ if (!nodes) return { label: null, resolved: false, attempted: true };
49
55
  const seg = String(path[i]);
50
56
  const node = nodes.find((n) => String(n?.name) === seg) as MetaTreeNode | undefined;
51
- if (!node) break; // 停在第一个无效段之前
57
+ if (!node) return { label: null, resolved: false, attempted: true };
52
58
 
53
59
  titleChain.push(String(node.title ?? node.name ?? seg));
54
- matchedCount = i + 1;
55
60
 
56
61
  if (i < path.length - 1) {
57
62
  if (Array.isArray(node.children)) {
@@ -70,36 +75,43 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
70
75
  }
71
76
  }
72
77
 
73
- if (matchedCount === 0) return null;
74
-
75
- let label = titleChain.map(ctx.t).join('/');
76
- if (matchedCount < path.length) {
77
- const tail = path.slice(matchedCount).join('/');
78
- label = tail ? `${label}/${tail}` : label;
79
- }
80
- return label;
78
+ return { label: titleChain.map(ctx.t).join('/'), resolved: true, attempted: true };
81
79
  };
82
80
 
83
81
  // 1) 优先使用已解析到的节点(包含完整父标题链)
84
82
  if (metaTreeNode?.parentTitles) {
85
- return [...metaTreeNode.parentTitles, metaTreeNode.title].map(ctx.t).join('/');
83
+ return {
84
+ text: [...metaTreeNode.parentTitles, metaTreeNode.title].map(ctx.t).join('/'),
85
+ invalid: false,
86
+ };
86
87
  }
87
88
 
88
89
  // 2) metaTreeNode 存在但缺少 parentTitles:尝试根据 value/metaTreeNode.paths 从 metaTree 还原完整路径
89
90
  if (metaTreeNode) {
90
- const rawPath = parseValueToPath(value) || metaTreeNode.paths;
91
- const label = await resolveLabelFromPath(rawPath as any);
92
- return label ?? ctx.t(metaTreeNode.title) ?? '';
91
+ const rawPath = resolvedPath || parseValueToPath(value) || metaTreeNode.paths;
92
+ const result = await resolveLabelFromPath(rawPath as any);
93
+ if (result.resolved && result.label != null) {
94
+ return { text: result.label, invalid: false };
95
+ }
96
+ if (result.attempted) {
97
+ return { text: VARIABLE_PARSING_FAILED_TEXT, invalid: true, rawValue: String(value ?? '') };
98
+ }
99
+ return { text: ctx.t(metaTreeNode.title) ?? '', invalid: false };
93
100
  }
94
101
 
95
102
  // 3) 无 metaTreeNode:从 value 还原路径并拼接标题链;若找不到任何前缀则回退原始路径字符串
96
- if (!value) return String(value);
97
- const rawPath = parseValueToPath(value);
98
- const label = await resolveLabelFromPath(rawPath as any);
99
- if (label != null) return label;
100
- return Array.isArray(rawPath) ? rawPath.join('/') : String(value);
103
+ if (!value) return { text: String(value), invalid: false };
104
+ const rawPath = resolvedPath || parseValueToPath(value);
105
+ const result = await resolveLabelFromPath(rawPath as any);
106
+ if (result.resolved && result.label != null) {
107
+ return { text: result.label, invalid: false };
108
+ }
109
+ if (result.attempted) {
110
+ return { text: VARIABLE_PARSING_FAILED_TEXT, invalid: true, rawValue: String(value ?? '') };
111
+ }
112
+ return { text: Array.isArray(rawPath) ? rawPath.join('/') : String(value), invalid: false };
101
113
  },
102
- { refreshDeps: [resolvedMetaTree, value, metaTreeNode] },
114
+ { refreshDeps: [resolvedMetaTree, resolvedPath, value, metaTreeNode] },
103
115
  );
104
116
 
105
117
  const { token } = theme.useToken();
@@ -119,13 +131,13 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
119
131
  `;
120
132
 
121
133
  const customTagRender = (props: any) => {
122
- const { label } = props;
123
- const fullText = typeof label === 'string' ? label : String(label);
134
+ const fullText = displayState?.text || (typeof props.label === 'string' ? props.label : String(props.label));
135
+ const tooltipText = displayState?.invalid ? displayState.rawValue || fullText : fullText;
124
136
 
125
137
  return (
126
- <Tooltip title={fullText} placement="top" getPopupContainer={() => document.body}>
138
+ <Tooltip title={tooltipText} placement="top" getPopupContainer={() => document.body}>
127
139
  <Tag
128
- color="blue"
140
+ color={displayState?.invalid ? 'error' : 'blue'}
129
141
  style={{
130
142
  margin: `0 ${token.marginXXS || token.marginXS}px`,
131
143
  borderRadius: token.borderRadiusSM,
@@ -166,12 +178,14 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
166
178
  flex: '1 1 auto',
167
179
  ...style,
168
180
  }}
169
- value={displayedValue ? [displayedValue] : []}
170
- mode="tags"
181
+ value={displayState?.text ? [displayState.text] : []}
182
+ mode={allowCustomTagInput ? 'tags' : 'multiple'}
171
183
  open={false}
172
- allowClear={!!onClear}
173
- onClear={onClear}
174
- disabled={!onClear}
184
+ showSearch={allowCustomTagInput}
185
+ searchValue={allowCustomTagInput ? undefined : ''}
186
+ allowClear={!disabled && !!onClear}
187
+ onClear={disabled ? undefined : onClear}
188
+ disabled={disabled || !onClear}
175
189
  variant="outlined"
176
190
  suffixIcon={null}
177
191
  tagRender={customTagRender}
@@ -101,6 +101,127 @@ describe('VariableInput', () => {
101
101
  );
102
102
  });
103
103
 
104
+ it('renders a variable tag when custom converters resolve a non-ctx workflow variable format', async () => {
105
+ const flowContext = createTestFlowContext();
106
+ const workflowMetaTree = [
107
+ {
108
+ name: '$context',
109
+ title: 'Trigger variables',
110
+ type: 'object',
111
+ paths: ['$context'],
112
+ children: [
113
+ {
114
+ name: 'data',
115
+ title: 'Trigger data',
116
+ type: 'object',
117
+ paths: ['$context', 'data'],
118
+ children: [
119
+ {
120
+ name: 'updatedAt',
121
+ title: 'Last updated at',
122
+ type: 'string',
123
+ paths: ['$context', 'data', 'updatedAt'],
124
+ },
125
+ ],
126
+ },
127
+ ],
128
+ },
129
+ ];
130
+
131
+ render(
132
+ <TestFlowContextWrapper context={flowContext}>
133
+ <VariableInput
134
+ value="{{$context.data.updatedAt}}"
135
+ metaTree={workflowMetaTree}
136
+ converters={{
137
+ resolvePathFromValue: (currentValue) =>
138
+ currentValue === '{{$context.data.updatedAt}}' ? ['$context', 'data', 'updatedAt'] : undefined,
139
+ resolveValueFromPath: () => undefined,
140
+ }}
141
+ />
142
+ </TestFlowContextWrapper>,
143
+ );
144
+
145
+ await waitFor(
146
+ () => {
147
+ const variableTag = screen.getByText('Trigger variables/Trigger data/Last updated at');
148
+ expect(variableTag).toBeInTheDocument();
149
+ expect(variableTag.closest('.ant-tag')).toBeInTheDocument();
150
+ },
151
+ { timeout: 3000 },
152
+ );
153
+
154
+ const selectorButton = screen.getByRole('button');
155
+ expect(selectorButton.className).toContain('ant-btn-primary');
156
+ });
157
+
158
+ it('renders a parsing failure tag when a custom variable path is missing from the meta tree', async () => {
159
+ const flowContext = createTestFlowContext();
160
+ const workflowMetaTree = [
161
+ {
162
+ name: '$context',
163
+ title: 'Trigger variables',
164
+ type: 'object',
165
+ paths: ['$context'],
166
+ children: [
167
+ {
168
+ name: 'data',
169
+ title: 'Trigger data',
170
+ type: 'object',
171
+ paths: ['$context', 'data'],
172
+ },
173
+ ],
174
+ },
175
+ ];
176
+
177
+ render(
178
+ <TestFlowContextWrapper context={flowContext}>
179
+ <VariableInput
180
+ value="{{$context.data.id}}"
181
+ metaTree={workflowMetaTree}
182
+ converters={{
183
+ resolvePathFromValue: (currentValue) =>
184
+ currentValue === '{{$context.data.id}}' ? ['$context', 'data', 'id'] : undefined,
185
+ resolveValueFromPath: () => undefined,
186
+ }}
187
+ />
188
+ </TestFlowContextWrapper>,
189
+ );
190
+
191
+ await waitFor(
192
+ () => {
193
+ const variableTag = screen.getByText('Variable parsing failed');
194
+ expect(variableTag).toBeInTheDocument();
195
+ expect(variableTag.closest('.ant-tag')).toHaveClass('ant-tag-error');
196
+ },
197
+ { timeout: 3000 },
198
+ );
199
+
200
+ expect(screen.queryByDisplayValue('{{$context.data.id}}')).not.toBeInTheDocument();
201
+ expect(screen.getByRole('button').className).toContain('ant-btn-primary');
202
+ });
203
+
204
+ it('disables custom tag input when rendering a selected variable', async () => {
205
+ const flowContext = createTestFlowContext();
206
+ const { container } = render(
207
+ <TestFlowContextWrapper context={flowContext}>
208
+ <VariableInput value="{{ ctx.user.name }}" metaTree={() => flowContext.getPropertyMetaTree()} />
209
+ </TestFlowContextWrapper>,
210
+ );
211
+
212
+ await waitFor(
213
+ () => {
214
+ expect(screen.getByText('User/Name')).toBeInTheDocument();
215
+ },
216
+ { timeout: 3000 },
217
+ );
218
+
219
+ const selectElement = container.querySelector('.ant-select.variable');
220
+ expect(selectElement).toBeInTheDocument();
221
+ expect(selectElement).not.toHaveClass('ant-select-show-search');
222
+ expect(container.querySelector('.ant-select-selection-search-input')).toHaveAttribute('readonly');
223
+ });
224
+
104
225
  it('should render FlowContextSelector button', async () => {
105
226
  const flowContext = createTestFlowContext();
106
227
  render(
@@ -334,7 +455,24 @@ describe('VariableInput', () => {
334
455
  expect(input).toHaveClass('custom-class');
335
456
 
336
457
  // Note: The disabled prop might not be correctly passed through in the current implementation
337
- // This is a known limitation of the current component design
458
+ expect(input).toBeDisabled();
459
+ });
460
+
461
+ it('disables the rendered variable tag when the input is disabled', async () => {
462
+ const flowContext = createTestFlowContext();
463
+ const { container } = render(
464
+ <TestFlowContextWrapper context={flowContext}>
465
+ <VariableInput value="{{ ctx.user.name }}" metaTree={() => flowContext.getPropertyMetaTree()} disabled />
466
+ </TestFlowContextWrapper>,
467
+ );
468
+
469
+ await waitFor(() => {
470
+ expect(screen.getByText('User/Name')).toBeInTheDocument();
471
+ });
472
+
473
+ const selectElement = container.querySelector('.ant-select.variable');
474
+ expect(selectElement).toHaveClass('ant-select-disabled');
475
+ expect(container.querySelector('.ant-select-clear')).not.toBeInTheDocument();
338
476
  });
339
477
 
340
478
  it('should handle empty metaTree', async () => {
@@ -54,6 +54,44 @@ describe('VariableTag', () => {
54
54
  expect(onClear).toBeInstanceOf(Function);
55
55
  });
56
56
 
57
+ it('keeps custom tag input enabled by default', async () => {
58
+ const onClear = vi.fn();
59
+ const { container } = renderWithCtx(<VariableTag value="{{ ctx.User.Email }}" onClear={onClear} />);
60
+
61
+ await waitFor(
62
+ () => {
63
+ expect(screen.getByText('User/Email')).toBeInTheDocument();
64
+ },
65
+ { timeout: 3000 },
66
+ );
67
+
68
+ const selectElement = container.querySelector('.ant-select.variable');
69
+ expect(selectElement).toBeInTheDocument();
70
+ expect(selectElement).toHaveClass('ant-select-show-search');
71
+ expect(container.querySelector('.ant-select-selection-search-input')).not.toHaveAttribute('readonly');
72
+ });
73
+
74
+ it('can disable custom tag input while keeping clear enabled', async () => {
75
+ const onClear = vi.fn();
76
+ const { container } = renderWithCtx(
77
+ <VariableTag value="{{ ctx.User.Email }}" onClear={onClear} allowCustomTagInput={false} />,
78
+ );
79
+
80
+ await waitFor(
81
+ () => {
82
+ expect(screen.getByText('User/Email')).toBeInTheDocument();
83
+ },
84
+ { timeout: 3000 },
85
+ );
86
+
87
+ const selectElement = container.querySelector('.ant-select.variable');
88
+ expect(selectElement).toBeInTheDocument();
89
+ expect(selectElement).not.toHaveClass('ant-select-disabled');
90
+ expect(selectElement).not.toHaveClass('ant-select-show-search');
91
+ expect(container.querySelector('.ant-select-clear')).toBeInTheDocument();
92
+ expect(container.querySelector('.ant-select-selection-search-input')).toHaveAttribute('readonly');
93
+ });
94
+
57
95
  it('should not show close button when onClear is not provided', async () => {
58
96
  const { container } = renderWithCtx(<VariableTag value="{{ ctx.User.Name }}" />);
59
97
 
@@ -166,6 +204,32 @@ describe('VariableTag', () => {
166
204
  }
167
205
  });
168
206
 
207
+ it('renders a red failure pill when the variable path cannot be resolved', async () => {
208
+ renderWithCtx(
209
+ <VariableTag
210
+ value="{{ ctx.missing.field }}"
211
+ metaTree={[
212
+ {
213
+ name: 'user',
214
+ title: 'User',
215
+ type: 'object',
216
+ paths: ['user'],
217
+ children: [{ name: 'name', title: 'Name', type: 'string', paths: ['user', 'name'] }],
218
+ },
219
+ ]}
220
+ />,
221
+ );
222
+
223
+ await waitFor(
224
+ () => {
225
+ const tag = screen.getByText('Variable parsing failed');
226
+ expect(tag).toBeInTheDocument();
227
+ expect(tag.closest('.ant-tag')).toHaveClass('ant-tag-error');
228
+ },
229
+ { timeout: 3000 },
230
+ );
231
+ });
232
+
169
233
  it('should render Select component with proper structure', async () => {
170
234
  const { container } = render(<VariableTag value="{{ ctx.Test }}" />);
171
235
 
@@ -233,6 +297,22 @@ describe('VariableTag', () => {
233
297
  expect(selectElement).not.toHaveClass('ant-select-disabled');
234
298
  });
235
299
 
300
+ it('does not show clear affordance when disabled even if onClear is provided', async () => {
301
+ const onClear = vi.fn();
302
+ const { container } = renderWithCtx(<VariableTag value="{{ ctx.Test }}" onClear={onClear} disabled />);
303
+
304
+ await waitFor(
305
+ () => {
306
+ expect(screen.getByText('Test')).toBeInTheDocument();
307
+ },
308
+ { timeout: 3000 },
309
+ );
310
+
311
+ const selectElement = container.querySelector('.ant-select.variable');
312
+ expect(selectElement).toHaveClass('ant-select-disabled');
313
+ expect(container.querySelector('.ant-select-clear')).not.toBeInTheDocument();
314
+ });
315
+
236
316
  it('should have proper accessibility attributes for Select component', async () => {
237
317
  const { container } = renderWithCtx(<VariableTag value="{{ ctx.Test }}" />);
238
318
 
@@ -94,7 +94,10 @@ export interface VariableInputProps {
94
94
 
95
95
  export interface VariableTagProps {
96
96
  value?: string;
97
+ resolvedPath?: Array<string | number>;
97
98
  onClear?: () => void;
99
+ disabled?: boolean;
100
+ allowCustomTagInput?: boolean;
98
101
  className?: string;
99
102
  style?: React.CSSProperties;
100
103
  metaTreeNode?: MetaTreeNode | null;
package/src/types.ts CHANGED
@@ -147,6 +147,8 @@ export enum ActionScene {
147
147
  DYNAMIC_EVENT_FLOW,
148
148
  /** 菜单项联动规则可用 */
149
149
  MENU_LINKAGE_RULES,
150
+ /** 标签页联动规则可用 */
151
+ TAB_LINKAGE_RULES,
150
152
  }
151
153
 
152
154
  /**