diff --git a/package.json b/package.json index a83962876f..93b2a31341 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,8 @@ "classnames": "^2.5.1", "lodash.isequal": "^4.5.0", "lodash.kebabcase": "^4.1.1", - "react-transition-group": "^4.4.5" + "react-transition-group": "^4.4.5", + "rc-util": "^5.32.2" }, "devDependencies": { "@babel/core": "^7.23.9", diff --git a/src/packages/form/demo.taro.tsx b/src/packages/form/demo.taro.tsx index 3881c4d845..a0a9d56ca1 100644 --- a/src/packages/form/demo.taro.tsx +++ b/src/packages/form/demo.taro.tsx @@ -9,6 +9,7 @@ import Demo4 from './demos/taro/demo4' import Demo5 from './demos/taro/demo5' import Demo6 from './demos/taro/demo6' import Demo7 from './demos/taro/demo7' +import Demo8 from './demos/taro/demo8' const FormDemo = () => { const [translated] = useTranslate({ @@ -19,6 +20,7 @@ const FormDemo = () => { relatedDisplay: '关联展示', title4: 'Form.useForm 对表单数据域进行交互。', title5: '表单类型', + title6: 'Form.useWatch 对表单数据监听', validateTrigger: '校验触发时机', }, 'en-US': { @@ -28,6 +30,7 @@ const FormDemo = () => { relatedDisplay: 'Related Display', title4: 'Interact with form data fields via Form.useForm', title5: 'Form Type', + title6: 'Watch field data change with Form.useWatch', validateTrigger: 'Validate Trigger', }, }) @@ -50,6 +53,8 @@ const FormDemo = () => {

{translated.title5}

+

{translated.title6}

+ ) diff --git a/src/packages/form/demo.tsx b/src/packages/form/demo.tsx index effa42d77a..1e19b33530 100644 --- a/src/packages/form/demo.tsx +++ b/src/packages/form/demo.tsx @@ -7,6 +7,7 @@ import Demo4 from './demos/h5/demo4' import Demo5 from './demos/h5/demo5' import Demo6 from './demos/h5/demo6' import Demo7 from './demos/h5/demo7' +import Demo8 from './demos/h5/demo8' const FormDemo = () => { const [translated] = useTranslate({ @@ -17,6 +18,7 @@ const FormDemo = () => { relatedDisplay: '关联展示', title4: 'Form.useForm 对表单数据域进行交互。', title5: '表单类型', + title6: 'Form.useWatch 对表单数据监听', validateTrigger: '校验触发时机', }, 'en-US': { @@ -26,6 +28,7 @@ const FormDemo = () => { relatedDisplay: 'Related Display', title4: 'Interact with form data fields via Form.useForm', title5: 'Form Type', + title6: 'Watch field data change with Form.useWatch', validateTrigger: 'Validate Trigger', }, }) @@ -47,6 +50,8 @@ const FormDemo = () => {

{translated.title5}

+

{translated.title6}

+ ) diff --git a/src/packages/form/demos/h5/demo8.tsx b/src/packages/form/demos/h5/demo8.tsx new file mode 100644 index 0000000000..904caf4af7 --- /dev/null +++ b/src/packages/form/demos/h5/demo8.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { Cell, Form, Input, Picker, Radio, Toast } from '@nutui/nutui-react' +import { ArrowRight } from '@nutui/icons-react' + +const Demo8 = () => { + const pickerOptions = [ + { value: 4, text: 'BeiJing' }, + { value: 1, text: 'NanJing' }, + { value: 2, text: 'WuXi' }, + { value: 8, text: 'DaQing' }, + { value: 9, text: 'SuiHua' }, + { value: 10, text: 'WeiFang' }, + { value: 12, text: 'ShiJiaZhuang' }, + ] + const submitFailed = (error: any) => { + Toast.show({ content: JSON.stringify(error), icon: 'fail' }) + } + + const submitSucceed = (values: any) => { + Toast.show({ content: JSON.stringify(values), icon: 'success' }) + } + + const [form] = Form.useForm() + const usernameWatch = Form.useWatch('username', form) + + const noteWatch = Form.useWatch((values) => { + return values.picker + }, form) + + const genderWatch = Form.useWatch((values) => { + return values.gender + }, form) + + return ( + <> +
submitSucceed(values)} + onFinishFailed={(values, errors) => submitFailed(errors)} + > + + + + args[1]} + onClick={(event, ref: any) => { + ref.open() + }} + > + + {(value: any) => { + return ( + po.value === value[0])[0] + ?.text + : 'Please select' + } + extra={} + align="center" + /> + ) + }} + + + + + male + female + + +
+ +
字段A:{usernameWatch}
+
+ +
Picker:{noteWatch}
+
+ +
字段B:{genderWatch}
+
+ + ) +} + +export default Demo8 diff --git a/src/packages/form/demos/taro/demo8.tsx b/src/packages/form/demos/taro/demo8.tsx new file mode 100644 index 0000000000..c974ddb86a --- /dev/null +++ b/src/packages/form/demos/taro/demo8.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import Taro from '@tarojs/taro' +import { Cell, Form, Input, Picker, Radio } from '@nutui/nutui-react-taro' +import { ArrowRight } from '@nutui/icons-react-taro' + +const Demo8 = () => { + const pickerOptions = [ + { value: 4, text: 'BeiJing' }, + { value: 1, text: 'NanJing' }, + { value: 2, text: 'WuXi' }, + { value: 8, text: 'DaQing' }, + { value: 9, text: 'SuiHua' }, + { value: 10, text: 'WeiFang' }, + { value: 12, text: 'ShiJiaZhuang' }, + ] + const submitFailed = (error: any) => { + Taro.showToast({ title: JSON.stringify(error), icon: 'error' }) + } + + const submitSucceed = (values: any) => { + Taro.showToast({ title: JSON.stringify(values), icon: 'success' }) + } + + const [form] = Form.useForm() + const usernameWatch = Form.useWatch('username', form) + + const noteWatch = Form.useWatch((values) => { + return values.picker + }, form) + + const genderWatch = Form.useWatch((values) => { + return values.gender + }, form) + + return ( + <> +
submitSucceed(values)} + onFinishFailed={(values, errors) => submitFailed(errors)} + > + + + + args[1]} + onClick={(event, ref: any) => { + ref.open() + }} + > + + {(value: any) => { + return ( + po.value === value[0])[0] + ?.text + : 'Please select' + } + extra={} + align="center" + /> + ) + }} + + + + + male + female + + +
+ +
字段A:{usernameWatch}
+
+ +
Picker:{noteWatch}
+
+ +
字段B:{genderWatch}
+
+ + ) +} + +export default Demo8 diff --git a/src/packages/form/doc.md b/src/packages/form/doc.md index 2b176efff3..2c5e8eeb1e 100644 --- a/src/packages/form/doc.md +++ b/src/packages/form/doc.md @@ -66,6 +66,14 @@ import { Form } from '@nutui/nutui-react' ::: +### Form.useWatch 对表单数据监听 + +:::demo + + + +::: + ## Form ### Props @@ -120,8 +128,6 @@ import { Form } from '@nutui/nutui-react' ### FormInstance -Form.useForm()创建 Form 实例,用于管理所有数据状态。 - | 属性 | 说明 | 类型 | | --- | --- | --- | | getFieldValue | 获取对应字段名的值 | `(name: NamePath) => any` | @@ -131,6 +137,13 @@ Form.useForm()创建 Form 实例,用于管理所有数据状态。 | resetFields | 重置表单提示状态 | `() => void` | | submit | 提交表单进行校验的方法 | `Promise` | +### Hook + +| 方法名 | 说明 | 类型 | +| --- | --- | --- | +| Form.useForm | 创建 Form 实例,用于管理所有数据状态。 | `() => FormInstance` | +| Form.useWatch | 用于直接获取 form 中字段对应的值。 | `(name: NamePath \| (values: T) => any,options: FormInstance \| WatchOptions) => any` | + ## 主题定制 ### 样式变量 diff --git a/src/packages/form/index.taro.ts b/src/packages/form/index.taro.ts index 093d5218c8..15bf2a00c0 100644 --- a/src/packages/form/index.taro.ts +++ b/src/packages/form/index.taro.ts @@ -3,6 +3,7 @@ import { Form, FormProps } from './form.taro' import { FormItem } from '../formitem/formitem.taro' import { FormInstance } from './types' import { useForm } from '@/packages/form/useform.taro' +import useWatch from './useWatch.taro' export type { FormItemRuleWithoutValidator, @@ -17,11 +18,13 @@ type CompoundedComponent = React.ForwardRefExoticComponent< > & { Item: typeof FormItem useForm: typeof useForm + useWatch: typeof useWatch } const InnerForm = Form as CompoundedComponent InnerForm.Item = FormItem InnerForm.useForm = useForm +InnerForm.useWatch = useWatch export default InnerForm diff --git a/src/packages/form/index.ts b/src/packages/form/index.ts index 6563f8a986..b52d45d263 100644 --- a/src/packages/form/index.ts +++ b/src/packages/form/index.ts @@ -3,6 +3,7 @@ import { Form, FormProps } from './form' import { FormItem } from '../formitem/formitem' import { FormInstance } from './types' import { useForm } from '@/packages/form/useform' +import useWatch from './useWatch' export type { FormItemRuleWithoutValidator, @@ -17,11 +18,13 @@ type CompoundedComponent = React.ForwardRefExoticComponent< > & { Item: typeof FormItem useForm: typeof useForm + useWatch: typeof useWatch } const InnerForm = Form as CompoundedComponent InnerForm.Item = FormItem InnerForm.useForm = useForm +InnerForm.useWatch = useWatch export default InnerForm diff --git a/src/packages/form/types.ts b/src/packages/form/types.ts index e0350f8809..af265b8cc6 100644 --- a/src/packages/form/types.ts +++ b/src/packages/form/types.ts @@ -8,6 +8,7 @@ export interface FormItemRuleWithoutValidator { type StoreValue = any export type NamePath = string | number +export type InternalNamePath = (string | number)[] export interface Callbacks { onValuesChange?: (values: Values) => void @@ -30,6 +31,15 @@ export interface FormInstance { validateFields: (nameList?: NamePath[]) => Promise } +export type InternalFormInstance = FormInstance & { _init: boolean } + +export type WatchCallBack = ( + allValues: Store, + namePathList: InternalNamePath[] +) => void +export interface WatchOptions
{ + form?: Form +} export interface FormFieldEntity { onStoreChange: (type?: string) => void getNamePath: () => NamePath diff --git a/src/packages/form/useWatch.taro.ts b/src/packages/form/useWatch.taro.ts new file mode 100644 index 0000000000..b47045e712 --- /dev/null +++ b/src/packages/form/useWatch.taro.ts @@ -0,0 +1,87 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import getValue from 'rc-util/lib/utils/get' +import { + FormInstance, + InternalFormInstance, + NamePath, + Store, + WatchOptions, +} from './types' +import { SECRET } from './useform.taro' +import { getNamePath } from './utils' + +export function stringify(value: any) { + try { + return JSON.stringify(value) + } catch (err) { + return Math.random() + } +} + +export function isFormInstance( + form: T | FormInstance +): form is FormInstance { + return form && !!(form as InternalFormInstance)._init +} + +function useWatch( + ...args: [ + NamePath | ((values: Store) => any), + FormInstance | WatchOptions, + ] +) { + const [dependencies, _form = {}] = args + const options = isFormInstance(_form) ? { form: _form } : _form + const form = options.form + const [value, setValue] = useState() + + const valueStr = useMemo(() => stringify(value), [value]) + const valueStrRef = useRef(valueStr) + valueStrRef.current = valueStr + + const formInstance = form as InternalFormInstance + const isValidForm = formInstance && formInstance._init + + // @ts-ignore + const namePath = getNamePath(dependencies) + const namePathRef = useRef(namePath) + namePathRef.current = namePath + + useEffect(() => { + if (!isValidForm) { + return + } + + const { getFieldsValue, getInternal } = formInstance + const { registerWatch } = getInternal(SECRET) + + const getWatchValue = (allValues: any) => { + const watchValue = allValues + return typeof dependencies === 'function' + ? dependencies(watchValue) + : getValue(watchValue, namePathRef.current) + } + + const cancelRegister = registerWatch((allValues: any) => { + const newValue = getWatchValue(allValues) + const nextValueStr = stringify(newValue) + // Compare stringify in case it's nest object + if (valueStrRef.current !== nextValueStr) { + valueStrRef.current = nextValueStr + setValue(newValue) + } + }) + + const initialValue = getWatchValue(getFieldsValue(true)) + + if (value !== initialValue) { + setValue(initialValue) + } + + return cancelRegister + }, [isValidForm]) + + return value +} + +export default useWatch diff --git a/src/packages/form/useWatch.ts b/src/packages/form/useWatch.ts new file mode 100644 index 0000000000..339fa515ba --- /dev/null +++ b/src/packages/form/useWatch.ts @@ -0,0 +1,87 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import getValue from 'rc-util/lib/utils/get' +import { + FormInstance, + InternalFormInstance, + NamePath, + Store, + WatchOptions, +} from './types' +import { SECRET } from './useform' +import { getNamePath } from './utils' + +export function stringify(value: any) { + try { + return JSON.stringify(value) + } catch (err) { + return Math.random() + } +} + +export function isFormInstance( + form: T | FormInstance +): form is FormInstance { + return form && !!(form as InternalFormInstance)._init +} + +function useWatch( + ...args: [ + NamePath | ((values: Store) => any), + FormInstance | WatchOptions, + ] +) { + const [dependencies, _form = {}] = args + const options = isFormInstance(_form) ? { form: _form } : _form + const form = options.form + const [value, setValue] = useState() + + const valueStr = useMemo(() => stringify(value), [value]) + const valueStrRef = useRef(valueStr) + valueStrRef.current = valueStr + + const formInstance = form as InternalFormInstance + const isValidForm = formInstance && formInstance._init + + // @ts-ignore + const namePath = getNamePath(dependencies) + const namePathRef = useRef(namePath) + namePathRef.current = namePath + + useEffect(() => { + if (!isValidForm) { + return + } + + const { getFieldsValue, getInternal } = formInstance + const { registerWatch } = getInternal(SECRET) + + const getWatchValue = (allValues: any) => { + const watchValue = allValues + return typeof dependencies === 'function' + ? dependencies(watchValue) + : getValue(watchValue, namePathRef.current) + } + + const cancelRegister = registerWatch((allValues: any) => { + const newValue = getWatchValue(allValues) + const nextValueStr = stringify(newValue) + // Compare stringify in case it's nest object + if (valueStrRef.current !== nextValueStr) { + valueStrRef.current = nextValueStr + setValue(newValue) + } + }) + + const initialValue = getWatchValue(getFieldsValue(true)) + + if (value !== initialValue) { + setValue(initialValue) + } + + return cancelRegister + }, [isValidForm]) + + return value +} + +export default useWatch diff --git a/src/packages/form/useform.taro.ts b/src/packages/form/useform.taro.ts index 8559e5c5d7..45c7827c66 100644 --- a/src/packages/form/useform.taro.ts +++ b/src/packages/form/useform.taro.ts @@ -5,8 +5,10 @@ import { Callbacks, FormFieldEntity, FormInstance, + InternalNamePath, NamePath, Store, + WatchCallBack, } from './types' export const SECRET = 'NUT_FORM_INTERNAL' @@ -65,7 +67,7 @@ class FormStore { * 获取全部字段 */ getFieldsValue = (nameList: NamePath[] | true): { [key: NamePath]: any } => { - if (typeof nameList === 'boolean') { + if (typeof nameList === 'boolean' || nameList === undefined) { return JSON.parse(JSON.stringify(this.store)) } const fieldsValue: { [key: NamePath]: any } = {} @@ -77,6 +79,7 @@ class FormStore { updateStore(nextStore: Store) { this.store = nextStore + this.notifyWatch() } /** @@ -220,6 +223,28 @@ class FormStore { } } + // + private watchList: WatchCallBack[] = [] + + private registerWatch = (callback: any) => { + this.watchList.push(callback) + + return () => { + this.watchList = this.watchList.filter((fn) => fn !== callback) + } + } + + private notifyWatch = (namePath: InternalNamePath[] = []) => { + // No need to cost perf when nothing need to watch + if (this.watchList.length) { + const allValues = this.getFieldsValue(true) + + this.watchList.forEach((callback) => { + callback(allValues, namePath) + }) + } + } + dispatch = ({ name }: { name: string }) => { this.validateFields([name]) } @@ -234,6 +259,7 @@ class FormStore { store: this.store, fieldEntities: this.fieldEntities, registerUpdate: this.registerUpdate, + registerWatch: this.registerWatch, } } } @@ -249,6 +275,7 @@ class FormStore { submit: this.submit, errors: this.errors, getInternal: this.getInternal, + _init: true, } } } diff --git a/src/packages/form/useform.ts b/src/packages/form/useform.ts index 8559e5c5d7..7a2e52bb49 100644 --- a/src/packages/form/useform.ts +++ b/src/packages/form/useform.ts @@ -5,8 +5,10 @@ import { Callbacks, FormFieldEntity, FormInstance, + InternalNamePath, NamePath, Store, + WatchCallBack, } from './types' export const SECRET = 'NUT_FORM_INTERNAL' @@ -64,8 +66,8 @@ class FormStore { /** * 获取全部字段 */ - getFieldsValue = (nameList: NamePath[] | true): { [key: NamePath]: any } => { - if (typeof nameList === 'boolean') { + getFieldsValue = (nameList?: NamePath[] | true): { [key: NamePath]: any } => { + if (typeof nameList === 'boolean' || nameList === undefined) { return JSON.parse(JSON.stringify(this.store)) } const fieldsValue: { [key: NamePath]: any } = {} @@ -77,6 +79,7 @@ class FormStore { updateStore(nextStore: Store) { this.store = nextStore + this.notifyWatch() } /** @@ -220,6 +223,28 @@ class FormStore { } } + // + private watchList: WatchCallBack[] = [] + + private registerWatch = (callback: any) => { + this.watchList.push(callback) + + return () => { + this.watchList = this.watchList.filter((fn) => fn !== callback) + } + } + + private notifyWatch = (namePath: InternalNamePath[] = []) => { + // No need to cost perf when nothing need to watch + if (this.watchList.length) { + const allValues = this.getFieldsValue(true) + + this.watchList.forEach((callback) => { + callback(allValues, namePath) + }) + } + } + dispatch = ({ name }: { name: string }) => { this.validateFields([name]) } @@ -234,6 +259,7 @@ class FormStore { store: this.store, fieldEntities: this.fieldEntities, registerUpdate: this.registerUpdate, + registerWatch: this.registerWatch, } } } @@ -249,6 +275,7 @@ class FormStore { submit: this.submit, errors: this.errors, getInternal: this.getInternal, + _init: true, } } } diff --git a/src/packages/form/utils.ts b/src/packages/form/utils.ts new file mode 100644 index 0000000000..5eb36c5854 --- /dev/null +++ b/src/packages/form/utils.ts @@ -0,0 +1,9 @@ +import { InternalNamePath, NamePath } from './types' + +export function getNamePath(path: NamePath | null): InternalNamePath { + if (path === undefined || path === null) { + return [] + } + + return Array.isArray(path) ? path : [path] +}