@nocobase/flow-engine 2.2.0-alpha.2 → 2.2.0-alpha.3

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.
@@ -90,6 +90,7 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
90
90
  value,
91
91
  onChange,
92
92
  children,
93
+ active,
93
94
  metaTree,
94
95
  showSearch = false,
95
96
  parseValueToPath: customParseValueToPath = import_utils.parseValueToPath,
@@ -220,9 +221,9 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
220
221
  (0, import_utils.preloadContextSelectorPath)(options, pathToPreload, triggerUpdate);
221
222
  }, [options, pathToPreload, triggerUpdate]);
222
223
  const defaultChildren = (0, import_react.useMemo)(() => {
223
- const hasSelected = currentPath && currentPath.length > 0;
224
+ const hasSelected = active ?? Boolean(currentPath && currentPath.length > 0);
224
225
  return /* @__PURE__ */ import_react.default.createElement(import_antd.Button, { type: hasSelected ? "primary" : "default", style: defaultButtonStyle }, "x");
225
- }, [currentPath]);
226
+ }, [active, currentPath]);
226
227
  const handleChange = (0, import_react.useCallback)(
227
228
  (selectedValues, selectedOptions) => {
228
229
  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,
@@ -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/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.3",
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.3",
12
+ "@nocobase/shared": "2.2.0-alpha.3",
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": "e3ec19361fd7981af9fe2a7f24ad335025cbefcd"
41
41
  }
@@ -85,6 +85,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
85
85
  value,
86
86
  onChange,
87
87
  children,
88
+ active,
88
89
  metaTree,
89
90
  showSearch = false,
90
91
  parseValueToPath: customParseValueToPath = parseValueToPath,
@@ -266,13 +267,13 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
266
267
 
267
268
  // 默认按钮组件
268
269
  const defaultChildren = useMemo(() => {
269
- const hasSelected = currentPath && currentPath.length > 0;
270
+ const hasSelected = active ?? Boolean(currentPath && currentPath.length > 0);
270
271
  return (
271
272
  <Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle}>
272
273
  x
273
274
  </Button>
274
275
  );
275
- }, [currentPath]);
276
+ }, [active, currentPath]);
276
277
 
277
278
  // 处理选择变化事件
278
279
  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
+ });
@@ -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());
@@ -405,10 +422,13 @@ describe('FlowContextSelector', () => {
405
422
 
406
423
  const clearIcon = document.querySelector('.ant-input-clear-icon') as HTMLElement | null;
407
424
  expect(clearIcon).toBeInTheDocument();
425
+ if (!clearIcon) {
426
+ throw new Error('Expected inline clear icon to be present');
427
+ }
408
428
 
409
- fireEvent.mouseDown(clearIcon!);
410
- fireEvent.mouseUp(clearIcon!);
411
- fireEvent.click(clearIcon!);
429
+ fireEvent.mouseDown(clearIcon);
430
+ fireEvent.mouseUp(clearIcon);
431
+ fireEvent.click(clearIcon);
412
432
 
413
433
  await waitFor(() => {
414
434
  expect(onChange).toHaveBeenCalledWith('', undefined);
@@ -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;