Skip to content

Commit 00b8704

Browse files
committed
feat(form): update validation logic with standard resolver and improved error handling
1 parent 6a9bed5 commit 00b8704

File tree

9 files changed

+298
-37
lines changed

9 files changed

+298
-37
lines changed

playground/src/app/(demo)/demo-hook-form/page.tsx

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,23 @@ import { useForm } from 'react-hook-form';
55

66
type Inputs = {
77
example: string;
8-
exampleRequired: string;
8+
exampleRequired: number;
99
};
1010

11+
let count = 0;
12+
1113
export default function App() {
1214
const {
13-
formState: { errors },
15+
formState: { errors, validatingFields },
1416
handleSubmit,
15-
register,
16-
setValue,
17-
watch
17+
register
1818
} = useForm<Inputs>();
19-
const onSubmit: SubmitHandler<Inputs> = data => console.log(data);
2019

21-
console.log(watch('example')); // watch input value by passing the name of it
20+
const onSubmit: SubmitHandler<Inputs> = data => console.log(data);
2221

23-
const demo = register('exampleRequired', { required: true });
22+
count += 1;
2423

25-
console.log('demo', demo);
24+
console.log('validatingFields', validatingFields, errors);
2625

2726
return (
2827
/* "handleSubmit" will validate your inputs before invoking "onSubmit" */
@@ -33,20 +32,13 @@ export default function App() {
3332
{...register('example')}
3433
/>
3534

35+
<div>count: {count}</div>
36+
3637
{/* include validation with required or other standard HTML validation rules */}
37-
<input {...demo} />
38+
<input {...register('exampleRequired', { min: 28, minLength: 2, required: true })} />
3839
{/* errors will return when field validation fails */}
3940
{errors.exampleRequired && <span>This field is required</span>}
4041

41-
<button
42-
className="mr-4"
43-
onClick={() => {
44-
setValue('exampleRequired', '123777');
45-
}}
46-
>
47-
demo
48-
</button>
49-
5042
<input type="submit" />
5143
</form>
5244
);

playground/src/app/(demo)/form/modules/ZodResolver.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { useEffect } from 'react';
4-
import { Button, Card, Form, FormField, Input, createZodResolver, useForm } from 'soybean-react-ui';
4+
import { Button, Card, Form, FormField, Input, createStandardResolver, useForm } from 'soybean-react-ui';
55
import { z } from 'zod';
66

77
// Define Zod Schema
@@ -33,7 +33,7 @@ const ZodResolverDemo = () => {
3333
const [form] = useForm<Inputs>();
3434

3535
useEffect(() => {
36-
form.use(createZodResolver(zodSchema));
36+
form.use(createStandardResolver(zodSchema));
3737
}, [form]);
3838

3939
return (

primitives/filed-form /src/form-core/createStore.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -245,21 +245,35 @@ class FormStore {
245245
this.arrayOp(a.name, a.args);
246246
break;
247247
case 'setExternalErrors': {
248-
// Handle external validation errors
249-
// entries format: Array<[fieldKey: string, errors: string[]]>
250248
const { entries } = a as any;
251-
const changed: string[] = [];
252-
this.begin();
253249

254-
entries.forEach(([k, errs]: [string, string[]]) => {
255-
if (errs && errs.length) this._errors.set(k, errs);
256-
else this._errors.delete(k);
257-
changed.push(k);
250+
this.transaction(() => {
251+
if (entries.length === 0) {
252+
// ✅ 空数组代表全通过,清空所有错误
253+
this._errors.clear();
254+
this.enqueueNotify(
255+
Array.from(this._fieldEntities, e => e.name as string),
256+
ChangeTag.Errors
257+
);
258+
} else {
259+
const changed: string[] = [];
260+
entries.forEach(([k, errs]: [string, string[]]) => {
261+
if (errs && errs.length) {
262+
this._errors.set(k, errs);
263+
} else {
264+
this._errors.delete(k);
265+
}
266+
changed.push(k);
267+
});
268+
if (changed.length) {
269+
this.enqueueNotify(changed, ChangeTag.Errors);
270+
}
271+
}
258272
});
259-
if (changed.length) this.enqueueNotify(changed, ChangeTag.Errors);
260-
this.commit();
273+
261274
break;
262275
}
276+
263277
default:
264278
// No action matched, skip processing
265279
break;
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/* eslint-disable consistent-return */
2+
import type { AllPathsKeys } from 'skyroc-type-utils';
3+
4+
import { keyOfName } from '../../utils/util';
5+
import type { Middleware } from '../middleware';
6+
7+
import { dispatchIssues } from './utils';
8+
9+
/**
10+
* The Standard Schema interface.
11+
*/
12+
export type StandardSchemaV1<Input = unknown, Output = Input> = {
13+
/**
14+
* The Standard Schema properties.
15+
*/
16+
readonly '~standard': StandardSchemaV1Props<Input, Output>;
17+
};
18+
19+
/**
20+
* The Standard Schema types interface.
21+
*/
22+
interface StandardSchemaV1Types<Input = unknown, Output = Input> {
23+
/**
24+
* The input type of the schema.
25+
*/
26+
readonly input: Input;
27+
/**
28+
* The output type of the schema.
29+
*/
30+
readonly output: Output;
31+
}
32+
33+
/**
34+
* The Standard Schema properties interface.
35+
*/
36+
interface StandardSchemaV1Props<Input = unknown, Output = Input> {
37+
/**
38+
* Inferred types associated with the schema.
39+
*/
40+
readonly types?: StandardSchemaV1Types<Input, Output> | undefined;
41+
/**
42+
* Validates unknown input values.
43+
*/
44+
readonly validate: (value: unknown) => StandardSchemaV1Result<Output> | Promise<StandardSchemaV1Result<Output>>;
45+
/**
46+
* The vendor name of the schema library.
47+
*/
48+
readonly vendor: string;
49+
/**
50+
* The version number of the standard.
51+
*/
52+
readonly version: 1;
53+
}
54+
55+
/**
56+
* The result interface of the validate function.
57+
*/
58+
type StandardSchemaV1Result<Output> = StandardSchemaV1SuccessResult<Output> | StandardSchemaV1FailureResult;
59+
/**
60+
* The result interface if validation succeeds.
61+
*/
62+
interface StandardSchemaV1SuccessResult<Output> {
63+
/**
64+
* The non-existent issues.
65+
*/
66+
readonly issues?: undefined;
67+
/**
68+
* The typed output value.
69+
*/
70+
readonly value: Output;
71+
}
72+
/**
73+
* The result interface if validation fails.
74+
*/
75+
interface StandardSchemaV1FailureResult {
76+
/**
77+
* The issues of failed validation.
78+
*/
79+
readonly issues: ReadonlyArray<StandardSchemaV1Issue>;
80+
}
81+
82+
/**
83+
* The issue interface of the failure output.
84+
*/
85+
export interface StandardSchemaV1Issue {
86+
/**
87+
* The error message of the issue.
88+
*/
89+
readonly message: string;
90+
/**
91+
* The path of the issue, if any.
92+
*/
93+
readonly path?: ReadonlyArray<PropertyKey | StandardSchemaV1PathSegment> | undefined;
94+
}
95+
96+
/**
97+
* Internal normalized issue type
98+
* 路径已经被扁平化为 string[]
99+
*/
100+
export interface StandardSchemaV1NormalizedIssue {
101+
/** 错误信息 */
102+
readonly message: string;
103+
/** 扁平化路径 */
104+
readonly path: readonly string[];
105+
}
106+
/**
107+
* The path segment interface of the issue.
108+
*/
109+
interface StandardSchemaV1PathSegment {
110+
/**
111+
* The key representing a path segment.
112+
*/
113+
readonly key: PropertyKey;
114+
}
115+
116+
function isStandardSchema(obj: any): obj is StandardSchemaV1 {
117+
return obj && obj['~standard'] && typeof obj['~standard'].validate === 'function';
118+
}
119+
120+
/**
121+
* Standard Schema Resolver
122+
* 支持 sync/async validate,同时处理 validateField 和 validateFields
123+
*/
124+
export function createStandardResolver<Values = any>(
125+
schema: StandardSchemaV1<Values, unknown>
126+
): Middleware<Values, AllPathsKeys<Values>> {
127+
if (!isStandardSchema(schema)) {
128+
throw new Error('Invalid StandardSchema object');
129+
}
130+
131+
return ({ dispatch, getState }) =>
132+
next =>
133+
async action => {
134+
if (action.type !== 'validateField' && action.type !== 'validateFields') {
135+
return next(action);
136+
}
137+
138+
const state = getState();
139+
const result = await Promise.resolve(schema['~standard'].validate(state));
140+
141+
console.log('result', result);
142+
143+
if (!('issues' in result)) {
144+
// ✅ 没有错误,清空所有
145+
dispatch({ entries: [], type: 'setExternalErrors' });
146+
return;
147+
}
148+
149+
// 把 issues 转成统一格式
150+
const issues: StandardSchemaV1NormalizedIssue[] =
151+
result.issues?.map(issue => ({
152+
message: issue.message,
153+
path: issue.path?.map(seg => (typeof seg === 'object' && 'key' in seg ? String(seg.key) : String(seg))) || []
154+
})) || [];
155+
156+
// === validateField ===
157+
if (action.type === 'validateField') {
158+
const name = keyOfName(action.name);
159+
160+
const filtered = issues.filter(it => it.path?.join('.') === name || (it.path?.length === 0 && name === 'root'));
161+
162+
dispatchIssues<Values>(dispatch, filtered);
163+
return;
164+
}
165+
166+
// === validateFields ===
167+
if (action.type === 'validateFields') {
168+
dispatchIssues<Values>(dispatch, issues);
169+
}
170+
};
171+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/* eslint-disable consistent-return */
2+
3+
import type { AllPathsKeys } from 'skyroc-type-utils';
4+
5+
import { keyOfName } from '../../utils/util';
6+
import type { Action, Middleware } from '../middleware';
7+
8+
import type { StandardSchemaV1NormalizedIssue } from './standard';
9+
10+
export function toEntries<Values = any>(issues: StandardSchemaV1NormalizedIssue[]): [AllPathsKeys<Values>, string[]][] {
11+
const map = new Map<string, string[]>();
12+
for (const { message, path } of issues) {
13+
const k = keyOfName(path);
14+
15+
const arr = map.get(k) || [];
16+
arr.push(message);
17+
map.set(k, arr);
18+
}
19+
return Array.from(map.entries()) as [AllPathsKeys<Values>, string[]][];
20+
}
21+
22+
export function dispatchIssues<Values = any>(
23+
dispatch: (a: Action<Values>) => void,
24+
issues: StandardSchemaV1NormalizedIssue[]
25+
) {
26+
const entries = toEntries<Values>(issues);
27+
28+
dispatch({ entries, type: 'setExternalErrors' });
29+
}
30+
31+
/**
32+
* 工厂函数:生成通用 resolver
33+
*/
34+
export function createGenericResolver(
35+
validate: (state: any, name?: string | string[]) => Promise<StandardSchemaV1NormalizedIssue[]>
36+
): Middleware {
37+
return ({ dispatch, getState }) =>
38+
next =>
39+
async action => {
40+
if (action.type === 'validateField' || action.type === 'validateFields') {
41+
const issues = await validate(getState(), action.name);
42+
43+
dispatchIssues(dispatch, issues);
44+
45+
return;
46+
}
47+
48+
return next(action);
49+
};
50+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { type Issue, createGenericResolver } from './utils';
2+
3+
export function createYupResolver(schema: any) {
4+
return createGenericResolver(async (state, name): Promise<Issue[]> => {
5+
try {
6+
if (name) {
7+
await schema.validateAt(name, state);
8+
return []; // 单字段通过
9+
}
10+
await schema.validate(state, { abortEarly: false });
11+
return []; // 全部通过
12+
} catch (e: any) {
13+
return (e.inner || []).map((err: any) => ({
14+
message: err.message,
15+
path: err.path || 'root'
16+
}));
17+
}
18+
});
19+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { type Issue, createGenericResolver } from './utils';
2+
3+
export function createZodResolver(schema: any) {
4+
return createGenericResolver(async (state): Promise<Issue[]> => {
5+
const res = schema.safeParse(state);
6+
7+
if (res.success) return [];
8+
9+
return res.error.issues.map((issue: any) => ({
10+
message: issue.message,
11+
path: issue.path?.length ? issue.path.join('.') : 'root'
12+
}));
13+
});
14+
}

0 commit comments

Comments
 (0)