@nocobase/client-v2 2.1.3 → 2.1.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.
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +5 -0
- package/es/flow/utils/dataScopeRowSnapshot.d.ts +59 -0
- package/es/flow/utils/formValueDeps.d.ts +32 -0
- package/es/index.mjs +67 -67
- package/lib/index.js +81 -81
- package/package.json +7 -7
- package/src/components/AppComponents.tsx +19 -3
- package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +1022 -0
- package/src/flow/actions/__tests__/subFormFieldLinkageRules.inputArgs.test.ts +2 -2
- package/src/flow/actions/linkageRules.tsx +1 -1
- package/src/flow/actions/linkageRulesFormValueRefresh.ts +22 -130
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +117 -18
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +185 -0
- package/src/flow/models/fields/InputFieldModel.tsx +48 -2
- package/src/flow/models/fields/TextareaFieldModel.tsx +49 -3
- package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +100 -1
- package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +100 -0
- package/src/flow/utils/dataScopeFormValueClear.ts +218 -81
- package/src/flow/utils/dataScopeRowSnapshot.ts +616 -0
- package/src/flow/utils/formValueDeps.ts +170 -0
|
@@ -9,18 +9,64 @@
|
|
|
9
9
|
|
|
10
10
|
import { EditableItemModel, FilterableItemModel, tExpr } from '@nocobase/flow-engine';
|
|
11
11
|
import { Input } from 'antd';
|
|
12
|
-
import
|
|
12
|
+
import type { InputProps, InputRef } from 'antd';
|
|
13
|
+
import React, { useEffect, useRef } from 'react';
|
|
13
14
|
import { customAlphabet as Alphabet } from 'nanoid';
|
|
14
15
|
import { FieldModel } from '../base/FieldModel';
|
|
15
16
|
import { ScanInput } from '../../../components/form/ScanInput';
|
|
16
17
|
|
|
18
|
+
function IMESafeInput(props: InputProps) {
|
|
19
|
+
const { value, onChange, onCompositionStart, onCompositionEnd, ...rest } = props;
|
|
20
|
+
const inputRef = useRef<InputRef>(null);
|
|
21
|
+
const previousValueRef = useRef(value);
|
|
22
|
+
const defaultValue = typeof value === 'bigint' ? String(value) : value;
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (Object.is(previousValueRef.current, value)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
previousValueRef.current = value;
|
|
29
|
+
|
|
30
|
+
const input = inputRef.current?.input;
|
|
31
|
+
if (input) {
|
|
32
|
+
const nextValue = value == null ? '' : String(value);
|
|
33
|
+
if (input.value !== nextValue) {
|
|
34
|
+
input.value = nextValue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}, [value]);
|
|
38
|
+
|
|
39
|
+
const getEventValue = (event: React.ChangeEvent<HTMLInputElement> | React.CompositionEvent<HTMLInputElement>) =>
|
|
40
|
+
event.currentTarget.value;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Input
|
|
44
|
+
{...rest}
|
|
45
|
+
ref={inputRef}
|
|
46
|
+
defaultValue={defaultValue}
|
|
47
|
+
onChange={(event) => {
|
|
48
|
+
previousValueRef.current = getEventValue(event);
|
|
49
|
+
onChange?.(event);
|
|
50
|
+
}}
|
|
51
|
+
onCompositionStart={(event) => {
|
|
52
|
+
previousValueRef.current = getEventValue(event);
|
|
53
|
+
onCompositionStart?.(event);
|
|
54
|
+
}}
|
|
55
|
+
onCompositionEnd={(event) => {
|
|
56
|
+
previousValueRef.current = getEventValue(event);
|
|
57
|
+
onCompositionEnd?.(event);
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
17
63
|
export class InputFieldModel extends FieldModel {
|
|
18
64
|
render() {
|
|
19
65
|
if (this.props.enableScan) {
|
|
20
66
|
return <ScanInput {...this.props} />;
|
|
21
67
|
}
|
|
22
68
|
const { enableScan, disableManualInput, ...inputProps } = this.props;
|
|
23
|
-
return <
|
|
69
|
+
return <IMESafeInput {...inputProps} />;
|
|
24
70
|
}
|
|
25
71
|
}
|
|
26
72
|
|
|
@@ -8,15 +8,61 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Input } from 'antd';
|
|
11
|
-
import
|
|
12
|
-
import
|
|
11
|
+
import type { TextAreaProps } from 'antd/es/input';
|
|
12
|
+
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
|
13
|
+
import React, { useEffect, useRef } from 'react';
|
|
13
14
|
import { largeField, EditableItemModel } from '@nocobase/flow-engine';
|
|
14
15
|
import { FieldModel } from '../base/FieldModel';
|
|
15
16
|
|
|
17
|
+
function IMESafeTextArea(props: TextAreaProps) {
|
|
18
|
+
const { value, onChange, onCompositionStart, onCompositionEnd, ...rest } = props;
|
|
19
|
+
const textAreaRef = useRef<TextAreaRef>(null);
|
|
20
|
+
const previousValueRef = useRef(value);
|
|
21
|
+
const defaultValue = typeof value === 'bigint' ? String(value) : value;
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (Object.is(previousValueRef.current, value)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
previousValueRef.current = value;
|
|
28
|
+
|
|
29
|
+
const textArea = textAreaRef.current?.resizableTextArea?.textArea;
|
|
30
|
+
if (textArea) {
|
|
31
|
+
const nextValue = value == null ? '' : String(value);
|
|
32
|
+
if (textArea.value !== nextValue) {
|
|
33
|
+
textArea.value = nextValue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}, [value]);
|
|
37
|
+
|
|
38
|
+
const getEventValue = (event: React.ChangeEvent<HTMLTextAreaElement> | React.CompositionEvent<HTMLTextAreaElement>) =>
|
|
39
|
+
event.currentTarget.value;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Input.TextArea
|
|
43
|
+
{...rest}
|
|
44
|
+
ref={textAreaRef}
|
|
45
|
+
defaultValue={defaultValue}
|
|
46
|
+
onChange={(event) => {
|
|
47
|
+
previousValueRef.current = getEventValue(event);
|
|
48
|
+
onChange?.(event);
|
|
49
|
+
}}
|
|
50
|
+
onCompositionStart={(event) => {
|
|
51
|
+
previousValueRef.current = getEventValue(event);
|
|
52
|
+
onCompositionStart?.(event);
|
|
53
|
+
}}
|
|
54
|
+
onCompositionEnd={(event) => {
|
|
55
|
+
previousValueRef.current = getEventValue(event);
|
|
56
|
+
onCompositionEnd?.(event);
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
16
62
|
@largeField()
|
|
17
63
|
export class TextareaFieldModel extends FieldModel {
|
|
18
64
|
render() {
|
|
19
|
-
return <
|
|
65
|
+
return <IMESafeTextArea {...this.props} />;
|
|
20
66
|
}
|
|
21
67
|
}
|
|
22
68
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { FlowEngine } from '@nocobase/flow-engine';
|
|
11
|
-
import { render, screen } from '@testing-library/react';
|
|
11
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
12
12
|
import React from 'react';
|
|
13
13
|
import { describe, expect, it, vi } from 'vitest';
|
|
14
14
|
import { InputFieldModel } from '../InputFieldModel';
|
|
@@ -43,6 +43,105 @@ describe('InputFieldModel', () => {
|
|
|
43
43
|
consoleError.mockRestore();
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it('keeps IME composition text visible before the parent value is committed', () => {
|
|
47
|
+
const onChange = vi.fn();
|
|
48
|
+
const onCompositionStart = vi.fn();
|
|
49
|
+
const onCompositionEnd = vi.fn();
|
|
50
|
+
const model = createInputFieldModel({
|
|
51
|
+
value: '',
|
|
52
|
+
onChange,
|
|
53
|
+
onCompositionStart,
|
|
54
|
+
onCompositionEnd,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
render(<>{model.render()}</>);
|
|
58
|
+
|
|
59
|
+
const input = screen.getByRole('textbox') as HTMLInputElement;
|
|
60
|
+
|
|
61
|
+
fireEvent.compositionStart(input);
|
|
62
|
+
fireEvent.change(input, { target: { value: 'ni' }, nativeEvent: { isComposing: true } });
|
|
63
|
+
|
|
64
|
+
expect(input.value).toBe('ni');
|
|
65
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(onCompositionStart).toHaveBeenCalledTimes(1);
|
|
67
|
+
|
|
68
|
+
fireEvent.compositionEnd(input, { target: { value: '你' } });
|
|
69
|
+
|
|
70
|
+
expect(input.value).toBe('你');
|
|
71
|
+
expect(onCompositionEnd).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns to the committed parent value after IME composition is submitted', () => {
|
|
75
|
+
const onChange = vi.fn();
|
|
76
|
+
const onCompositionEnd = vi.fn();
|
|
77
|
+
const createModel = (value: string) =>
|
|
78
|
+
createInputFieldModel({
|
|
79
|
+
value,
|
|
80
|
+
onChange,
|
|
81
|
+
onCompositionEnd,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const { rerender } = render(<>{createModel('').render()}</>);
|
|
85
|
+
const input = screen.getByRole('textbox') as HTMLInputElement;
|
|
86
|
+
|
|
87
|
+
fireEvent.compositionStart(input);
|
|
88
|
+
fireEvent.change(input, { target: { value: 'ni' }, nativeEvent: { isComposing: true } });
|
|
89
|
+
fireEvent.compositionEnd(input, { target: { value: '你' } });
|
|
90
|
+
|
|
91
|
+
expect(input.value).toBe('你');
|
|
92
|
+
|
|
93
|
+
rerender(<>{createModel('你').render()}</>);
|
|
94
|
+
|
|
95
|
+
expect(input.value).toBe('你');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('does not overwrite in-progress DOM input with a stale parent value', async () => {
|
|
99
|
+
const onChange = vi.fn();
|
|
100
|
+
const createModel = (value: string) =>
|
|
101
|
+
createInputFieldModel({
|
|
102
|
+
value,
|
|
103
|
+
onChange,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const { rerender } = render(<>{createModel('').render()}</>);
|
|
107
|
+
const input = screen.getByRole('textbox') as HTMLInputElement;
|
|
108
|
+
|
|
109
|
+
fireEvent.change(input, { target: { value: 'té' } });
|
|
110
|
+
rerender(<>{createModel('').render()}</>);
|
|
111
|
+
|
|
112
|
+
expect(input.value).toBe('té');
|
|
113
|
+
|
|
114
|
+
rerender(<>{createModel('reset').render()}</>);
|
|
115
|
+
|
|
116
|
+
await waitFor(() => {
|
|
117
|
+
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('reset');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('keeps replacement-style Vietnamese IME edits in the DOM', () => {
|
|
122
|
+
const onChange = vi.fn();
|
|
123
|
+
const createModel = (value: string) =>
|
|
124
|
+
createInputFieldModel({
|
|
125
|
+
value,
|
|
126
|
+
onChange,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const { rerender } = render(<>{createModel('').render()}</>);
|
|
130
|
+
const input = screen.getByRole('textbox') as HTMLInputElement;
|
|
131
|
+
|
|
132
|
+
fireEvent.change(input, { target: { value: 'te' } });
|
|
133
|
+
rerender(<>{createModel('te').render()}</>);
|
|
134
|
+
expect(input.value).toBe('te');
|
|
135
|
+
|
|
136
|
+
fireEvent.change(input, { target: { value: 'té' } });
|
|
137
|
+
rerender(<>{createModel('te').render()}</>);
|
|
138
|
+
expect(input.value).toBe('té');
|
|
139
|
+
|
|
140
|
+
fireEvent.change(input, { target: { value: 'tét' } });
|
|
141
|
+
rerender(<>{createModel('té').render()}</>);
|
|
142
|
+
expect(input.value).toBe('tét');
|
|
143
|
+
});
|
|
144
|
+
|
|
46
145
|
it('renders ScanInput when scan input is enabled', () => {
|
|
47
146
|
const model = createInputFieldModel({ enableScan: true });
|
|
48
147
|
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { FlowEngine } from '@nocobase/flow-engine';
|
|
11
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { TextareaFieldModel } from '../TextareaFieldModel';
|
|
15
|
+
|
|
16
|
+
function createTextareaFieldModel(props?: Record<string, unknown>) {
|
|
17
|
+
const engine = new FlowEngine();
|
|
18
|
+
return new TextareaFieldModel({
|
|
19
|
+
uid: 'textarea-field-model-test',
|
|
20
|
+
flowEngine: engine,
|
|
21
|
+
props,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('TextareaFieldModel', () => {
|
|
26
|
+
it('keeps IME composition text visible before the parent value is committed', () => {
|
|
27
|
+
const onChange = vi.fn();
|
|
28
|
+
const onCompositionStart = vi.fn();
|
|
29
|
+
const onCompositionEnd = vi.fn();
|
|
30
|
+
const model = createTextareaFieldModel({
|
|
31
|
+
value: '',
|
|
32
|
+
onChange,
|
|
33
|
+
onCompositionStart,
|
|
34
|
+
onCompositionEnd,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
render(<>{model.render()}</>);
|
|
38
|
+
|
|
39
|
+
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
|
|
40
|
+
|
|
41
|
+
fireEvent.compositionStart(textarea);
|
|
42
|
+
fireEvent.change(textarea, { target: { value: 'ni' }, nativeEvent: { isComposing: true } });
|
|
43
|
+
|
|
44
|
+
expect(textarea.value).toBe('ni');
|
|
45
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
46
|
+
expect(onCompositionStart).toHaveBeenCalledTimes(1);
|
|
47
|
+
|
|
48
|
+
fireEvent.compositionEnd(textarea, { target: { value: '你' } });
|
|
49
|
+
|
|
50
|
+
expect(textarea.value).toBe('你');
|
|
51
|
+
expect(onCompositionEnd).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not overwrite in-progress DOM input with a stale parent value', async () => {
|
|
55
|
+
const onChange = vi.fn();
|
|
56
|
+
const createModel = (value: string) =>
|
|
57
|
+
createTextareaFieldModel({
|
|
58
|
+
value,
|
|
59
|
+
onChange,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const { rerender } = render(<>{createModel('').render()}</>);
|
|
63
|
+
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
|
|
64
|
+
|
|
65
|
+
fireEvent.change(textarea, { target: { value: 'té' } });
|
|
66
|
+
rerender(<>{createModel('').render()}</>);
|
|
67
|
+
|
|
68
|
+
expect(textarea.value).toBe('té');
|
|
69
|
+
|
|
70
|
+
rerender(<>{createModel('reset').render()}</>);
|
|
71
|
+
|
|
72
|
+
await waitFor(() => {
|
|
73
|
+
expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toBe('reset');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps replacement-style Vietnamese IME edits in the DOM', () => {
|
|
78
|
+
const onChange = vi.fn();
|
|
79
|
+
const createModel = (value: string) =>
|
|
80
|
+
createTextareaFieldModel({
|
|
81
|
+
value,
|
|
82
|
+
onChange,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const { rerender } = render(<>{createModel('').render()}</>);
|
|
86
|
+
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
|
|
87
|
+
|
|
88
|
+
fireEvent.change(textarea, { target: { value: 'te' } });
|
|
89
|
+
rerender(<>{createModel('te').render()}</>);
|
|
90
|
+
expect(textarea.value).toBe('te');
|
|
91
|
+
|
|
92
|
+
fireEvent.change(textarea, { target: { value: 'té' } });
|
|
93
|
+
rerender(<>{createModel('te').render()}</>);
|
|
94
|
+
expect(textarea.value).toBe('té');
|
|
95
|
+
|
|
96
|
+
fireEvent.change(textarea, { target: { value: 'tét' } });
|
|
97
|
+
rerender(<>{createModel('té').render()}</>);
|
|
98
|
+
expect(textarea.value).toBe('tét');
|
|
99
|
+
});
|
|
100
|
+
});
|