diff --git a/src/packages/form/__tests__/form.spec.tsx b/src/packages/form/__tests__/form.spec.tsx index a78b5a02cb..b283195df0 100644 --- a/src/packages/form/__tests__/form.spec.tsx +++ b/src/packages/form/__tests__/form.spec.tsx @@ -1,7 +1,8 @@ import * as React from 'react' -import { render, fireEvent, waitFor } from '@testing-library/react' -import '@testing-library/jest-dom' 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' @@ -167,7 +168,9 @@ test('form set required', () => { ) - expect(container.querySelectorAll('.required')).toHaveLength(1) + expect( + container.querySelectorAll('.nut-form-item-label-required') + ).toHaveLength(1) }) test('form set change value', async () => { @@ -310,3 +313,126 @@ test('no-style and render function', async () => { expect(relatedInput).toBeTruthy() }) }) + +test('reset usename filed', async () => { + const Demo1 = () => { + const [form] = Form.useForm() + return ( + <> +
+
{ + form.resetFields(['username']) + }} + > + Reset +
+ + } + > + + + +
+ + ) + } + const { container } = render() + const input = container.querySelector('input') + const reset = container.querySelector('#reset') + if (input) { + fireEvent.change(input, { target: { value: 'NutUI React Taro' } }) + await waitFor(() => { + expect( + container.querySelector('.nut-form-item-body-tips') + ).toHaveTextContent('字段A不能超过5个字') + }) + } + if (reset) { + fireEvent.click(reset) + await waitFor(() => { + expect(container.querySelector('.nut-form-item-body-tips')).toBeNull() + }) + } +}) + +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 f1a8072cb0..5561187c22 100644 --- a/src/packages/form/demo.taro.tsx +++ b/src/packages/form/demo.taro.tsx @@ -10,6 +10,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({ @@ -21,6 +22,7 @@ const FormDemo = () => { title4: 'Form.useForm 对表单数据域进行交互。', title5: '表单类型', validateTrigger: '校验触发时机', + useWatch: 'useWatch', }, 'en-US': { basic: 'Basic Usage', @@ -30,6 +32,7 @@ const FormDemo = () => { title4: 'Interact with form data fields via Form.useForm', title5: 'Form Type', validateTrigger: 'Validate Trigger', + useWatch: 'useWatch', }, }) @@ -51,6 +54,8 @@ const FormDemo = () => { {translated.title5} + {translated.useWatch} + ) diff --git a/src/packages/form/demo.tsx b/src/packages/form/demo.tsx index 650e54b2eb..dfbcccd246 100644 --- a/src/packages/form/demo.tsx +++ b/src/packages/form/demo.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { useTranslate } from '@/sites/assets/locale' +import { useTranslate } from '../../sites/assets/locale' import Demo1 from './demos/h5/demo1' import Demo2 from './demos/h5/demo2' import Demo3 from './demos/h5/demo3' @@ -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/demo1.tsx b/src/packages/form/demos/h5/demo1.tsx index fb834210c0..1e4c3bfd74 100644 --- a/src/packages/form/demos/h5/demo1.tsx +++ b/src/packages/form/demos/h5/demo1.tsx @@ -52,6 +52,10 @@ const Demo1 = () => { args[0]} > diff --git a/src/packages/form/demos/h5/demo7.tsx b/src/packages/form/demos/h5/demo7.tsx index f0387f47a7..b917d8ea30 100644 --- a/src/packages/form/demos/h5/demo7.tsx +++ b/src/packages/form/demos/h5/demo7.tsx @@ -58,7 +58,7 @@ const Demo7 = () => { - + 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/demo1.tsx b/src/packages/form/demos/taro/demo1.tsx index 6908e1643a..f95bbcc651 100644 --- a/src/packages/form/demos/taro/demo1.tsx +++ b/src/packages/form/demos/taro/demo1.tsx @@ -58,6 +58,10 @@ const Demo1 = () => { args[0]} > diff --git a/src/packages/form/demos/taro/demo4.tsx b/src/packages/form/demos/taro/demo4.tsx index 57a9860995..ec294daa3f 100644 --- a/src/packages/form/demos/taro/demo4.tsx +++ b/src/packages/form/demos/taro/demo4.tsx @@ -6,7 +6,6 @@ import { Button, type FormItemRuleWithoutValidator, } from '@nutui/nutui-react-taro' -import { View } from '@tarojs/components' const Demo4 = () => { const submitFailed = (error: any) => { @@ -37,7 +36,7 @@ const Demo4 = () => { onFinish={(values) => submitSucceed(values)} onFinishFailed={(values, errors) => submitFailed(errors)} footer={ - { - + } > { - + diff --git a/src/packages/form/demos/taro/demo8.tsx b/src/packages/form/demos/taro/demo8.tsx new file mode 100644 index 0000000000..9652b45b05 --- /dev/null +++ b/src/packages/form/demos/taro/demo8.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { Button, Form, Input, Radio, Space } from '@nutui/nutui-react-taro' +import { View } from '@tarojs/components' + +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 53c3d2cedd..36cb759393 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 @@ -80,6 +88,8 @@ import { Form } from '@nutui/nutui-react' | name | form name | `any` | `-` | | labelPosition | The position of the form item label | `top` \| `left` \| `right` | `right` | | starPosition | The red star position of the required form item label | `left` \| `right` | `left` | +| disabled | Disable all form fields | `boolean` | `false` | +| validateTrigger | uniformly set the timing for fields to trigger validation | `string` \| `string[]`\| `false` | `onChange` | | onFinish | Triggered after verification is successful | `(values: any) => void` | `-` | | onFinishFailed | Triggered when any form item fails validation | `(values: any, errorFields: any) => void` | `-` | @@ -119,16 +129,19 @@ 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 | | --- | --- | --- | | getFieldValue | Get the value of the corresponding field name | `(name: NamePath) => any` | | getFieldsValue | Get values by a set of field names. Return according to the corresponding structure. Default return mounted field value, but you can use getFieldsValue(true) to get all values | `(name: NamePath \| boolean) => any` | -| setFieldsValue | set field values | `(values) => void` | -| resetFields | Reset form prompt state | `() => void` | +| setFieldsValue | Set the value of the form (the value will be passed directly to the form store. If you do not want the object passed in to be modified, please copy it and pass it in) | `(values) => void` | +| setFieldValue | Set the value of the corresponding field name | `(name: NamePath, value: T) => void` | +| resetFields | Reset form prompt state | `(namePaths?: NamePath[]) => 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 ad2b2aadd3..b4b4d90b09 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 @@ -78,6 +86,8 @@ import { Form } from '@nutui/nutui-react' | name | 表单名称 | `any` | `-` | | labelPosition | 表单项 label 的位置 | `top` \| `left` \| `right` | `right` | | starPosition | 必填表单项 label 的红色星标位置 | `left` \| `right` | `left` | +| validateTrigger | 统一设置字段触发验证的时机 | `string` \| `string[]` \| `false` | `onChange` | +| disabled | 是否禁用 | `boolean` | `false` | | onFinish | 校验成功后触发 | `(values: any) => void` | `-` | | onFinishFailed | 任一表单项被校验失败后触发 | `(values: any, errorFields: any) => void` | `-` | @@ -118,16 +128,19 @@ import { Form } from '@nutui/nutui-react' ### FormInstance -Form.useForm()创建 Form 实例,用于管理所有数据状态。 +`Form.useForm()`创建 Form 实例,用于管理所有数据状态。 | 属性 | 说明 | 类型 | | --- | --- | --- | | getFieldValue | 获取对应字段名的值 | `(name: NamePath) => any` | | getFieldsValue | 获取一组字段名对应的值,会按照对应结构返回。默认返回现存字段值,当调用 getFieldsValue(true) 时返回所有值 | `(name: NamePath \| boolean) => any` | -| setFieldsValue | 设置表单的值 | `(values) => void` | -| resetFields | 重置表单提示状态 | `() => void` | +| setFieldsValue | 设置表单的值(该值将直接传入 form store 中。如果你不希望传入对象被修改,请克隆后传入) | `(values) => void` | +| setFieldValue | 设置对应字段名的值 | `(name: NamePath, value: T) => void` | +| resetFields | 重置表单提示状态 | `(namePaths?: NamePath[]) => void` | | submit | 提交表单进行校验的方法 | `Promise` | +`Form.useWatch()`此方法将监视指定的输入并返回其值。它对于呈现输入值和确定根据条件呈现的内容很有用。 + ## 主题定制 ### 样式变量 diff --git a/src/packages/form/doc.taro.md b/src/packages/form/doc.taro.md index c4cec28d2e..a2b8e62131 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 @@ -78,6 +86,8 @@ import { Form } from '@nutui/nutui-react-taro' | name | 表单名称 | `any` | `-` | | labelPosition | 表单项 label 的位置 | \`\`'top' | 'left'\` | \`'right'\`\` | | starPosition | 必填表单项 label 的红色星标位置 | `left` \| `right` | `left` | +| validateTrigger | 统一设置字段触发验证的时机 | `string` \| `string[]`\| `false` | `onChange` | +| disabled | 是否禁用 | `boolean` | `false` | | onFinish | 校验成功后触发 | `(values: any) => void` | `-` | | onFinishFailed | 任一表单项被校验失败后触发 | `(values: any, errorFields: any) => void` | `-` | @@ -118,16 +128,19 @@ import { Form } from '@nutui/nutui-react-taro' ### FormInstance -Form.useForm()创建 Form 实例,用于管理所有数据状态。 +`Form.useForm()`创建 Form 实例,用于管理所有数据状态。 | 属性 | 说明 | 类型 | | --- | --- | --- | | getFieldValue | 获取对应字段名的值 | `(name: NamePath) => any` | | getFieldsValue | 获取一组字段名对应的值,会按照对应结构返回。默认返回现存字段值,当调用 getFieldsValue(true) 时返回所有值 | `(name: NamePath \| boolean) => any` | -| setFieldsValue | 设置表单的值 | `(values) => void` | -| resetFields | 重置表单提示状态 | `() => void` | +| setFieldsValue | 设置表单的值(该值将直接传入 form store 中。如果你不希望传入对象被修改,请克隆后传入) | `(values) => void` | +| setFieldValue | 设置对应字段名的值 | `(name: NamePath, value: T) => void` | +| resetFields | 重置表单提示状态 | `(namePaths?: NamePath[]) => void` | | submit | 提交表单进行校验的方法 | `Promise` | +`Form.useWatch()`此方法将监视指定的输入并返回其值。它对于呈现输入值和确定根据条件呈现的内容很有用。 + ## 主题定制 ### 样式变量 diff --git a/src/packages/form/doc.zh-TW.md b/src/packages/form/doc.zh-TW.md index 741f7a0620..33e4cd61b8 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 @@ -79,6 +87,8 @@ import { Form } from '@nutui/nutui-react' | label | 标签名 | `ReactNode` | `-` | | labelPosition | 錶單項 label 的位置 | `top` \| `left` \| `right` | `right` | | starPosition | 必填錶單項 label 的紅色星標位置 | `left` \| `right` | `left` | +| validateTrigger | 統一設定字段觸發驗證的時機 | `string` \| `string[]`\| `false` | `onChange` | +| disabled | 是否禁用 | `boolean` | `false` | | onFinish | 校驗成功後觸發 | `(values: any) => void` | `-` | | onFinishFailed | 任一錶單項被校驗失敗後觸發 | `(values: any, errorFields: any) => void` | `-` | @@ -98,7 +108,7 @@ import { Form } from '@nutui/nutui-react' | align | 對齊方式 | `flex-start` \| `center` \| `flex-end` | `flex-start` | | valuePropName | 子節點的值的屬性,如 Checkbox 的是 'checked' | `string` | `-` | | getValueFromEvent | 設置如何將 event 的值轉換成字段值 | `(...args: any) => any` | `-` | -| validateTrigger | 统一设置字段触发验证的时机 | `string \| string[]` | `onChange` | +| validateTrigger | 統一設定字段觸發驗證的時機 | `string \| string[]` | `onChange` | | onClick | 點擊事件併收集子組件 Ref | `(event: React.MouseEvent, componentRef: React.MutableRefObject) => void` | `-` | ### Form.Item Rule @@ -118,16 +128,19 @@ import { Form } from '@nutui/nutui-react' ### FormInstance -Form.useForm()創建 Form 實例,用於管理所有數據狀態。 +`Form.useForm()`創建 Form 實例,用於管理所有數據狀態。 | 屬性 | 說明 | 類型 | | --- | --- | --- | | getFieldValue | 獲取對應字段名的值 | `(name: NamePath) => any` | | getFieldsValue | 获取一组字段名对应的值,会按照对应结构返回。默认返回现存字段值,当调用 getFieldsValue(true) 时返回所有值 | `(name: NamePath \| boolean) => any` | -| setFieldsValue | 設置錶單的值 | `(values) => void` | -| resetFields | 重置錶單提示狀態 | `() => void` | +| setFieldsValue | 設定表單的值(該值將直接傳入 form store 中。如果你不希望傳入物件被修改,請複製後傳入) | `(values) => void` | +| setFieldValue | 設定對應欄位名的值 | `(name: NamePath, value: T) => void` | +| resetFields | 重置錶單提示狀態 | `(namePaths?: NamePath[]) => void` | | submit | 提交錶單進行校驗的方法 | `Promise` | +`Form.useWatch()`此方法將監視指定的輸入並傳回其值。它對於呈現輸入值和確定根據條件呈現的內容很有用。 + ## 主題定制 ### 樣式變量 diff --git a/src/packages/form/form.scss b/src/packages/form/form.scss index b4374f71e4..5cabfe1cd1 100644 --- a/src/packages/form/form.scss +++ b/src/packages/form/form.scss @@ -1,71 +1,3 @@ @import '../cellgroup/cellgroup.scss'; @import '../cell/cell.scss'; @import '../formitem/formitem.scss'; - -.form-layout-right .nut-form-item-label { - text-align: right; - padding-right: 24px; - white-space: nowrap; -} - -.form-layout-left .nut-form-item-label { - position: relative; - text-align: left; - padding-left: 12px; - white-space: nowrap; - - .required { - display: block; - line-height: 1.5; - position: absolute; - left: 0.1em; - } -} - -.form-layout-top .nut-form-item { - flex-direction: column; - align-items: flex-start; - white-space: nowrap; -} - -.form-layout-top .nut-form-item-label { - padding-bottom: 4px; - display: block; - padding-right: 24px; -} - -.form-layout-top .nut-form-item-body { - margin-left: 0; - width: 100%; -} - -[dir='rtl'] .form-layout-right .nut-form-item-label, -.nut-rtl .form-layout-right .nut-form-item-label { - text-align: left; - padding-right: 0; - padding-left: 24px; -} - -[dir='rtl'] .form-layout-left .nut-form-item-label, -.nut-rtl .form-layout-left .nut-form-item-label { - text-align: right; - padding-left: 0; - padding-right: 12px; - - .required { - left: auto; - right: 0.1em; - } -} - -[dir='rtl'] .form-layout-top .nut-form-item-label, -.nut-rtl .form-layout-top .nut-form-item-label { - padding-right: 0; - padding-left: 24px; -} - -[dir='rtl'] .form-layout-top .nut-form-item-body, -.nut-rtl .form-layout-top .nut-form-item-body { - margin-left: 0; - margin-right: 0; -} diff --git a/src/packages/form/form.taro.tsx b/src/packages/form/form.taro.tsx index 48f95510c5..c65c3eb9bd 100644 --- a/src/packages/form/form.taro.tsx +++ b/src/packages/form/form.taro.tsx @@ -12,7 +12,9 @@ export interface FormProps extends TFormProps { initialValues: any name: string form: any + disabled: boolean divider: boolean + validateTrigger: string | string[] | false labelPosition: 'top' | 'left' | 'right' starPosition: 'left' | 'right' onFinish: (values: any) => void @@ -23,7 +25,9 @@ const defaultProps = { ...ComponentDefaults, labelPosition: 'right', starPosition: 'left', + disabled: false, divider: false, + validateTrigger: 'onChange', onFinish: (values) => {}, onFinishFailed: (values, errorFields) => {}, } as FormProps @@ -44,8 +48,10 @@ export const Form = React.forwardRef>( children, initialValues, divider, + disabled, onFinish, onFinishFailed, + validateTrigger, labelPosition, starPosition, form, @@ -99,7 +105,11 @@ export const Form = React.forwardRef>( }} > - {children} + + {children} + {footer ? ( {footer} ) : null} diff --git a/src/packages/form/form.tsx b/src/packages/form/form.tsx index caf6331332..5c4a9b83d5 100644 --- a/src/packages/form/form.tsx +++ b/src/packages/form/form.tsx @@ -11,7 +11,9 @@ export interface FormProps extends BasicComponent { initialValues: any name: string form: any + disabled: boolean divider: boolean + validateTrigger: string | string[] | false labelPosition: 'top' | 'left' | 'right' starPosition: 'left' | 'right' onFinish: (values: any) => void @@ -22,7 +24,9 @@ const defaultProps = { ...ComponentDefaults, labelPosition: 'right', starPosition: 'left', + disabled: false, divider: false, + validateTrigger: 'onChange', onFinish: (values) => {}, onFinishFailed: (values, errorFields) => {}, } as FormProps @@ -43,8 +47,10 @@ export const Form = React.forwardRef>( children, initialValues, divider, + disabled, onFinish, onFinishFailed, + validateTrigger, labelPosition, starPosition, form, @@ -96,7 +102,11 @@ export const Form = React.forwardRef>( }} > - {children} + + {children} + {footer ? ( {footer} ) : null} 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/types.ts b/src/packages/form/types.ts index 5b474c0dbb..e0350f8809 100644 --- a/src/packages/form/types.ts +++ b/src/packages/form/types.ts @@ -1,5 +1,6 @@ export interface FormItemRuleWithoutValidator { [key: string]: any + regex?: RegExp required?: boolean message?: string @@ -20,8 +21,9 @@ export interface Store { export interface FormInstance { getFieldValue: (name: NamePath) => StoreValue + setFieldValue: (name: NamePath, value: T) => void getFieldsValue: (nameList: NamePath[] | true) => { [key: NamePath]: any } - setFieldsValue: (value: any) => void + setFieldsValue: (value: Store) => void resetFields: (fields?: NamePath[]) => void submit: () => void getInternal: (secret: string) => any diff --git a/src/packages/form/useform.taro.ts b/src/packages/form/useform.taro.ts index be4ca8b3de..8755f9261f 100644 --- a/src/packages/form/useform.taro.ts +++ b/src/packages/form/useform.taro.ts @@ -1,15 +1,17 @@ -import { useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import Schema from 'async-validator' +import { merge, recursive } from '@/utils/merge' import { - Store, Callbacks, - FormInstance, FormFieldEntity, + FormInstance, NamePath, + Store, } from './types' export const SECRET = 'NUT_FORM_INTERNAL' type UpdateItem = { entity: FormFieldEntity; condition: any } +type WatchCallback = (value: Store, namePath: NamePath[]) => void /** * 用于存储表单的数据 @@ -44,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] - } } } @@ -74,16 +74,22 @@ class FormStore { return fieldsValue } + updateStore(nextStore: Store) { + this.store = nextStore + } + /** * 设置 form 的初始值,之后在 reset 的时候使用 * @param values * @param init */ - setInitialValues = (values: Store, init: boolean) => { + setInitialValues = (initialValues: Store, init: boolean) => { + this.initialValues = initialValues || {} if (init) { - this.initialValues = values - this.store = values + const nextStore = merge(initialValues, this.store) + this.updateStore(nextStore) + this.notifyWatch() } } @@ -91,11 +97,9 @@ class FormStore { * 存储组件数据 * @param newStore { [name]: newValue } */ - setFieldsValue = (newStore: any, needValidate = true) => { - this.store = { - ...this.store, - ...newStore, - } + setFieldsValue = (newStore: any) => { + const nextStore = recursive(true, this.store, newStore) + this.updateStore(nextStore) this.fieldEntities.forEach((entity: FormFieldEntity) => { const { name } = entity.props Object.keys(newStore).forEach((key) => { @@ -113,7 +117,15 @@ class FormStore { item.entity.onStoreChange('update') } }) - needValidate && this.validateFields() + this.notifyWatch() + } + + setFieldValue = (name: NamePath, value: T) => { + const store = { + [name]: value, + } + this.setFieldsValue(store) + this.notifyWatch([name]) } setCallback = (callback: Callbacks) => { @@ -125,6 +137,12 @@ class FormStore { validateEntities = async (entity: FormFieldEntity, errs: any[]) => { const { name, rules = [] } = entity.props + + if (!name) { + console.warn('Form field missing name property') + return + } + const descriptor: any = {} if (rules.length) { // 多条校验规则 @@ -158,7 +176,7 @@ class FormStore { validateFields = async (nameList?: NamePath[]) => { let filterEntities = [] - this.errors.length = 0 + // this.errors.length = 0 if (!nameList || nameList.length === 0) { filterEntities = this.fieldEntities } else { @@ -184,12 +202,29 @@ class FormStore { } } - resetFields = () => { - this.errors.length = 0 - this.store = this.initialValues - this.fieldEntities.forEach((entity: FormFieldEntity) => { - entity.onStoreChange('reset') - }) + resetFields = (namePaths?: NamePath[]) => { + if (namePaths && namePaths.length) { + namePaths.forEach((path) => { + this.errors[path] = null + this.fieldEntities.forEach((entity: FormFieldEntity) => { + const name = entity.props.name + if (name === path) { + if (path in this.initialValues) { + this.updateStore({ [path]: this.initialValues[path] }) + } else { + delete this.store[path] + } + entity.onStoreChange('reset') + } + }) + }) + } else { + const nextStore = merge({}, this.initialValues) + this.updateStore(nextStore) + this.fieldEntities.forEach((entity: FormFieldEntity) => { + entity.onStoreChange('reset') + }) + } } // 监听事件 @@ -217,6 +252,7 @@ class FormStore { store: this.store, fieldEntities: this.fieldEntities, registerUpdate: this.registerUpdate, + registerWatch: this.registerWatch, } } } @@ -226,6 +262,7 @@ class FormStore { getFieldValue: this.getFieldValue, getFieldsValue: this.getFieldsValue, setFieldsValue: this.setFieldsValue, + setFieldValue: this.setFieldValue, resetFields: this.resetFields, validateFields: this.validateFields, submit: this.submit, @@ -233,6 +270,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] => { @@ -245,5 +306,24 @@ export const useForm = (form?: FormInstance): [FormInstance] => { formRef.current = formStore.getForm() as FormInstance } } - return [formRef.current] + 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 2b2b413a3b..8755f9261f 100644 --- a/src/packages/form/useform.ts +++ b/src/packages/form/useform.ts @@ -1,15 +1,17 @@ -import { useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import Schema from 'async-validator' +import { merge, recursive } from '@/utils/merge' import { - Store, Callbacks, - FormInstance, FormFieldEntity, + FormInstance, NamePath, + Store, } from './types' export const SECRET = 'NUT_FORM_INTERNAL' type UpdateItem = { entity: FormFieldEntity; condition: any } +type WatchCallback = (value: Store, namePath: NamePath[]) => void /** * 用于存储表单的数据 @@ -44,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] - } } } @@ -74,16 +74,22 @@ class FormStore { return fieldsValue } + updateStore(nextStore: Store) { + this.store = nextStore + } + /** * 设置 form 的初始值,之后在 reset 的时候使用 * @param values * @param init */ - setInitialValues = (values: Store, init: boolean) => { + setInitialValues = (initialValues: Store, init: boolean) => { + this.initialValues = initialValues || {} if (init) { - this.initialValues = values - this.store = values + const nextStore = merge(initialValues, this.store) + this.updateStore(nextStore) + this.notifyWatch() } } @@ -91,11 +97,9 @@ class FormStore { * 存储组件数据 * @param newStore { [name]: newValue } */ - setFieldsValue = (newStore: any, needValidate = true) => { - this.store = { - ...this.store, - ...newStore, - } + setFieldsValue = (newStore: any) => { + const nextStore = recursive(true, this.store, newStore) + this.updateStore(nextStore) this.fieldEntities.forEach((entity: FormFieldEntity) => { const { name } = entity.props Object.keys(newStore).forEach((key) => { @@ -113,7 +117,15 @@ class FormStore { item.entity.onStoreChange('update') } }) - needValidate && this.validateFields() + this.notifyWatch() + } + + setFieldValue = (name: NamePath, value: T) => { + const store = { + [name]: value, + } + this.setFieldsValue(store) + this.notifyWatch([name]) } setCallback = (callback: Callbacks) => { @@ -123,48 +135,61 @@ class FormStore { } } + validateEntities = async (entity: FormFieldEntity, errs: any[]) => { + const { name, rules = [] } = entity.props + + if (!name) { + console.warn('Form field missing name property') + return + } + + const descriptor: any = {} + if (rules.length) { + // 多条校验规则 + if (rules.length > 1) { + descriptor[name] = [] + rules.forEach((v: any) => { + descriptor[name].push(v) + }) + } else { + descriptor[name] = rules[0] + } + } + const validator = new Schema(descriptor) + // 此处合并无值message 没有意义? + // validator.messages() + try { + await validator.validate({ [name]: this.store?.[name] }) + } catch ({ errors }: any) { + if (errors) { + errs.push(...(errors as any[])) + this.errors[name] = errors + } + } finally { + if (!errs || errs.length === 0) { + this.errors[name] = [] + } + } + + entity.onStoreChange('validate') + } + validateFields = async (nameList?: NamePath[]) => { - let filterEntitys = [] - const errs = [] - this.errors.length = 0 + let filterEntities = [] + // this.errors.length = 0 if (!nameList || nameList.length === 0) { - filterEntitys = this.fieldEntities + filterEntities = this.fieldEntities } else { - filterEntitys = this.fieldEntities.filter(({ props: { name } }) => + filterEntities = this.fieldEntities.filter(({ props: { name } }) => nameList.includes(name) ) } - for (const entity of filterEntitys) { - const { name, rules = [] } = entity.props - const descriptor: any = {} - if (rules.length) { - // 多条校验规则 - if (rules.length > 1) { - descriptor[name] = [] - rules.forEach((v: any) => { - descriptor[name].push(v) - }) - } else { - descriptor[name] = rules[0] - } - } - const validator = new Schema(descriptor) - // 此处合并无值message 没有意义? - // validator.messages() - try { - await validator.validate({ [name]: this.store?.[name] }) - } catch ({ errors }: any) { - if (errors) { - errs.push(...(errors as any[])) - this.errors[name] = errors - } - } finally { - if (!errs || errs.length === 0) { - this.errors[name] = [] - } - } - entity.onStoreChange('validate') - } + const errs: any[] = [] + await Promise.all( + filterEntities.map(async (entity) => { + await this.validateEntities(entity, errs) + }) + ) return errs } @@ -177,12 +202,29 @@ class FormStore { } } - resetFields = () => { - this.errors.length = 0 - this.store = this.initialValues - this.fieldEntities.forEach((entity: FormFieldEntity) => { - entity.onStoreChange('reset') - }) + resetFields = (namePaths?: NamePath[]) => { + if (namePaths && namePaths.length) { + namePaths.forEach((path) => { + this.errors[path] = null + this.fieldEntities.forEach((entity: FormFieldEntity) => { + const name = entity.props.name + if (name === path) { + if (path in this.initialValues) { + this.updateStore({ [path]: this.initialValues[path] }) + } else { + delete this.store[path] + } + entity.onStoreChange('reset') + } + }) + }) + } else { + const nextStore = merge({}, this.initialValues) + this.updateStore(nextStore) + this.fieldEntities.forEach((entity: FormFieldEntity) => { + entity.onStoreChange('reset') + }) + } } // 监听事件 @@ -210,6 +252,7 @@ class FormStore { store: this.store, fieldEntities: this.fieldEntities, registerUpdate: this.registerUpdate, + registerWatch: this.registerWatch, } } } @@ -219,6 +262,7 @@ class FormStore { getFieldValue: this.getFieldValue, getFieldsValue: this.getFieldsValue, setFieldsValue: this.setFieldsValue, + setFieldValue: this.setFieldValue, resetFields: this.resetFields, validateFields: this.validateFields, submit: this.submit, @@ -226,6 +270,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] => { @@ -238,5 +306,24 @@ export const useForm = (form?: FormInstance): [FormInstance] => { formRef.current = formStore.getForm() as FormInstance } } - return [formRef.current] + 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/formitem/formitem.scss b/src/packages/formitem/formitem.scss index fe248e72b4..25b38e1d2c 100644 --- a/src/packages/formitem/formitem.scss +++ b/src/packages/formitem/formitem.scss @@ -1,40 +1,41 @@ .nut-form-item { display: flex; - align-items: center; - padding: 4px 12px; + line-height: unset; - &.error { - &.line { - &::before { - border-bottom: 1px solid $form-item-error-line-color; - transform: scaleX(1); - transition: transform 200ms cubic-bezier(0, 0, 0.2, 1) 0ms; - } - } + &-disabled { + opacity: 0.4; + pointer-events: none; } &-label { + display: flex; + flex-direction: row; font-size: $form-item-label-font-size; font-weight: normal; width: $form-item-label-width; margin-right: $form-item-label-margin-right; - flex: none !important; - display: inline-block !important; + flex: 0 0 auto; word-wrap: break-word; text-align: $form-item-label-text-align; + line-height: unset; + } - .required { - &::before { - content: '*'; - color: $form-item-required-color; - margin-right: $form-item-required-margin-right; - } - } + &-label-required { + color: $form-item-required-color; + margin-right: $form-item-required-margin-right; + display: block; + position: absolute; + left: -10px; + } + + .nut-form-item-labeltxt { + position: relative; + font-size: 12px; } &-body { flex: 1; - display: flex !important; + display: flex; flex-direction: column; &-slots { @@ -45,6 +46,10 @@ border: 0; } + .nut-input-container { + height: auto; + } + .nut-input-text { font-size: $form-item-body-font-size; text-align: $form-item-body-input-text-align; @@ -61,7 +66,7 @@ } .nut-textarea { - padding: 0 !important; + padding: 0; .nut-textarea-textarea { font: inherit; @@ -77,6 +82,7 @@ } } } + [dir='rtl'] .nut-form-item, .nut-rtl .nut-form-item { &-label { @@ -91,22 +97,96 @@ } } } + &-body { &-slots { text-align: right; + .nut-icon-ArrowRight, .nut-icon-ArrowLeft { transform: rotateY(180deg); } + .nut-input-text { text-align: right; } + .nut-textarea-textarea { text-align: right; } } } + &-tips { text-align: right; } } + +/* position */ + +.nut-form-item-label-right { + justify-content: flex-end; + padding-right: 24px; + white-space: nowrap; +} + +.nut-form-item-label-left { + position: relative; + padding-left: 12px; + white-space: nowrap; +} + +.nut-form-item-label-left-required { + display: block; + line-height: 1.5; + position: absolute; + left: 0.1em; +} + +.nut-form-item-top { + flex-direction: column; + align-items: flex-start; + white-space: nowrap; +} + +.nut-form-item-label-top { + display: block; + padding-bottom: 4px; + padding-right: 24px; +} + +.nut-form-item-body-top { + margin-left: 0; + width: 100%; +} + +[dir='rtl'] .form-layout-right .nut-form-item-label, +.nut-rtl .form-layout-right .nut-form-item-label { + text-align: left; + padding-right: 0; + padding-left: 24px; +} + +[dir='rtl'] .form-layout-left .nut-form-item-label, +.nut-rtl .form-layout-left .nut-form-item-label { + text-align: right; + padding-left: 0; + padding-right: 12px; + + .required { + left: auto; + right: 0.1em; + } +} + +[dir='rtl'] .form-layout-top .nut-form-item-label, +.nut-rtl .form-layout-top .nut-form-item-label { + padding-right: 0; + padding-left: 24px; +} + +[dir='rtl'] .form-layout-top .nut-form-item-body, +.nut-rtl .form-layout-top .nut-form-item-body { + margin-left: 0; + margin-right: 0; +} diff --git a/src/packages/formitem/formitem.taro.tsx b/src/packages/formitem/formitem.taro.tsx index 9a0739f7f9..4c7c97fcbd 100644 --- a/src/packages/formitem/formitem.taro.tsx +++ b/src/packages/formitem/formitem.taro.tsx @@ -1,10 +1,11 @@ import React, { ReactNode } from 'react' -import { View } from '@tarojs/components' +import { Text, View } from '@tarojs/components' import { BaseFormField } from './types' import { Context } from '../form/context' import Cell from '@/packages/cell/index.taro' import { BasicComponent, ComponentDefaults } from '@/utils/typings' import { isForwardRefComponent } from '@/utils/is-forward-ref-component' +import { toArray } from '@/utils/to-array' import { SECRET } from '@/packages/form/useform.taro' type TextAlign = @@ -45,7 +46,6 @@ const defaultProps = { label: '', rules: [{ required: false, message: '' }], errorMessageAlign: 'left', - validateTrigger: 'onChange', shouldUpdate: false, noStyle: false, } as FormItemProps @@ -76,7 +76,8 @@ export class FormItem extends React.Component< componentDidMount() { // Form设置initialValues时的处理 - const { store = {}, setInitialValues } = this.context.getInternal(SECRET) + const { store = {}, setInitialValues } = + this.context.formInstance.getInternal(SECRET) if ( this.props.initialValue && this.props.name && @@ -88,7 +89,8 @@ export class FormItem extends React.Component< ) } // 注册组件实例到FormStore - const { registerField, registerUpdate } = this.context.getInternal(SECRET) + const { registerField, registerUpdate } = + this.context.formInstance.getInternal(SECRET) this.cancelRegister = registerField(this) // 这里需要增加事件监听,因为此实现属于依赖触发 this.eventOff = registerUpdate(this, this.props.shouldUpdate) @@ -105,17 +107,23 @@ export class FormItem extends React.Component< // children添加value属性和onChange事件 getControlled = (children: React.ReactElement) => { - const { setFieldsValue, getFieldValue } = this.context - const { dispatch } = this.context.getInternal(SECRET) + const { setFieldsValue, getFieldValue } = this.context.formInstance + const { dispatch } = this.context.formInstance.getInternal(SECRET) const { name = '' } = this.props if (children?.props?.defaultValue) { - console.warn('通过 initialValue 设置初始值') + if (process.env.NODE_ENV !== 'production') { + console.warn( + '[NutUI] FormItem:', + '请通过 initialValue 设置初始值,而不是 defaultValue' + ) + } } const fieldValue = getFieldValue(name) const controlled = { ...children.props, + className: children.props.className, [this.props.valuePropName || 'value']: fieldValue !== undefined ? fieldValue : this.props.initialValue, [this.props.trigger || 'onChange']: (...args: any) => { @@ -130,30 +138,27 @@ export class FormItem extends React.Component< if (this.props.getValueFromEvent) { next = this.props.getValueFromEvent(...args) } - setFieldsValue({ [name]: next }, false) + setFieldsValue({ [name]: next }) }, } const { validateTrigger } = this.props - let validateTriggers: string[] = [this.props.trigger || 'onChange'] - if (validateTrigger) { - validateTriggers = - typeof validateTrigger === 'string' - ? [validateTrigger] - : [...validateTrigger] - validateTriggers.forEach((trigger) => { - const originTrigger = controlled[trigger] - controlled[trigger] = (...args: any) => { - if (originTrigger) { - originTrigger(...args) - } - if (this.props.rules && this.props.rules.length) { - dispatch({ - name: this.props.name, - }) - } + const mergedValidateTrigger = + validateTrigger || this.context.validateTrigger + + const validateTriggers: string[] = toArray(mergedValidateTrigger) + validateTriggers.forEach((trigger) => { + const originTrigger = controlled[trigger] + controlled[trigger] = (...args: any) => { + if (originTrigger) { + originTrigger(...args) } - }) - } + if (this.props.rules && this.props.rules.length) { + dispatch({ + name: this.props.name, + }) + } + } + }) if (isForwardRefComponent(children)) { controlled.ref = (componentInstance: any) => { @@ -181,13 +186,20 @@ export class FormItem extends React.Component< onStoreChange = (type?: string) => { if (type === 'reset') { - this.context.errors[this.props.name as string] = [] + this.context.formInstance.errors[this.props.name as string] = [] this.refresh() } else { this.forceUpdate() } } + getClassNameWithDirection(className: string) { + if (className && this.context.labelPosition) { + return `${className} ${className}-${this.context.labelPosition}` + } + return className + } + renderLayout = (childNode: React.ReactNode) => { const { label, @@ -196,30 +208,32 @@ export class FormItem extends React.Component< rules, className, style, - align, errorMessageAlign, + align, } = { ...defaultProps, ...this.props, } const requiredInRules = rules?.some((rule: any) => rule.required) - const item = name ? this.context.errors[name] : [] + const item = name ? this.context.formInstance.errors[name] : [] - const { starPosition } = this.context + const { starPosition } = this.context.formInstance const renderStar = (required || requiredInRules) && ( - + * ) const renderLabel = ( <> - {starPosition === 'left' ? renderStar : null} - {label} + + {starPosition === 'left' ? renderStar : null} + {label} + {starPosition === 'right' ? renderStar : null} ) return ( @@ -227,11 +241,15 @@ export class FormItem extends React.Component< } > {label ? ( - + {renderLabel} ) : null} - + {childNode} - {this.props.noStyle - ? returnChildNode - : this.renderLayout(returnChildNode)} + + {this.props.noStyle + ? returnChildNode + : this.renderLayout(returnChildNode)} + ) } diff --git a/src/packages/formitem/formitem.tsx b/src/packages/formitem/formitem.tsx index 8ccd66f007..8f9d8f0132 100644 --- a/src/packages/formitem/formitem.tsx +++ b/src/packages/formitem/formitem.tsx @@ -4,6 +4,7 @@ import { Context } from '../form/context' import Cell from '@/packages/cell' import { BasicComponent, ComponentDefaults } from '@/utils/typings' import { isForwardRefComponent } from '@/utils/is-forward-ref-component' +import { toArray } from '@/utils/to-array' import { SECRET } from '@/packages/form/useform' type TextAlign = @@ -44,7 +45,6 @@ const defaultProps = { label: '', rules: [{ required: false, message: '' }], errorMessageAlign: 'left', - validateTrigger: 'onChange', shouldUpdate: false, noStyle: false, } as FormItemProps @@ -75,7 +75,8 @@ export class FormItem extends React.Component< componentDidMount() { // Form设置initialValues时的处理 - const { store = {}, setInitialValues } = this.context.getInternal(SECRET) + const { store = {}, setInitialValues } = + this.context.formInstance.getInternal(SECRET) if ( this.props.initialValue && this.props.name && @@ -87,7 +88,8 @@ export class FormItem extends React.Component< ) } // 注册组件实例到FormStore - const { registerField, registerUpdate } = this.context.getInternal(SECRET) + const { registerField, registerUpdate } = + this.context.formInstance.getInternal(SECRET) this.cancelRegister = registerField(this) // 这里需要增加事件监听,因为此实现属于依赖触发 this.eventOff = registerUpdate(this, this.props.shouldUpdate) @@ -104,16 +106,23 @@ export class FormItem extends React.Component< // children添加value属性和onChange事件 getControlled = (children: React.ReactElement) => { - const { setFieldsValue, getFieldValue } = this.context - const { dispatch } = this.context.getInternal(SECRET) + const { setFieldsValue, getFieldValue } = this.context.formInstance + const { dispatch } = this.context.formInstance.getInternal(SECRET) const { name = '' } = this.props if (children?.props?.defaultValue) { - console.warn('通过 initialValue 设置初始值') + if (process.env.NODE_ENV !== 'production') { + console.warn( + '[NutUI] FormItem:', + '请通过 initialValue 设置初始值,而不是 defaultValue' + ) + } } + const fieldValue = getFieldValue(name) const controlled = { ...children.props, + className: children.props.className, [this.props.valuePropName || 'value']: fieldValue !== undefined ? fieldValue : this.props.initialValue, [this.props.trigger || 'onChange']: (...args: any) => { @@ -128,30 +137,27 @@ export class FormItem extends React.Component< if (this.props.getValueFromEvent) { next = this.props.getValueFromEvent(...args) } - setFieldsValue({ [name]: next }, false) + setFieldsValue({ [name]: next }) }, } const { validateTrigger } = this.props - let validateTriggers: string[] = [this.props.trigger || 'onChange'] - if (validateTrigger) { - validateTriggers = - typeof validateTrigger === 'string' - ? [validateTrigger] - : [...validateTrigger] - validateTriggers.forEach((trigger) => { - const originTrigger = controlled[trigger] - controlled[trigger] = (...args: any) => { - if (originTrigger) { - originTrigger(...args) - } - if (this.props.rules && this.props.rules.length) { - dispatch({ - name: this.props.name, - }) - } + const mergedValidateTrigger = + validateTrigger || this.context.validateTrigger + + const validateTriggers: string[] = toArray(mergedValidateTrigger) + validateTriggers.forEach((trigger) => { + const originTrigger = controlled[trigger] + controlled[trigger] = (...args: any) => { + if (originTrigger) { + originTrigger(...args) } - }) - } + if (this.props.rules && this.props.rules.length) { + dispatch({ + name: this.props.name, + }) + } + } + }) if (isForwardRefComponent(children)) { controlled.ref = (componentInstance: any) => { @@ -179,13 +185,20 @@ export class FormItem extends React.Component< onStoreChange = (type?: string) => { if (type === 'reset') { - this.context.errors[this.props.name as string] = [] + this.context.formInstance.errors[this.props.name as string] = [] this.refresh() } else { this.forceUpdate() } } + getClassNameWithDirection(className: string) { + if (className && this.context.labelPosition) { + return `${className} ${className}-${this.context.labelPosition}` + } + return className + } + renderLayout = (childNode: React.ReactNode) => { const { label, @@ -202,22 +215,24 @@ export class FormItem extends React.Component< } const requiredInRules = rules?.some((rule: any) => rule.required) - const item = name ? this.context.errors[name] : [] + const item = name ? this.context.formInstance.errors[name] : [] - const { starPosition } = this.context + const { starPosition } = this.context.formInstance const renderStar = (required || requiredInRules) && ( - +
*
) const renderLabel = ( <> - {starPosition === 'left' ? renderStar : null} - {label} + + {starPosition === 'left' ? renderStar : null} + {label} + {starPosition === 'right' ? renderStar : null} ) return ( @@ -225,11 +240,15 @@ export class FormItem extends React.Component< } > {label ? ( -
+
{renderLabel}
) : null} -
+
{childNode}
{item && item.length > 0 && (
- {this.props.noStyle - ? returnChildNode - : this.renderLayout(returnChildNode)} +
+ {this.props.noStyle + ? returnChildNode + : this.renderLayout(returnChildNode)} +
) } diff --git a/src/utils/merge.ts b/src/utils/merge.ts new file mode 100644 index 0000000000..fe2668ab92 --- /dev/null +++ b/src/utils/merge.ts @@ -0,0 +1,89 @@ +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 + continue + const value = isClone ? clone(item[key]) : item[key] + result[key] = isRecursive ? _recursiveMerge(result[key], value) : value + } + } + + return result +} diff --git a/src/utils/to-array.ts b/src/utils/to-array.ts new file mode 100644 index 0000000000..0e483b7625 --- /dev/null +++ b/src/utils/to-array.ts @@ -0,0 +1,7 @@ +export function toArray(value?: T | T[] | null): T[] { + if (value === undefined || value === null) { + return [] + } + + return Array.isArray(value) ? value : [value] +}