@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.
- package/lib/components/FlowContextSelector.js +12 -55
- package/lib/components/FormItem.js +7 -11
- package/lib/components/variables/VariableHybridInput.d.ts +0 -9
- package/lib/components/variables/VariableHybridInput.js +17 -146
- package/lib/components/variables/VariableInput.js +1 -3
- package/lib/components/variables/types.d.ts +0 -18
- package/lib/flowI18n.js +3 -3
- package/package.json +4 -4
- package/src/__tests__/flowI18n.test.ts +0 -11
- package/src/components/FlowContextSelector.tsx +11 -66
- package/src/components/FormItem.tsx +7 -12
- package/src/components/__tests__/FormItem.test.tsx +2 -17
- package/src/components/variables/VariableHybridInput.tsx +14 -185
- package/src/components/variables/VariableInput.tsx +1 -3
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +3 -60
- package/src/components/variables/__tests__/VariableInput.test.tsx +5 -63
- package/src/components/variables/types.ts +0 -18
- package/src/flowI18n.ts +3 -8
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +0 -212
|
@@ -40,18 +40,6 @@ type SelectedPathInfo = {
|
|
|
40
40
|
meta?: ContextSelectorItem['meta'];
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
type MetaNodeTooltipOptions = { tooltip?: React.ReactNode };
|
|
44
|
-
|
|
45
|
-
function getMetaNodeTooltip(meta?: ContextSelectorItem['meta']): React.ReactNode {
|
|
46
|
-
if (!meta) {
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const metaWithTooltip = meta as ContextSelectorItem['meta'] & MetaNodeTooltipOptions;
|
|
51
|
-
const options = meta.options as MetaNodeTooltipOptions | undefined;
|
|
52
|
-
return metaWithTooltip.tooltip ?? options?.tooltip;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
43
|
const normalizePath = (path: unknown): string[] | undefined => {
|
|
56
44
|
if (!Array.isArray(path)) {
|
|
57
45
|
return undefined;
|
|
@@ -97,7 +85,6 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
97
85
|
value,
|
|
98
86
|
onChange,
|
|
99
87
|
children,
|
|
100
|
-
active,
|
|
101
88
|
metaTree,
|
|
102
89
|
showSearch = false,
|
|
103
90
|
parseValueToPath: customParseValueToPath = parseValueToPath,
|
|
@@ -105,7 +92,6 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
105
92
|
open,
|
|
106
93
|
onlyLeafSelectable = false,
|
|
107
94
|
ignoreFieldNames,
|
|
108
|
-
dropdownFooter,
|
|
109
95
|
...cascaderProps
|
|
110
96
|
}) => {
|
|
111
97
|
const { token } = theme.useToken();
|
|
@@ -133,28 +119,17 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
133
119
|
|
|
134
120
|
// 文本国际化:仅当 label 为字符串时进行翻译
|
|
135
121
|
const baseLabel = typeof o.label === 'string' ? flowCtx.t(o.label) : o.label;
|
|
136
|
-
const labelText = typeof baseLabel === 'string' ? baseLabel : String(o.value);
|
|
137
|
-
const tooltip = getMetaNodeTooltip(meta);
|
|
138
|
-
const tooltipTitle = disabled
|
|
139
|
-
? disabledReason || tooltip || flowCtx.t('This variable is not available')
|
|
140
|
-
: tooltip;
|
|
141
122
|
|
|
142
|
-
const label =
|
|
123
|
+
const label = disabled ? (
|
|
143
124
|
<span>
|
|
144
125
|
{baseLabel}
|
|
145
126
|
<Tooltip
|
|
146
|
-
title={
|
|
147
|
-
placement="
|
|
148
|
-
|
|
127
|
+
title={disabledReason || flowCtx.t('This variable is not available')}
|
|
128
|
+
placement="right"
|
|
129
|
+
overlayClassName="flow-variable-disabled-tip"
|
|
149
130
|
destroyTooltipOnHide
|
|
150
131
|
>
|
|
151
|
-
<QuestionCircleOutlined
|
|
152
|
-
aria-label={`${labelText} tooltip`}
|
|
153
|
-
style={{
|
|
154
|
-
marginLeft: token.marginXXS,
|
|
155
|
-
color: disabled ? token.colorTextDisabled : token.colorTextDescription,
|
|
156
|
-
}}
|
|
157
|
-
/>
|
|
132
|
+
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.35)' }} />
|
|
158
133
|
</Tooltip>
|
|
159
134
|
</span>
|
|
160
135
|
) : (
|
|
@@ -169,7 +144,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
169
144
|
};
|
|
170
145
|
});
|
|
171
146
|
},
|
|
172
|
-
[flowCtx
|
|
147
|
+
[flowCtx],
|
|
173
148
|
);
|
|
174
149
|
|
|
175
150
|
// 用于强制重新渲染的状态
|
|
@@ -290,13 +265,13 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
290
265
|
|
|
291
266
|
// 默认按钮组件
|
|
292
267
|
const defaultChildren = useMemo(() => {
|
|
293
|
-
const hasSelected =
|
|
268
|
+
const hasSelected = currentPath && currentPath.length > 0;
|
|
294
269
|
return (
|
|
295
|
-
<Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle}
|
|
270
|
+
<Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle}>
|
|
296
271
|
x
|
|
297
272
|
</Button>
|
|
298
273
|
);
|
|
299
|
-
}, [
|
|
274
|
+
}, [currentPath]);
|
|
300
275
|
|
|
301
276
|
// 处理选择变化事件
|
|
302
277
|
const handleChange = useCallback(
|
|
@@ -385,41 +360,12 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
385
360
|
[cascaderOnDropdownVisibleChange, open],
|
|
386
361
|
);
|
|
387
362
|
|
|
388
|
-
// Footer hint at the bottom of the dropdown. Defaults to the "double click to choose entire object" hint whenever
|
|
389
|
-
// non-leaf selection is allowed (double-clicking a non-leaf selects the whole object). Callers can override with
|
|
390
|
-
// their own node, or pass `null` to hide it.
|
|
391
|
-
const footerNode = useMemo(() => {
|
|
392
|
-
if (dropdownFooter !== undefined) {
|
|
393
|
-
return dropdownFooter;
|
|
394
|
-
}
|
|
395
|
-
if (onlyLeafSelectable) {
|
|
396
|
-
return null;
|
|
397
|
-
}
|
|
398
|
-
return (
|
|
399
|
-
<div
|
|
400
|
-
className={css`
|
|
401
|
-
padding: 6px 12px;
|
|
402
|
-
color: ${token.colorTextDescription};
|
|
403
|
-
border-top: 1px solid ${token.colorSplit};
|
|
404
|
-
font-size: ${token.fontSizeSM}px;
|
|
405
|
-
`}
|
|
406
|
-
>
|
|
407
|
-
{flowCtx.t('Double click to choose entire object')}
|
|
408
|
-
</div>
|
|
409
|
-
);
|
|
410
|
-
}, [dropdownFooter, onlyLeafSelectable, token, flowCtx]);
|
|
411
|
-
|
|
412
363
|
const renderDropdown = useCallback(
|
|
413
364
|
(menu: React.ReactElement) => {
|
|
414
365
|
const cascaderMenuNode = cascaderDropdownRender ? cascaderDropdownRender(menu) : menu;
|
|
415
366
|
const cascaderMenu = React.isValidElement(cascaderMenuNode) ? cascaderMenuNode : <>{cascaderMenuNode}</>;
|
|
416
367
|
if (!isSearchEnabled || children === null) {
|
|
417
|
-
return
|
|
418
|
-
<>
|
|
419
|
-
{cascaderMenu}
|
|
420
|
-
{footerNode}
|
|
421
|
-
</>
|
|
422
|
-
);
|
|
368
|
+
return cascaderMenu;
|
|
423
369
|
}
|
|
424
370
|
|
|
425
371
|
return (
|
|
@@ -435,11 +381,10 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
435
381
|
/>
|
|
436
382
|
</div>
|
|
437
383
|
{cascaderMenu}
|
|
438
|
-
{footerNode}
|
|
439
384
|
</>
|
|
440
385
|
);
|
|
441
386
|
},
|
|
442
|
-
[cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText
|
|
387
|
+
[cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText],
|
|
443
388
|
);
|
|
444
389
|
|
|
445
390
|
const inlinePlaceholder =
|
|
@@ -54,20 +54,15 @@ const formItemPropKeys: (keyof ExtendedFormItemProps)[] = [
|
|
|
54
54
|
'showLabel',
|
|
55
55
|
];
|
|
56
56
|
|
|
57
|
-
const modelInternalPropKeys = ['globalSort'];
|
|
58
|
-
|
|
59
57
|
export const FormItem = ({
|
|
60
58
|
children,
|
|
61
59
|
showLabel = true,
|
|
62
60
|
labelWidth,
|
|
63
61
|
...rest
|
|
64
62
|
}: ExtendedFormItemProps & ChildExtraProps) => {
|
|
65
|
-
const forwardedRest = Object.fromEntries(
|
|
66
|
-
Object.entries(rest).filter(([key]) => !modelInternalPropKeys.includes(key)),
|
|
67
|
-
) as ExtendedFormItemProps & ChildExtraProps;
|
|
68
63
|
// 过滤掉 Form.Item 专用 props,只保留要传给子组件的
|
|
69
64
|
const childProps = Object.fromEntries(
|
|
70
|
-
Object.entries(
|
|
65
|
+
Object.entries(rest).filter(([key]) => !formItemPropKeys.includes(key as keyof ExtendedFormItemProps)),
|
|
71
66
|
);
|
|
72
67
|
|
|
73
68
|
const processedChildren =
|
|
@@ -79,7 +74,7 @@ export const FormItem = ({
|
|
|
79
74
|
}
|
|
80
75
|
return child;
|
|
81
76
|
});
|
|
82
|
-
const { label, labelWrap, colon = true, layout } =
|
|
77
|
+
const { label, labelWrap, colon = true, layout } = rest;
|
|
83
78
|
const effectiveLabelWrap = !layout || layout === 'vertical' ? true : labelWrap;
|
|
84
79
|
const labelColStyle =
|
|
85
80
|
layout === 'vertical' ? { width: labelWidth, ...verticalFormItemLabelStyle } : { width: labelWidth };
|
|
@@ -127,17 +122,17 @@ export const FormItem = ({
|
|
|
127
122
|
};
|
|
128
123
|
return (
|
|
129
124
|
<Form.Item
|
|
130
|
-
{...
|
|
131
|
-
style={{ ...formItemStyle, ...
|
|
125
|
+
{...rest}
|
|
126
|
+
style={{ ...formItemStyle, ...rest.style }}
|
|
132
127
|
labelCol={{ style: labelColStyle }}
|
|
133
128
|
layout={layout}
|
|
134
129
|
label={renderLabel()}
|
|
135
130
|
colon={false}
|
|
136
|
-
extra={
|
|
131
|
+
extra={rest.extra && <span style={{ whiteSpace: 'pre-wrap' }}>{rest.extra}</span>}
|
|
137
132
|
tooltip={
|
|
138
|
-
|
|
133
|
+
rest.tooltip &&
|
|
139
134
|
({
|
|
140
|
-
title:
|
|
135
|
+
title: rest.tooltip,
|
|
141
136
|
overlayInnerStyle: { whiteSpace: 'pre-line' },
|
|
142
137
|
} as TooltipProps)
|
|
143
138
|
}
|
|
@@ -7,10 +7,8 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
-
import { FormItem, formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import { formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
|
|
14
12
|
|
|
15
13
|
describe('FormItem', () => {
|
|
16
14
|
it('keeps vertical label-to-value spacing consistent with v1', () => {
|
|
@@ -24,17 +22,4 @@ describe('FormItem', () => {
|
|
|
24
22
|
marginBottom: 12,
|
|
25
23
|
});
|
|
26
24
|
});
|
|
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
|
-
});
|
|
40
25
|
});
|
|
@@ -9,17 +9,12 @@
|
|
|
9
9
|
|
|
10
10
|
import { css, cx } from '@emotion/css';
|
|
11
11
|
import { Space, theme } from 'antd';
|
|
12
|
-
import {
|
|
13
|
-
import React, { isValidElement, useContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
12
|
+
import React, { isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
14
13
|
import type { MetaTreeNode } from '../../flowContext';
|
|
15
14
|
import { useFlowContext } from '../../FlowContextProvider';
|
|
16
15
|
import { FlowContextSelector } from '../FlowContextSelector';
|
|
17
16
|
import { useResolvedMetaTree } from './useResolvedMetaTree';
|
|
18
|
-
import {
|
|
19
|
-
formatPathToValue as defaultFormatPathToValue,
|
|
20
|
-
loadMetaTreeChildren,
|
|
21
|
-
parseValueToPath as defaultParseValueToPath,
|
|
22
|
-
} from './utils';
|
|
17
|
+
import { formatPathToValue as defaultFormatPathToValue, parseValueToPath as defaultParseValueToPath } from './utils';
|
|
23
18
|
|
|
24
19
|
type RangeIndexes = [number, number, number, number];
|
|
25
20
|
|
|
@@ -36,21 +31,12 @@ export interface VariableHybridInputProps {
|
|
|
36
31
|
value?: string;
|
|
37
32
|
onChange?: (value: string) => void;
|
|
38
33
|
disabled?: boolean;
|
|
39
|
-
readOnly?: boolean;
|
|
40
34
|
placeholder?: string;
|
|
41
35
|
addonBefore?: React.ReactNode;
|
|
42
36
|
metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
|
|
43
37
|
converters?: VariableHybridInputConverters;
|
|
44
38
|
style?: React.CSSProperties;
|
|
45
39
|
className?: string;
|
|
46
|
-
/**
|
|
47
|
-
* Validation status — turns the input border red (`error`) or amber
|
|
48
|
-
* (`warning`). Usually omitted: when rendered inside an antd `Form.Item`, the
|
|
49
|
-
* status is read automatically from `FormItemInputContext`, so dropping this
|
|
50
|
-
* into a `Form.Item` with failing rules colours the border with no extra
|
|
51
|
-
* wiring. An explicit prop wins over the inherited form status.
|
|
52
|
-
*/
|
|
53
|
-
status?: 'error' | 'warning';
|
|
54
40
|
}
|
|
55
41
|
|
|
56
42
|
function reactNodeToPlainText(node: React.ReactNode): string {
|
|
@@ -97,38 +83,14 @@ function normalizeVariableKey(value: string): string {
|
|
|
97
83
|
.trim();
|
|
98
84
|
}
|
|
99
85
|
|
|
100
|
-
function renderHTML(value: string,
|
|
86
|
+
function renderHTML(value: string, labelMap: Map<string, string>, regExp: RegExp) {
|
|
101
87
|
const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
|
|
102
88
|
return escapeHtml(value || '').replace(re, (matched) => {
|
|
103
|
-
const label =
|
|
89
|
+
const label = labelMap.get(normalizeVariableKey(matched)) || matched;
|
|
104
90
|
return createTagHTML(matched, label);
|
|
105
91
|
});
|
|
106
92
|
}
|
|
107
93
|
|
|
108
|
-
// Resolve a `{{ … }}` reference path to its slash-joined title chain by walking the (possibly lazily-expanded) meta tree
|
|
109
|
-
// live. Unlike `buildLabelMap` — which pre-walks only already-loaded (array) children into a memoized map — this reads
|
|
110
|
-
// the tree's CURRENT contents at call time, so a level expanded in place (by the cascader's loadData when the user
|
|
111
|
-
// drills in, or by the preload effect) is reflected on the very next render without the memoized map having to rebuild.
|
|
112
|
-
// This is what fixes a just-picked deep node rendering as its raw `{{ … }}` token. Returns undefined if any segment is
|
|
113
|
-
// missing or sits below a still-unresolved (thunk) level — the caller then falls back to the raw token.
|
|
114
|
-
function resolveTitlesByPath(
|
|
115
|
-
roots: MetaTreeNode[] | undefined,
|
|
116
|
-
path: string[] | undefined,
|
|
117
|
-
ctxT: (text: string) => string,
|
|
118
|
-
): string | undefined {
|
|
119
|
-
if (!roots || !path || !path.length) return undefined;
|
|
120
|
-
const titles: string[] = [];
|
|
121
|
-
let nodes: MetaTreeNode[] | undefined = roots;
|
|
122
|
-
for (const segment of path) {
|
|
123
|
-
if (!nodes) return undefined;
|
|
124
|
-
const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
|
|
125
|
-
if (!matched) return undefined;
|
|
126
|
-
titles.push(reactNodeToPlainText(matched.title || matched.name));
|
|
127
|
-
nodes = Array.isArray(matched.children) ? (matched.children as MetaTreeNode[]) : undefined;
|
|
128
|
-
}
|
|
129
|
-
return titles.map(ctxT).join('/');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
94
|
function buildLabelMap(
|
|
133
95
|
nodes: MetaTreeNode[] | undefined,
|
|
134
96
|
ctxT: (text: string) => string,
|
|
@@ -154,42 +116,6 @@ function buildLabelMap(
|
|
|
154
116
|
return map;
|
|
155
117
|
}
|
|
156
118
|
|
|
157
|
-
// Collect every variable reference path in `value` (one per `{{ … }}` token). Used to preload lazy meta-tree levels so
|
|
158
|
-
// a saved reference whose label lives below an unexpanded (thunk) level still resolves to a readable tag.
|
|
159
|
-
function collectReferencePaths(
|
|
160
|
-
value: string,
|
|
161
|
-
regExp: RegExp,
|
|
162
|
-
parseValueToPath: (value?: string) => string[] | undefined,
|
|
163
|
-
): string[][] {
|
|
164
|
-
const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
|
|
165
|
-
const paths: string[][] = [];
|
|
166
|
-
for (const matched of value.match(re) ?? []) {
|
|
167
|
-
const path = parseValueToPath(matched);
|
|
168
|
-
if (path && path.length) {
|
|
169
|
-
paths.push(path);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return paths;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Walk one reference path down the meta tree, resolving each lazy `children` thunk in place. Returns true if it
|
|
176
|
-
// resolved at least one level (so the caller knows to recompute the label map). Mirrors `TypedVariableInput`'s preload.
|
|
177
|
-
async function preloadReferencePath(path: string[], roots: MetaTreeNode[]): Promise<boolean> {
|
|
178
|
-
let nodes: MetaTreeNode[] | undefined = roots;
|
|
179
|
-
let didLoad = false;
|
|
180
|
-
for (const segment of path) {
|
|
181
|
-
if (!nodes) break;
|
|
182
|
-
const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
|
|
183
|
-
if (!matched) break;
|
|
184
|
-
if (typeof matched.children === 'function') {
|
|
185
|
-
matched.children = await loadMetaTreeChildren(matched);
|
|
186
|
-
didLoad = true;
|
|
187
|
-
}
|
|
188
|
-
nodes = Array.isArray(matched.children) ? matched.children : undefined;
|
|
189
|
-
}
|
|
190
|
-
return didLoad;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
119
|
function pasteHTML(container: HTMLElement, html: string, indexes?: RangeIndexes) {
|
|
194
120
|
const selection = window.getSelection?.();
|
|
195
121
|
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
|
@@ -296,14 +222,10 @@ function getCurrentRange(element: HTMLElement): RangeIndexes {
|
|
|
296
222
|
}
|
|
297
223
|
|
|
298
224
|
const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props) => {
|
|
299
|
-
const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder,
|
|
225
|
+
const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, style } = props;
|
|
300
226
|
const { token } = theme.useToken();
|
|
301
227
|
const ctx = useFlowContext();
|
|
302
228
|
const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
|
|
303
|
-
// Inherit the antd Form.Item validation status (red/amber border) unless an explicit `status` prop overrides it — so
|
|
304
|
-
// the border colours automatically inside a failing `Form.Item`, no extra wiring for callers.
|
|
305
|
-
const formItemStatus = useContext(FormItemInputContext)?.status;
|
|
306
|
-
const effectiveStatus = props.status ?? formItemStatus;
|
|
307
229
|
const inputRef = useRef<HTMLDivElement>(null);
|
|
308
230
|
const [isComposing, setIsComposing] = useState(false);
|
|
309
231
|
const [changed, setChanged] = useState(false);
|
|
@@ -311,63 +233,13 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
311
233
|
|
|
312
234
|
const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : String(props.value);
|
|
313
235
|
const variableRegExp = converters?.variableRegExp ?? DEFAULT_VARIABLE_REGEXP;
|
|
314
|
-
const parseValueToPath = converters?.parseValueToPath ?? defaultParseValueToPath;
|
|
315
|
-
|
|
316
|
-
// Bumped after a saved reference's lazy meta-tree levels are resolved, so the label map (below) recomputes once the
|
|
317
|
-
// deep titles are actually loaded.
|
|
318
|
-
const [loadedFlag, setLoadedFlag] = useState(0);
|
|
319
|
-
|
|
320
|
-
// Preload the lazy levels every `{{ … }}` reference in `value` points through. `buildLabelMap` only walks
|
|
321
|
-
// already-loaded (array) children, so without this a reference below an unexpanded thunk level (e.g. a workflow
|
|
322
|
-
// node's output fields under `$jobsMapByNodeKey.<nodeKey>`) renders as the raw `{{ … }}` text instead of its
|
|
323
|
-
// node/field labels. Mirrors `TypedVariableInput`'s preload.
|
|
324
|
-
useEffect(() => {
|
|
325
|
-
if (!value || !Array.isArray(resolvedMetaTree) || !resolvedMetaTree.length) {
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
const paths = collectReferencePaths(value, variableRegExp, parseValueToPath);
|
|
329
|
-
if (!paths.length) {
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
let cancelled = false;
|
|
333
|
-
const run = async () => {
|
|
334
|
-
let didLoad = false;
|
|
335
|
-
for (const path of paths) {
|
|
336
|
-
const loaded = await preloadReferencePath(path, resolvedMetaTree as MetaTreeNode[]);
|
|
337
|
-
if (cancelled) return;
|
|
338
|
-
didLoad = didLoad || loaded;
|
|
339
|
-
}
|
|
340
|
-
if (didLoad && !cancelled) {
|
|
341
|
-
setLoadedFlag((prev) => prev + 1);
|
|
342
|
-
}
|
|
343
|
-
};
|
|
344
|
-
run();
|
|
345
|
-
return () => {
|
|
346
|
-
cancelled = true;
|
|
347
|
-
};
|
|
348
|
-
}, [value, resolvedMetaTree, variableRegExp, parseValueToPath]);
|
|
349
236
|
|
|
350
237
|
const labelMap = useMemo(
|
|
351
238
|
() => buildLabelMap(resolvedMetaTree as MetaTreeNode[] | undefined, ctx.t, converters),
|
|
352
|
-
|
|
353
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
354
|
-
[resolvedMetaTree, ctx, converters, loadedFlag],
|
|
355
|
-
);
|
|
356
|
-
|
|
357
|
-
// Resolve one `{{ … }}` token to its label: the pre-built map first (cheap, covers statically-loaded levels), then a LIVE walk of the current meta tree. The live fallback is what makes a just-picked deep reference render its label immediately — when the user drills into a lazy level, the cascader resolves that level onto the meta tree in place WITHOUT changing the tree reference or bumping `loadedFlag`, so the memoized `labelMap` still misses it; walking the tree's current contents finds the freshly-loaded titles on the same render.
|
|
358
|
-
const resolveLabel = useCallback(
|
|
359
|
-
(matched: string): string | undefined => {
|
|
360
|
-
const mapped = labelMap.get(normalizeVariableKey(matched));
|
|
361
|
-
if (mapped) return mapped;
|
|
362
|
-
const path = parseValueToPath(matched);
|
|
363
|
-
return resolveTitlesByPath(resolvedMetaTree as MetaTreeNode[] | undefined, path, ctx.t);
|
|
364
|
-
},
|
|
365
|
-
// `loadedFlag` is read so a resolved lazy level re-creates this callback and re-renders the tags. `ctx` carries the translation fn.
|
|
366
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
367
|
-
[labelMap, parseValueToPath, resolvedMetaTree, ctx, loadedFlag],
|
|
239
|
+
[resolvedMetaTree, ctx, converters],
|
|
368
240
|
);
|
|
369
241
|
|
|
370
|
-
const [html, setHtml] = useState(() => renderHTML(value,
|
|
242
|
+
const [html, setHtml] = useState(() => renderHTML(value, labelMap, variableRegExp));
|
|
371
243
|
|
|
372
244
|
const emitChange = useCallback(
|
|
373
245
|
(target: HTMLElement) => {
|
|
@@ -377,12 +249,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
377
249
|
);
|
|
378
250
|
|
|
379
251
|
useEffect(() => {
|
|
380
|
-
setHtml(renderHTML(value,
|
|
252
|
+
setHtml(renderHTML(value, labelMap, variableRegExp));
|
|
381
253
|
if (!changed) {
|
|
382
254
|
setRange([-1, 0, -1, 0]);
|
|
383
255
|
}
|
|
384
256
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
385
|
-
}, [value,
|
|
257
|
+
}, [value, labelMap]);
|
|
386
258
|
|
|
387
259
|
// Restore caret position after html update
|
|
388
260
|
useEffect(() => {
|
|
@@ -459,13 +331,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
459
331
|
|
|
460
332
|
const handleInput = useCallback(
|
|
461
333
|
({ currentTarget }: React.FormEvent<HTMLDivElement>) => {
|
|
462
|
-
if (readOnly) return;
|
|
463
334
|
if (isComposing) return;
|
|
464
335
|
setChanged(true);
|
|
465
336
|
setRange(getCurrentRange(currentTarget));
|
|
466
337
|
emitChange(currentTarget);
|
|
467
338
|
},
|
|
468
|
-
[emitChange, isComposing
|
|
339
|
+
[emitChange, isComposing],
|
|
469
340
|
);
|
|
470
341
|
|
|
471
342
|
const handleBlur = useCallback(({ currentTarget }: React.FocusEvent<HTMLDivElement>) => {
|
|
@@ -481,7 +352,6 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
481
352
|
const handlePaste = useCallback(
|
|
482
353
|
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
|
483
354
|
event.preventDefault();
|
|
484
|
-
if (readOnly) return;
|
|
485
355
|
// Paste as plain text only; variable tags must be inserted via the picker.
|
|
486
356
|
const text = event.clipboardData.getData('text/plain').replace(/\n/g, ' ');
|
|
487
357
|
if (!text) return;
|
|
@@ -490,19 +360,18 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
490
360
|
setRange(getCurrentRange(event.currentTarget));
|
|
491
361
|
emitChange(event.currentTarget);
|
|
492
362
|
},
|
|
493
|
-
[emitChange
|
|
363
|
+
[emitChange],
|
|
494
364
|
);
|
|
495
365
|
|
|
496
366
|
const handleCompositionStart = useCallback(() => setIsComposing(true), []);
|
|
497
367
|
const handleCompositionEnd = useCallback(
|
|
498
368
|
({ currentTarget }: React.CompositionEvent<HTMLDivElement>) => {
|
|
499
369
|
setIsComposing(false);
|
|
500
|
-
if (readOnly) return;
|
|
501
370
|
setChanged(true);
|
|
502
371
|
setRange(getCurrentRange(currentTarget));
|
|
503
372
|
emitChange(currentTarget);
|
|
504
373
|
},
|
|
505
|
-
[emitChange
|
|
374
|
+
[emitChange],
|
|
506
375
|
);
|
|
507
376
|
|
|
508
377
|
const wrapperClassName = useMemo(
|
|
@@ -623,42 +492,6 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
623
492
|
border-color: ${token.colorBorder};
|
|
624
493
|
}
|
|
625
494
|
}
|
|
626
|
-
|
|
627
|
-
&.is-readonly {
|
|
628
|
-
cursor: default;
|
|
629
|
-
|
|
630
|
-
&:hover {
|
|
631
|
-
border-color: ${token.colorBorder};
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
&.is-error {
|
|
636
|
-
border-color: ${token.colorError};
|
|
637
|
-
|
|
638
|
-
&:hover {
|
|
639
|
-
border-color: ${token.colorErrorBorderHover};
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
&:focus,
|
|
643
|
-
&:focus-visible {
|
|
644
|
-
border-color: ${token.colorError};
|
|
645
|
-
box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.colorErrorOutline};
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
&.is-warning {
|
|
650
|
-
border-color: ${token.colorWarning};
|
|
651
|
-
|
|
652
|
-
&:hover {
|
|
653
|
-
border-color: ${token.colorWarningBorderHover};
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
&:focus,
|
|
657
|
-
&:focus-visible {
|
|
658
|
-
border-color: ${token.colorWarning};
|
|
659
|
-
box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.colorWarningOutline};
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
495
|
`;
|
|
663
496
|
}, [token, addonBefore]);
|
|
664
497
|
|
|
@@ -672,12 +505,8 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
672
505
|
aria-label="textbox"
|
|
673
506
|
className={cx(editorClassName, {
|
|
674
507
|
'is-disabled': disabled,
|
|
675
|
-
'is-readonly': readOnly,
|
|
676
|
-
'is-error': effectiveStatus === 'error',
|
|
677
|
-
'is-warning': effectiveStatus === 'warning',
|
|
678
508
|
})}
|
|
679
|
-
contentEditable={!disabled
|
|
680
|
-
aria-readonly={readOnly}
|
|
509
|
+
contentEditable={!disabled}
|
|
681
510
|
data-placeholder={placeholder}
|
|
682
511
|
onInput={handleInput}
|
|
683
512
|
onBlur={handleBlur}
|
|
@@ -690,7 +519,7 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
690
519
|
<FlowContextSelector
|
|
691
520
|
metaTree={metaTree}
|
|
692
521
|
disabled={disabled}
|
|
693
|
-
parseValueToPath={parseValueToPath}
|
|
522
|
+
parseValueToPath={converters?.parseValueToPath ?? defaultParseValueToPath}
|
|
694
523
|
formatPathToValue={(item) => converters?.formatPathToValue?.(item) || defaultFormatPathToValue(item)}
|
|
695
524
|
onChange={handleSelectorChange}
|
|
696
525
|
/>
|
|
@@ -192,7 +192,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
192
192
|
};
|
|
193
193
|
|
|
194
194
|
restoreFromValue();
|
|
195
|
-
}, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode
|
|
195
|
+
}, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode]);
|
|
196
196
|
|
|
197
197
|
const ValueComponent = useMemo(() => {
|
|
198
198
|
const Component = renderInputComponent?.(resolvedMetaTreeNode);
|
|
@@ -356,8 +356,6 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
356
356
|
<FlowContextSelector
|
|
357
357
|
metaTree={resolvedMetaTree}
|
|
358
358
|
value={innerValue}
|
|
359
|
-
active={isVariableValue(innerValue)}
|
|
360
|
-
disabled={disabled}
|
|
361
359
|
onChange={handleVariableSelect}
|
|
362
360
|
parseValueToPath={resolvePathFromValue}
|
|
363
361
|
formatPathToValue={resolveValueFromPath}
|
|
@@ -67,23 +67,6 @@ describe('FlowContextSelector', () => {
|
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
it('uses explicit active=false to keep the default trigger unhighlighted even when value parses to a path', async () => {
|
|
71
|
-
const flowContext = createTestFlowContext();
|
|
72
|
-
|
|
73
|
-
render(
|
|
74
|
-
<TestFlowContextWrapper context={flowContext}>
|
|
75
|
-
<FlowContextSelector
|
|
76
|
-
metaTree={() => flowContext.getPropertyMetaTree()}
|
|
77
|
-
value="{{ ctx.user.name }}"
|
|
78
|
-
active={false}
|
|
79
|
-
/>
|
|
80
|
-
</TestFlowContextWrapper>,
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
const button = await screen.findByRole('button');
|
|
84
|
-
expect(button.className).not.toContain('ant-btn-primary');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
70
|
it('should support function metaTree loading', async () => {
|
|
88
71
|
const flowContext = createTestFlowContext();
|
|
89
72
|
const metaTreeFn = vi.fn(() => flowContext.getPropertyMetaTree());
|
|
@@ -284,43 +267,6 @@ describe('FlowContextSelector', () => {
|
|
|
284
267
|
});
|
|
285
268
|
});
|
|
286
269
|
|
|
287
|
-
it('shows enabled variable item tooltip above the menu item', async () => {
|
|
288
|
-
const flowContext = createTestFlowContext();
|
|
289
|
-
const metaTree: MetaTreeNode[] = [
|
|
290
|
-
{
|
|
291
|
-
name: '$system',
|
|
292
|
-
title: 'System variables',
|
|
293
|
-
type: '',
|
|
294
|
-
paths: ['$system'],
|
|
295
|
-
children: [
|
|
296
|
-
{
|
|
297
|
-
name: 'instanceId',
|
|
298
|
-
title: 'Instance ID',
|
|
299
|
-
type: '',
|
|
300
|
-
paths: ['$system', 'instanceId'],
|
|
301
|
-
options: { tooltip: 'The ID of current server instance' },
|
|
302
|
-
},
|
|
303
|
-
],
|
|
304
|
-
},
|
|
305
|
-
];
|
|
306
|
-
|
|
307
|
-
render(
|
|
308
|
-
<TestFlowContextWrapper context={flowContext}>
|
|
309
|
-
<FlowContextSelector metaTree={metaTree} />
|
|
310
|
-
</TestFlowContextWrapper>,
|
|
311
|
-
);
|
|
312
|
-
|
|
313
|
-
fireEvent.click(screen.getByRole('button'));
|
|
314
|
-
fireEvent.click(await screen.findByText('System variables'));
|
|
315
|
-
|
|
316
|
-
expect(await screen.findByText('Instance ID')).toBeInTheDocument();
|
|
317
|
-
const tooltipIcon = screen.getByLabelText('Instance ID tooltip');
|
|
318
|
-
fireEvent.mouseEnter(tooltipIcon);
|
|
319
|
-
|
|
320
|
-
const tooltip = await screen.findByText('The ID of current server instance');
|
|
321
|
-
expect(tooltip.closest('.ant-tooltip')).toHaveClass('ant-tooltip-placement-top');
|
|
322
|
-
});
|
|
323
|
-
|
|
324
270
|
it('should support inline search input when children is null and keep lazy expand', async () => {
|
|
325
271
|
const flowContext = createTestFlowContext();
|
|
326
272
|
const loadOrgChildren = vi.fn(async () => [
|
|
@@ -459,13 +405,10 @@ describe('FlowContextSelector', () => {
|
|
|
459
405
|
|
|
460
406
|
const clearIcon = document.querySelector('.ant-input-clear-icon') as HTMLElement | null;
|
|
461
407
|
expect(clearIcon).toBeInTheDocument();
|
|
462
|
-
if (!clearIcon) {
|
|
463
|
-
throw new Error('Expected inline clear icon to be present');
|
|
464
|
-
}
|
|
465
408
|
|
|
466
|
-
fireEvent.mouseDown(clearIcon);
|
|
467
|
-
fireEvent.mouseUp(clearIcon);
|
|
468
|
-
fireEvent.click(clearIcon);
|
|
409
|
+
fireEvent.mouseDown(clearIcon!);
|
|
410
|
+
fireEvent.mouseUp(clearIcon!);
|
|
411
|
+
fireEvent.click(clearIcon!);
|
|
469
412
|
|
|
470
413
|
await waitFor(() => {
|
|
471
414
|
expect(onChange).toHaveBeenCalledWith('', undefined);
|