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

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;
@@ -113,15 +122,27 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
113
122
  const disabled = meta ? !!(typeof meta.disabled === "function" ? meta.disabled() : meta.disabled) : false;
114
123
  const disabledReason = meta ? typeof meta.disabledReason === "function" ? meta.disabledReason() : meta.disabledReason : void 0;
115
124
  const baseLabel = typeof o.label === "string" ? flowCtx.t(o.label) : o.label;
116
- 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(
117
129
  import_antd.Tooltip,
118
130
  {
119
- title: disabledReason || flowCtx.t("This variable is not available"),
120
- placement: "right",
121
- overlayClassName: "flow-variable-disabled-tip",
131
+ title: typeof tooltipTitle === "string" ? flowCtx.t(tooltipTitle) : tooltipTitle,
132
+ placement: "top",
133
+ classNames: { root: "flow-variable-tip" },
122
134
  destroyTooltipOnHide: true
123
135
  },
124
- /* @__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
+ )
125
146
  )) : baseLabel;
126
147
  return {
127
148
  ...o,
@@ -131,7 +152,7 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
131
152
  };
132
153
  });
133
154
  },
134
- [flowCtx]
155
+ [flowCtx, token.colorTextDescription, token.colorTextDisabled, token.marginXXS]
135
156
  );
136
157
  const [updateFlag, setUpdateFlag] = (0, import_react.useState)(0);
137
158
  const [searchText, setSearchText] = (0, import_react.useState)("");
@@ -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,
@@ -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.3",
3
+ "version": "2.2.0-alpha.5",
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.3",
12
- "@nocobase/shared": "2.2.0-alpha.3",
11
+ "@nocobase/sdk": "2.2.0-alpha.5",
12
+ "@nocobase/shared": "2.2.0-alpha.5",
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": "e3ec19361fd7981af9fe2a7f24ad335025cbefcd"
40
+ "gitHead": "814ff497dd1233ca7218c9f7f2a016c2ead6516d"
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;
@@ -121,17 +133,28 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
121
133
 
122
134
  // 文本国际化:仅当 label 为字符串时进行翻译
123
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;
124
141
 
125
- const label = disabled ? (
142
+ const label = tooltipTitle ? (
126
143
  <span>
127
144
  {baseLabel}
128
145
  <Tooltip
129
- title={disabledReason || flowCtx.t('This variable is not available')}
130
- placement="right"
131
- overlayClassName="flow-variable-disabled-tip"
146
+ title={typeof tooltipTitle === 'string' ? flowCtx.t(tooltipTitle) : tooltipTitle}
147
+ placement="top"
148
+ classNames={{ root: 'flow-variable-tip' }}
132
149
  destroyTooltipOnHide
133
150
  >
134
- <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
+ />
135
158
  </Tooltip>
136
159
  </span>
137
160
  ) : (
@@ -146,7 +169,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
146
169
  };
147
170
  });
148
171
  },
149
- [flowCtx],
172
+ [flowCtx, token.colorTextDescription, token.colorTextDisabled, token.marginXXS],
150
173
  );
151
174
 
152
175
  // 用于强制重新渲染的状态
@@ -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}
@@ -284,6 +284,43 @@ describe('FlowContextSelector', () => {
284
284
  });
285
285
  });
286
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
+
287
324
  it('should support inline search input when children is null and keep lazy expand', async () => {
288
325
  const flowContext = createTestFlowContext();
289
326
  const loadOrgChildren = vi.fn(async () => [
@@ -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
  });
@@ -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: () => {