@rainbow-oh/yee-c 0.8.20 → 0.9.1

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.
Files changed (51) hide show
  1. package/dist/cjs/Form/Field.js +6 -6
  2. package/dist/cjs/Form/demo/async-validate.js +2 -2
  3. package/dist/cjs/Form/demo/field-status.d.ts +3 -0
  4. package/dist/cjs/Form/demo/field-status.js +116 -0
  5. package/dist/cjs/Form/demo/validate.js +2 -2
  6. package/dist/cjs/Form/form-store.d.ts +6 -1
  7. package/dist/cjs/Form/form-store.js +93 -8
  8. package/dist/cjs/Form/index.d.ts +1 -1
  9. package/dist/cjs/Form/interface.d.ts +26 -3
  10. package/dist/cjs/Form/style/index.less +26 -0
  11. package/dist/cjs/Form/utils/path.js +1 -1
  12. package/dist/cjs/TreeSelect/tree-select.js +14 -4
  13. package/dist/cjs/style/themes.css +1 -1
  14. package/dist/cjs/style/variables.css +1 -1
  15. package/dist/cjs/version.d.ts +1 -1
  16. package/dist/cjs/version.js +1 -1
  17. package/dist/esm/Form/Field.js +6 -6
  18. package/dist/esm/Form/demo/async-validate.js +2 -2
  19. package/dist/esm/Form/demo/field-status.d.ts +3 -0
  20. package/dist/esm/Form/demo/field-status.js +104 -0
  21. package/dist/esm/Form/demo/validate.js +2 -2
  22. package/dist/esm/Form/form-store.d.ts +6 -1
  23. package/dist/esm/Form/form-store.js +118 -13
  24. package/dist/esm/Form/hooks/useForm.js +0 -175
  25. package/dist/esm/Form/index.d.ts +1 -1
  26. package/dist/esm/Form/interface.d.ts +26 -3
  27. package/dist/esm/Form/style/index.less +26 -0
  28. package/dist/esm/Form/utils/path.js +2 -1
  29. package/dist/esm/TreeSelect/tree-select.js +20 -6
  30. package/dist/esm/style/themes.css +1 -1
  31. package/dist/esm/style/variables.css +1 -1
  32. package/dist/esm/version.d.ts +1 -1
  33. package/dist/esm/version.js +1 -1
  34. package/dist/umd/yee-c.min.css +1 -1
  35. package/dist/umd/yee-c.min.js +1 -1
  36. package/package.json +1 -1
  37. package/src/Form/Field.tsx +7 -8
  38. package/src/Form/demo/async-validate.tsx +3 -2
  39. package/src/Form/demo/field-status.tsx +108 -0
  40. package/src/Form/demo/validate.tsx +3 -2
  41. package/src/Form/form-store.test.ts +156 -0
  42. package/src/Form/form-store.ts +117 -8
  43. package/src/Form/hooks/useForm.ts +0 -175
  44. package/src/Form/index.en-US.md +93 -88
  45. package/src/Form/index.ts +3 -0
  46. package/src/Form/index.zh-CN.md +56 -51
  47. package/src/Form/interface.ts +28 -3
  48. package/src/Form/style/index.less +26 -0
  49. package/src/Form/utils/path.ts +2 -1
  50. package/src/TreeSelect/tree-select.tsx +21 -4
  51. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-oh/yee-c",
3
- "version": "0.8.20",
3
+ "version": "0.9.1",
4
4
  "description": "yee basic components",
5
5
  "keywords": [
6
6
  "react",
@@ -44,7 +44,7 @@ const Field: React.FC<FieldProps> = (props) => {
44
44
  } = props;
45
45
  const {
46
46
  getCallbacks,
47
- getFieldValidate,
47
+ getFieldStatus,
48
48
  getFieldValue,
49
49
  getFieldsValue,
50
50
  setFieldsValue,
@@ -88,7 +88,7 @@ const Field: React.FC<FieldProps> = (props) => {
88
88
  );
89
89
  };
90
90
 
91
- const validate = getFieldValidate?.(normalizedName);
91
+ const fieldStatus = getFieldStatus?.(normalizedName);
92
92
 
93
93
  // Normalize tooltip into { icon, ...tooltipProps }, or null when not provided.
94
94
  // Everything except `icon` is forwarded to <Tooltip>, so any Tooltip prop
@@ -152,8 +152,7 @@ const Field: React.FC<FieldProps> = (props) => {
152
152
  `${prefixCls}-item`,
153
153
  `${prefixCls}-item-${layout}`,
154
154
  {
155
- [`${prefixCls}-item-${validate?.status}`]:
156
- validate && validate.status,
155
+ [`${prefixCls}-item-${fieldStatus?.status}`]: fieldStatus?.status,
157
156
  },
158
157
  className,
159
158
  )}
@@ -200,18 +199,18 @@ const Field: React.FC<FieldProps> = (props) => {
200
199
  </div>
201
200
  </div>
202
201
  <AnimatePresence>
203
- {validate && (
202
+ {fieldStatus?.message && (
204
203
  <motion.small
205
204
  initial={{ opacity: 0, y: -10 }}
206
205
  animate={{ opacity: 1, y: 0 }}
207
206
  exit={{ opacity: 0, y: -10 }}
208
207
  transition={{ duration: 0.16 }}
209
208
  className={clsx(`${prefixCls}-item-additional`, {
210
- [`${prefixCls}-item-additional-${validate.status}`]:
211
- validate.status,
209
+ [`${prefixCls}-item-additional-${fieldStatus.status}`]:
210
+ fieldStatus.status,
212
211
  })}
213
212
  >
214
- {validate.message}
213
+ {fieldStatus.message}
215
214
  </motion.small>
216
215
  )}
217
216
  </AnimatePresence>
@@ -1,4 +1,5 @@
1
1
  import { Button, Form, Grid, Input, Space } from '@rainbow-oh/yee-c';
2
+ import type { ValidateMessage } from '@rainbow-oh/yee-c';
2
3
  import React from 'react';
3
4
 
4
5
  // Simulate an async uniqueness check against a backend API.
@@ -16,8 +17,8 @@ export default () => {
16
17
  console.log('Received values:', values);
17
18
  };
18
19
 
19
- const onFinishFailed = (errorInfo: unknown) => {
20
- console.log('Failed:', errorInfo);
20
+ const onFinishFailed = (errors: ValidateMessage[]) => {
21
+ console.log('Failed:', errors);
21
22
  };
22
23
 
23
24
  return (
@@ -0,0 +1,108 @@
1
+ import {
2
+ Button,
3
+ Form,
4
+ Input,
5
+ Space,
6
+ type FieldStatusData,
7
+ } from '@rainbow-oh/yee-c';
8
+ import React, { useState } from 'react';
9
+
10
+ export default () => {
11
+ const [form] = Form.useForm();
12
+ const [currentStatus, setCurrentStatus] = useState<FieldStatusData | null>(
13
+ null,
14
+ );
15
+ const [submitResult, setSubmitResult] = useState('');
16
+
17
+ const applyStatus = (status: FieldStatusData | null) => {
18
+ form.setFieldStatus('username', status);
19
+ setCurrentStatus(form.getFieldStatus('username'));
20
+ setSubmitResult('');
21
+ };
22
+
23
+ return (
24
+ <Form
25
+ form={form}
26
+ initialValues={{ username: 'demo-user' }}
27
+ onFinish={() => setSubmitResult('Submit succeeded')}
28
+ onFinishFailed={() =>
29
+ setSubmitResult('Submit blocked by the blocking error')
30
+ }
31
+ >
32
+ <Form.Field
33
+ name="username"
34
+ label="Username"
35
+ rules={[{ required: true, message: 'Please input your username!' }]}
36
+ >
37
+ <Input placeholder="Please input your username" />
38
+ </Form.Field>
39
+
40
+ <Space style={{ marginTop: 28, flexWrap: 'wrap' }}>
41
+ <Button
42
+ onClick={() =>
43
+ applyStatus({
44
+ status: 'error',
45
+ message: 'This username is unavailable',
46
+ })
47
+ }
48
+ >
49
+ Error
50
+ </Button>
51
+ <Button
52
+ onClick={() =>
53
+ applyStatus({
54
+ status: 'error',
55
+ message: 'This error prevents submission',
56
+ blocking: true,
57
+ })
58
+ }
59
+ >
60
+ Blocking error
61
+ </Button>
62
+ <Button
63
+ onClick={() =>
64
+ applyStatus({
65
+ status: 'warning',
66
+ message: 'This username is commonly used',
67
+ })
68
+ }
69
+ >
70
+ Warning
71
+ </Button>
72
+ <Button
73
+ onClick={() =>
74
+ applyStatus({
75
+ status: 'success',
76
+ message: 'This username is available',
77
+ })
78
+ }
79
+ >
80
+ Success
81
+ </Button>
82
+ <Button
83
+ onClick={() =>
84
+ applyStatus({
85
+ status: 'info',
86
+ message: 'The username can be changed later',
87
+ })
88
+ }
89
+ >
90
+ Info
91
+ </Button>
92
+ <Button onClick={() => applyStatus(null)}>Clear</Button>
93
+ <Button type="primary" htmlType="submit">
94
+ Submit
95
+ </Button>
96
+ </Space>
97
+
98
+ <pre style={{ marginTop: 16 }}>
99
+ Current status: {JSON.stringify(currentStatus, null, 2)}
100
+ </pre>
101
+ {submitResult ? <div>{submitResult}</div> : null}
102
+ <div style={{ marginTop: 8, color: '#999' }}>
103
+ Tip: set Success, then clear the input — the rule error takes display
104
+ precedence over the manually set status.
105
+ </div>
106
+ </Form>
107
+ );
108
+ };
@@ -1,4 +1,5 @@
1
1
  import { Button, Form, Grid, Input, Space } from '@rainbow-oh/yee-c';
2
+ import type { ValidateMessage } from '@rainbow-oh/yee-c';
2
3
  import React from 'react';
3
4
 
4
5
  export default () => {
@@ -8,8 +9,8 @@ export default () => {
8
9
  console.log('Received values:', values);
9
10
  };
10
11
 
11
- const onFinishFailed = (errorInfo: unknown) => {
12
- console.log('Failed:', errorInfo);
12
+ const onFinishFailed = (errors: ValidateMessage[]) => {
13
+ console.log('Failed:', errors);
13
14
  };
14
15
 
15
16
  return (
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { ReactElement } from 'react';
3
+ import { FormStore } from './form-store';
4
+ import type { FieldEntity, Rule } from './interface';
5
+
6
+ const createEntity = (
7
+ name: string,
8
+ onStoreChange = vi.fn(),
9
+ rules?: Rule[],
10
+ ): FieldEntity => ({
11
+ props: {
12
+ name,
13
+ rules,
14
+ children: {} as unknown as ReactElement,
15
+ },
16
+ onStoreChange,
17
+ });
18
+
19
+ describe('FormStore.clearFields', () => {
20
+ it('keeps values of unrelated fields on partial clear', () => {
21
+ const store = new FormStore();
22
+ store.setFieldsValue({ a: 1, b: 2 }, 'fill');
23
+
24
+ store.clearFields(['a']);
25
+
26
+ expect(store.getFieldsValue()).toEqual({ a: undefined, b: 2 });
27
+ });
28
+
29
+ it('clears a nested path leaf and keeps its siblings', () => {
30
+ const store = new FormStore();
31
+ store.setFieldsValue({ 'user.name': 'tom', 'user.age': 18 }, 'fill');
32
+
33
+ store.clearFields(['user.name']);
34
+
35
+ expect(store.getFieldValue('user.name')).toBeUndefined();
36
+ expect(store.getFieldValue('user.age')).toBe(18);
37
+ });
38
+
39
+ it('sets every top-level key to undefined when name is omitted', () => {
40
+ const store = new FormStore();
41
+ store.setFieldsValue({ a: 1, 'user.name': 'tom' }, 'fill');
42
+
43
+ store.clearFields();
44
+
45
+ const values = store.getFieldsValue();
46
+ expect(values.a).toBeUndefined();
47
+ expect(store.getFieldValue('user.name')).toBeUndefined();
48
+ expect(Object.keys(values)).toEqual(['a', 'user']);
49
+ });
50
+
51
+ it('clears validation messages only for cleared fields', async () => {
52
+ const store = new FormStore();
53
+ store.registerFieldEntities(
54
+ createEntity('a', vi.fn(), [{ required: true, message: 'a required' }]),
55
+ );
56
+ store.registerFieldEntities(
57
+ createEntity('b', vi.fn(), [{ required: true, message: 'b required' }]),
58
+ );
59
+
60
+ const errors = await store.validateFields();
61
+ expect(errors).toHaveLength(2);
62
+ expect(store.getFieldValidate('a')?.message).toBe('a required');
63
+
64
+ store.clearFields(['a']);
65
+
66
+ expect(store.getFieldValidate('a')).toBeNull();
67
+ expect(store.getFieldValidate('b')?.message).toBe('b required');
68
+ });
69
+
70
+ it('clears manual field statuses only for cleared fields', () => {
71
+ const store = new FormStore();
72
+ store.setFieldStatus('a', { status: 'warning', message: 'check a' });
73
+ store.setFieldStatus('b', { status: 'warning', message: 'check b' });
74
+
75
+ store.clearFields(['a']);
76
+
77
+ expect(store.getFieldStatus('a')).toBeNull();
78
+ expect(store.getFieldStatus('b')?.message).toBe('check b');
79
+ });
80
+
81
+ it('refreshes only fields related to the cleared names', () => {
82
+ const store = new FormStore();
83
+ const onChangeA = vi.fn();
84
+ const onChangeB = vi.fn();
85
+ const onChangeNested = vi.fn();
86
+ store.registerFieldEntities(createEntity('a', onChangeA));
87
+ store.registerFieldEntities(createEntity('b', onChangeB));
88
+ store.registerFieldEntities(createEntity('a.x', onChangeNested));
89
+ store.setFieldsValue({ a: 1, b: 2 }, 'fill');
90
+ onChangeA.mockClear();
91
+ onChangeB.mockClear();
92
+ onChangeNested.mockClear();
93
+
94
+ store.clearFields(['a']);
95
+
96
+ expect(onChangeA).toHaveBeenCalled();
97
+ expect(onChangeNested).toHaveBeenCalled();
98
+ expect(onChangeB).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it('notifies only watchers related to the cleared names', () => {
102
+ const store = new FormStore();
103
+ const watchA = vi.fn();
104
+ const watchB = vi.fn();
105
+ const watchAll = vi.fn();
106
+ store.subscribe('a', watchA);
107
+ store.subscribe('b', watchB);
108
+ store.subscribe(undefined, watchAll);
109
+
110
+ store.setFieldsValue({ a: 1, b: 2 }, 'fill');
111
+ watchA.mockClear();
112
+ watchB.mockClear();
113
+ watchAll.mockClear();
114
+
115
+ store.clearFields(['a']);
116
+
117
+ expect(watchA).toHaveBeenCalled();
118
+ expect(watchAll).toHaveBeenCalled();
119
+ expect(watchB).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('fires the onClear callback', () => {
123
+ const store = new FormStore();
124
+ const onClear = vi.fn();
125
+ store.setCallbacks({ onClear });
126
+
127
+ store.clearFields(['a']);
128
+
129
+ expect(onClear).toHaveBeenCalled();
130
+ });
131
+ });
132
+
133
+ describe('FormStore.initialize initialValues', () => {
134
+ it('resolves nested objects by field path', () => {
135
+ const store = new FormStore();
136
+ store.initialize({ initialValues: { user: { name: 'tom' }, age: 18 } });
137
+
138
+ expect(store.getFieldValue('user.name')).toBe('tom');
139
+ expect(store.getFieldValue('age')).toBe(18);
140
+
141
+ store.setFieldsValue({ 'user.name': 'jerry' }, 'fill');
142
+ store.resetFields(['user.name']);
143
+ expect(store.getFieldValue('user.name')).toBe('tom');
144
+ });
145
+ });
146
+
147
+ describe('FormStore.setFieldsValue with clear trigger', () => {
148
+ it('merges over the store instead of replacing it', () => {
149
+ const store = new FormStore();
150
+ store.setFieldsValue({ a: 1, b: 2 }, 'fill');
151
+
152
+ store.setFieldsValue({ a: undefined }, 'clear');
153
+
154
+ expect(store.getFieldsValue()).toEqual({ a: undefined, b: 2 });
155
+ });
156
+ });
@@ -9,6 +9,7 @@ import validate from './utils/validate';
9
9
  import type {
10
10
  Callbacks,
11
11
  FieldEntity,
12
+ FieldStatusData,
12
13
  FormInstance,
13
14
  FormListField,
14
15
  FormListOperation,
@@ -43,6 +44,7 @@ export class FormStore {
43
44
  private fieldEntities: FieldEntity[] = [];
44
45
  private fieldMap: Map<Name, FieldEntity> = new Map();
45
46
  private validateMessages: Map<Name, ValidateMessage[]> = new Map();
47
+ private fieldStatuses: Map<Name, FieldStatusData> = new Map();
46
48
  private initialized = false;
47
49
  private initialValues: Store = {};
48
50
  // List key manager
@@ -114,9 +116,81 @@ export class FormStore {
114
116
  return null;
115
117
  };
116
118
 
119
+ getFieldStatus = (name: NamePath): FieldStatusData | null => {
120
+ const normalizedName = stringifyPath(getNamePath(name));
121
+ const manualStatus = this.fieldStatuses.get(normalizedName);
122
+ const validateMessage = this.validateMessages.get(normalizedName)?.[0];
123
+
124
+ // Rule errors reflect the current value and auto-clear once validation
125
+ // passes, so they always take display precedence — a stale manually set
126
+ // status must never hide why submission failed.
127
+ if (validateMessage?.status === 'error') {
128
+ return {
129
+ status: 'error',
130
+ message: validateMessage.message,
131
+ };
132
+ }
133
+
134
+ if (manualStatus) {
135
+ return { ...manualStatus };
136
+ }
137
+
138
+ if (validateMessage) {
139
+ return {
140
+ status: validateMessage.status,
141
+ message: validateMessage.message,
142
+ };
143
+ }
144
+
145
+ return null;
146
+ };
147
+
148
+ setFieldStatus = (name: NamePath, status: FieldStatusData | null) => {
149
+ const normalizedName = stringifyPath(getNamePath(name));
150
+ if (!normalizedName) return;
151
+
152
+ if (status) {
153
+ this.fieldStatuses.set(normalizedName, { ...status });
154
+ } else {
155
+ this.fieldStatuses.delete(normalizedName);
156
+ }
157
+
158
+ this.fieldMap.get(normalizedName)?.onStoreChange();
159
+ };
160
+
161
+ // Delete entries whose key equals or nests under one of `names`
162
+ // (no names = delete all). Keeps rule messages and manual statuses in
163
+ // sync with the fields that reset/clear actually touched.
164
+ private deleteRelatedEntries = <T>(
165
+ entries: Map<Name, T>,
166
+ names?: NamePath[],
167
+ ): void => {
168
+ if (!names) {
169
+ entries.clear();
170
+ return;
171
+ }
172
+
173
+ const normalizedNames = names.map((name) =>
174
+ stringifyPath(getNamePath(name)),
175
+ );
176
+ Array.from(entries.keys()).forEach((fieldName) => {
177
+ if (normalizedNames.some((name) => isRelatedNamePath(fieldName, name))) {
178
+ entries.delete(fieldName);
179
+ }
180
+ });
181
+ };
182
+
183
+ private clearFieldStatuses = (names?: NamePath[]) => {
184
+ this.deleteRelatedEntries(this.fieldStatuses, names);
185
+ };
186
+
117
187
  setFieldsValue = (newStore: Store, trigger: TRIGGER = 'onChange') => {
118
188
  if (trigger === 'reset' || trigger === 'clear') {
119
- this.store = newStore;
189
+ // 'reset' replaces the whole store; 'clear' merges the given keys
190
+ // (typically undefined) over the store so a partial clear never
191
+ // wipes values of unrelated fields.
192
+ this.store =
193
+ trigger === 'reset' ? newStore : { ...this.store, ...newStore };
120
194
  if (trigger === 'reset') {
121
195
  this.callbacks?.onReset?.();
122
196
  } else {
@@ -196,7 +270,10 @@ export class FormStore {
196
270
  });
197
271
  }
198
272
  this.callbacks?.onReset?.();
199
- this.validateMessages.clear();
273
+ // A named reset only clears rule messages of the fields it touched;
274
+ // unrelated fields keep their (still valid) validation state.
275
+ this.deleteRelatedEntries(this.validateMessages, name);
276
+ this.clearFieldStatuses(name);
200
277
  this.groupEntities.forEach((entity) => entity.reset());
201
278
  this.refreshField();
202
279
  };
@@ -204,11 +281,29 @@ export class FormStore {
204
281
  clearFields = (name?: Name[]) => {
205
282
  const names = name || Object.keys(this.store);
206
283
 
207
- const clearStore = names.reduce((acc, name) => {
208
- acc[name] = undefined;
209
- return acc;
210
- }, {} as Store);
211
- this.setFieldsValue(clearStore, 'clear');
284
+ // Merge undefined over the current store (nested paths included) —
285
+ // unrelated fields keep their values.
286
+ let clearStore = { ...this.store };
287
+ names.forEach((n) => {
288
+ const parsedPath = parsePath(n);
289
+ if (parsedPath.length > 1) {
290
+ clearStore = setValueByPath(clearStore, parsedPath, undefined);
291
+ } else {
292
+ clearStore[n] = undefined;
293
+ }
294
+ });
295
+ this.deleteRelatedEntries(this.validateMessages, name);
296
+ this.clearFieldStatuses(name);
297
+ this.store = clearStore;
298
+ this.callbacks?.onClear?.();
299
+
300
+ // clearStore carries the whole store (nested paths must materialize
301
+ // it), so derive the refresh scope from `names` — only cleared
302
+ // fields re-render, not the entire form.
303
+ this.getRelatedFieldNames(names).forEach((fieldName) => {
304
+ this.fieldMap.get(fieldName)?.onStoreChange();
305
+ });
306
+ this.notifyWatchers(names);
212
307
  };
213
308
 
214
309
  validateField = async (
@@ -567,7 +662,19 @@ export class FormStore {
567
662
  const { onFinish, onFinishFailed } = this.callbacks;
568
663
  const fieldErrors = await this.validateFields();
569
664
  const groupErrors = await this.validateGroups(true);
570
- const allErrors = [...fieldErrors, ...groupErrors];
665
+ const statusErrors = Array.from(this.fieldStatuses.entries())
666
+ .filter(
667
+ ([, status]) => status.status === 'error' && status.blocking === true,
668
+ )
669
+ .map(
670
+ ([name, status]): ValidateMessage => ({
671
+ name,
672
+ message: status.message ?? '',
673
+ value: this.getFieldValue(name),
674
+ status: 'error',
675
+ }),
676
+ );
677
+ const allErrors = [...fieldErrors, ...groupErrors, ...statusErrors];
571
678
  if (allErrors.length === 0) {
572
679
  onFinish?.(this.getFieldsValue());
573
680
  } else {
@@ -580,6 +687,8 @@ export class FormStore {
580
687
  getForm(): FormInstance {
581
688
  return {
582
689
  getFieldValidate: this.getFieldValidate,
690
+ getFieldStatus: this.getFieldStatus,
691
+ setFieldStatus: this.setFieldStatus,
583
692
  getFieldsValue: this.getFieldsValue,
584
693
  getFieldValue: this.getFieldValue,
585
694
  setFieldsValue: this.setFieldsValue,