diff --git a/src/packages/empty/empty.taro.tsx b/src/packages/empty/empty.taro.tsx index f37cdc1075..3d23f51170 100644 --- a/src/packages/empty/empty.taro.tsx +++ b/src/packages/empty/empty.taro.tsx @@ -69,7 +69,7 @@ export const Empty: FunctionComponent< useEffect(() => { setImgStyle(() => { - if (!imageSize) { + if (typeof imageSize !== 'number' && typeof imageSize !== 'string') { return {} } if (typeof imageSize === 'number') { diff --git a/src/packages/empty/empty.tsx b/src/packages/empty/empty.tsx index 6117a84344..662062f812 100644 --- a/src/packages/empty/empty.tsx +++ b/src/packages/empty/empty.tsx @@ -71,7 +71,7 @@ export const Empty: FunctionComponent< useEffect(() => { setImgStyle(() => { - if (!imageSize) { + if (typeof imageSize !== 'number' && typeof imageSize !== 'string') { return {} } if (typeof imageSize === 'number') { diff --git a/src/packages/form/__tests__/form.spec.tsx b/src/packages/form/__tests__/form.spec.tsx index 8a07b93e01..d5d3950501 100644 --- a/src/packages/form/__tests__/form.spec.tsx +++ b/src/packages/form/__tests__/form.spec.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { useEffect } from 'react' import { fireEvent, render, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' +import { Button, Radio, Space } from '@nutui/nutui-react' import Form, { FormInstance } from '@/packages/form' import Input from '@/packages/input' @@ -312,3 +313,73 @@ test('no-style and render function', async () => { expect(relatedInput).toBeTruthy() }) }) + +test('useWatch', async () => { + const Demo = () => { + const [form] = Form.useForm() + const account = Form.useWatch('account', form) + const loginMethod = Form.useWatch('loginMethod', form) + return ( +
+
+
+ 你将使用 {loginMethod === 'mobile' ? '手机号' : '邮箱'}{' '} + {account} 登录 +
+ +
+ + } + > + + + + 手机号 + + 邮箱 + + + + + + <> + {loginMethod === 'mobile' && ( + + + + )} + {loginMethod === 'email' && ( + + + + )} + +
+ ) + } + + const { container } = render() + const clickTest = container.querySelector('.clickTest') + if (clickTest) { + fireEvent.click(clickTest) + const result = container.querySelector('.result') + expect(result).toHaveTextContent('你将使用 邮箱 123 登录') + } +}) diff --git a/src/packages/form/__tests__/merge.spec.ts b/src/packages/form/__tests__/merge.spec.ts new file mode 100644 index 0000000000..773b44bbac --- /dev/null +++ b/src/packages/form/__tests__/merge.spec.ts @@ -0,0 +1,263 @@ +import { merge, clone, recursive, isPlainObject } from '@/utils/merge' + +describe('merge', () => { + it('merges two objects', () => { + expect(merge({ a: 1 }, { b: 2 })).toStrictEqual({ a: 1, b: 2 }) + }) + + it('merges nested levels', () => { + expect(merge({ a: 1 }, { b: { c: { d: 2 } } })).toStrictEqual({ + a: 1, + b: { c: { d: 2 } }, + }) + }) + it('clones the target', () => { + let input = { + a: 1, + b: { + c: { + d: 2, + e: ['x', 'y', { z: { w: ['k'] } }], + }, + }, + f: null, + g: undefined, + h: true, + } + + const original = { + a: 1, + b: { + c: { + d: 2, + e: ['x', 'y', { z: { w: ['k'] } }], + }, + }, + f: null, + g: undefined, + h: true, + } + + let output = merge(true, input) + + input.b.c.d++ + ;(input.b.c.e[2] as any).z.w = null + ;(input as any).h = null + + expect(output).toStrictEqual(original) + + input = original + + output = merge(true, input, { a: 2 }) + + expect(output.a).toBe(2) + expect(input.a).toBe(1) + }) + + it('ignores the sources', () => { + const values = createNonPlainObjects() + const $merge = vi.fn().mockImplementation(merge) + + for (const value of values) expect($merge(value)).toStrictEqual({}) + + expect(values.length).toBeGreaterThan(0) + expect($merge).toBeCalledTimes(values.length) + expect( + merge(...values, [0, 1, 2], ...values, { a: 1 }, ...values, { + b: 2, + }) + ).toStrictEqual({ a: 1, b: 2 }) + }) + + it('does not merge non plain objects', () => { + const values = createNonPlainObjects() + expect(values.length).toBeGreaterThan(0) + const input: any = {} + + for (const [index, value] of Object.entries(values)) { + input[`value${index}`] = value + } + + const output = merge({}, input) + + for (const [index] of Object.entries(values)) { + const key = `value${index}` + const inputValue = input[key] + const outputValue = output[key] + + // eslint-disable-next-line no-restricted-globals + if (typeof outputValue === 'number' && isNaN(outputValue)) { + // eslint-disable-next-line no-restricted-globals + expect(isNaN(inputValue), key).toBeTruthy() + } else { + expect(inputValue === outputValue, key).toBeTruthy() + } + } + }) + + it('is safe', () => { + expect( + merge({}, JSON.parse('{"__proto__": {"evil": true}}')) + ).toStrictEqual({}) + expect(({} as any).evil).toBeUndefined() + }) +}) + +describe('clone', () => { + it('clones the input', () => { + const object1 = { a: 1, b: { c: 2 } } + const object2 = clone(object1) + + expect(object1).toStrictEqual(object2) + expect(object1 === object2).toBeFalsy() + expect(object1.b === object2.b).toBeFalsy() + }) + + it('clones each item of the array', () => { + const object1 = [{ a: 1, b: { c: 2 } }] + const object2 = clone(object1) + + expect(object1).toStrictEqual(object2) + expect(object1 === object2).toBeFalsy() + expect(object1[0] === object2[0]).toBeFalsy() + expect(object1[0].b === object2[0].b).toBeFalsy() + }) + + it('returns the same input', () => { + const values = createNonPlainObjects() + const $clone = vi.fn().mockImplementation(clone) + for (const value of values) { + const cloned = $clone(value) + // eslint-disable-next-line no-restricted-globals + if (typeof cloned === 'number' && isNaN(cloned)) { + // eslint-disable-next-line no-restricted-globals + expect(isNaN(value)).toBeTruthy() + } else if (Array.isArray(cloned)) { + expect(Array.isArray(value)).toBeTruthy() + } else { + expect(cloned === value).toBeTruthy() + } + } + expect(values.length).toBeGreaterThan(0) + expect($clone).toBeCalledTimes(values.length) + }) +}) + +describe('recursive', () => { + it('merges recursively', () => { + expect(recursive({ a: { b: 1 } }, { a: { c: 1 } })).toStrictEqual({ + a: { b: 1, c: 1 }, + }) + + expect(recursive({ a: { b: 1, c: 1 } }, { a: { b: 2 } })).toStrictEqual({ + a: { b: 2, c: 1 }, + }) + + expect( + recursive({ a: { b: [1, 2, 3], c: 1 } }, { a: { b: ['a'] } }) + ).toStrictEqual({ a: { b: ['a'], c: 1 } }) + + expect( + recursive({ a: { b: { b: 2 }, c: 1 } }, { a: { b: 2 } }) + ).toStrictEqual({ + a: { b: 2, c: 1 }, + }) + }) + + it('clones recursively', () => { + const test1 = { a: { b: 1 } } + + expect(recursive(true, test1, { a: { c: 1 } })).toStrictEqual({ + a: { b: 1, c: 1 }, + }) + + expect(test1).toStrictEqual({ a: { b: 1 } }) + + const test2 = { a: { b: 1, c: 1 } } + + expect(recursive(true, test2, { a: { b: 2 } })).toStrictEqual({ + a: { b: 2, c: 1 }, + }) + + expect(test2).toStrictEqual({ a: { b: 1, c: 1 } }) + + const test3 = { a: { b: [1, 2, 3], c: 1 } } + + expect(recursive(true, test3, { a: { b: ['a'] } })).toStrictEqual({ + a: { b: ['a'], c: 1 }, + }) + + expect(test3).toStrictEqual({ a: { b: [1, 2, 3], c: 1 } }) + + const test4 = { a: { b: { b: 2 }, c: 1 } } + + expect(recursive(true, test4, { a: { b: 2 } })).toStrictEqual({ + a: { b: 2, c: 1 }, + }) + + expect(test4).toStrictEqual({ a: { b: { b: 2 }, c: 1 } }) + }) + + it('does not merge non plain objects', () => { + const object = recursive({ map: { length: 1 } }, { map: new Map() }) + expect(object.map).toBeInstanceOf(Map) + }) + + it('is safe', () => { + const payload = '{"__proto__": {"a": true}}' + expect(recursive({}, JSON.parse(payload))).toStrictEqual({}) + expect(({} as any).a).toBeUndefined() + expect(recursive({ deep: {} }, JSON.parse(payload))).toStrictEqual({ + deep: {}, + }) + expect(({} as any).b).toBeUndefined() + }) +}) + +describe('isPlainObject', () => { + it('returns true', () => { + expect(isPlainObject({})).toBeTruthy() + expect(isPlainObject({ v: 1 })).toBeTruthy() + expect(isPlainObject(Object.create(null))).toBeTruthy() + expect(isPlainObject({})).toBeTruthy() + }) + it('returns false', () => { + const values = createNonPlainObjects() + const $isPlainObject = vi.fn().mockImplementation(isPlainObject) + for (const value of values) expect($isPlainObject(value)).toBeFalsy() + expect(values.length).toBeGreaterThan(0) + expect($isPlainObject).toBeCalledTimes(values.length) + }) +}) + +function createNonPlainObjects(): any[] { + class SubObject extends Object {} + + return [ + null, + undefined, + 1, + '', + 'str', + [], + [1], + () => {}, + function () {}, + true, + false, + NaN, + Infinity, + class {}, + new (class {})(), + new Map(), + new Set(), + new Date(), + [], + new Date(), + /./, + /./, + SubObject, + new SubObject(), + Symbol(''), + ] +} diff --git a/src/packages/form/demo.taro.tsx b/src/packages/form/demo.taro.tsx index 3881c4d845..0076b72f6a 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({ @@ -20,6 +21,7 @@ const FormDemo = () => { title4: 'Form.useForm 对表单数据域进行交互。', title5: '表单类型', validateTrigger: '校验触发时机', + useWatch: 'useWatch', }, 'en-US': { basic: 'Basic Usage', @@ -29,6 +31,7 @@ const FormDemo = () => { title4: 'Interact with form data fields via Form.useForm', title5: 'Form Type', validateTrigger: 'Validate Trigger', + useWatch: 'useWatch', }, }) @@ -50,6 +53,8 @@ const FormDemo = () => {

{translated.title5}

+

{translated.useWatch}

+ ) diff --git a/src/packages/form/demo.tsx b/src/packages/form/demo.tsx index effa42d77a..dfbcccd246 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({ @@ -18,6 +19,7 @@ const FormDemo = () => { title4: 'Form.useForm 对表单数据域进行交互。', title5: '表单类型', validateTrigger: '校验触发时机', + useWatch: 'useWatch', }, 'en-US': { basic: 'Basic Usage', @@ -27,6 +29,7 @@ const FormDemo = () => { title4: 'Interact with form data fields via Form.useForm', title5: 'Form Type', validateTrigger: 'Validate Trigger', + useWatch: 'useWatch', }, }) @@ -47,6 +50,8 @@ const FormDemo = () => {

{translated.title5}

+

{translated.useWatch}

+ ) diff --git a/src/packages/form/demos/h5/demo8.tsx b/src/packages/form/demos/h5/demo8.tsx new file mode 100644 index 0000000000..4cec12da48 --- /dev/null +++ b/src/packages/form/demos/h5/demo8.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { Button, Form, Input, Radio, Space } from '@nutui/nutui-react' + +type FieldType = { account?: string; loginMethod?: 'mobile' | 'email' } + +const Demo8 = () => { + const [form] = Form.useForm() + const account = Form.useWatch('account', form) + const loginMethod = Form.useWatch('loginMethod', form) + + return ( +
+
+
+ 你将使用{loginMethod === 'mobile' ? '手机号' : '电子邮箱'} + {account}登录 +
+ +
+ + } + > + + + + 手机号 + 电子邮箱 + + + + + <> + {loginMethod === 'mobile' && ( + + + + )} + {loginMethod === 'email' && ( + + + + )} + +
+ ) +} + +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..ca743e2e13 --- /dev/null +++ b/src/packages/form/demos/taro/demo8.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { Button, Form, Input, Radio, Space } from '@nutui/nutui-react-taro' + +type FieldType = { account?: string; loginMethod?: 'mobile' | 'email' } + +const Demo8 = () => { + const [form] = Form.useForm() + const account = Form.useWatch('account', form) + const loginMethod = Form.useWatch('loginMethod', form) + + return ( +
+
+
+ 你将使用{loginMethod === 'mobile' ? '手机号' : '电子邮箱'} + {account}登录 +
+ +
+ + } + > + + + + 手机号 + 电子邮箱 + + + + + <> + {loginMethod === 'mobile' && ( + + + + )} + {loginMethod === 'email' && ( + + + + )} + +
+ ) +} + +export default Demo8 diff --git a/src/packages/form/doc.en-US.md b/src/packages/form/doc.en-US.md index ed414f760b..fa01e2fac5 100644 --- a/src/packages/form/doc.en-US.md +++ b/src/packages/form/doc.en-US.md @@ -58,6 +58,14 @@ import { Form } from '@nutui/nutui-react' ::: +### useWatch + +:::demo + + + +::: + ### Form Type :::demo @@ -121,7 +129,7 @@ The rule validation process is based on [async-validator](https://github.com/yim ### FormInstance -Form.useForm() creates a Form instance, which is used to manage all data states. +`Form.useForm()` creates a Form instance, which is used to manage all data states. | Property | Description | Type | | --- | --- | --- | @@ -132,6 +140,8 @@ Form.useForm() creates a Form instance, which is used to manage all data states. | resetFields | Reset form prompt state | `() => void` | | submit | method to submit a form for validation | `Promise` | +`Form.useWatch()`, this method will watch specified inputs and return their values. It is useful to render input value and for determining what to render by condition. + ## Theming ### CSS Variables diff --git a/src/packages/form/doc.md b/src/packages/form/doc.md index 2b176efff3..d9619d0048 100644 --- a/src/packages/form/doc.md +++ b/src/packages/form/doc.md @@ -58,6 +58,14 @@ import { Form } from '@nutui/nutui-react' ::: +### useWatch + +:::demo + + + +::: + ### 表单类型 :::demo @@ -120,7 +128,7 @@ import { Form } from '@nutui/nutui-react' ### FormInstance -Form.useForm()创建 Form 实例,用于管理所有数据状态。 +`Form.useForm()`创建 Form 实例,用于管理所有数据状态。 | 属性 | 说明 | 类型 | | --- | --- | --- | @@ -131,6 +139,8 @@ Form.useForm()创建 Form 实例,用于管理所有数据状态。 | resetFields | 重置表单提示状态 | `() => void` | | submit | 提交表单进行校验的方法 | `Promise` | +`Form.useWatch()`此方法将监视指定的输入并返回其值。它对于呈现输入值和确定根据条件呈现的内容很有用。 + ## 主题定制 ### 样式变量 diff --git a/src/packages/form/doc.taro.md b/src/packages/form/doc.taro.md index a6b01d1b5b..748db03665 100644 --- a/src/packages/form/doc.taro.md +++ b/src/packages/form/doc.taro.md @@ -58,6 +58,14 @@ import { Form } from '@nutui/nutui-react-taro' ::: +### useWatch + +:::demo + + + +::: + ### 表单类型 :::demo @@ -120,7 +128,7 @@ import { Form } from '@nutui/nutui-react-taro' ### FormInstance -Form.useForm()创建 Form 实例,用于管理所有数据状态。 +`Form.useForm()`创建 Form 实例,用于管理所有数据状态。 | 属性 | 说明 | 类型 | | --- | --- | --- | @@ -131,6 +139,8 @@ Form.useForm()创建 Form 实例,用于管理所有数据状态。 | resetFields | 重置表单提示状态 | `() => void` | | submit | 提交表单进行校验的方法 | `Promise` | +`Form.useWatch()`此方法将监视指定的输入并返回其值。它对于呈现输入值和确定根据条件呈现的内容很有用。 + ## 主题定制 ### 样式变量 diff --git a/src/packages/form/doc.zh-TW.md b/src/packages/form/doc.zh-TW.md index 8abf1361c3..8725a0a372 100644 --- a/src/packages/form/doc.zh-TW.md +++ b/src/packages/form/doc.zh-TW.md @@ -58,6 +58,14 @@ import { Form } from '@nutui/nutui-react' ::: +### useWatch + +:::demo + + + +::: + ### 表單類型 :::demo @@ -120,7 +128,7 @@ import { Form } from '@nutui/nutui-react' ### FormInstance -Form.useForm()創建 Form 實例,用於管理所有數據狀態。 +`Form.useForm()`創建 Form 實例,用於管理所有數據狀態。 | 屬性 | 說明 | 類型 | | --- | --- | --- | @@ -131,6 +139,8 @@ Form.useForm()創建 Form 實例,用於管理所有數據狀態。 | resetFields | 重置錶單提示狀態 | `() => void` | | submit | 提交錶單進行校驗的方法 | `Promise` | +`Form.useWatch()`此方法將監視指定的輸入並傳回其值。它對於呈現輸入值和確定根據條件呈現的內容很有用。 + ## 主題定制 ### 樣式變量 diff --git a/src/packages/form/index.taro.ts b/src/packages/form/index.taro.ts index 093d5218c8..fdfefdae35 100644 --- a/src/packages/form/index.taro.ts +++ b/src/packages/form/index.taro.ts @@ -2,7 +2,7 @@ import React from 'react' import { Form, FormProps } from './form.taro' import { FormItem } from '../formitem/formitem.taro' import { FormInstance } from './types' -import { useForm } from '@/packages/form/useform.taro' +import { useForm, useWatch } from '@/packages/form/useform.taro' export type { FormItemRuleWithoutValidator, @@ -17,11 +17,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..748c374c25 100644 --- a/src/packages/form/index.ts +++ b/src/packages/form/index.ts @@ -2,7 +2,7 @@ import React from 'react' import { Form, FormProps } from './form' import { FormItem } from '../formitem/formitem' import { FormInstance } from './types' -import { useForm } from '@/packages/form/useform' +import { useForm, useWatch } from '@/packages/form/useform' export type { FormItemRuleWithoutValidator, @@ -17,11 +17,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/useform.taro.ts b/src/packages/form/useform.taro.ts index 8559e5c5d7..4ca2f92679 100644 --- a/src/packages/form/useform.taro.ts +++ b/src/packages/form/useform.taro.ts @@ -1,6 +1,6 @@ -import { useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import Schema from 'async-validator' -import { merge } from '@/utils/merge' +import { merge, recursive } from '@/utils/merge' import { Callbacks, FormFieldEntity, @@ -11,6 +11,7 @@ import { export const SECRET = 'NUT_FORM_INTERNAL' type UpdateItem = { entity: FormFieldEntity; condition: any } +type WatchCallback = (value: Store, namePath: NamePath[]) => void /** * 用于存储表单的数据 @@ -45,11 +46,9 @@ class FormStore { */ registerField = (field: any) => { this.fieldEntities.push(field) + return () => { this.fieldEntities = this.fieldEntities.filter((item) => item !== field) - if (this.store) { - delete this.store[field.props.name] - } } } @@ -90,6 +89,7 @@ class FormStore { if (init) { const nextStore = merge(initialValues, this.store) this.updateStore(nextStore) + this.notifyWatch() } } @@ -98,7 +98,7 @@ class FormStore { * @param newStore { [name]: newValue } */ setFieldsValue = (newStore: any) => { - const nextStore = merge(this.store, newStore) + const nextStore = recursive(true, this.store, newStore) this.updateStore(nextStore) this.fieldEntities.forEach((entity: FormFieldEntity) => { const { name } = entity.props @@ -117,6 +117,7 @@ class FormStore { item.entity.onStoreChange('update') } }) + this.notifyWatch() } setFieldValue = (name: NamePath, value: T) => { @@ -124,6 +125,7 @@ class FormStore { [name]: value, } this.setFieldsValue(store) + this.notifyWatch([name]) } setCallback = (callback: Callbacks) => { @@ -234,6 +236,7 @@ class FormStore { store: this.store, fieldEntities: this.fieldEntities, registerUpdate: this.registerUpdate, + registerWatch: this.registerWatch, } } } @@ -251,6 +254,30 @@ class FormStore { getInternal: this.getInternal, } } + + private watchList: WatchCallback[] = [] + + private registerWatch = (callback: WatchCallback) => { + this.watchList.push(callback) + + return () => { + this.watchList = this.watchList.filter((fn) => fn !== callback) + } + } + + private notifyWatch = (namePath: NamePath[] = []) => { + if (this.watchList.length) { + let allValues + if (!namePath || namePath.length === 0) { + allValues = this.getFieldsValue(true) + } else { + allValues = this.getFieldsValue(namePath) + } + this.watchList.forEach((callback) => { + callback(allValues, namePath) + }) + } + } } export const useForm = (form?: FormInstance): [FormInstance] => { @@ -265,3 +292,22 @@ export const useForm = (form?: FormInstance): [FormInstance] => { } return [formRef.current as FormInstance] } + +export const useWatch = (path: NamePath, form: FormInstance) => { + const formInstance = form.getInternal(SECRET) + const [value, setValue] = useState() + useEffect(() => { + const unsubscribe = formInstance.registerWatch( + (data: any, namePath: NamePath) => { + const value = data[path] + setValue(value) + } + ) + const initialValue = form.getFieldsValue(true) + if (value !== initialValue[path]) { + setValue(initialValue[path]) + } + return () => unsubscribe() + }, [form]) + return value +} diff --git a/src/packages/form/useform.ts b/src/packages/form/useform.ts index 8559e5c5d7..4ca2f92679 100644 --- a/src/packages/form/useform.ts +++ b/src/packages/form/useform.ts @@ -1,6 +1,6 @@ -import { useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import Schema from 'async-validator' -import { merge } from '@/utils/merge' +import { merge, recursive } from '@/utils/merge' import { Callbacks, FormFieldEntity, @@ -11,6 +11,7 @@ import { export const SECRET = 'NUT_FORM_INTERNAL' type UpdateItem = { entity: FormFieldEntity; condition: any } +type WatchCallback = (value: Store, namePath: NamePath[]) => void /** * 用于存储表单的数据 @@ -45,11 +46,9 @@ class FormStore { */ registerField = (field: any) => { this.fieldEntities.push(field) + return () => { this.fieldEntities = this.fieldEntities.filter((item) => item !== field) - if (this.store) { - delete this.store[field.props.name] - } } } @@ -90,6 +89,7 @@ class FormStore { if (init) { const nextStore = merge(initialValues, this.store) this.updateStore(nextStore) + this.notifyWatch() } } @@ -98,7 +98,7 @@ class FormStore { * @param newStore { [name]: newValue } */ setFieldsValue = (newStore: any) => { - const nextStore = merge(this.store, newStore) + const nextStore = recursive(true, this.store, newStore) this.updateStore(nextStore) this.fieldEntities.forEach((entity: FormFieldEntity) => { const { name } = entity.props @@ -117,6 +117,7 @@ class FormStore { item.entity.onStoreChange('update') } }) + this.notifyWatch() } setFieldValue = (name: NamePath, value: T) => { @@ -124,6 +125,7 @@ class FormStore { [name]: value, } this.setFieldsValue(store) + this.notifyWatch([name]) } setCallback = (callback: Callbacks) => { @@ -234,6 +236,7 @@ class FormStore { store: this.store, fieldEntities: this.fieldEntities, registerUpdate: this.registerUpdate, + registerWatch: this.registerWatch, } } } @@ -251,6 +254,30 @@ class FormStore { getInternal: this.getInternal, } } + + private watchList: WatchCallback[] = [] + + private registerWatch = (callback: WatchCallback) => { + this.watchList.push(callback) + + return () => { + this.watchList = this.watchList.filter((fn) => fn !== callback) + } + } + + private notifyWatch = (namePath: NamePath[] = []) => { + if (this.watchList.length) { + let allValues + if (!namePath || namePath.length === 0) { + allValues = this.getFieldsValue(true) + } else { + allValues = this.getFieldsValue(namePath) + } + this.watchList.forEach((callback) => { + callback(allValues, namePath) + }) + } + } } export const useForm = (form?: FormInstance): [FormInstance] => { @@ -265,3 +292,22 @@ export const useForm = (form?: FormInstance): [FormInstance] => { } return [formRef.current as FormInstance] } + +export const useWatch = (path: NamePath, form: FormInstance) => { + const formInstance = form.getInternal(SECRET) + const [value, setValue] = useState() + useEffect(() => { + const unsubscribe = formInstance.registerWatch( + (data: any, namePath: NamePath) => { + const value = data[path] + setValue(value) + } + ) + const initialValue = form.getFieldsValue(true) + if (value !== initialValue[path]) { + setValue(initialValue[path]) + } + return () => unsubscribe() + }, [form]) + return value +} diff --git a/src/packages/popup/doc.taro.md b/src/packages/popup/doc.taro.md index 8e9880be00..5badf6c610 100644 --- a/src/packages/popup/doc.taro.md +++ b/src/packages/popup/doc.taro.md @@ -135,3 +135,7 @@ import { Popup } from '@nutui/nutui-react-taro' | \--nutui-popup-title-height | 标题栏的高度 | `50px` | | \--nutui-popup-title-border-bottom | 标题栏底部边框 | `0` | | \--nutui-popup-animation-duration | 弹框动画的延时 | `0.3s` | + +## FAQ + +在 iOS 下,有时候 `lockScroll` 可能不生效,此刻在打开 `popup` 时,可将宿主页面的样式增加 `overflow: hidden;`,关闭弹层时,再重置样式。 diff --git a/src/utils/merge.ts b/src/utils/merge.ts index 0d6cb8ced6..fe2668ab92 100644 --- a/src/utils/merge.ts +++ b/src/utils/merge.ts @@ -1,29 +1,89 @@ -export function merge(...objects: any[]) { - const result: any = Array.isArray(objects[0]) ? [] : {} - - function mergeHelper(obj: any, path: string[] = []) { - for (const [key, value] of Object.entries(obj)) { - const newPath = [...path, key] - - if (Array.isArray(value)) { - // Arrays are always overridden - result[key] = [...value] - } else if (typeof value === 'object' && value !== null) { - // Check for circular references +export default main + +export function main(clone: boolean, ...items: any[]): any +export function main(...items: any[]): any +export function main(...items: any[]) { + return merge(...items) +} + +main.clone = clone +main.isPlainObject = isPlainObject +main.recursive = recursive + +export function merge(clone: boolean, ...items: any[]): any +export function merge(...items: any[]): any +export function merge(...items: any[]) { + return _merge(items[0] === true, false, items) +} + +export function recursive(clone: boolean, ...items: any[]): any +export function recursive(...items: any[]): any +export function recursive(...items: any[]) { + return _merge(items[0] === true, true, items) +} + +export function clone(input: T): T { + if (Array.isArray(input)) { + const output = [] + + for (let index = 0; index < input.length; ++index) + output.push(clone(input[index])) + + return output as any + } + if (isPlainObject(input)) { + const output: any = {} + + // eslint-disable-next-line guard-for-in + for (const index in input) output[index] = clone((input as any)[index]) + + return output as any + } + return input +} + +export function isPlainObject(input: unknown): input is NonNullable { + if (input === null || typeof input !== 'object') return false + if (Object.getPrototypeOf(input) === null) return true + let ref = input + while (Object.getPrototypeOf(ref) !== null) ref = Object.getPrototypeOf(ref) + return Object.getPrototypeOf(input) === ref +} + +function _recursiveMerge(base: any, extend: any) { + if (!isPlainObject(base) || !isPlainObject(extend)) return extend + for (const key in extend) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') + // eslint-disable-next-line no-continue + continue + base[key] = + isPlainObject(base[key]) && isPlainObject(extend[key]) + ? _recursiveMerge(base[key], extend[key]) + : extend[key] + } + + return base +} + +function _merge(isClone: boolean, isRecursive: boolean, items: any[]) { + let result + + if (isClone || !isPlainObject((result = items.shift()))) result = {} + + for (let index = 0; index < items.length; ++index) { + const item = items[index] + + // eslint-disable-next-line no-continue + if (!isPlainObject(item)) continue + + for (const key in item) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') // eslint-disable-next-line no-continue - if (path.some((p: any) => p === value)) continue - - // Recursively merge objects - if (!result[key]) result[key] = {} - mergeHelper(value, newPath) - } else { - // Set primitive values - result[key] = value - } + continue + const value = isClone ? clone(item[key]) : item[key] + result[key] = isRecursive ? _recursiveMerge(result[key], value) : value } } - objects.filter((obj) => !!obj).forEach((obj) => mergeHelper(obj)) - return result }