@nocobase/flow-engine 2.2.0-beta.2 → 2.2.0-beta.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.
@@ -58,41 +58,54 @@ const flowModelRendererPropKeys = [
58
58
  "extraToolbarItems"
59
59
  ];
60
60
  function FieldModelRenderer(props) {
61
- const { model, ...rest } = props;
61
+ const { model, onChange, ...rest } = props;
62
62
  const composingRef = (0, import_react.useRef)(false);
63
- const handleChange = /* @__PURE__ */ __name((e) => {
64
- var _a;
65
- let val;
66
- if (e && e.target && typeof e.target.value !== "undefined") {
67
- val = e.target.value;
68
- } else if (typeof e === "string" || typeof e === "number" || typeof e === "boolean" || typeof e === "object" && !(e instanceof Event)) {
69
- val = e;
70
- } else {
71
- val = null;
72
- }
73
- model.setProps({ value: val });
74
- if (!composingRef.current) {
75
- (_a = props.onChange) == null ? void 0 : _a.call(props, val);
76
- }
77
- }, "handleChange");
78
- const handleCompositionStart = /* @__PURE__ */ __name(() => {
63
+ const handleChange = (0, import_react.useCallback)(
64
+ (e) => {
65
+ var _a;
66
+ let val;
67
+ if (e && e.target && typeof e.target.value !== "undefined") {
68
+ val = e.target.value;
69
+ } else if (typeof e === "string" || typeof e === "number" || typeof e === "boolean" || typeof e === "object" && !(e instanceof Event)) {
70
+ val = e;
71
+ } else {
72
+ val = null;
73
+ }
74
+ const isComposing = composingRef.current || ((_a = e == null ? void 0 : e.nativeEvent) == null ? void 0 : _a.isComposing);
75
+ if (isComposing) {
76
+ return;
77
+ }
78
+ model.setProps({ value: val });
79
+ if (!composingRef.current) {
80
+ onChange == null ? void 0 : onChange(val);
81
+ }
82
+ },
83
+ [model, onChange]
84
+ );
85
+ const handleCompositionStart = (0, import_react.useCallback)(() => {
79
86
  composingRef.current = true;
80
- }, "handleCompositionStart");
81
- const handleCompositionEnd = /* @__PURE__ */ __name((e, flag = true) => {
82
- composingRef.current = false;
83
- if (flag) {
84
- props.onChange(e);
85
- }
86
- }, "handleCompositionEnd");
87
- const modelProps = {
88
- onCompositionStart: handleCompositionStart,
89
- onCompositionEnd: handleCompositionEnd,
90
- ...import_lodash.default.omit(rest, flowModelRendererPropKeys),
91
- onChange: handleChange
92
- };
87
+ }, []);
88
+ const handleCompositionEnd = (0, import_react.useCallback)(
89
+ (e, flag = true) => {
90
+ composingRef.current = false;
91
+ if (flag) {
92
+ handleChange(e);
93
+ }
94
+ },
95
+ [handleChange]
96
+ );
97
+ const modelProps = (0, import_react.useMemo)(
98
+ () => ({
99
+ onCompositionStart: handleCompositionStart,
100
+ onCompositionEnd: handleCompositionEnd,
101
+ ...import_lodash.default.omit(rest, flowModelRendererPropKeys),
102
+ onChange: handleChange
103
+ }),
104
+ [handleChange, handleCompositionEnd, handleCompositionStart, rest]
105
+ );
93
106
  (0, import_react.useEffect)(() => {
94
107
  model && model.setProps(modelProps);
95
- }, [modelProps]);
108
+ }, [model, modelProps]);
96
109
  return /* @__PURE__ */ import_react.default.createElement(import_FlowModelRenderer.FlowModelRenderer, { model, ...rest });
97
110
  }
98
111
  __name(FieldModelRenderer, "FieldModelRenderer");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.2.0-beta.2",
3
+ "version": "2.2.0-beta.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-beta.2",
12
- "@nocobase/shared": "2.2.0-beta.2",
11
+ "@nocobase/sdk": "2.2.0-beta.3",
12
+ "@nocobase/shared": "2.2.0-beta.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": "2fe3e6a86fc1d0cd039349a68b2e4d1d6945ce45"
40
+ "gitHead": "7b16bb2cfd427c110c6671252138cd85155723c5"
41
41
  }
@@ -10,7 +10,7 @@
10
10
  import type { FlowModelRendererProps } from './FlowModelRenderer';
11
11
  import { FlowModelRenderer } from './FlowModelRenderer';
12
12
  import _ from 'lodash';
13
- import React, { useEffect, useMemo, useRef } from 'react';
13
+ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
14
14
 
15
15
  const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
16
16
  'model',
@@ -28,49 +28,63 @@ const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
28
28
  ];
29
29
 
30
30
  export function FieldModelRenderer(props: any) {
31
- const { model, ...rest } = props;
31
+ const { model, onChange, ...rest } = props;
32
32
  const composingRef = useRef(false);
33
33
 
34
- const handleChange = (e: any) => {
35
- let val;
36
- if (e && e.target && typeof e.target.value !== 'undefined') {
37
- val = e.target.value;
38
- } else if (
39
- typeof e === 'string' ||
40
- typeof e === 'number' ||
41
- typeof e === 'boolean' ||
42
- (typeof e === 'object' && !(e instanceof Event))
43
- ) {
44
- val = e;
45
- } else {
46
- val = null;
47
- }
34
+ const handleChange = useCallback(
35
+ (e: any) => {
36
+ let val;
37
+ if (e && e.target && typeof e.target.value !== 'undefined') {
38
+ val = e.target.value;
39
+ } else if (
40
+ typeof e === 'string' ||
41
+ typeof e === 'number' ||
42
+ typeof e === 'boolean' ||
43
+ (typeof e === 'object' && !(e instanceof Event))
44
+ ) {
45
+ val = e;
46
+ } else {
47
+ val = null;
48
+ }
48
49
 
49
- model.setProps({ value: val });
50
- if (!composingRef.current) {
51
- props.onChange?.(val);
52
- }
53
- };
54
- const handleCompositionStart = () => {
50
+ const isComposing = composingRef.current || e?.nativeEvent?.isComposing;
51
+ if (isComposing) {
52
+ return;
53
+ }
54
+
55
+ model.setProps({ value: val });
56
+ if (!composingRef.current) {
57
+ onChange?.(val);
58
+ }
59
+ },
60
+ [model, onChange],
61
+ );
62
+ const handleCompositionStart = useCallback(() => {
55
63
  composingRef.current = true;
56
- };
64
+ }, []);
57
65
 
58
- const handleCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
59
- composingRef.current = false;
60
- if (flag) {
61
- props.onChange(e);
62
- }
63
- };
66
+ const handleCompositionEnd = useCallback(
67
+ (e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
68
+ composingRef.current = false;
69
+ if (flag) {
70
+ handleChange(e);
71
+ }
72
+ },
73
+ [handleChange],
74
+ );
64
75
 
65
- const modelProps = {
66
- onCompositionStart: handleCompositionStart,
67
- onCompositionEnd: handleCompositionEnd,
68
- ..._.omit(rest, flowModelRendererPropKeys),
69
- onChange: handleChange,
70
- };
76
+ const modelProps = useMemo(
77
+ () => ({
78
+ onCompositionStart: handleCompositionStart,
79
+ onCompositionEnd: handleCompositionEnd,
80
+ ..._.omit(rest, flowModelRendererPropKeys),
81
+ onChange: handleChange,
82
+ }),
83
+ [handleChange, handleCompositionEnd, handleCompositionStart, rest],
84
+ );
71
85
  useEffect(() => {
72
86
  model && model.setProps(modelProps);
73
- }, [modelProps]);
87
+ }, [model, modelProps]);
74
88
 
75
89
  return <FlowModelRenderer model={model} {...rest} />;
76
90
  }
@@ -0,0 +1,165 @@
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 { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
11
+ import React from 'react';
12
+ import { vi } from 'vitest';
13
+ import { FlowEngine } from '../../flowEngine';
14
+ import { FlowModel } from '../../models/flowModel';
15
+ import { FlowEngineProvider } from '../../provider';
16
+ import { FieldModelRenderer } from '../FieldModelRenderer';
17
+
18
+ class InputModel extends FlowModel {
19
+ render(): React.ReactNode {
20
+ return (
21
+ <input
22
+ data-testid="field-input"
23
+ value={this.props.value ?? ''}
24
+ onChange={this.props.onChange}
25
+ onCompositionStart={this.props.onCompositionStart}
26
+ onCompositionEnd={this.props.onCompositionEnd}
27
+ />
28
+ );
29
+ }
30
+ }
31
+
32
+ describe('FieldModelRenderer', () => {
33
+ it('updates model and form value for normal input changes', async () => {
34
+ const flowEngine = new FlowEngine();
35
+ const model = new InputModel({
36
+ uid: 'normal-input-field-model',
37
+ flowEngine,
38
+ props: {
39
+ value: '',
40
+ },
41
+ });
42
+ model.dispatchEvent = vi.fn().mockResolvedValue([]);
43
+ const onChange = vi.fn();
44
+
45
+ render(
46
+ <FlowEngineProvider engine={flowEngine}>
47
+ <FieldModelRenderer model={model} onChange={onChange} />
48
+ </FlowEngineProvider>,
49
+ );
50
+
51
+ const input = await screen.findByTestId('field-input');
52
+ await waitFor(() => {
53
+ expect(model.props.onChange).toBeTypeOf('function');
54
+ });
55
+
56
+ fireEvent.change(input, { target: { value: 'abc' } });
57
+
58
+ expect(model.props.value).toBe('abc');
59
+ expect(onChange).toHaveBeenCalledTimes(1);
60
+ expect(onChange).toHaveBeenCalledWith('abc');
61
+ });
62
+
63
+ it('updates model and form value when field components pass values directly', async () => {
64
+ const flowEngine = new FlowEngine();
65
+ const model = new InputModel({
66
+ uid: 'direct-value-field-model',
67
+ flowEngine,
68
+ props: {
69
+ value: '',
70
+ },
71
+ });
72
+ model.dispatchEvent = vi.fn().mockResolvedValue([]);
73
+ const onChange = vi.fn();
74
+
75
+ render(
76
+ <FlowEngineProvider engine={flowEngine}>
77
+ <FieldModelRenderer model={model} onChange={onChange} />
78
+ </FlowEngineProvider>,
79
+ );
80
+
81
+ await screen.findByTestId('field-input');
82
+ await waitFor(() => {
83
+ expect(model.props.onChange).toBeTypeOf('function');
84
+ });
85
+
86
+ act(() => {
87
+ model.props.onChange(123);
88
+ });
89
+
90
+ expect(model.props.value).toBe(123);
91
+ expect(onChange).toHaveBeenCalledTimes(1);
92
+ expect(onChange).toHaveBeenCalledWith(123);
93
+ });
94
+
95
+ it('defers model and form value updates while IME composition is active', async () => {
96
+ const flowEngine = new FlowEngine();
97
+ const model = new InputModel({
98
+ uid: 'input-field-model',
99
+ flowEngine,
100
+ props: {
101
+ value: '',
102
+ },
103
+ });
104
+ model.dispatchEvent = vi.fn().mockResolvedValue([]);
105
+ const onChange = vi.fn();
106
+
107
+ render(
108
+ <FlowEngineProvider engine={flowEngine}>
109
+ <FieldModelRenderer model={model} onChange={onChange} />
110
+ </FlowEngineProvider>,
111
+ );
112
+
113
+ const input = await screen.findByTestId('field-input');
114
+ await waitFor(() => {
115
+ expect(model.props.onChange).toBeTypeOf('function');
116
+ });
117
+
118
+ fireEvent.compositionStart(input);
119
+ fireEvent.change(input, { target: { value: 't' }, nativeEvent: { isComposing: true } });
120
+
121
+ expect(model.props.value).toBe('');
122
+ expect(onChange).not.toHaveBeenCalled();
123
+
124
+ fireEvent.compositionEnd(input, { target: { value: 'te' } });
125
+
126
+ expect(model.props.value).toBe('te');
127
+ expect(onChange).toHaveBeenCalledTimes(1);
128
+ expect(onChange).toHaveBeenCalledWith('te');
129
+ });
130
+
131
+ it('does not commit composition value when composition end opts out', async () => {
132
+ const flowEngine = new FlowEngine();
133
+ const model = new InputModel({
134
+ uid: 'composition-opt-out-field-model',
135
+ flowEngine,
136
+ props: {
137
+ value: '',
138
+ },
139
+ });
140
+ model.dispatchEvent = vi.fn().mockResolvedValue([]);
141
+ const onChange = vi.fn();
142
+
143
+ render(
144
+ <FlowEngineProvider engine={flowEngine}>
145
+ <FieldModelRenderer model={model} onChange={onChange} />
146
+ </FlowEngineProvider>,
147
+ );
148
+
149
+ await screen.findByTestId('field-input');
150
+ await waitFor(() => {
151
+ expect(model.props.onChange).toBeTypeOf('function');
152
+ expect(model.props.onCompositionStart).toBeTypeOf('function');
153
+ expect(model.props.onCompositionEnd).toBeTypeOf('function');
154
+ });
155
+
156
+ act(() => {
157
+ model.props.onCompositionStart();
158
+ model.props.onChange({ target: { value: 'search' }, nativeEvent: { isComposing: true } });
159
+ model.props.onCompositionEnd({ target: { value: 'search' } }, false);
160
+ });
161
+
162
+ expect(model.props.value).toBe('');
163
+ expect(onChange).not.toHaveBeenCalled();
164
+ });
165
+ });