@nocobase/flow-engine 2.2.0-alpha.2 → 2.2.0-alpha.4
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 +30 -8
- package/lib/components/MobilePopup.style.js +16 -5
- package/lib/components/variables/VariableHybridInput.d.ts +1 -0
- package/lib/components/variables/VariableHybridInput.js +18 -5
- package/lib/components/variables/VariableInput.js +2 -1
- package/lib/components/variables/types.d.ts +10 -0
- package/lib/flowContext.js +10 -6
- package/package.json +4 -4
- package/src/__tests__/flowContext.test.ts +23 -0
- package/src/components/FlowContextSelector.tsx +32 -8
- package/src/components/MobilePopup.style.ts +22 -6
- package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
- package/src/components/variables/VariableHybridInput.tsx +19 -5
- package/src/components/variables/VariableInput.tsx +2 -1
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +60 -3
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +35 -1
- package/src/components/variables/__tests__/VariableInput.test.tsx +51 -5
- package/src/components/variables/types.ts +10 -0
- package/src/flowContext.ts +4 -3
|
@@ -57,6 +57,15 @@ const cascaderPopupAutoHeightClassName = import_css.css`
|
|
|
57
57
|
max-height: 50vh;
|
|
58
58
|
}
|
|
59
59
|
`;
|
|
60
|
+
function getMetaNodeTooltip(meta) {
|
|
61
|
+
if (!meta) {
|
|
62
|
+
return void 0;
|
|
63
|
+
}
|
|
64
|
+
const metaWithTooltip = meta;
|
|
65
|
+
const options = meta.options;
|
|
66
|
+
return metaWithTooltip.tooltip ?? (options == null ? void 0 : options.tooltip);
|
|
67
|
+
}
|
|
68
|
+
__name(getMetaNodeTooltip, "getMetaNodeTooltip");
|
|
60
69
|
const normalizePath = /* @__PURE__ */ __name((path) => {
|
|
61
70
|
if (!Array.isArray(path)) {
|
|
62
71
|
return void 0;
|
|
@@ -90,6 +99,7 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
|
|
|
90
99
|
value,
|
|
91
100
|
onChange,
|
|
92
101
|
children,
|
|
102
|
+
active,
|
|
93
103
|
metaTree,
|
|
94
104
|
showSearch = false,
|
|
95
105
|
parseValueToPath: customParseValueToPath = import_utils.parseValueToPath,
|
|
@@ -112,15 +122,27 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
|
|
|
112
122
|
const disabled = meta ? !!(typeof meta.disabled === "function" ? meta.disabled() : meta.disabled) : false;
|
|
113
123
|
const disabledReason = meta ? typeof meta.disabledReason === "function" ? meta.disabledReason() : meta.disabledReason : void 0;
|
|
114
124
|
const baseLabel = typeof o.label === "string" ? flowCtx.t(o.label) : o.label;
|
|
115
|
-
const
|
|
125
|
+
const labelText = typeof baseLabel === "string" ? baseLabel : String(o.value);
|
|
126
|
+
const tooltip = getMetaNodeTooltip(meta);
|
|
127
|
+
const tooltipTitle = disabled ? disabledReason || tooltip || flowCtx.t("This variable is not available") : tooltip;
|
|
128
|
+
const label = tooltipTitle ? /* @__PURE__ */ import_react.default.createElement("span", null, baseLabel, /* @__PURE__ */ import_react.default.createElement(
|
|
116
129
|
import_antd.Tooltip,
|
|
117
130
|
{
|
|
118
|
-
title:
|
|
119
|
-
placement: "
|
|
120
|
-
|
|
131
|
+
title: typeof tooltipTitle === "string" ? flowCtx.t(tooltipTitle) : tooltipTitle,
|
|
132
|
+
placement: "top",
|
|
133
|
+
classNames: { root: "flow-variable-tip" },
|
|
121
134
|
destroyTooltipOnHide: true
|
|
122
135
|
},
|
|
123
|
-
/* @__PURE__ */ import_react.default.createElement(
|
|
136
|
+
/* @__PURE__ */ import_react.default.createElement(
|
|
137
|
+
import_icons.QuestionCircleOutlined,
|
|
138
|
+
{
|
|
139
|
+
"aria-label": `${labelText} tooltip`,
|
|
140
|
+
style: {
|
|
141
|
+
marginLeft: token.marginXXS,
|
|
142
|
+
color: disabled ? token.colorTextDisabled : token.colorTextDescription
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
)
|
|
124
146
|
)) : baseLabel;
|
|
125
147
|
return {
|
|
126
148
|
...o,
|
|
@@ -130,7 +152,7 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
|
|
|
130
152
|
};
|
|
131
153
|
});
|
|
132
154
|
},
|
|
133
|
-
[flowCtx]
|
|
155
|
+
[flowCtx, token.colorTextDescription, token.colorTextDisabled, token.marginXXS]
|
|
134
156
|
);
|
|
135
157
|
const [updateFlag, setUpdateFlag] = (0, import_react.useState)(0);
|
|
136
158
|
const [searchText, setSearchText] = (0, import_react.useState)("");
|
|
@@ -220,9 +242,9 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
|
|
|
220
242
|
(0, import_utils.preloadContextSelectorPath)(options, pathToPreload, triggerUpdate);
|
|
221
243
|
}, [options, pathToPreload, triggerUpdate]);
|
|
222
244
|
const defaultChildren = (0, import_react.useMemo)(() => {
|
|
223
|
-
const hasSelected = currentPath && currentPath.length > 0;
|
|
245
|
+
const hasSelected = active ?? Boolean(currentPath && currentPath.length > 0);
|
|
224
246
|
return /* @__PURE__ */ import_react.default.createElement(import_antd.Button, { type: hasSelected ? "primary" : "default", style: defaultButtonStyle }, "x");
|
|
225
|
-
}, [currentPath]);
|
|
247
|
+
}, [active, currentPath]);
|
|
226
248
|
const handleChange = (0, import_react.useCallback)(
|
|
227
249
|
(selectedValues, selectedOptions) => {
|
|
228
250
|
const lastOption = selectedOptions == null ? void 0 : selectedOptions[selectedOptions.length - 1];
|
|
@@ -96,9 +96,17 @@ const genStyleHook = /* @__PURE__ */ __name((component, styleFn) => {
|
|
|
96
96
|
return [genCommonStyle(token, prefixCls), styleInterpolation];
|
|
97
97
|
}
|
|
98
98
|
);
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
const wrapSSRDeps = [theme2, token, hashId, prefixCls, iconPrefixCls, rootPrefixCls, props];
|
|
100
|
+
const wrapSSRCacheRef = (0, import_react.useRef)();
|
|
101
|
+
const currentCache = wrapSSRCacheRef.current;
|
|
102
|
+
let memoizedWrapSSR = (currentCache == null ? void 0 : currentCache.wrapSSR) || wrapSSR;
|
|
103
|
+
if (!currentCache || wrapSSRDeps.some((dep, index) => dep !== currentCache.deps[index])) {
|
|
104
|
+
wrapSSRCacheRef.current = {
|
|
105
|
+
deps: wrapSSRDeps,
|
|
106
|
+
wrapSSR
|
|
107
|
+
};
|
|
108
|
+
memoizedWrapSSR = wrapSSR;
|
|
109
|
+
}
|
|
102
110
|
return {
|
|
103
111
|
wrapSSR: memoizedWrapSSR,
|
|
104
112
|
hashId,
|
|
@@ -112,7 +120,7 @@ const useMobileActionDrawerStyle = genStyleHook("nb-mobile-action-drawer", (toke
|
|
|
112
120
|
return {
|
|
113
121
|
[componentCls]: {
|
|
114
122
|
".nb-mobile-action-drawer-header": {
|
|
115
|
-
height: "var(--nb-mobile-page-header-height)",
|
|
123
|
+
height: "var(--nb-mobile-page-header-height, 46px)",
|
|
116
124
|
display: "flex",
|
|
117
125
|
alignItems: "center",
|
|
118
126
|
justifyContent: "space-between",
|
|
@@ -139,7 +147,10 @@ const useMobileActionDrawerStyle = genStyleHook("nb-mobile-action-drawer", (toke
|
|
|
139
147
|
".nb-mobile-action-drawer-body": {
|
|
140
148
|
borderTopLeftRadius: 8,
|
|
141
149
|
borderTopRightRadius: 8,
|
|
142
|
-
maxHeight: "calc(
|
|
150
|
+
maxHeight: "calc(100vh - var(--nb-mobile-page-header-height, 46px))",
|
|
151
|
+
"@supports (height: 100dvh)": {
|
|
152
|
+
maxHeight: "calc(100dvh - var(--nb-mobile-page-header-height, 46px))"
|
|
153
|
+
},
|
|
143
154
|
overflowY: "auto",
|
|
144
155
|
overflowX: "hidden",
|
|
145
156
|
backgroundColor: token.colorBgLayout,
|
|
@@ -17,6 +17,7 @@ export interface VariableHybridInputProps {
|
|
|
17
17
|
value?: string;
|
|
18
18
|
onChange?: (value: string) => void;
|
|
19
19
|
disabled?: boolean;
|
|
20
|
+
readOnly?: boolean;
|
|
20
21
|
placeholder?: string;
|
|
21
22
|
addonBefore?: React.ReactNode;
|
|
22
23
|
metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
|
|
@@ -257,7 +257,7 @@ function getCurrentRange(element) {
|
|
|
257
257
|
__name(getCurrentRange, "getCurrentRange");
|
|
258
258
|
const VariableHybridInputComponent = /* @__PURE__ */ __name((props) => {
|
|
259
259
|
var _a;
|
|
260
|
-
const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, style } = props;
|
|
260
|
+
const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, readOnly, style } = props;
|
|
261
261
|
const { token } = import_antd.theme.useToken();
|
|
262
262
|
const ctx = (0, import_FlowContextProvider.useFlowContext)();
|
|
263
263
|
const { resolvedMetaTree } = (0, import_useResolvedMetaTree.useResolvedMetaTree)(metaTree);
|
|
@@ -390,12 +390,13 @@ const VariableHybridInputComponent = /* @__PURE__ */ __name((props) => {
|
|
|
390
390
|
);
|
|
391
391
|
const handleInput = (0, import_react.useCallback)(
|
|
392
392
|
({ currentTarget }) => {
|
|
393
|
+
if (readOnly) return;
|
|
393
394
|
if (isComposing) return;
|
|
394
395
|
setChanged(true);
|
|
395
396
|
setRange(getCurrentRange(currentTarget));
|
|
396
397
|
emitChange(currentTarget);
|
|
397
398
|
},
|
|
398
|
-
[emitChange, isComposing]
|
|
399
|
+
[emitChange, isComposing, readOnly]
|
|
399
400
|
);
|
|
400
401
|
const handleBlur = (0, import_react.useCallback)(({ currentTarget }) => {
|
|
401
402
|
setRange(getCurrentRange(currentTarget));
|
|
@@ -408,6 +409,7 @@ const VariableHybridInputComponent = /* @__PURE__ */ __name((props) => {
|
|
|
408
409
|
const handlePaste = (0, import_react.useCallback)(
|
|
409
410
|
(event) => {
|
|
410
411
|
event.preventDefault();
|
|
412
|
+
if (readOnly) return;
|
|
411
413
|
const text = event.clipboardData.getData("text/plain").replace(/\n/g, " ");
|
|
412
414
|
if (!text) return;
|
|
413
415
|
setChanged(true);
|
|
@@ -415,17 +417,18 @@ const VariableHybridInputComponent = /* @__PURE__ */ __name((props) => {
|
|
|
415
417
|
setRange(getCurrentRange(event.currentTarget));
|
|
416
418
|
emitChange(event.currentTarget);
|
|
417
419
|
},
|
|
418
|
-
[emitChange]
|
|
420
|
+
[emitChange, readOnly]
|
|
419
421
|
);
|
|
420
422
|
const handleCompositionStart = (0, import_react.useCallback)(() => setIsComposing(true), []);
|
|
421
423
|
const handleCompositionEnd = (0, import_react.useCallback)(
|
|
422
424
|
({ currentTarget }) => {
|
|
423
425
|
setIsComposing(false);
|
|
426
|
+
if (readOnly) return;
|
|
424
427
|
setChanged(true);
|
|
425
428
|
setRange(getCurrentRange(currentTarget));
|
|
426
429
|
emitChange(currentTarget);
|
|
427
430
|
},
|
|
428
|
-
[emitChange]
|
|
431
|
+
[emitChange, readOnly]
|
|
429
432
|
);
|
|
430
433
|
const wrapperClassName = (0, import_react.useMemo)(
|
|
431
434
|
() => import_css.css`
|
|
@@ -544,6 +547,14 @@ const VariableHybridInputComponent = /* @__PURE__ */ __name((props) => {
|
|
|
544
547
|
}
|
|
545
548
|
}
|
|
546
549
|
|
|
550
|
+
&.is-readonly {
|
|
551
|
+
cursor: default;
|
|
552
|
+
|
|
553
|
+
&:hover {
|
|
554
|
+
border-color: ${token.colorBorder};
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
547
558
|
&.is-error {
|
|
548
559
|
border-color: ${token.colorError};
|
|
549
560
|
|
|
@@ -581,10 +592,12 @@ const VariableHybridInputComponent = /* @__PURE__ */ __name((props) => {
|
|
|
581
592
|
"aria-label": "textbox",
|
|
582
593
|
className: (0, import_css.cx)(editorClassName, {
|
|
583
594
|
"is-disabled": disabled,
|
|
595
|
+
"is-readonly": readOnly,
|
|
584
596
|
"is-error": effectiveStatus === "error",
|
|
585
597
|
"is-warning": effectiveStatus === "warning"
|
|
586
598
|
}),
|
|
587
|
-
contentEditable: !disabled,
|
|
599
|
+
contentEditable: !disabled && !readOnly,
|
|
600
|
+
"aria-readonly": readOnly,
|
|
588
601
|
"data-placeholder": placeholder,
|
|
589
602
|
onInput: handleInput,
|
|
590
603
|
onBlur: handleBlur,
|
|
@@ -188,7 +188,7 @@ const VariableInputComponent = /* @__PURE__ */ __name(({
|
|
|
188
188
|
}
|
|
189
189
|
}, "restoreFromValue");
|
|
190
190
|
restoreFromValue();
|
|
191
|
-
}, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode]);
|
|
191
|
+
}, [resolvedMetaTree, innerValue, resolvePathFromValue, currentMetaTreeNode, value]);
|
|
192
192
|
const ValueComponent = (0, import_react.useMemo)(() => {
|
|
193
193
|
const Component = renderInputComponent == null ? void 0 : renderInputComponent(resolvedMetaTreeNode);
|
|
194
194
|
const CustomComponent = resolvedMetaTreeNode == null ? void 0 : resolvedMetaTreeNode.render;
|
|
@@ -325,6 +325,7 @@ const VariableInputComponent = /* @__PURE__ */ __name(({
|
|
|
325
325
|
{
|
|
326
326
|
metaTree: resolvedMetaTree,
|
|
327
327
|
value: innerValue,
|
|
328
|
+
active: (0, import_utils.isVariableValue)(innerValue),
|
|
328
329
|
onChange: handleVariableSelect,
|
|
329
330
|
parseValueToPath: resolvePathFromValue,
|
|
330
331
|
formatPathToValue: resolveValueFromPath,
|
|
@@ -13,6 +13,16 @@ export interface FlowContextSelectorProps extends Omit<CascaderProps<ContextSele
|
|
|
13
13
|
value?: string;
|
|
14
14
|
onChange?: (value: string, metaTreeNode?: MetaTreeNode) => void;
|
|
15
15
|
children?: CascaderProps<ContextSelectorItem>['children'];
|
|
16
|
+
/**
|
|
17
|
+
* Controls whether the default `x` trigger button is rendered as active
|
|
18
|
+
* (`type="primary"`). When omitted, the selector falls back to its parsed
|
|
19
|
+
* `value` path (`true` iff a valid variable path is currently selected).
|
|
20
|
+
*
|
|
21
|
+
* Use this when callers intentionally feed synthetic paths such as
|
|
22
|
+
* `['constant']` / `['null']` into the cascader to keep menu state aligned,
|
|
23
|
+
* but only want real variable references to show the blue active button.
|
|
24
|
+
*/
|
|
25
|
+
active?: boolean;
|
|
16
26
|
metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
|
|
17
27
|
parseValueToPath?: (value: string) => string[] | undefined;
|
|
18
28
|
formatPathToValue?: (item: MetaTreeNode) => string;
|
package/lib/flowContext.js
CHANGED
|
@@ -2626,12 +2626,16 @@ const _FlowEngineContext = class _FlowEngineContext extends BaseFlowEngineContex
|
|
|
2626
2626
|
}, "get")
|
|
2627
2627
|
});
|
|
2628
2628
|
this.defineProperty("auth", {
|
|
2629
|
-
get: /* @__PURE__ */ __name(() =>
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2629
|
+
get: /* @__PURE__ */ __name(() => {
|
|
2630
|
+
var _a, _b, _c, _d, _e, _f;
|
|
2631
|
+
return {
|
|
2632
|
+
roleName: (_b = (_a = this.api) == null ? void 0 : _a.auth) == null ? void 0 : _b.role,
|
|
2633
|
+
locale: (_d = (_c = this.api) == null ? void 0 : _c.auth) == null ? void 0 : _d.locale,
|
|
2634
|
+
token: (_f = (_e = this.api) == null ? void 0 : _e.auth) == null ? void 0 : _f.token,
|
|
2635
|
+
user: this.user
|
|
2636
|
+
};
|
|
2637
|
+
}, "get"),
|
|
2638
|
+
cache: false
|
|
2635
2639
|
});
|
|
2636
2640
|
this.defineProperty("date", {
|
|
2637
2641
|
get: /* @__PURE__ */ __name(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/flow-engine",
|
|
3
|
-
"version": "2.2.0-alpha.
|
|
3
|
+
"version": "2.2.0-alpha.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@formily/antd-v5": "1.x",
|
|
10
10
|
"@formily/reactive": "2.x",
|
|
11
|
-
"@nocobase/sdk": "2.2.0-alpha.
|
|
12
|
-
"@nocobase/shared": "2.2.0-alpha.
|
|
11
|
+
"@nocobase/sdk": "2.2.0-alpha.4",
|
|
12
|
+
"@nocobase/shared": "2.2.0-alpha.4",
|
|
13
13
|
"ahooks": "^3.7.2",
|
|
14
14
|
"axios": "^1.7.0",
|
|
15
15
|
"dayjs": "^1.11.9",
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
],
|
|
38
38
|
"author": "NocoBase Team",
|
|
39
39
|
"license": "Apache-2.0",
|
|
40
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "81bf8733db4bccf08a6bc5e6d97f274bce349161"
|
|
41
41
|
}
|
|
@@ -1607,6 +1607,29 @@ describe('FlowEngine context', () => {
|
|
|
1607
1607
|
await expect((engine.context as any).getVar('foo.bar')).rejects.toThrow();
|
|
1608
1608
|
});
|
|
1609
1609
|
|
|
1610
|
+
it('model.context.getVar should resolve the latest ctx.auth.user after user changes', async () => {
|
|
1611
|
+
const engine = new FlowEngine();
|
|
1612
|
+
engine.context.defineProperty('api', {
|
|
1613
|
+
value: { auth: { role: 'admin', locale: 'en-US', token: 'token-1' } },
|
|
1614
|
+
});
|
|
1615
|
+
engine.context.defineProperty('user', { value: { id: 1, nickname: 'Alice' } });
|
|
1616
|
+
|
|
1617
|
+
class AuthUserModel extends FlowModel {}
|
|
1618
|
+
engine.registerModels({ AuthUserModel });
|
|
1619
|
+
const model = engine.createModel({ use: 'AuthUserModel' });
|
|
1620
|
+
|
|
1621
|
+
expect(await model.context.getVar('ctx.auth.user.id')).toBe(1);
|
|
1622
|
+
expect(await model.context.getVar('ctx.auth.token')).toBe('token-1');
|
|
1623
|
+
|
|
1624
|
+
engine.context.api.auth.role = 'member';
|
|
1625
|
+
engine.context.api.auth.token = 'token-2';
|
|
1626
|
+
engine.context.defineProperty('user', { value: { id: 2, nickname: 'Bob' } });
|
|
1627
|
+
|
|
1628
|
+
expect(await model.context.getVar('ctx.auth.user.id')).toBe(2);
|
|
1629
|
+
expect(await model.context.getVar('ctx.auth.roleName')).toBe('member');
|
|
1630
|
+
expect(await model.context.getVar('ctx.auth.token')).toBe('token-2');
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1610
1633
|
it('engine.context.runAction should resolve action from engine.getAction', async () => {
|
|
1611
1634
|
const engine = new FlowEngine();
|
|
1612
1635
|
|
|
@@ -40,6 +40,18 @@ 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
|
+
|
|
43
55
|
const normalizePath = (path: unknown): string[] | undefined => {
|
|
44
56
|
if (!Array.isArray(path)) {
|
|
45
57
|
return undefined;
|
|
@@ -85,6 +97,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
85
97
|
value,
|
|
86
98
|
onChange,
|
|
87
99
|
children,
|
|
100
|
+
active,
|
|
88
101
|
metaTree,
|
|
89
102
|
showSearch = false,
|
|
90
103
|
parseValueToPath: customParseValueToPath = parseValueToPath,
|
|
@@ -120,17 +133,28 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
120
133
|
|
|
121
134
|
// 文本国际化:仅当 label 为字符串时进行翻译
|
|
122
135
|
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;
|
|
123
141
|
|
|
124
|
-
const label =
|
|
142
|
+
const label = tooltipTitle ? (
|
|
125
143
|
<span>
|
|
126
144
|
{baseLabel}
|
|
127
145
|
<Tooltip
|
|
128
|
-
title={
|
|
129
|
-
placement="
|
|
130
|
-
|
|
146
|
+
title={typeof tooltipTitle === 'string' ? flowCtx.t(tooltipTitle) : tooltipTitle}
|
|
147
|
+
placement="top"
|
|
148
|
+
classNames={{ root: 'flow-variable-tip' }}
|
|
131
149
|
destroyTooltipOnHide
|
|
132
150
|
>
|
|
133
|
-
<QuestionCircleOutlined
|
|
151
|
+
<QuestionCircleOutlined
|
|
152
|
+
aria-label={`${labelText} tooltip`}
|
|
153
|
+
style={{
|
|
154
|
+
marginLeft: token.marginXXS,
|
|
155
|
+
color: disabled ? token.colorTextDisabled : token.colorTextDescription,
|
|
156
|
+
}}
|
|
157
|
+
/>
|
|
134
158
|
</Tooltip>
|
|
135
159
|
</span>
|
|
136
160
|
) : (
|
|
@@ -145,7 +169,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
145
169
|
};
|
|
146
170
|
});
|
|
147
171
|
},
|
|
148
|
-
[flowCtx],
|
|
172
|
+
[flowCtx, token.colorTextDescription, token.colorTextDisabled, token.marginXXS],
|
|
149
173
|
);
|
|
150
174
|
|
|
151
175
|
// 用于强制重新渲染的状态
|
|
@@ -266,13 +290,13 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
266
290
|
|
|
267
291
|
// 默认按钮组件
|
|
268
292
|
const defaultChildren = useMemo(() => {
|
|
269
|
-
const hasSelected = currentPath && currentPath.length > 0;
|
|
293
|
+
const hasSelected = active ?? Boolean(currentPath && currentPath.length > 0);
|
|
270
294
|
return (
|
|
271
295
|
<Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle}>
|
|
272
296
|
x
|
|
273
297
|
</Button>
|
|
274
298
|
);
|
|
275
|
-
}, [currentPath]);
|
|
299
|
+
}, [active, currentPath]);
|
|
276
300
|
|
|
277
301
|
// 处理选择变化事件
|
|
278
302
|
const handleChange = useCallback(
|
|
@@ -11,7 +11,7 @@ import type { CSSInterpolation, CSSObject } from '@ant-design/cssinjs';
|
|
|
11
11
|
import { useStyleRegister } from '@ant-design/cssinjs';
|
|
12
12
|
import { merge } from '@formily/shared';
|
|
13
13
|
import type { ComponentTokenMap } from 'antd/es/theme/interface';
|
|
14
|
-
import {
|
|
14
|
+
import { useContext, useRef } from 'react';
|
|
15
15
|
import { ConfigProvider, theme } from 'antd';
|
|
16
16
|
|
|
17
17
|
const usePrefixCls = (
|
|
@@ -83,6 +83,10 @@ type UseComponentStyleResult = {
|
|
|
83
83
|
componentCls: string;
|
|
84
84
|
rootPrefixCls: string;
|
|
85
85
|
};
|
|
86
|
+
type WrapSSRCache = {
|
|
87
|
+
deps: unknown[];
|
|
88
|
+
wrapSSR: ReturnType<typeof useStyleRegister>;
|
|
89
|
+
};
|
|
86
90
|
|
|
87
91
|
const genStyleHook = <ComponentName extends OverrideComponent>(
|
|
88
92
|
component: ComponentName,
|
|
@@ -122,9 +126,18 @@ const genStyleHook = <ComponentName extends OverrideComponent>(
|
|
|
122
126
|
|
|
123
127
|
// useStyleRegister 有 BUG,会导致重复渲染,所以这里做了一层缓存
|
|
124
128
|
// 等 https://github.com/ant-design/cssinjs/pull/176 合并后,可以去掉这层缓存
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
const wrapSSRDeps = [theme, token, hashId, prefixCls, iconPrefixCls, rootPrefixCls, props];
|
|
130
|
+
const wrapSSRCacheRef = useRef<WrapSSRCache>();
|
|
131
|
+
const currentCache = wrapSSRCacheRef.current;
|
|
132
|
+
let memoizedWrapSSR: ReturnType<typeof useStyleRegister> = currentCache?.wrapSSR || wrapSSR;
|
|
133
|
+
|
|
134
|
+
if (!currentCache || wrapSSRDeps.some((dep, index) => dep !== currentCache.deps[index])) {
|
|
135
|
+
wrapSSRCacheRef.current = {
|
|
136
|
+
deps: wrapSSRDeps,
|
|
137
|
+
wrapSSR,
|
|
138
|
+
};
|
|
139
|
+
memoizedWrapSSR = wrapSSR;
|
|
140
|
+
}
|
|
128
141
|
|
|
129
142
|
return {
|
|
130
143
|
wrapSSR: memoizedWrapSSR,
|
|
@@ -140,7 +153,7 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
|
|
|
140
153
|
return {
|
|
141
154
|
[componentCls]: {
|
|
142
155
|
'.nb-mobile-action-drawer-header': {
|
|
143
|
-
height: 'var(--nb-mobile-page-header-height)',
|
|
156
|
+
height: 'var(--nb-mobile-page-header-height, 46px)',
|
|
144
157
|
display: 'flex',
|
|
145
158
|
alignItems: 'center',
|
|
146
159
|
justifyContent: 'space-between',
|
|
@@ -171,7 +184,10 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
|
|
|
171
184
|
'.nb-mobile-action-drawer-body': {
|
|
172
185
|
borderTopLeftRadius: 8,
|
|
173
186
|
borderTopRightRadius: 8,
|
|
174
|
-
maxHeight: 'calc(
|
|
187
|
+
maxHeight: 'calc(100vh - var(--nb-mobile-page-header-height, 46px))',
|
|
188
|
+
'@supports (height: 100dvh)': {
|
|
189
|
+
maxHeight: 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))',
|
|
190
|
+
},
|
|
175
191
|
overflowY: 'auto',
|
|
176
192
|
overflowX: 'hidden',
|
|
177
193
|
backgroundColor: token.colorBgLayout,
|
|
@@ -0,0 +1,103 @@
|
|
|
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, { CSSProperties, ReactNode } from 'react';
|
|
11
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
12
|
+
import { ConfigProvider } from 'antd';
|
|
13
|
+
import { vi } from 'vitest';
|
|
14
|
+
import { MobilePopup } from '../MobilePopup';
|
|
15
|
+
import { useMobileActionDrawerStyle } from '../MobilePopup.style';
|
|
16
|
+
|
|
17
|
+
vi.mock('antd-mobile', () => ({
|
|
18
|
+
Popup: ({
|
|
19
|
+
bodyClassName,
|
|
20
|
+
bodyStyle,
|
|
21
|
+
children,
|
|
22
|
+
className,
|
|
23
|
+
visible,
|
|
24
|
+
}: {
|
|
25
|
+
bodyClassName?: string;
|
|
26
|
+
bodyStyle?: CSSProperties;
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
visible?: boolean;
|
|
30
|
+
}) =>
|
|
31
|
+
visible ? (
|
|
32
|
+
<div className={className} data-testid="popup-root">
|
|
33
|
+
<div className={`adm-popup-body ${bodyClassName || ''}`} data-testid="popup-body" style={bodyStyle}>
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
) : null,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('antd-mobile-icons', () => ({
|
|
41
|
+
CloseOutline: () => <span data-testid="close-outline" />,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock('react-i18next', () => ({
|
|
45
|
+
useTranslation: () => ({
|
|
46
|
+
t: (key: string) => key,
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
function StyleProbe() {
|
|
51
|
+
const { componentCls, hashId } = useMobileActionDrawerStyle();
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={`${componentCls} ${hashId}`}>
|
|
55
|
+
<div className="nb-mobile-action-drawer-header" data-testid="drawer-header" />
|
|
56
|
+
<div className="nb-mobile-action-drawer-body" data-testid="drawer-body" />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getInjectedStyles() {
|
|
62
|
+
return Array.from(document.querySelectorAll('style'))
|
|
63
|
+
.map((style) => style.textContent || '')
|
|
64
|
+
.join('\n')
|
|
65
|
+
.replace(/\s+/g, '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('MobilePopup styles', () => {
|
|
69
|
+
it('mounts the action drawer class on the real popup body', async () => {
|
|
70
|
+
render(
|
|
71
|
+
<MobilePopup visible title="Edit" onClose={vi.fn()}>
|
|
72
|
+
<div style={{ height: 1200 }}>Long content</div>
|
|
73
|
+
</MobilePopup>,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const popupBody = await screen.findByTestId('popup-body');
|
|
77
|
+
|
|
78
|
+
expect(screen.getByTestId('popup-root')).toHaveClass('ant-nb-mobile-action-drawer');
|
|
79
|
+
expect(popupBody).toHaveClass('adm-popup-body');
|
|
80
|
+
expect(popupBody).toHaveClass('nb-mobile-action-drawer-body');
|
|
81
|
+
expect(popupBody).toHaveStyle({ padding: '0px' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('limits the mobile action drawer body by viewport height', async () => {
|
|
85
|
+
render(
|
|
86
|
+
<ConfigProvider>
|
|
87
|
+
<StyleProbe />
|
|
88
|
+
</ConfigProvider>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(getInjectedStyles()).toContain('.nb-mobile-action-drawer-body');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const styles = getInjectedStyles();
|
|
96
|
+
|
|
97
|
+
expect(styles).toContain('height:var(--nb-mobile-page-header-height,46px)');
|
|
98
|
+
expect(styles).toContain('max-height:calc(100vh-var(--nb-mobile-page-header-height,46px))');
|
|
99
|
+
expect(styles).toContain('max-height:calc(100dvh-var(--nb-mobile-page-header-height,46px))');
|
|
100
|
+
expect(styles).toContain('overflow-y:auto');
|
|
101
|
+
expect(styles).not.toContain('max-height:calc(100%-var(--nb-mobile-page-header-height))');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -36,6 +36,7 @@ export interface VariableHybridInputProps {
|
|
|
36
36
|
value?: string;
|
|
37
37
|
onChange?: (value: string) => void;
|
|
38
38
|
disabled?: boolean;
|
|
39
|
+
readOnly?: boolean;
|
|
39
40
|
placeholder?: string;
|
|
40
41
|
addonBefore?: React.ReactNode;
|
|
41
42
|
metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
|
|
@@ -295,7 +296,7 @@ function getCurrentRange(element: HTMLElement): RangeIndexes {
|
|
|
295
296
|
}
|
|
296
297
|
|
|
297
298
|
const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props) => {
|
|
298
|
-
const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, style } = props;
|
|
299
|
+
const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, readOnly, style } = props;
|
|
299
300
|
const { token } = theme.useToken();
|
|
300
301
|
const ctx = useFlowContext();
|
|
301
302
|
const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
|
|
@@ -458,12 +459,13 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
458
459
|
|
|
459
460
|
const handleInput = useCallback(
|
|
460
461
|
({ currentTarget }: React.FormEvent<HTMLDivElement>) => {
|
|
462
|
+
if (readOnly) return;
|
|
461
463
|
if (isComposing) return;
|
|
462
464
|
setChanged(true);
|
|
463
465
|
setRange(getCurrentRange(currentTarget));
|
|
464
466
|
emitChange(currentTarget);
|
|
465
467
|
},
|
|
466
|
-
[emitChange, isComposing],
|
|
468
|
+
[emitChange, isComposing, readOnly],
|
|
467
469
|
);
|
|
468
470
|
|
|
469
471
|
const handleBlur = useCallback(({ currentTarget }: React.FocusEvent<HTMLDivElement>) => {
|
|
@@ -479,6 +481,7 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
479
481
|
const handlePaste = useCallback(
|
|
480
482
|
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
|
481
483
|
event.preventDefault();
|
|
484
|
+
if (readOnly) return;
|
|
482
485
|
// Paste as plain text only; variable tags must be inserted via the picker.
|
|
483
486
|
const text = event.clipboardData.getData('text/plain').replace(/\n/g, ' ');
|
|
484
487
|
if (!text) return;
|
|
@@ -487,18 +490,19 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
487
490
|
setRange(getCurrentRange(event.currentTarget));
|
|
488
491
|
emitChange(event.currentTarget);
|
|
489
492
|
},
|
|
490
|
-
[emitChange],
|
|
493
|
+
[emitChange, readOnly],
|
|
491
494
|
);
|
|
492
495
|
|
|
493
496
|
const handleCompositionStart = useCallback(() => setIsComposing(true), []);
|
|
494
497
|
const handleCompositionEnd = useCallback(
|
|
495
498
|
({ currentTarget }: React.CompositionEvent<HTMLDivElement>) => {
|
|
496
499
|
setIsComposing(false);
|
|
500
|
+
if (readOnly) return;
|
|
497
501
|
setChanged(true);
|
|
498
502
|
setRange(getCurrentRange(currentTarget));
|
|
499
503
|
emitChange(currentTarget);
|
|
500
504
|
},
|
|
501
|
-
[emitChange],
|
|
505
|
+
[emitChange, readOnly],
|
|
502
506
|
);
|
|
503
507
|
|
|
504
508
|
const wrapperClassName = useMemo(
|
|
@@ -620,6 +624,14 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
620
624
|
}
|
|
621
625
|
}
|
|
622
626
|
|
|
627
|
+
&.is-readonly {
|
|
628
|
+
cursor: default;
|
|
629
|
+
|
|
630
|
+
&:hover {
|
|
631
|
+
border-color: ${token.colorBorder};
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
623
635
|
&.is-error {
|
|
624
636
|
border-color: ${token.colorError};
|
|
625
637
|
|
|
@@ -660,10 +672,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
660
672
|
aria-label="textbox"
|
|
661
673
|
className={cx(editorClassName, {
|
|
662
674
|
'is-disabled': disabled,
|
|
675
|
+
'is-readonly': readOnly,
|
|
663
676
|
'is-error': effectiveStatus === 'error',
|
|
664
677
|
'is-warning': effectiveStatus === 'warning',
|
|
665
678
|
})}
|
|
666
|
-
contentEditable={!disabled}
|
|
679
|
+
contentEditable={!disabled && !readOnly}
|
|
680
|
+
aria-readonly={readOnly}
|
|
667
681
|
data-placeholder={placeholder}
|
|
668
682
|
onInput={handleInput}
|
|
669
683
|
onBlur={handleBlur}
|
|
@@ -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, value]);
|
|
196
196
|
|
|
197
197
|
const ValueComponent = useMemo(() => {
|
|
198
198
|
const Component = renderInputComponent?.(resolvedMetaTreeNode);
|
|
@@ -356,6 +356,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
|
|
|
356
356
|
<FlowContextSelector
|
|
357
357
|
metaTree={resolvedMetaTree}
|
|
358
358
|
value={innerValue}
|
|
359
|
+
active={isVariableValue(innerValue)}
|
|
359
360
|
onChange={handleVariableSelect}
|
|
360
361
|
parseValueToPath={resolvePathFromValue}
|
|
361
362
|
formatPathToValue={resolveValueFromPath}
|
|
@@ -67,6 +67,23 @@ 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
|
+
|
|
70
87
|
it('should support function metaTree loading', async () => {
|
|
71
88
|
const flowContext = createTestFlowContext();
|
|
72
89
|
const metaTreeFn = vi.fn(() => flowContext.getPropertyMetaTree());
|
|
@@ -267,6 +284,43 @@ describe('FlowContextSelector', () => {
|
|
|
267
284
|
});
|
|
268
285
|
});
|
|
269
286
|
|
|
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
|
+
|
|
270
324
|
it('should support inline search input when children is null and keep lazy expand', async () => {
|
|
271
325
|
const flowContext = createTestFlowContext();
|
|
272
326
|
const loadOrgChildren = vi.fn(async () => [
|
|
@@ -405,10 +459,13 @@ describe('FlowContextSelector', () => {
|
|
|
405
459
|
|
|
406
460
|
const clearIcon = document.querySelector('.ant-input-clear-icon') as HTMLElement | null;
|
|
407
461
|
expect(clearIcon).toBeInTheDocument();
|
|
462
|
+
if (!clearIcon) {
|
|
463
|
+
throw new Error('Expected inline clear icon to be present');
|
|
464
|
+
}
|
|
408
465
|
|
|
409
|
-
fireEvent.mouseDown(clearIcon
|
|
410
|
-
fireEvent.mouseUp(clearIcon
|
|
411
|
-
fireEvent.click(clearIcon
|
|
466
|
+
fireEvent.mouseDown(clearIcon);
|
|
467
|
+
fireEvent.mouseUp(clearIcon);
|
|
468
|
+
fireEvent.click(clearIcon);
|
|
412
469
|
|
|
413
470
|
await waitFor(() => {
|
|
414
471
|
expect(onChange).toHaveBeenCalledWith('', undefined);
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Plus the top-level (already-loaded) and not-in-tree (raw-token fallback) cases.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { render, waitFor } from '@testing-library/react';
|
|
17
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
18
18
|
import React from 'react';
|
|
19
19
|
import { describe, expect, it, vi } from 'vitest';
|
|
20
20
|
import { VariableHybridInput, type VariableHybridInputConverters } from '../VariableHybridInput';
|
|
@@ -175,4 +175,38 @@ describe('VariableHybridInput — saved reference labels', () => {
|
|
|
175
175
|
expect(tag?.textContent).toBe('{{$missing.field}}');
|
|
176
176
|
});
|
|
177
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
|
+
});
|
|
178
212
|
});
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import { Input } from 'antd';
|
|
11
12
|
import React from 'react';
|
|
12
13
|
import { describe, expect, it, vi } from 'vitest';
|
|
13
14
|
import type { ContextSelectorItem } from '../types';
|
|
@@ -112,6 +113,50 @@ describe('VariableInput', () => {
|
|
|
112
113
|
expect(selectorButton).toBeInTheDocument();
|
|
113
114
|
});
|
|
114
115
|
|
|
116
|
+
it('should not highlight the selector button for synthetic constant/null paths', async () => {
|
|
117
|
+
const flowContext = createTestFlowContext();
|
|
118
|
+
|
|
119
|
+
render(
|
|
120
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
121
|
+
<VariableInput
|
|
122
|
+
value=""
|
|
123
|
+
metaTree={[
|
|
124
|
+
{ name: 'constant', title: 'Constant', type: 'string', paths: ['constant'] },
|
|
125
|
+
{ name: 'null', title: 'Null', type: 'object', paths: ['null'] },
|
|
126
|
+
...flowContext.getPropertyMetaTree(),
|
|
127
|
+
]}
|
|
128
|
+
converters={{
|
|
129
|
+
renderInputComponent: (meta) => {
|
|
130
|
+
const first = meta?.paths?.[0];
|
|
131
|
+
if (first === 'constant') {
|
|
132
|
+
return (props: any) => <input aria-label="constant-value" {...props} />;
|
|
133
|
+
}
|
|
134
|
+
if (first === 'null') {
|
|
135
|
+
return () => <Input placeholder="<Null>" readOnly />;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
},
|
|
139
|
+
resolveValueFromPath: (meta) => {
|
|
140
|
+
const first = meta?.paths?.[0];
|
|
141
|
+
if (first === 'constant') return '';
|
|
142
|
+
if (first === 'null') return null;
|
|
143
|
+
return undefined;
|
|
144
|
+
},
|
|
145
|
+
resolvePathFromValue: (currentValue) => {
|
|
146
|
+
if (currentValue === null) return ['null'];
|
|
147
|
+
const trimmed = typeof currentValue === 'string' ? currentValue.trim() : currentValue;
|
|
148
|
+
if (trimmed === '') return ['constant'];
|
|
149
|
+
return undefined;
|
|
150
|
+
},
|
|
151
|
+
}}
|
|
152
|
+
/>
|
|
153
|
+
</TestFlowContextWrapper>,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const selectorButton = await screen.findByRole('button');
|
|
157
|
+
expect(selectorButton.className).not.toContain('ant-btn-primary');
|
|
158
|
+
});
|
|
159
|
+
|
|
115
160
|
it('should handle onChange from Input', async () => {
|
|
116
161
|
const onChange = vi.fn();
|
|
117
162
|
const flowContext = createTestFlowContext();
|
|
@@ -195,20 +240,21 @@ describe('VariableInput', () => {
|
|
|
195
240
|
|
|
196
241
|
const selectElement = container.querySelector('.ant-select');
|
|
197
242
|
expect(selectElement).toBeInTheDocument();
|
|
243
|
+
if (!selectElement) {
|
|
244
|
+
throw new Error('Expected variable tag select wrapper to be present');
|
|
245
|
+
}
|
|
198
246
|
|
|
199
247
|
// 触发鼠标悬停以显示清除按钮
|
|
200
|
-
fireEvent.mouseEnter(selectElement
|
|
248
|
+
fireEvent.mouseEnter(selectElement);
|
|
201
249
|
|
|
202
250
|
// 尝试触发清除功能
|
|
203
251
|
// 方法1: 直接触发 Select 组件的 onClear 事件
|
|
204
|
-
const selectInstance = selectElement as any;
|
|
205
|
-
|
|
206
252
|
// 尝试通过键盘事件触发清除
|
|
207
|
-
fireEvent.keyDown(selectElement
|
|
253
|
+
fireEvent.keyDown(selectElement, { key: 'Backspace', code: 'Backspace' });
|
|
208
254
|
|
|
209
255
|
// 或者尝试触发自定义的清除逻辑
|
|
210
256
|
const clearEvents = new CustomEvent('clear');
|
|
211
|
-
selectElement
|
|
257
|
+
selectElement.dispatchEvent(clearEvents);
|
|
212
258
|
|
|
213
259
|
// 检查是否调用了清除功能(可能需要调整期望)
|
|
214
260
|
// 如果清除按钮不能直接测试,我们验证组件支持清除功能
|
|
@@ -16,6 +16,16 @@ 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;
|
|
19
29
|
metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
|
|
20
30
|
parseValueToPath?: (value: string) => string[] | undefined;
|
|
21
31
|
formatPathToValue?: (item: MetaTreeNode) => string;
|
package/src/flowContext.ts
CHANGED
|
@@ -3504,11 +3504,12 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3504
3504
|
});
|
|
3505
3505
|
this.defineProperty('auth', {
|
|
3506
3506
|
get: () => ({
|
|
3507
|
-
roleName: this.api
|
|
3508
|
-
locale: this.api
|
|
3509
|
-
token: this.api
|
|
3507
|
+
roleName: this.api?.auth?.role,
|
|
3508
|
+
locale: this.api?.auth?.locale,
|
|
3509
|
+
token: this.api?.auth?.token,
|
|
3510
3510
|
user: this.user,
|
|
3511
3511
|
}),
|
|
3512
|
+
cache: false,
|
|
3512
3513
|
});
|
|
3513
3514
|
this.defineProperty('date', {
|
|
3514
3515
|
get: () => {
|