@nocobase/flow-engine 2.2.0-beta.8 → 2.2.0
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/acl/Acl.d.ts +2 -1
- package/lib/acl/Acl.js +28 -0
- package/lib/components/FlowContextSelector.js +62 -13
- package/lib/components/FormItem.js +11 -7
- package/lib/components/MobilePopup.js +39 -10
- package/lib/components/MobilePopup.style.js +11 -1
- package/lib/components/subModel/LazyDropdown.js +62 -33
- package/lib/components/variables/VariableHybridInput.d.ts +9 -0
- package/lib/components/variables/VariableHybridInput.js +146 -17
- package/lib/components/variables/VariableInput.js +19 -7
- package/lib/components/variables/VariableTag.js +48 -36
- package/lib/components/variables/types.d.ts +21 -0
- package/lib/flowContext.d.ts +12 -1
- package/lib/flowContext.js +57 -12
- package/lib/flowEngine.js +6 -0
- package/lib/flowI18n.js +3 -3
- package/lib/locale/en-US.json +2 -0
- package/lib/locale/index.d.ts +4 -0
- package/lib/locale/zh-CN.json +2 -0
- package/lib/resources/flowResource.js +1 -0
- package/lib/types.d.ts +3 -1
- package/lib/types.js +1 -0
- package/lib/utils/associationObjectVariable.d.ts +10 -0
- package/lib/utils/associationObjectVariable.js +10 -7
- package/lib/utils/dateVariable.d.ts +22 -0
- package/lib/utils/dateVariable.js +123 -16
- package/lib/utils/dirtyAwareApiClient.d.ts +1 -0
- package/lib/utils/dirtyAwareApiClient.js +280 -13
- package/lib/utils/index.d.ts +3 -3
- package/lib/utils/index.js +8 -0
- package/lib/utils/loadedPageCache.d.ts +1 -0
- package/lib/utils/loadedPageCache.js +6 -0
- package/lib/utils/params-resolvers.d.ts +3 -0
- package/lib/utils/params-resolvers.js +10 -0
- package/lib/utils/variablesParams.js +5 -0
- package/lib/views/createViewMeta.d.ts +1 -0
- package/lib/views/createViewMeta.js +53 -22
- package/package.json +4 -4
- package/src/__tests__/createViewMeta.popup.test.ts +84 -1
- package/src/__tests__/flowContext.test.ts +31 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/objectVariable.test.ts +6 -1
- package/src/__tests__/runjsFormSubmit.test.ts +138 -0
- package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
- package/src/acl/Acl.tsx +36 -1
- package/src/acl/__tests__/Acl.test.tsx +70 -0
- package/src/components/FlowContextSelector.tsx +73 -12
- package/src/components/FormItem.tsx +12 -7
- package/src/components/MobilePopup.style.ts +12 -1
- package/src/components/MobilePopup.tsx +42 -10
- package/src/components/__tests__/FormItem.test.tsx +17 -2
- package/src/components/__tests__/MobilePopup.test.tsx +150 -0
- package/src/components/subModel/LazyDropdown.tsx +71 -38
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/components/subModel/__tests__/LazyDropdown.test.tsx +202 -0
- package/src/components/variables/VariableHybridInput.tsx +185 -14
- package/src/components/variables/VariableInput.tsx +32 -7
- package/src/components/variables/VariableTag.tsx +51 -37
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +95 -3
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
- package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
- package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
- package/src/components/variables/types.ts +21 -0
- package/src/flowContext.ts +83 -9
- package/src/flowEngine.ts +6 -0
- package/src/flowI18n.ts +8 -3
- package/src/locale/__tests__/index.test.ts +21 -0
- package/src/locale/en-US.json +2 -0
- package/src/locale/zh-CN.json +2 -0
- package/src/resources/__tests__/flowResource.test.ts +3 -0
- package/src/resources/flowResource.ts +1 -0
- package/src/types.ts +2 -0
- package/src/utils/__tests__/dateVariable.test.ts +57 -4
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +321 -0
- package/src/utils/__tests__/variablesParams.test.ts +28 -1
- package/src/utils/associationObjectVariable.ts +9 -6
- package/src/utils/dateVariable.ts +145 -18
- package/src/utils/dirtyAwareApiClient.ts +348 -13
- package/src/utils/index.ts +17 -2
- package/src/utils/loadedPageCache.ts +7 -0
- package/src/utils/params-resolvers.ts +12 -0
- package/src/utils/variablesParams.ts +10 -0
- package/src/views/createViewMeta.ts +52 -18
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
|
|
10
10
|
import { css, cx } from '@emotion/css';
|
|
11
11
|
import { Space, theme } from 'antd';
|
|
12
|
-
import
|
|
12
|
+
import { FormItemInputContext } from 'antd/es/form/context';
|
|
13
|
+
import React, { isValidElement, useContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
14
|
import type { MetaTreeNode } from '../../flowContext';
|
|
14
15
|
import { useFlowContext } from '../../FlowContextProvider';
|
|
15
16
|
import { FlowContextSelector } from '../FlowContextSelector';
|
|
16
17
|
import { useResolvedMetaTree } from './useResolvedMetaTree';
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
formatPathToValue as defaultFormatPathToValue,
|
|
20
|
+
loadMetaTreeChildren,
|
|
21
|
+
parseValueToPath as defaultParseValueToPath,
|
|
22
|
+
} from './utils';
|
|
18
23
|
|
|
19
24
|
type RangeIndexes = [number, number, number, number];
|
|
20
25
|
|
|
@@ -31,12 +36,21 @@ export interface VariableHybridInputProps {
|
|
|
31
36
|
value?: string;
|
|
32
37
|
onChange?: (value: string) => void;
|
|
33
38
|
disabled?: boolean;
|
|
39
|
+
readOnly?: boolean;
|
|
34
40
|
placeholder?: string;
|
|
35
41
|
addonBefore?: React.ReactNode;
|
|
36
42
|
metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
|
|
37
43
|
converters?: VariableHybridInputConverters;
|
|
38
44
|
style?: React.CSSProperties;
|
|
39
45
|
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';
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
function reactNodeToPlainText(node: React.ReactNode): string {
|
|
@@ -83,14 +97,38 @@ function normalizeVariableKey(value: string): string {
|
|
|
83
97
|
.trim();
|
|
84
98
|
}
|
|
85
99
|
|
|
86
|
-
function renderHTML(value: string,
|
|
100
|
+
function renderHTML(value: string, regExp: RegExp, resolveLabel: (matched: string) => string | undefined) {
|
|
87
101
|
const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
|
|
88
102
|
return escapeHtml(value || '').replace(re, (matched) => {
|
|
89
|
-
const label =
|
|
103
|
+
const label = resolveLabel(matched) || matched;
|
|
90
104
|
return createTagHTML(matched, label);
|
|
91
105
|
});
|
|
92
106
|
}
|
|
93
107
|
|
|
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
|
+
|
|
94
132
|
function buildLabelMap(
|
|
95
133
|
nodes: MetaTreeNode[] | undefined,
|
|
96
134
|
ctxT: (text: string) => string,
|
|
@@ -116,6 +154,42 @@ function buildLabelMap(
|
|
|
116
154
|
return map;
|
|
117
155
|
}
|
|
118
156
|
|
|
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
|
+
|
|
119
193
|
function pasteHTML(container: HTMLElement, html: string, indexes?: RangeIndexes) {
|
|
120
194
|
const selection = window.getSelection?.();
|
|
121
195
|
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
|
@@ -222,10 +296,14 @@ function getCurrentRange(element: HTMLElement): RangeIndexes {
|
|
|
222
296
|
}
|
|
223
297
|
|
|
224
298
|
const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props) => {
|
|
225
|
-
const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, style } = props;
|
|
299
|
+
const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, readOnly, style } = props;
|
|
226
300
|
const { token } = theme.useToken();
|
|
227
301
|
const ctx = useFlowContext();
|
|
228
302
|
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;
|
|
229
307
|
const inputRef = useRef<HTMLDivElement>(null);
|
|
230
308
|
const [isComposing, setIsComposing] = useState(false);
|
|
231
309
|
const [changed, setChanged] = useState(false);
|
|
@@ -233,13 +311,63 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
233
311
|
|
|
234
312
|
const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : String(props.value);
|
|
235
313
|
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]);
|
|
236
349
|
|
|
237
350
|
const labelMap = useMemo(
|
|
238
351
|
() => buildLabelMap(resolvedMetaTree as MetaTreeNode[] | undefined, ctx.t, converters),
|
|
239
|
-
|
|
352
|
+
// `loadedFlag` is read so the map recomputes after a lazy level resolves.
|
|
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],
|
|
240
368
|
);
|
|
241
369
|
|
|
242
|
-
const [html, setHtml] = useState(() => renderHTML(value,
|
|
370
|
+
const [html, setHtml] = useState(() => renderHTML(value, variableRegExp, resolveLabel));
|
|
243
371
|
|
|
244
372
|
const emitChange = useCallback(
|
|
245
373
|
(target: HTMLElement) => {
|
|
@@ -249,12 +377,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
249
377
|
);
|
|
250
378
|
|
|
251
379
|
useEffect(() => {
|
|
252
|
-
setHtml(renderHTML(value,
|
|
380
|
+
setHtml(renderHTML(value, variableRegExp, resolveLabel));
|
|
253
381
|
if (!changed) {
|
|
254
382
|
setRange([-1, 0, -1, 0]);
|
|
255
383
|
}
|
|
256
384
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
257
|
-
}, [value,
|
|
385
|
+
}, [value, resolveLabel]);
|
|
258
386
|
|
|
259
387
|
// Restore caret position after html update
|
|
260
388
|
useEffect(() => {
|
|
@@ -331,12 +459,13 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
331
459
|
|
|
332
460
|
const handleInput = useCallback(
|
|
333
461
|
({ currentTarget }: React.FormEvent<HTMLDivElement>) => {
|
|
462
|
+
if (readOnly) return;
|
|
334
463
|
if (isComposing) return;
|
|
335
464
|
setChanged(true);
|
|
336
465
|
setRange(getCurrentRange(currentTarget));
|
|
337
466
|
emitChange(currentTarget);
|
|
338
467
|
},
|
|
339
|
-
[emitChange, isComposing],
|
|
468
|
+
[emitChange, isComposing, readOnly],
|
|
340
469
|
);
|
|
341
470
|
|
|
342
471
|
const handleBlur = useCallback(({ currentTarget }: React.FocusEvent<HTMLDivElement>) => {
|
|
@@ -352,6 +481,7 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
352
481
|
const handlePaste = useCallback(
|
|
353
482
|
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
|
354
483
|
event.preventDefault();
|
|
484
|
+
if (readOnly) return;
|
|
355
485
|
// Paste as plain text only; variable tags must be inserted via the picker.
|
|
356
486
|
const text = event.clipboardData.getData('text/plain').replace(/\n/g, ' ');
|
|
357
487
|
if (!text) return;
|
|
@@ -360,18 +490,19 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
360
490
|
setRange(getCurrentRange(event.currentTarget));
|
|
361
491
|
emitChange(event.currentTarget);
|
|
362
492
|
},
|
|
363
|
-
[emitChange],
|
|
493
|
+
[emitChange, readOnly],
|
|
364
494
|
);
|
|
365
495
|
|
|
366
496
|
const handleCompositionStart = useCallback(() => setIsComposing(true), []);
|
|
367
497
|
const handleCompositionEnd = useCallback(
|
|
368
498
|
({ currentTarget }: React.CompositionEvent<HTMLDivElement>) => {
|
|
369
499
|
setIsComposing(false);
|
|
500
|
+
if (readOnly) return;
|
|
370
501
|
setChanged(true);
|
|
371
502
|
setRange(getCurrentRange(currentTarget));
|
|
372
503
|
emitChange(currentTarget);
|
|
373
504
|
},
|
|
374
|
-
[emitChange],
|
|
505
|
+
[emitChange, readOnly],
|
|
375
506
|
);
|
|
376
507
|
|
|
377
508
|
const wrapperClassName = useMemo(
|
|
@@ -492,6 +623,42 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
492
623
|
border-color: ${token.colorBorder};
|
|
493
624
|
}
|
|
494
625
|
}
|
|
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
|
+
}
|
|
495
662
|
`;
|
|
496
663
|
}, [token, addonBefore]);
|
|
497
664
|
|
|
@@ -505,8 +672,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
505
672
|
aria-label="textbox"
|
|
506
673
|
className={cx(editorClassName, {
|
|
507
674
|
'is-disabled': disabled,
|
|
675
|
+
'is-readonly': readOnly,
|
|
676
|
+
'is-error': effectiveStatus === 'error',
|
|
677
|
+
'is-warning': effectiveStatus === 'warning',
|
|
508
678
|
})}
|
|
509
|
-
contentEditable={!disabled}
|
|
679
|
+
contentEditable={!disabled && !readOnly}
|
|
680
|
+
aria-readonly={readOnly}
|
|
510
681
|
data-placeholder={placeholder}
|
|
511
682
|
onInput={handleInput}
|
|
512
683
|
onBlur={handleBlur}
|
|
@@ -519,7 +690,7 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
519
690
|
<FlowContextSelector
|
|
520
691
|
metaTree={metaTree}
|
|
521
692
|
disabled={disabled}
|
|
522
|
-
parseValueToPath={
|
|
693
|
+
parseValueToPath={parseValueToPath}
|
|
523
694
|
formatPathToValue={(item) => converters?.formatPathToValue?.(item) || defaultFormatPathToValue(item)}
|
|
524
695
|
onChange={handleSelectorChange}
|
|
525
696
|
/>
|
|
@@ -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
|
-
|
|
132
|
-
|
|
133
|
-
return findMetaTreeNodeByPath(resolvedMetaTree, path);
|
|
135
|
+
if (resolvedPath) {
|
|
136
|
+
return findMetaTreeNodeByPath(resolvedMetaTree, resolvedPath);
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
139
|
return null;
|
|
137
|
-
}, [currentMetaTreeNode,
|
|
140
|
+
}, [currentMetaTreeNode, resolvedMetaTree, resolvedPath]);
|
|
138
141
|
|
|
139
142
|
// 当 value 存在但 currentMetaTreeNode 还未恢复,尝试按路径逐级加载(支持 children 为函数的场景)
|
|
140
143
|
useEffect(() => {
|
|
@@ -192,14 +195,30 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
192
195
|
};
|
|
193
196
|
|
|
194
197
|
restoreFromValue();
|
|
195
|
-
}, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode]);
|
|
198
|
+
}, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode, value]);
|
|
196
199
|
|
|
197
200
|
const ValueComponent = useMemo(() => {
|
|
198
201
|
const Component = renderInputComponent?.(resolvedMetaTreeNode);
|
|
199
202
|
const CustomComponent = resolvedMetaTreeNode?.render;
|
|
200
|
-
|
|
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,6 +379,8 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
356
379
|
<FlowContextSelector
|
|
357
380
|
metaTree={resolvedMetaTree}
|
|
358
381
|
value={innerValue}
|
|
382
|
+
active={isVariableActive}
|
|
383
|
+
disabled={disabled}
|
|
359
384
|
onChange={handleVariableSelect}
|
|
360
385
|
parseValueToPath={resolvePathFromValue}
|
|
361
386
|
formatPathToValue={resolveValueFromPath}
|
|
@@ -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:
|
|
36
|
+
const { data: displayState } = useRequest(
|
|
32
37
|
async () => {
|
|
33
|
-
const resolveLabelFromPath = async (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (!
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
92
|
-
|
|
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
|
|
99
|
-
if (label != null)
|
|
100
|
-
|
|
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
|
|
123
|
-
const
|
|
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={
|
|
138
|
+
<Tooltip title={tooltipText} placement="top" getPopupContainer={() => document.body}>
|
|
127
139
|
<Tag
|
|
128
|
-
color=
|
|
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={
|
|
170
|
-
mode=
|
|
181
|
+
value={displayState?.text ? [displayState.text] : []}
|
|
182
|
+
mode={allowCustomTagInput ? 'tags' : 'multiple'}
|
|
171
183
|
open={false}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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}
|