@nocobase/flow-engine 2.2.0-alpha.6 → 2.2.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,10 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, expect, it } from 'vitest';
11
- import { formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
10
+ import { render } from '@testing-library/react';
11
+ import React from 'react';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { FormItem, formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
12
14
 
13
15
  describe('FormItem', () => {
14
16
  it('keeps vertical label-to-value spacing consistent with v1', () => {
@@ -22,4 +24,17 @@ describe('FormItem', () => {
22
24
  marginBottom: 12,
23
25
  });
24
26
  });
27
+
28
+ it('does not forward model-internal globalSort props to field children', () => {
29
+ const Field = vi.fn(() => <input aria-label="field" />);
30
+
31
+ render(
32
+ <FormItem globalSort={['title']} placeholder="Title">
33
+ <Field />
34
+ </FormItem>,
35
+ );
36
+
37
+ expect(Field).toHaveBeenCalledWith(expect.not.objectContaining({ globalSort: expect.anything() }), {});
38
+ expect(Field).toHaveBeenCalledWith(expect.objectContaining({ placeholder: 'Title' }), {});
39
+ });
25
40
  });
@@ -0,0 +1,109 @@
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 React from 'react';
11
+ import { fireEvent, render, screen } from '@testing-library/react';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { MobilePopup } from '../MobilePopup';
14
+
15
+ vi.mock('react-i18next', () => ({
16
+ useTranslation: () => ({
17
+ t: (key: string) => key,
18
+ }),
19
+ }));
20
+
21
+ vi.mock('../MobilePopup.style', () => ({
22
+ useMobileActionDrawerStyle: () => ({
23
+ componentCls: 'nb-mobile-action-drawer',
24
+ hashId: 'hash',
25
+ }),
26
+ }));
27
+
28
+ vi.mock('../../lazy-helper', () => ({
29
+ lazy: (_loader: unknown, name: string) => {
30
+ if (name === 'Popup') {
31
+ const Popup = ({
32
+ children,
33
+ bodyStyle,
34
+ maskStyle,
35
+ style,
36
+ }: {
37
+ children: React.ReactNode;
38
+ bodyStyle?: React.CSSProperties;
39
+ maskStyle?: React.CSSProperties;
40
+ style?: React.CSSProperties;
41
+ }) => (
42
+ <div data-testid="mobile-popup" style={style}>
43
+ <div data-testid="mobile-popup-mask" style={maskStyle} />
44
+ <div data-testid="mobile-popup-body" style={bodyStyle}>
45
+ {children}
46
+ </div>
47
+ </div>
48
+ );
49
+
50
+ return { Popup };
51
+ }
52
+
53
+ return {
54
+ CloseOutline: () => <span data-testid="close-outline" />,
55
+ };
56
+ },
57
+ }));
58
+
59
+ describe('MobilePopup', () => {
60
+ const MobilePopupWithDrawerStyles = MobilePopup as React.ComponentType<
61
+ React.ComponentProps<typeof MobilePopup> & { styles?: { body?: React.CSSProperties } }
62
+ >;
63
+
64
+ it('applies drawer body styles as max bounds without forcing fixed half-window height', () => {
65
+ render(
66
+ <MobilePopupWithDrawerStyles visible title="Title" styles={{ body: { maxHeight: '50vh' } }} onClose={vi.fn()}>
67
+ body
68
+ </MobilePopupWithDrawerStyles>,
69
+ );
70
+
71
+ expect(screen.getByTestId('mobile-popup')).toHaveStyle({
72
+ maxHeight: '50vh',
73
+ });
74
+ expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
75
+ maxHeight: '50vh',
76
+ });
77
+ expect(screen.getByTestId('mobile-popup-mask')).not.toHaveStyle({
78
+ maxHeight: '50vh',
79
+ });
80
+ });
81
+
82
+ it('closes from the header close icon with Enter or Space', () => {
83
+ const onClose = vi.fn();
84
+ render(
85
+ <MobilePopup visible title="Title" onClose={onClose}>
86
+ body
87
+ </MobilePopup>,
88
+ );
89
+
90
+ const closeButton = screen.getByRole('button', { name: 'Close' });
91
+ fireEvent.keyDown(closeButton, { key: 'Enter' });
92
+ fireEvent.keyDown(closeButton, { key: ' ' });
93
+
94
+ expect(onClose).toHaveBeenCalledTimes(2);
95
+ });
96
+
97
+ it('keeps long header titles in a constrained title element separate from the close button', () => {
98
+ const longTitle = 'A very long table column title that should not push the close button outside the drawer';
99
+
100
+ render(
101
+ <MobilePopup visible title={longTitle} onClose={vi.fn()}>
102
+ body
103
+ </MobilePopup>,
104
+ );
105
+
106
+ expect(screen.getByText(longTitle)).toHaveClass('nb-mobile-action-drawer-title');
107
+ expect(screen.getByRole('button', { name: 'Close' })).toHaveClass('nb-mobile-action-drawer-close-icon');
108
+ });
109
+ });
@@ -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,31 @@ 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
+ // Those values are not recognized by `isVariableValue`, but if the active converters can resolve them back to a
205
+ // real meta-tree path and that path maps to a plain variable node (not Constant / Null / RunJS), they should still
206
+ // render as the labelled pill instead of falling back to the raw input text.
207
+ const shouldRenderVariableTag =
208
+ isVariableValue(innerValue) ||
209
+ (Boolean(resolvedMetaTreeNode) &&
210
+ Array.isArray(resolvedPath) &&
211
+ resolvedPath.length > 0 &&
212
+ !Component &&
213
+ !CustomComponent);
214
+ const finalComponent = shouldRenderVariableTag ? VariableTag : Component || CustomComponent || Input;
201
215
  return finalComponent;
202
- }, [renderInputComponent, resolvedMetaTreeNode, innerValue]);
216
+ }, [renderInputComponent, resolvedMetaTreeNode, innerValue, resolvedPath]);
217
+
218
+ const isVariableActive = useMemo(() => {
219
+ return (
220
+ isVariableValue(innerValue) ||
221
+ (Boolean(resolvedMetaTreeNode) &&
222
+ Array.isArray(resolvedPath) &&
223
+ resolvedPath.length > 0 &&
224
+ !renderInputComponent?.(resolvedMetaTreeNode) &&
225
+ !resolvedMetaTreeNode?.render)
226
+ );
227
+ }, [innerValue, renderInputComponent, resolvedMetaTreeNode, resolvedPath]);
203
228
 
204
229
  useEffect(() => {
205
230
  if (!resolvedMetaTreeNode) return;
@@ -303,6 +328,8 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
303
328
  return {
304
329
  ...baseProps,
305
330
  onClear: handleClear,
331
+ disabled,
332
+ allowCustomTagInput: false,
306
333
  metaTreeNode: resolvedMetaTreeNode,
307
334
  metaTree,
308
335
  style: stableProps.style,
@@ -356,7 +383,8 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
356
383
  <FlowContextSelector
357
384
  metaTree={resolvedMetaTree}
358
385
  value={innerValue}
359
- active={isVariableValue(innerValue)}
386
+ active={isVariableActive}
387
+ disabled={disabled}
360
388
  onChange={handleVariableSelect}
361
389
  parseValueToPath={resolvePathFromValue}
362
390
  formatPathToValue={resolveValueFromPath}
@@ -17,9 +17,13 @@ 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,
@@ -28,30 +32,30 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
28
32
  const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
29
33
  const ctx = useFlowContext();
30
34
 
31
- const { data: displayedValue } = useRequest(
35
+ const { data: displayState } = useRequest(
32
36
  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;
37
+ const resolveLabelFromPath = async (
38
+ rawPath?: (string | number)[],
39
+ ): Promise<{ label: string | null; resolved: boolean; attempted: boolean }> => {
40
+ if (!rawPath) return { label: null, resolved: false, attempted: false };
41
+ if (!Array.isArray(rawPath)) return { label: null, resolved: false, attempted: false };
42
+ if (!Array.isArray(resolvedMetaTree)) return { label: null, resolved: false, attempted: false };
37
43
 
38
44
  // 兼容 metaTree 为子树:顶层不含首段时,裁剪首段
39
45
  const topNames = new Set((resolvedMetaTree || []).map((n: any) => String(n?.name)));
40
46
  const path = !topNames.has(String(rawPath[0])) ? rawPath.slice(1) : rawPath;
41
- if (!path.length) return '';
47
+ if (!path.length) return { label: '', resolved: true, attempted: true };
42
48
 
43
49
  let nodes: MetaTreeNode[] | undefined = resolvedMetaTree as MetaTreeNode[];
44
50
  const titleChain: string[] = [];
45
- let matchedCount = 0;
46
51
 
47
52
  for (let i = 0; i < path.length; i++) {
48
- if (!nodes) break;
53
+ if (!nodes) return { label: null, resolved: false, attempted: true };
49
54
  const seg = String(path[i]);
50
55
  const node = nodes.find((n) => String(n?.name) === seg) as MetaTreeNode | undefined;
51
- if (!node) break; // 停在第一个无效段之前
56
+ if (!node) return { label: null, resolved: false, attempted: true };
52
57
 
53
58
  titleChain.push(String(node.title ?? node.name ?? seg));
54
- matchedCount = i + 1;
55
59
 
56
60
  if (i < path.length - 1) {
57
61
  if (Array.isArray(node.children)) {
@@ -70,34 +74,41 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
70
74
  }
71
75
  }
72
76
 
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;
77
+ return { label: titleChain.map(ctx.t).join('/'), resolved: true, attempted: true };
81
78
  };
82
79
 
83
80
  // 1) 优先使用已解析到的节点(包含完整父标题链)
84
81
  if (metaTreeNode?.parentTitles) {
85
- return [...metaTreeNode.parentTitles, metaTreeNode.title].map(ctx.t).join('/');
82
+ return {
83
+ text: [...metaTreeNode.parentTitles, metaTreeNode.title].map(ctx.t).join('/'),
84
+ invalid: false,
85
+ };
86
86
  }
87
87
 
88
88
  // 2) metaTreeNode 存在但缺少 parentTitles:尝试根据 value/metaTreeNode.paths 从 metaTree 还原完整路径
89
89
  if (metaTreeNode) {
90
90
  const rawPath = parseValueToPath(value) || metaTreeNode.paths;
91
- const label = await resolveLabelFromPath(rawPath as any);
92
- return label ?? ctx.t(metaTreeNode.title) ?? '';
91
+ const result = await resolveLabelFromPath(rawPath as any);
92
+ if (result.resolved && result.label != null) {
93
+ return { text: result.label, invalid: false };
94
+ }
95
+ if (result.attempted) {
96
+ return { text: VARIABLE_PARSING_FAILED_TEXT, invalid: true, rawValue: String(value ?? '') };
97
+ }
98
+ return { text: ctx.t(metaTreeNode.title) ?? '', invalid: false };
93
99
  }
94
100
 
95
101
  // 3) 无 metaTreeNode:从 value 还原路径并拼接标题链;若找不到任何前缀则回退原始路径字符串
96
- if (!value) return String(value);
102
+ if (!value) return { text: String(value), invalid: false };
97
103
  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);
104
+ const result = await resolveLabelFromPath(rawPath as any);
105
+ if (result.resolved && result.label != null) {
106
+ return { text: result.label, invalid: false };
107
+ }
108
+ if (result.attempted) {
109
+ return { text: VARIABLE_PARSING_FAILED_TEXT, invalid: true, rawValue: String(value ?? '') };
110
+ }
111
+ return { text: Array.isArray(rawPath) ? rawPath.join('/') : String(value), invalid: false };
101
112
  },
102
113
  { refreshDeps: [resolvedMetaTree, value, metaTreeNode] },
103
114
  );
@@ -119,13 +130,13 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
119
130
  `;
120
131
 
121
132
  const customTagRender = (props: any) => {
122
- const { label } = props;
123
- const fullText = typeof label === 'string' ? label : String(label);
133
+ const fullText = displayState?.text || (typeof props.label === 'string' ? props.label : String(props.label));
134
+ const tooltipText = displayState?.invalid ? displayState.rawValue || fullText : fullText;
124
135
 
125
136
  return (
126
- <Tooltip title={fullText} placement="top" getPopupContainer={() => document.body}>
137
+ <Tooltip title={tooltipText} placement="top" getPopupContainer={() => document.body}>
127
138
  <Tag
128
- color="blue"
139
+ color={displayState?.invalid ? 'error' : 'blue'}
129
140
  style={{
130
141
  margin: `0 ${token.marginXXS || token.marginXS}px`,
131
142
  borderRadius: token.borderRadiusSM,
@@ -166,12 +177,14 @@ const VariableTagComponent: React.FC<VariableTagProps> = ({
166
177
  flex: '1 1 auto',
167
178
  ...style,
168
179
  }}
169
- value={displayedValue ? [displayedValue] : []}
170
- mode="tags"
180
+ value={displayState?.text ? [displayState.text] : []}
181
+ mode={allowCustomTagInput ? 'tags' : 'multiple'}
171
182
  open={false}
172
- allowClear={!!onClear}
173
- onClear={onClear}
174
- disabled={!onClear}
183
+ showSearch={allowCustomTagInput}
184
+ searchValue={allowCustomTagInput ? undefined : ''}
185
+ allowClear={!disabled && !!onClear}
186
+ onClear={disabled ? undefined : onClear}
187
+ disabled={disabled || !onClear}
175
188
  variant="outlined"
176
189
  suffixIcon={null}
177
190
  tagRender={customTagRender}
@@ -101,6 +101,81 @@ 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('disables custom tag input when rendering a selected variable', async () => {
159
+ const flowContext = createTestFlowContext();
160
+ const { container } = render(
161
+ <TestFlowContextWrapper context={flowContext}>
162
+ <VariableInput value="{{ ctx.user.name }}" metaTree={() => flowContext.getPropertyMetaTree()} />
163
+ </TestFlowContextWrapper>,
164
+ );
165
+
166
+ await waitFor(
167
+ () => {
168
+ expect(screen.getByText('User/Name')).toBeInTheDocument();
169
+ },
170
+ { timeout: 3000 },
171
+ );
172
+
173
+ const selectElement = container.querySelector('.ant-select.variable');
174
+ expect(selectElement).toBeInTheDocument();
175
+ expect(selectElement).not.toHaveClass('ant-select-show-search');
176
+ expect(container.querySelector('.ant-select-selection-search-input')).toHaveAttribute('readonly');
177
+ });
178
+
104
179
  it('should render FlowContextSelector button', async () => {
105
180
  const flowContext = createTestFlowContext();
106
181
  render(
@@ -113,6 +188,18 @@ describe('VariableInput', () => {
113
188
  expect(selectorButton).toBeInTheDocument();
114
189
  });
115
190
 
191
+ it('disables the FlowContextSelector button when disabled', async () => {
192
+ const flowContext = createTestFlowContext();
193
+ render(
194
+ <TestFlowContextWrapper context={flowContext}>
195
+ <VariableInput value="test" metaTree={() => flowContext.getPropertyMetaTree()} disabled />
196
+ </TestFlowContextWrapper>,
197
+ );
198
+
199
+ const selectorButton = await screen.findByRole('button');
200
+ expect(selectorButton).toBeDisabled();
201
+ });
202
+
116
203
  it('should not highlight the selector button for synthetic constant/null paths', async () => {
117
204
  const flowContext = createTestFlowContext();
118
205
 
@@ -322,7 +409,24 @@ describe('VariableInput', () => {
322
409
  expect(input).toHaveClass('custom-class');
323
410
 
324
411
  // Note: The disabled prop might not be correctly passed through in the current implementation
325
- // This is a known limitation of the current component design
412
+ expect(input).toBeDisabled();
413
+ });
414
+
415
+ it('disables the rendered variable tag when the input is disabled', async () => {
416
+ const flowContext = createTestFlowContext();
417
+ const { container } = render(
418
+ <TestFlowContextWrapper context={flowContext}>
419
+ <VariableInput value="{{ ctx.user.name }}" metaTree={() => flowContext.getPropertyMetaTree()} disabled />
420
+ </TestFlowContextWrapper>,
421
+ );
422
+
423
+ await waitFor(() => {
424
+ expect(screen.getByText('User/Name')).toBeInTheDocument();
425
+ });
426
+
427
+ const selectElement = container.querySelector('.ant-select.variable');
428
+ expect(selectElement).toHaveClass('ant-select-disabled');
429
+ expect(container.querySelector('.ant-select-clear')).not.toBeInTheDocument();
326
430
  });
327
431
 
328
432
  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
 
@@ -95,6 +95,8 @@ export interface VariableInputProps {
95
95
  export interface VariableTagProps {
96
96
  value?: string;
97
97
  onClear?: () => void;
98
+ disabled?: boolean;
99
+ allowCustomTagInput?: boolean;
98
100
  className?: string;
99
101
  style?: React.CSSProperties;
100
102
  metaTreeNode?: MetaTreeNode | null;
package/src/flowEngine.ts CHANGED
@@ -1343,6 +1343,9 @@ export class FlowEngine {
1343
1343
  if (!this.ensureModelRepository()) return;
1344
1344
  const refresh = !!options?.refresh;
1345
1345
  const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
1346
+ if (this.context.flowSettingsEnabled) {
1347
+ this._loadedPageCache.markDirtyForOptions(options);
1348
+ }
1346
1349
  if (!refresh && !bypassLoadedPageCache) {
1347
1350
  const model = this.findModelByParentId(options.parentId, options.subKey);
1348
1351
  if (model) {
@@ -1412,6 +1415,9 @@ export class FlowEngine {
1412
1415
  if (!this.ensureModelRepository()) return;
1413
1416
  const { uid, parentId, subKey } = options;
1414
1417
  const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
1418
+ if (this.context.flowSettingsEnabled) {
1419
+ this._loadedPageCache.markDirtyForOptions(options);
1420
+ }
1415
1421
  if (uid && !bypassLoadedPageCache && this._modelInstances.has(uid)) {
1416
1422
  return this._modelInstances.get(uid) as T;
1417
1423
  }