Skip to content

Commit 057f18c

Browse files
committed
feat: add the useSelector hook to support selecting aggregated values from the form, and integrate the UseSelector component in the example to demonstrate its usage.
1 parent a10c752 commit 057f18c

File tree

5 files changed

+255
-47
lines changed

5 files changed

+255
-47
lines changed

packages/ui/src/components/form/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ export {
44
ComputedField as FormComputedField,
55
Form,
66
List as FormList,
7+
useEffectField,
78
useFieldError,
89
useFieldState,
910
useForm,
11+
useSelector,
1012
useWatch
1113
} from 'skyroc-form';
1214

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
'use client';
2+
3+
import type { FormInstance } from 'soybean-react-ui';
4+
import { Button, Card, Form, FormField, Input, useForm, useSelector } from 'soybean-react-ui';
5+
6+
import { showToastCode } from './toast';
7+
8+
type Inputs = {
9+
confirmPassword: string;
10+
info: {
11+
age: number;
12+
familyInfo: {
13+
phone: string;
14+
};
15+
gender: string;
16+
hobbies: string;
17+
};
18+
password: string;
19+
username: string;
20+
};
21+
22+
const SelectorEffect = ({ form }: { form: FormInstance<Inputs> }) => {
23+
// 1. Single-field selection: only listen to username
24+
const username = useSelector(get => get('username'), { deps: ['username'], form });
25+
26+
// 2. Multi-field combination: watch password & confirmPassword
27+
const passwordsMatch = useSelector(
28+
get => {
29+
const pass = get('password');
30+
const confirm = get('confirmPassword');
31+
return pass && confirm && pass === confirm;
32+
},
33+
{ deps: ['password', 'confirmPassword'], form }
34+
);
35+
36+
// 3. Deep dependency: watch info.age
37+
const age = useSelector(get => get('info.age'), { deps: ['info.age'], form });
38+
39+
// 4. Complex selection: username + age combination
40+
const userSummary = useSelector(
41+
(get, all) => ({
42+
age: get('info.age'),
43+
name: get('username'),
44+
phone: all.info?.familyInfo?.phone
45+
}),
46+
{ deps: ['username', 'info.age', 'info.familyInfo.phone'], form }
47+
);
48+
49+
console.log('username', username);
50+
console.log('passwordsMatch', passwordsMatch);
51+
console.log('age', age);
52+
console.log('userSummary', userSummary);
53+
54+
return null;
55+
};
56+
57+
const initialValues: Inputs = {
58+
confirmPassword: '123456',
59+
info: {
60+
age: 24,
61+
familyInfo: { phone: '110' },
62+
gender: 'male',
63+
hobbies: 'play lol'
64+
},
65+
password: '123456',
66+
username: 'ohh'
67+
};
68+
69+
const UseSelectorDemo = () => {
70+
const [form] = useForm<Inputs>();
71+
72+
const setValues = () => {
73+
form.setFieldsValue({
74+
confirmPassword: 'abc123',
75+
info: { age: 30, gender: 'female', hobbies: 'reading' },
76+
password: 'abc123',
77+
username: 'new_user'
78+
});
79+
};
80+
81+
const setWrongConfirm = () => {
82+
form.setFieldValue('confirmPassword', 'wrong_pass');
83+
};
84+
85+
const setPhone = () => {
86+
form.setFieldValue('info.familyInfo.phone', '1234567890');
87+
};
88+
89+
const getSummary = () => {
90+
const values = form.getFieldsValue();
91+
showToastCode('Summary', values);
92+
};
93+
94+
return (
95+
<Card title="UseSelector Demo">
96+
<Form
97+
className="w-[480px] max-sm:w-full space-y-4"
98+
form={form}
99+
initialValues={initialValues}
100+
>
101+
<FormField
102+
label="Username"
103+
name="username"
104+
>
105+
<Input />
106+
</FormField>
107+
108+
<FormField
109+
label="Password"
110+
name="password"
111+
>
112+
<Input type="password" />
113+
</FormField>
114+
115+
<FormField
116+
label="Confirm Password"
117+
name="confirmPassword"
118+
>
119+
<Input type="password" />
120+
</FormField>
121+
122+
<FormField
123+
label="Info Age"
124+
name="info.age"
125+
>
126+
<Input type="number" />
127+
</FormField>
128+
129+
<FormField
130+
label="Info Phone"
131+
name="info.familyInfo.phone"
132+
>
133+
<Input />
134+
</FormField>
135+
136+
<div className="flex gap-2 flex-wrap">
137+
<Button
138+
type="button"
139+
onClick={setValues}
140+
>
141+
Set Values
142+
</Button>
143+
<Button
144+
type="button"
145+
onClick={setWrongConfirm}
146+
>
147+
Set Wrong Confirm
148+
</Button>
149+
<Button
150+
type="button"
151+
onClick={setPhone}
152+
>
153+
Set Phone
154+
</Button>
155+
<Button
156+
type="button"
157+
onClick={getSummary}
158+
>
159+
Get All Values
160+
</Button>
161+
<Button type="submit">Submit</Button>
162+
</div>
163+
</Form>
164+
165+
{/* Observe dependency changes of useSelector */}
166+
<SelectorEffect form={form} />
167+
</Card>
168+
);
169+
};
170+
171+
export default UseSelectorDemo;

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import List from './modules/List';
66
import Preserve from './modules/Preserve';
77
import Reset from './modules/Reset';
88
import UseForm from './modules/UseForm';
9+
import UseSelector from './modules/UseSelector';
910
import UseWatch from './modules/UseWatch';
1011
import Validate from './modules/validate';
1112
import ValidateWarning from './modules/validateWaring';
@@ -29,6 +30,8 @@ const FormPage = () => {
2930

3031
<UseWatch />
3132

33+
<UseSelector />
34+
3235
<Reset />
3336

3437
<Preserve />
Lines changed: 75 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,83 @@
1-
// useSelector.ts
21
'use client';
3-
import * as React from 'react';
4-
import { useSyncExternalStore } from 'react';
2+
/* eslint-disable no-bitwise */
3+
import { useEffect, useRef, useState } from 'react';
4+
import { flushSync } from 'react-dom';
5+
import type { AllPathsKeys } from 'skyroc-type-utils';
6+
7+
import type { ChangeMask } from '../../form-core/event';
8+
import { ChangeTag } from '../../form-core/event';
59

610
import { useFieldContext } from './FieldContext';
7-
import type { ChangeMask } from './events';
8-
import { ChangeTag } from './events';
9-
import type { NamePath } from './types';
11+
import type { FormInstance, InternalFormInstance } from './FieldContext';
1012

1113
type Eq<T> = (a: T, b: T) => boolean;
1214

13-
export function useSelector<T>(
14-
selector: (get: (n: NamePath) => any, all: any) => T,
15-
eq: Eq<T> = Object.is,
16-
opt?: { mask?: ChangeMask; names?: NamePath[] }
17-
): T {
18-
const form = useFieldContext();
19-
const mask = opt?.mask ?? ChangeTag.Value | ChangeTag.Errors | ChangeTag.Validating;
20-
const names = opt?.names || [];
21-
22-
const getSel = React.useCallback(
23-
() => selector(form.getFieldValue.bind(form), form.getFieldsValue()),
24-
[form, selector]
25-
);
26-
27-
const subscribe = React.useCallback(
28-
(on: () => void) => {
29-
if (!names.length) {
30-
// 全局订阅:任意字段改变都试着比较
31-
return form.__store.subscribeField([], () => on(), { includeChildren: true, mask });
15+
type UseSelectorOpts<Values, R> = {
16+
/** 订阅字段,空则订阅全部 */
17+
deps?: AllPathsKeys<Values>[];
18+
/** 是否相等 */
19+
eq?: Eq<R>;
20+
/** 表单实例 */
21+
form?: FormInstance<Values>;
22+
/** 是否订阅子路径 */
23+
includeChildren?: boolean;
24+
/** 变更掩码 */
25+
mask?: ChangeMask;
26+
};
27+
28+
/**
29+
* 从表单中“选择”任意聚合值,只有依赖变化才刷新。
30+
*/
31+
export function useSelector<Values = any, R = unknown>(
32+
selector: (get: (n: AllPathsKeys<Values>) => any, all: Values) => R,
33+
opts?: UseSelectorOpts<Values, R>
34+
): R {
35+
const ctxForm = useFieldContext<Values>();
36+
const form = opts?.form ?? ctxForm;
37+
38+
const eq = opts?.eq ?? Object.is;
39+
40+
if (!form) {
41+
throw new Error('Can not find FormContext. Please make sure you wrap Field under Form or provide a form instance.');
42+
}
43+
44+
const { getInternalHooks } = form as unknown as InternalFormInstance<Values>;
45+
const { subscribeField } = getInternalHooks();
46+
47+
const deps = opts?.deps;
48+
49+
const mask = opts?.mask ?? ChangeTag.Value;
50+
const includeChildren = opts?.includeChildren;
51+
52+
// 计算当前选择值
53+
const compute = () => {
54+
const getField = form.getFieldValue;
55+
const all = form.getFieldsValue() as Values;
56+
return selector(getField, all);
57+
};
58+
59+
// state + ref 用于去抖渲染
60+
const [val, setVal] = useState<R>(compute);
61+
62+
const prevRef = useRef<R>(val);
63+
64+
useEffect(() => {
65+
// 订阅器
66+
const onChange = () => {
67+
const next = compute();
68+
if (!eq(prevRef.current, next)) {
69+
prevRef.current = next;
70+
// 与 useFieldState 一致:同步刷新,减少闪烁
71+
flushSync(() => setVal(next));
3272
}
33-
const offs = names.map(n => form.__store.subscribeField(n, () => on(), { includeChildren: true, mask }));
34-
return () => offs.forEach(f => f());
35-
},
36-
[form, names.map(String).join('|'), mask]
37-
);
38-
39-
const getSnap = React.useRef<T>(getSel());
40-
const getSnapshot = () => getSnap.current;
41-
const serverSnapshot = getSnapshot;
42-
43-
useSyncExternalStore(
44-
subscribe,
45-
() => {
46-
const next = getSel();
47-
const prev = getSnap.current;
48-
if (!eq(prev, next)) getSnap.current = next;
49-
return getSnap.current;
50-
},
51-
serverSnapshot
52-
);
53-
54-
return getSnap.current;
73+
};
74+
75+
// 指定字段订阅
76+
return subscribeField(deps, onChange, {
77+
includeChildren,
78+
mask
79+
});
80+
}, []);
81+
82+
return val;
5583
}

primitives/filed-form /src/react/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@ export * from './hooks/FieldContext';
1616

1717
export { useArrayField } from './hooks/useFieldArray';
1818

19+
export { useEffectField } from './hooks/useFieldEffect';
20+
1921
export { useFieldError } from './hooks/useFieldError';
2022

2123
export { useFieldState } from './hooks/useFieldState';
2224

2325
export { useForm } from './hooks/useForm';
2426

27+
export { useSelector } from './hooks/useSelector';
28+
2529
export { useWatch } from './hooks/useWatch';

0 commit comments

Comments
 (0)