@nocobase/flow-engine 2.2.0-alpha.7 → 2.2.0-beta.10

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.
@@ -8,7 +8,6 @@
8
8
  */
9
9
 
10
10
  import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
11
- import { Input } from 'antd';
12
11
  import React from 'react';
13
12
  import { describe, expect, it, vi } from 'vitest';
14
13
  import type { ContextSelectorItem } from '../types';
@@ -113,62 +112,6 @@ describe('VariableInput', () => {
113
112
  expect(selectorButton).toBeInTheDocument();
114
113
  });
115
114
 
116
- it('disables the FlowContextSelector button when disabled', async () => {
117
- const flowContext = createTestFlowContext();
118
- render(
119
- <TestFlowContextWrapper context={flowContext}>
120
- <VariableInput value="test" metaTree={() => flowContext.getPropertyMetaTree()} disabled />
121
- </TestFlowContextWrapper>,
122
- );
123
-
124
- const selectorButton = await screen.findByRole('button');
125
- expect(selectorButton).toBeDisabled();
126
- });
127
-
128
- it('should not highlight the selector button for synthetic constant/null paths', async () => {
129
- const flowContext = createTestFlowContext();
130
-
131
- render(
132
- <TestFlowContextWrapper context={flowContext}>
133
- <VariableInput
134
- value=""
135
- metaTree={[
136
- { name: 'constant', title: 'Constant', type: 'string', paths: ['constant'] },
137
- { name: 'null', title: 'Null', type: 'object', paths: ['null'] },
138
- ...flowContext.getPropertyMetaTree(),
139
- ]}
140
- converters={{
141
- renderInputComponent: (meta) => {
142
- const first = meta?.paths?.[0];
143
- if (first === 'constant') {
144
- return (props: any) => <input aria-label="constant-value" {...props} />;
145
- }
146
- if (first === 'null') {
147
- return () => <Input placeholder="<Null>" readOnly />;
148
- }
149
- return null;
150
- },
151
- resolveValueFromPath: (meta) => {
152
- const first = meta?.paths?.[0];
153
- if (first === 'constant') return '';
154
- if (first === 'null') return null;
155
- return undefined;
156
- },
157
- resolvePathFromValue: (currentValue) => {
158
- if (currentValue === null) return ['null'];
159
- const trimmed = typeof currentValue === 'string' ? currentValue.trim() : currentValue;
160
- if (trimmed === '') return ['constant'];
161
- return undefined;
162
- },
163
- }}
164
- />
165
- </TestFlowContextWrapper>,
166
- );
167
-
168
- const selectorButton = await screen.findByRole('button');
169
- expect(selectorButton.className).not.toContain('ant-btn-primary');
170
- });
171
-
172
115
  it('should handle onChange from Input', async () => {
173
116
  const onChange = vi.fn();
174
117
  const flowContext = createTestFlowContext();
@@ -252,21 +195,20 @@ describe('VariableInput', () => {
252
195
 
253
196
  const selectElement = container.querySelector('.ant-select');
254
197
  expect(selectElement).toBeInTheDocument();
255
- if (!selectElement) {
256
- throw new Error('Expected variable tag select wrapper to be present');
257
- }
258
198
 
259
199
  // 触发鼠标悬停以显示清除按钮
260
- fireEvent.mouseEnter(selectElement);
200
+ fireEvent.mouseEnter(selectElement!);
261
201
 
262
202
  // 尝试触发清除功能
263
203
  // 方法1: 直接触发 Select 组件的 onClear 事件
204
+ const selectInstance = selectElement as any;
205
+
264
206
  // 尝试通过键盘事件触发清除
265
- fireEvent.keyDown(selectElement, { key: 'Backspace', code: 'Backspace' });
207
+ fireEvent.keyDown(selectElement!, { key: 'Backspace', code: 'Backspace' });
266
208
 
267
209
  // 或者尝试触发自定义的清除逻辑
268
210
  const clearEvents = new CustomEvent('clear');
269
- selectElement.dispatchEvent(clearEvents);
211
+ selectElement!.dispatchEvent(clearEvents);
270
212
 
271
213
  // 检查是否调用了清除功能(可能需要调整期望)
272
214
  // 如果清除按钮不能直接测试,我们验证组件支持清除功能
@@ -16,30 +16,12 @@ export interface FlowContextSelectorProps
16
16
  value?: string;
17
17
  onChange?: (value: string, metaTreeNode?: MetaTreeNode) => void;
18
18
  children?: CascaderProps<ContextSelectorItem>['children'];
19
- /**
20
- * Controls whether the default `x` trigger button is rendered as active
21
- * (`type="primary"`). When omitted, the selector falls back to its parsed
22
- * `value` path (`true` iff a valid variable path is currently selected).
23
- *
24
- * Use this when callers intentionally feed synthetic paths such as
25
- * `['constant']` / `['null']` into the cascader to keep menu state aligned,
26
- * but only want real variable references to show the blue active button.
27
- */
28
- active?: boolean;
29
19
  metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
30
20
  parseValueToPath?: (value: string) => string[] | undefined;
31
21
  formatPathToValue?: (item: MetaTreeNode) => string;
32
22
  open?: boolean;
33
23
  onlyLeafSelectable?: boolean;
34
24
  ignoreFieldNames?: string[];
35
- /**
36
- * Footer rendered at the bottom of the dropdown. Defaults to a muted
37
- * "Double click to choose entire object" hint when non-leaf selection is
38
- * allowed (`onlyLeafSelectable` is false) — since double-clicking a non-leaf
39
- * node selects the whole object. Pass an explicit node to override, or `null`
40
- * to hide it.
41
- */
42
- dropdownFooter?: React.ReactNode;
43
25
  }
44
26
 
45
27
  export interface ContextSelectorItem {
package/src/flowI18n.ts CHANGED
@@ -64,9 +64,7 @@ export class FlowI18n {
64
64
  * @private
65
65
  */
66
66
  private isTemplate(str: string): boolean {
67
- // The closing quote is a backreference to the opening one (group 1) so an embedded quote of a different type — e.g.
68
- // {{t('… "Post-action event" …')}} — does not terminate the key early.
69
- return /\{\{\s*t\s*\(\s*(["'`])(?:\\.|(?!\1).)*?\1\s*(?:,\s*.*?)?\s*\)\s*\}\}/.test(str);
67
+ return /\{\{\s*t\s*\(\s*["'`].*?["'`]\s*(?:,\s*.*?)?\s*\)\s*\}\}/g.test(str);
70
68
  }
71
69
 
72
70
  /**
@@ -74,12 +72,9 @@ export class FlowI18n {
74
72
  * @private
75
73
  */
76
74
  private compileTemplate(template: string): string {
77
- // `(["'`])` captures the opening quote; the key allows escaped chars (`\\.`) and any char that is not that same
78
- // quote (`(?!\1).`), and `\1` closes on the matching quote. This keeps embedded quotes of a different type inside
79
- // the key instead of truncating it at the first quote of any kind.
80
75
  return template.replace(
81
- /\{\{\s*t\s*\(\s*(["'`])((?:\\.|(?!\1).)*?)\1\s*(?:,\s*((?:[^{}]|\{[^}]*\})*?))?\s*\)\s*\}\}/g,
82
- (match, _quote, key, optionsStr) => {
76
+ /\{\{\s*t\s*\(\s*["'`](.*?)["'`]\s*(?:,\s*((?:[^{}]|\{[^}]*\})*?))?\s*\)\s*\}\}/g,
77
+ (match, key, optionsStr) => {
83
78
  try {
84
79
  let templateOptions = {};
85
80
  if (optionsStr) {
@@ -1,212 +0,0 @@
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
- /**
11
- * Pins how a `{{ … }}` reference renders as its label when the label lives below a lazily-loaded meta-tree level (e.g. a workflow node's output fields under `$jobsMapByNodeKey.<nodeKey>`). `buildLabelMap` only pre-walks already-loaded (array) children into a memoized map, so the two real user flows each need their own resolution path, both pinned here:
12
- * - after reload — the value is already a deep reference at mount, its level still an unresolved thunk → the preload effect resolves it.
13
- * - after picking — the user drills into a lazy level (cascader resolves it IN PLACE, no tree-ref change) then picks a leaf → the live walk of the tree's current contents resolves it on the same render.
14
- * Plus the top-level (already-loaded) and not-in-tree (raw-token fallback) cases.
15
- */
16
-
17
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
18
- import React from 'react';
19
- import { describe, expect, it, vi } from 'vitest';
20
- import { VariableHybridInput, type VariableHybridInputConverters } from '../VariableHybridInput';
21
- import type { MetaTreeNode } from '../../../flowContext';
22
- import { createTestFlowContext, TestFlowContextWrapper } from './test-utils';
23
-
24
- // Workflow-style converters: `{{$root.a.b}}` (dotted path, no inner spaces).
25
- const VARIABLE_REGEXP = /\{\{\s*([^{}]+?)\s*\}\}/g;
26
- const workflowConverters: VariableHybridInputConverters = {
27
- formatPathToValue: (item?: MetaTreeNode) => {
28
- const path = item?.paths ?? [];
29
- return path.length ? `{{${path.join('.')}}}` : '';
30
- },
31
- parseValueToPath: (value?: string) => {
32
- if (typeof value !== 'string') return undefined;
33
- const match = value.trim().match(/^\{\{\s*(.+?)\s*\}\}$/);
34
- return match ? match[1].split('.') : undefined;
35
- },
36
- variableRegExp: VARIABLE_REGEXP,
37
- };
38
-
39
- const TAG_SELECTOR = '.nb-variable-tag';
40
-
41
- describe('VariableHybridInput — saved reference labels', () => {
42
- it('renders a top-level (already-loaded) reference as its label, not the raw token', async () => {
43
- const flowContext = createTestFlowContext();
44
- const metaTree: MetaTreeNode[] = [
45
- {
46
- name: '$user',
47
- title: 'User',
48
- type: '',
49
- paths: ['$user'],
50
- children: [{ name: 'name', title: 'Name', type: 'string', paths: ['$user', 'name'] }],
51
- },
52
- ];
53
-
54
- render(
55
- <TestFlowContextWrapper context={flowContext}>
56
- <VariableHybridInput value="{{$user.name}}" metaTree={metaTree} converters={workflowConverters} />
57
- </TestFlowContextWrapper>,
58
- );
59
-
60
- await waitFor(() => {
61
- const tag = document.querySelector(TAG_SELECTOR);
62
- expect(tag).toBeTruthy();
63
- expect(tag?.textContent).toBe('User/Name');
64
- });
65
- });
66
-
67
- it('shows the label after reload: a deep reference present at mount preloads its lazy level', async () => {
68
- const flowContext = createTestFlowContext();
69
- // The node's output fields are behind a lazy `children` thunk — exactly the `$jobsMapByNodeKey.<nodeKey>` shape produced by the workflow adapter.
70
- const loadChildren = vi.fn(
71
- async (): Promise<MetaTreeNode[]> => [
72
- { name: 'name', title: 'Name', type: 'string', paths: ['$jobsMapByNodeKey', 'node1', 'name'] },
73
- ],
74
- );
75
- const metaTree: MetaTreeNode[] = [
76
- {
77
- name: '$jobsMapByNodeKey',
78
- title: 'Node result',
79
- type: '',
80
- paths: ['$jobsMapByNodeKey'],
81
- children: [
82
- {
83
- name: 'node1',
84
- title: 'Query',
85
- type: '',
86
- paths: ['$jobsMapByNodeKey', 'node1'],
87
- children: loadChildren,
88
- },
89
- ],
90
- },
91
- ];
92
-
93
- render(
94
- <TestFlowContextWrapper context={flowContext}>
95
- <VariableHybridInput
96
- value="{{$jobsMapByNodeKey.node1.name}}"
97
- metaTree={metaTree}
98
- converters={workflowConverters}
99
- />
100
- </TestFlowContextWrapper>,
101
- );
102
-
103
- // The lazy thunk is invoked by the preload, and the deep label appears.
104
- await waitFor(() => {
105
- expect(loadChildren).toHaveBeenCalled();
106
- const tag = document.querySelector(TAG_SELECTOR);
107
- expect(tag?.textContent).toBe('Node result/Query/Name');
108
- });
109
- // It must NOT leave the raw token visible.
110
- expect(document.body.textContent).not.toContain('{{$jobsMapByNodeKey.node1.name}}');
111
- });
112
-
113
- it('shows the label after picking: a level expanded in place then selected resolves live', async () => {
114
- // Reproduces drilling into a lazy level in the cascader: that resolves the node's `children` onto the SAME meta-tree object in place — WITHOUT changing the tree reference — then the user picks a leaf, so `value` updates. The memoized `labelMap` (keyed on the tree reference) still misses the freshly loaded leaf; only a live walk of the tree's current contents resolves it.
115
- const flowContext = createTestFlowContext();
116
- const node1: MetaTreeNode = {
117
- name: 'node1',
118
- title: 'Query',
119
- type: '',
120
- paths: ['$jobsMapByNodeKey', 'node1'],
121
- // Starts WITHOUT children (an unexpanded branch). No thunk — so the preload effect/`loadedFlag` path does not fire; the fix must come from the live walk.
122
- };
123
- const metaTree: MetaTreeNode[] = [
124
- {
125
- name: '$jobsMapByNodeKey',
126
- title: 'Node result',
127
- type: '',
128
- paths: ['$jobsMapByNodeKey'],
129
- children: [node1],
130
- },
131
- ];
132
-
133
- // Mount with no reference yet — the deep level is not loaded.
134
- const { rerender } = render(
135
- <TestFlowContextWrapper context={flowContext}>
136
- <VariableHybridInput value="" metaTree={metaTree} converters={workflowConverters} />
137
- </TestFlowContextWrapper>,
138
- );
139
-
140
- // Simulate the cascader's loadData: resolve node1's children in place on the SAME tree object (no new reference, no thunk).
141
- node1.children = [
142
- { name: 'name', title: 'Role name', type: 'string', paths: ['$jobsMapByNodeKey', 'node1', 'name'] },
143
- ];
144
-
145
- // The user picks the leaf → value updates to the deep reference.
146
- rerender(
147
- <TestFlowContextWrapper context={flowContext}>
148
- <VariableHybridInput
149
- value="{{$jobsMapByNodeKey.node1.name}}"
150
- metaTree={metaTree}
151
- converters={workflowConverters}
152
- />
153
- </TestFlowContextWrapper>,
154
- );
155
-
156
- await waitFor(() => {
157
- const tag = document.querySelector(TAG_SELECTOR);
158
- expect(tag?.textContent).toBe('Node result/Query/Role name');
159
- });
160
- expect(document.body.textContent).not.toContain('{{$jobsMapByNodeKey.node1.name}}');
161
- });
162
-
163
- it('falls back to the raw token when the reference is not in the tree', async () => {
164
- const flowContext = createTestFlowContext();
165
- const metaTree: MetaTreeNode[] = [{ name: '$user', title: 'User', type: '', paths: ['$user'], children: [] }];
166
-
167
- render(
168
- <TestFlowContextWrapper context={flowContext}>
169
- <VariableHybridInput value="{{$missing.field}}" metaTree={metaTree} converters={workflowConverters} />
170
- </TestFlowContextWrapper>,
171
- );
172
-
173
- await waitFor(() => {
174
- const tag = document.querySelector(TAG_SELECTOR);
175
- expect(tag?.textContent).toBe('{{$missing.field}}');
176
- });
177
- });
178
-
179
- it('keeps the selector enabled when only the editor is read-only', async () => {
180
- const flowContext = createTestFlowContext();
181
- const onChange = vi.fn();
182
- const metaTree: MetaTreeNode[] = [
183
- {
184
- name: '$user',
185
- title: 'User',
186
- type: '',
187
- paths: ['$user'],
188
- children: [{ name: 'name', title: 'Name', type: 'string', paths: ['$user', 'name'] }],
189
- },
190
- ];
191
-
192
- render(
193
- <TestFlowContextWrapper context={flowContext}>
194
- <VariableHybridInput
195
- readOnly
196
- value=""
197
- onChange={onChange}
198
- metaTree={metaTree}
199
- converters={workflowConverters}
200
- />
201
- </TestFlowContextWrapper>,
202
- );
203
-
204
- const editor = screen.getByRole('textbox');
205
- expect(editor).toHaveAttribute('contenteditable', 'false');
206
- expect(editor).toHaveAttribute('aria-readonly', 'true');
207
-
208
- fireEvent.input(editor, { currentTarget: { textContent: 'manual' } });
209
- expect(onChange).not.toHaveBeenCalled();
210
- expect(screen.getByRole('button')).not.toBeDisabled();
211
- });
212
- });