@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.
@@ -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 label = disabled ? /* @__PURE__ */ import_react.default.createElement("span", null, baseLabel, /* @__PURE__ */ import_react.default.createElement(
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: disabledReason || flowCtx.t("This variable is not available"),
119
- placement: "right",
120
- overlayClassName: "flow-variable-disabled-tip",
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(import_icons.QuestionCircleOutlined, { style: { marginLeft: 6, color: "rgba(0,0,0,0.35)" } })
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 memoizedWrapSSR = (0, import_react.useMemo)(() => {
100
- return wrapSSR;
101
- }, [theme2, token, hashId, prefixCls, iconPrefixCls, rootPrefixCls, props]);
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(100% - var(--nb-mobile-page-header-height))",
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;
@@ -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
- roleName: this.api.auth.role,
2631
- locale: this.api.auth.locale,
2632
- token: this.api.auth.token,
2633
- user: this.user
2634
- }), "get")
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.2",
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.2",
12
- "@nocobase/shared": "2.2.0-alpha.2",
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": "9dd5224bb8f6c1ac12d766b9f175bdc29bddd845"
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 = disabled ? (
142
+ const label = tooltipTitle ? (
125
143
  <span>
126
144
  {baseLabel}
127
145
  <Tooltip
128
- title={disabledReason || flowCtx.t('This variable is not available')}
129
- placement="right"
130
- overlayClassName="flow-variable-disabled-tip"
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 style={{ marginLeft: 6, color: 'rgba(0,0,0,0.35)' }} />
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 { useMemo, useContext } from 'react';
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 memoizedWrapSSR = useMemo(() => {
126
- return wrapSSR;
127
- }, [theme, token, hashId, prefixCls, iconPrefixCls, rootPrefixCls, props]);
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(100% - var(--nb-mobile-page-header-height))',
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!, { key: 'Backspace', code: 'Backspace' });
253
+ fireEvent.keyDown(selectElement, { key: 'Backspace', code: 'Backspace' });
208
254
 
209
255
  // 或者尝试触发自定义的清除逻辑
210
256
  const clearEvents = new CustomEvent('clear');
211
- selectElement!.dispatchEvent(clearEvents);
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;
@@ -3504,11 +3504,12 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3504
3504
  });
3505
3505
  this.defineProperty('auth', {
3506
3506
  get: () => ({
3507
- roleName: this.api.auth.role,
3508
- locale: this.api.auth.locale,
3509
- token: this.api.auth.token,
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: () => {