diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fa13b066..8c5eeb3d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,9 @@ version: 2 updates: +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" - package-ecosystem: npm directory: "/" schedule: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 85650de4..41b0ecf0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,18 +13,18 @@ jobs: - name: checkout uses: actions/checkout@master - - uses: actions/setup-node@v1 + - uses: actions/setup-node@v4 with: - node-version: '16' + node-version: '20' - name: cache package-lock.json - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: package-temp-dir key: lock-${{ github.sha }} - name: create package-lock.json - run: npm i --package-lock-only + run: npm i --package-lock-only --legacy-peer-deps - name: hack for singe file run: | @@ -34,7 +34,7 @@ jobs: cp package-lock.json package-temp-dir - name: cache node_modules id: node_modules_cache_id - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: node_modules key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }} @@ -47,16 +47,16 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@master + uses: actions/checkout@v4 - name: restore cache from package-lock.json - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: package-temp-dir key: lock-${{ github.sha }} - name: restore cache from node_modules - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: node_modules key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }} @@ -70,16 +70,16 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@master + uses: actions/checkout@v4 - name: restore cache from package-lock.json - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: package-temp-dir key: lock-${{ github.sha }} - name: restore cache from node_modules - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: node_modules key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }} @@ -93,21 +93,26 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@master + uses: actions/checkout@v4 - name: restore cache from package-lock.json - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: package-temp-dir key: lock-${{ github.sha }} - name: restore cache from node_modules - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: node_modules key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }} - name: coverage - run: npm test -- --coverage && bash <(curl -s https://codecov.io/bash) + run: npm test -- --coverage + + - uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true # optional (default = false) + token: ${{ secrets.CODECOV_TOKEN }} # required needs: setup diff --git a/.gitignore b/.gitignore index 58f701c7..338e0c94 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ .umi-test .env.local .dumi/ +package-lock.json diff --git a/README.md b/README.md index 56011fcf..89c46006 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,13 @@ open http://localhost:8000 ```js | pure import Form, { Field } from 'rc-field-form'; -const Input = ({ value = "", ...props }) => ; +const Input = ({ value = '', ...props }) => ; const Demo = () => { return (
{ - console.log("Finish:", values); + onFinish={values => { + console.log('Finish:', values); }} > @@ -81,20 +81,20 @@ We use typescript to create the Type definition. You can view directly in IDE. B ### Field -| Prop | Description | Type | Default | -| ----------------- | ----------------------------------------------------------------------------- | ------------------------------------------- | -------- | -| dependencies | Will re-render if dependencies changed | [NamePath](#namepath)[] | - | -| getValueFromEvent | Specify how to get value from event | (..args: any[]) => any | - | -| getValueProps | Customize additional props with value. This prop will disable `valuePropName` | (value) => any | - | -| initialValue | Field initial value | any | - | -| name | Field name path | [NamePath](#namepath) | - | -| normalize | Normalize value before update | (value, prevValue, prevValues) => any | - | -| preserve | Preserve value when field removed | boolean | false | -| rules | Validate rules | [Rule](#rule)[] | - | +| Prop | Description | Type | Default | +| ----------------- | ----------------------------------------------------------------------------- | ---------------------------------------------- | -------- | +| dependencies | Will re-render if dependencies changed | [NamePath](#namepath)[] | - | +| getValueFromEvent | Specify how to get value from event | (..args: any[]) => any | - | +| getValueProps | Customize additional props with value. This prop will disable `valuePropName` | (value) => any | - | +| initialValue | Field initial value | any | - | +| name | Field name path | [NamePath](#namepath) | - | +| normalize | Normalize value before update | (value, prevValue, prevValues) => any | - | +| preserve | Preserve value when field removed | boolean | false | +| rules | Validate rules | [Rule](#rule)[] | - | | shouldUpdate | Check if Field should update | boolean \| (prevValues, nextValues) => boolean | - | -| trigger | Collect value update by event trigger | string | onChange | -| validateTrigger | Config trigger point with rule validate | string \| string[] | onChange | -| valuePropName | Config value mapping prop with element | string | value | +| trigger | Collect value update by event trigger | string | onChange | +| validateTrigger | Config trigger point with rule validate | string \| string[] | onChange | +| valuePropName | Config value mapping prop with element | string | value | ### List diff --git a/docs/demo/clearOnDestroy.md b/docs/demo/clearOnDestroy.md new file mode 100644 index 00000000..1603e079 --- /dev/null +++ b/docs/demo/clearOnDestroy.md @@ -0,0 +1,3 @@ +## clearOnDestroy + + diff --git a/docs/demo/fieldTouched.md b/docs/demo/fieldTouched.md new file mode 100644 index 00000000..eceb43d8 --- /dev/null +++ b/docs/demo/fieldTouched.md @@ -0,0 +1,3 @@ +## fieldTouched + + diff --git a/docs/demo/useWatch-selector.md b/docs/demo/useWatch-selector.md new file mode 100644 index 00000000..1c17ac8c --- /dev/null +++ b/docs/demo/useWatch-selector.md @@ -0,0 +1,3 @@ +## useWatch-selector + + diff --git a/docs/examples/clearOnDestroy.tsx b/docs/examples/clearOnDestroy.tsx new file mode 100644 index 00000000..46b1e34a --- /dev/null +++ b/docs/examples/clearOnDestroy.tsx @@ -0,0 +1,39 @@ +import Form, { Field } from 'rc-field-form'; +import React, { useState } from 'react'; +import Input from './components/Input'; + +export default () => { + const [load, setLoad] = useState(false); + const [count, setCount] = useState(0); + + const [form] = Form.useForm(undefined); + + return ( + <> + + + + {load && ( + + + + + + + )} + + ); +}; diff --git a/docs/examples/fieldTouched.tsx b/docs/examples/fieldTouched.tsx new file mode 100644 index 00000000..18770f65 --- /dev/null +++ b/docs/examples/fieldTouched.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import Form from 'rc-field-form'; + +export default () => { + + const [form] = Form.useForm(); + const [, forceUpdate] = React.useState({}); + + return ( +
+
+ + + +
+ + {(fields, { add }) => ( + <> + { + fields.map((field) => { + return ( +
+
+ + + +
+
+ + + +
+
+ ); + }) + } + + + )} +
+
+
+ + +
+
form.isFieldsTouched(true): {`${form.isFieldsTouched(true)}`}
+
{`form.isFieldsTouched(['list'], true)`}: {`${form.isFieldsTouched(['list'], true)}`}
+
+
+ ); +} \ No newline at end of file diff --git a/docs/examples/useWatch-selector.tsx b/docs/examples/useWatch-selector.tsx new file mode 100644 index 00000000..7ecde689 --- /dev/null +++ b/docs/examples/useWatch-selector.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import Form, { Field } from 'rc-field-form'; +import Input from './components/Input'; + +type FieldType = { + init?: string; + name?: string; + age?: number; +}; + +export default () => { + const [form] = Form.useForm(); + const values = Form.useWatch( + values => ({ init: values.init, newName: values.name, newAge: values.age }), + { form, preserve: true }, + ); + console.log('values', values); + return ( + <> +
+ name + + + + age + + + + no-watch + + + + values:{JSON.stringify(values)} +
+ + ); +}; diff --git a/package.json b/package.json index 8aa69ba7..e143faa6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-field-form", - "version": "1.40.0-0", + "version": "2.4.0", "description": "React Form Component", "typings": "es/index.d.ts", "engines": { @@ -49,33 +49,36 @@ "react-dom": ">=16.9.0" }, "dependencies": { - "@babel/runtime": "^7.23.2", - "@rc-component/async-validator": "^5.0.0", - "rc-util": "^5.38.0" + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" }, "devDependencies": { "@rc-component/father-plugin": "^1.0.0", - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/react": "^14.0.0", + "@testing-library/jest-dom": "^6.1.4", + "@testing-library/react": "^15.0.2", "@types/jest": "^29.2.5", "@types/lodash": "^4.14.135", + "@types/node": "^20.11.30", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", - "@umijs/fabric": "^2.5.2", + "@umijs/fabric": "^4.0.1", "dumi": "^2.0.0", - "eslint": "^7.18.0", + "eslint": "^8.54.0", + "eslint-plugin-jest": "^27.6.0", + "eslint-plugin-unicorn": "^52.0.0", "father": "^4.0.0", - "gh-pages": "^3.1.0", + "gh-pages": "^6.1.0", "jest": "^29.0.0", "np": "^5.0.0", "prettier": "^2.1.2", "rc-test": "^7.0.15", "react": "^18.0.0", - "react-dnd": "^8.0.3", + "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^8.0.3", "react-dom": "^18.0.0", - "react-redux": "^8.1.2", - "redux": "^4.2.1", + "react-redux": "^9.0.4", + "redux": "^5.0.0", "typescript": "^5.1.6" } } diff --git a/src/Field.tsx b/src/Field.tsx index 6b01ef14..964fff12 100644 --- a/src/Field.tsx +++ b/src/Field.tsx @@ -259,7 +259,11 @@ class Field extends React.Component implements F const namePathMatch = namePathList && containsNamePath(namePathList, namePath); // `setFieldsValue` is a quick access to update related status - if (info.type === 'valueUpdate' && info.source === 'external' && prevValue !== curValue) { + if ( + info.type === 'valueUpdate' && + info.source === 'external' && + !isEqual(prevValue, curValue) + ) { this.touched = true; this.dirty = true; this.validatePromise = null; @@ -293,7 +297,10 @@ class Field extends React.Component implements F * - Reset A, need clean B, C */ case 'remove': { - if (shouldUpdate) { + if ( + shouldUpdate && + requireUpdate(shouldUpdate, prevStore, store, prevValue, curValue, info) + ) { this.reRender(); return; } @@ -557,6 +564,7 @@ class Field extends React.Component implements F public getControlled = (childProps: ChildProps = {}) => { const { + name, trigger, validateTrigger, getValueFromEvent, @@ -575,12 +583,23 @@ class Field extends React.Component implements F const value = this.getValue(); const mergedGetValueProps = getValueProps || ((val: StoreValue) => ({ [valuePropName]: val })); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const originTriggerFunc: any = childProps[trigger]; + const originTriggerFunc = childProps[trigger]; + + const valueProps = name !== undefined ? mergedGetValueProps(value) : {}; + + // warning when prop value is function + if (process.env.NODE_ENV !== 'production' && valueProps) { + Object.keys(valueProps).forEach(key => { + warning( + typeof valueProps[key] !== 'function', + `It's not recommended to generate dynamic function prop by \`getValueProps\`. Please pass it to child component directly (prop: ${key})`, + ); + }); + } const control = { ...childProps, - ...mergedGetValueProps(value), + ...valueProps, }; // Add trigger diff --git a/src/Form.tsx b/src/Form.tsx index 370185cb..24972de5 100644 --- a/src/Form.tsx +++ b/src/Form.tsx @@ -6,6 +6,7 @@ import type { ValidateMessages, Callbacks, InternalFormInstance, + FormRef, } from './interface'; import useForm from './useForm'; import FieldContext, { HOOK_MARK } from './FieldContext'; @@ -33,9 +34,10 @@ export interface FormProps extends BaseFormProps { onFinishFailed?: Callbacks['onFinishFailed']; validateTrigger?: string | string[] | false; preserve?: boolean; + clearOnDestroy?: boolean; } -const Form: React.ForwardRefRenderFunction = ( +const Form: React.ForwardRefRenderFunction = ( { name, initialValues, @@ -50,10 +52,12 @@ const Form: React.ForwardRefRenderFunction = ( onFieldsChange, onFinish, onFinishFailed, + clearOnDestroy, ...restProps }: FormProps, ref, ) => { + const nativeElementRef = React.useRef(null); const formContext: FormContextProps = React.useContext(FormContext); // We customize handle event since Context will makes all the consumer re-render: @@ -69,7 +73,10 @@ const Form: React.ForwardRefRenderFunction = ( } = (formInstance as InternalFormInstance).getInternalHooks(HOOK_MARK); // Pass ref with form instance - React.useImperativeHandle(ref, () => formInstance); + React.useImperativeHandle(ref, () => ({ + ...formInstance, + nativeElement: nativeElementRef.current, + })); // Register form into Context React.useEffect(() => { @@ -112,7 +119,7 @@ const Form: React.ForwardRefRenderFunction = ( } React.useEffect( - () => destroyForm, + () => () => destroyForm(clearOnDestroy), // eslint-disable-next-line react-hooks/exhaustive-deps [], ); @@ -160,6 +167,7 @@ const Form: React.ForwardRefRenderFunction = ( return ( ) => { event.preventDefault(); event.stopPropagation(); diff --git a/src/index.tsx b/src/index.tsx index 3c6c6e5d..bf535b72 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { FormInstance } from './interface'; +import type { FormRef, FormInstance } from './interface'; import Field from './Field'; import List from './List'; import useForm from './useForm'; @@ -10,8 +10,8 @@ import FieldContext from './FieldContext'; import ListContext from './ListContext'; import useWatch from './useWatch'; -const InternalForm = React.forwardRef(FieldForm) as ( - props: FormProps & { ref?: React.Ref> }, +const InternalForm = React.forwardRef(FieldForm) as ( + props: FormProps & { ref?: React.Ref> }, ) => React.ReactElement; type InternalFormType = typeof InternalForm; @@ -33,6 +33,6 @@ RefForm.useWatch = useWatch; export { Field, List, useForm, FormProvider, FieldContext, ListContext, useWatch }; -export type { FormProps, FormInstance }; +export type { FormProps, FormInstance, FormRef }; export default RefForm; diff --git a/src/interface.ts b/src/interface.ts index a332ed20..88885024 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -24,8 +24,8 @@ export interface InternalFieldData extends Meta { /** * Used by `setFields` config */ -export interface FieldData extends Partial> { - name: NamePath; +export interface FieldData extends Partial> { + name: NamePath; } export type RuleType = @@ -137,6 +137,8 @@ export interface ValidateOptions { * e.g. [['a']] will validate ['a'] , ['a', 'b'] and ['a', 1]. */ recursive?: boolean; + /** Validate when a field is dirty (validated or touched) */ + dirty?: boolean; } export type ValidateFields = { @@ -223,7 +225,7 @@ export interface InternalHooks { registerField: (entity: FieldEntity) => () => void; useSubscribe: (subscribable: boolean) => void; setInitialValues: (values: Store, init: boolean) => void; - destroyForm: () => void; + destroyForm: (clearOnDestroy?: boolean) => void; setCallbacks: (callbacks: Callbacks) => void; registerWatch: (callback: WatchCallBack) => () => void; getFields: (namePathList?: InternalNamePath[]) => FieldData[]; @@ -233,15 +235,16 @@ export interface InternalHooks { } /** Only return partial when type is not any */ -type RecursivePartial = NonNullable extends object - ? { - [P in keyof T]?: NonNullable extends (infer U)[] - ? RecursivePartial[] - : NonNullable extends object - ? RecursivePartial - : T[P]; - } - : T; +type RecursivePartial = + NonNullable extends object + ? { + [P in keyof T]?: NonNullable extends (infer U)[] + ? RecursivePartial[] + : NonNullable extends object + ? RecursivePartial + : T[P]; + } + : T; export type FilterFunc = (meta: Meta) => boolean; @@ -249,21 +252,21 @@ export type GetFieldsValueConfig = { strict?: boolean; filter?: FilterFunc }; export interface FormInstance { // Origin Form API - getFieldValue: (name: NamePath) => StoreValue; + getFieldValue: (name: NamePath) => StoreValue; getFieldsValue: (() => Values) & - ((nameList: NamePath[] | true, filterFunc?: FilterFunc) => any) & + ((nameList: NamePath[] | true, filterFunc?: FilterFunc) => any) & ((config: GetFieldsValueConfig) => any); - getFieldError: (name: NamePath) => string[]; - getFieldsError: (nameList?: NamePath[]) => FieldError[]; - getFieldWarning: (name: NamePath) => string[]; - isFieldsTouched: ((nameList?: NamePath[], allFieldsTouched?: boolean) => boolean) & + getFieldError: (name: NamePath) => string[]; + getFieldsError: (nameList?: NamePath[]) => FieldError[]; + getFieldWarning: (name: NamePath) => string[]; + isFieldsTouched: ((nameList?: NamePath[], allFieldsTouched?: boolean) => boolean) & ((allFieldsTouched?: boolean) => boolean); - isFieldTouched: (name: NamePath) => boolean; - isFieldValidating: (name: NamePath) => boolean; - isFieldsValidating: (nameList?: NamePath[]) => boolean; - resetFields: (fields?: NamePath[]) => void; - setFields: (fields: FieldData[]) => void; - setFieldValue: (name: NamePath, value: any) => void; + isFieldTouched: (name: NamePath) => boolean; + isFieldValidating: (name: NamePath) => boolean; + isFieldsValidating: (nameList?: NamePath[]) => boolean; + resetFields: (fields?: NamePath[]) => void; + setFields: (fields: FieldData[]) => void; + setFieldValue: (name: NamePath, value: any) => void; setFieldsValue: (values: RecursivePartial) => void; validateFields: ValidateFields; @@ -271,6 +274,8 @@ export interface FormInstance { submit: () => void; } +export type FormRef = FormInstance & { nativeElement?: HTMLElement }; + export type InternalFormInstance = Omit & { validateFields: InternalValidateFields; diff --git a/src/namePathType.ts b/src/namePathType.ts index 8e54c6f1..38235915 100644 --- a/src/namePathType.ts +++ b/src/namePathType.ts @@ -6,27 +6,27 @@ type BaseNamePath = string | number | boolean | (string | number | boolean)[]; export type DeepNamePath< Store = any, ParentNamePath extends any[] = [], -> = ParentNamePath['length'] extends 10 +> = ParentNamePath['length'] extends 5 ? never : // Follow code is batch check if `Store` is base type - true extends (Store extends BaseNamePath ? true : false) - ? ParentNamePath['length'] extends 0 - ? Store | BaseNamePath // Return `BaseNamePath` instead of array if `ParentNamePath` is empty - : Store extends any[] - ? [...ParentNamePath, number] // Connect path - : never - : Store extends any[] // Check if `Store` is `any[]` - ? // Connect path. e.g. { a: { b: string }[] } - // Get: [a] | [ a,number] | [ a ,number , b] - [...ParentNamePath, number] | DeepNamePath - : keyof Store extends never // unknown - ? Store - : { - // Convert `Store` to . We mark key a `FieldKey` - [FieldKey in keyof Store]: Store[FieldKey] extends Function - ? never - : - | (ParentNamePath['length'] extends 0 ? FieldKey : never) // If `ParentNamePath` is empty, it can use `FieldKey` without array path - | [...ParentNamePath, FieldKey] // Exist `ParentNamePath`, connect it - | DeepNamePath[FieldKey], [...ParentNamePath, FieldKey]>; // If `Store[FieldKey]` is object - }[keyof Store]; + true extends (Store extends BaseNamePath ? true : false) + ? ParentNamePath['length'] extends 0 + ? Store | BaseNamePath // Return `BaseNamePath` instead of array if `ParentNamePath` is empty + : Store extends any[] + ? [...ParentNamePath, number] // Connect path + : never + : Store extends any[] // Check if `Store` is `any[]` + ? // Connect path. e.g. { a: { b: string }[] } + // Get: [a] | [ a,number] | [ a ,number , b] + [...ParentNamePath, number] | DeepNamePath + : keyof Store extends never // unknown + ? Store + : { + // Convert `Store` to . We mark key a `FieldKey` + [FieldKey in keyof Store]: Store[FieldKey] extends Function + ? never + : + | (ParentNamePath['length'] extends 0 ? FieldKey : never) // If `ParentNamePath` is empty, it can use `FieldKey` without array path + | [...ParentNamePath, FieldKey] // Exist `ParentNamePath`, connect it + | DeepNamePath[FieldKey], [...ParentNamePath, FieldKey]>; // If `Store[FieldKey]` is object + }[keyof Store]; diff --git a/src/useForm.ts b/src/useForm.ts index 7d25cff9..1be9f70d 100644 --- a/src/useForm.ts +++ b/src/useForm.ts @@ -156,15 +156,20 @@ export class FormStore { } }; - private destroyForm = () => { - const prevWithoutPreserves = new NameMap(); - this.getFieldEntities(true).forEach(entity => { - if (!this.isMergedPreserve(entity.isPreserve())) { - prevWithoutPreserves.set(entity.getNamePath(), true); - } - }); - - this.prevWithoutPreserves = prevWithoutPreserves; + private destroyForm = (clearOnDestroy?: boolean) => { + if (clearOnDestroy) { + // destroy form reset store + this.updateStore({}); + } else { + // Fill preserve fields + const prevWithoutPreserves = new NameMap(); + this.getFieldEntities(true).forEach(entity => { + if (!this.isMergedPreserve(entity.isPreserve())) { + prevWithoutPreserves.set(entity.getNamePath(), true); + } + }); + this.prevWithoutPreserves = prevWithoutPreserves; + } }; private getInitialValue = (namePath: InternalNamePath) => { @@ -395,7 +400,7 @@ export class FormStore { // ===== Will get fully compare when not config namePathList ===== if (!namePathList) { return isAllFieldsTouched - ? fieldEntities.every(isFieldTouched) + ? fieldEntities.every(entity => isFieldTouched(entity) || entity.isList()) : fieldEntities.some(isFieldTouched); } @@ -887,7 +892,7 @@ export class FormStore { const TMP_SPLIT = String(Date.now()); const validateNamePathList = new Set(); - const recursive = options?.recursive; + const { recursive, dirty } = options || {}; this.getFieldEntities(true).forEach((field: FieldEntity) => { // Add field if not provide `nameList` @@ -900,6 +905,11 @@ export class FormStore { return; } + // Skip if only validate dirty field + if (dirty && !field.isFieldDirty()) { + return; + } + const fieldNamePath = field.getNamePath(); validateNamePathList.add(fieldNamePath.join(TMP_SPLIT)); diff --git a/src/useWatch.ts b/src/useWatch.ts index e5144083..127c04d5 100644 --- a/src/useWatch.ts +++ b/src/useWatch.ts @@ -76,6 +76,18 @@ function useWatch( form?: TForm | WatchOptions, ): GetGeneric; +// ------- selector type ------- +function useWatch( + selector: (values: GetGeneric) => TSelected, + form?: TForm | WatchOptions, +): TSelected; + +function useWatch( + selector: (values: ValueType) => TSelected, + form?: FormInstance | WatchOptions, +): TSelected; +// ------- selector type end ------- + function useWatch( dependencies: NamePath, form?: TForm | WatchOptions, @@ -86,8 +98,10 @@ function useWatch( form?: FormInstance | WatchOptions, ): ValueType; -function useWatch(...args: [NamePath, FormInstance | WatchOptions]) { - const [dependencies = [], _form = {}] = args; +function useWatch( + ...args: [NamePath | ((values: Store) => any), FormInstance | WatchOptions] +) { + const [dependencies, _form = {}] = args; const options = isFormInstance(_form) ? { form: _form } : _form; const form = options.form; @@ -125,8 +139,15 @@ function useWatch(...args: [NamePath, FormInstance | WatchOptions] const { getFieldsValue, getInternalHooks } = formInstance; const { registerWatch } = getInternalHooks(HOOK_MARK); + const getWatchValue = (values: any, allValues: any) => { + const watchValue = options.preserve ? allValues : values; + return typeof dependencies === 'function' + ? dependencies(watchValue) + : getValue(watchValue, namePathRef.current); + }; + const cancelRegister = registerWatch((values, allValues) => { - const newValue = getValue(options.preserve ? allValues : values, namePathRef.current); + const newValue = getWatchValue(values, allValues); const nextValueStr = stringify(newValue); // Compare stringify in case it's nest object @@ -137,10 +158,7 @@ function useWatch(...args: [NamePath, FormInstance | WatchOptions] }); // TODO: We can improve this perf in future - const initialValue = getValue( - options.preserve ? getFieldsValue(true) : getFieldsValue(), - namePathRef.current, - ); + const initialValue = getWatchValue(getFieldsValue(), getFieldsValue(true)); // React 18 has the bug that will queue update twice even the value is not changed // ref: https://github.com/facebook/react/issues/27213 diff --git a/src/utils/validateUtil.ts b/src/utils/validateUtil.ts index 58f3adf5..da48b283 100644 --- a/src/utils/validateUtil.ts +++ b/src/utils/validateUtil.ts @@ -19,7 +19,10 @@ const AsyncValidator: any = RawAsyncValidator; * `I'm ${name}` + { name: 'bamboo' } = I'm bamboo */ function replaceMessage(template: string, kv: Record): string { - return template.replace(/\$\{\w+\}/g, (str: string) => { + return template.replace(/\\?\$\{\w+\}/g, (str: string) => { + if (str.startsWith('\\')) { + return str.slice(1); + } const key = str.slice(2, -1); return kv[key]; }); diff --git a/tests/clearOnDestroy.test.tsx b/tests/clearOnDestroy.test.tsx new file mode 100644 index 00000000..2f2cdb3c --- /dev/null +++ b/tests/clearOnDestroy.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render } from '@testing-library/react'; +import React, { useState } from 'react'; +import Form, { Field, type FormInstance } from '../src'; +import { changeValue, getInput } from './common'; +import { Input } from './common/InfoField'; + +describe('Form.clearOnDestroy', () => { + it('works', async () => { + let formCache: FormInstance | undefined; + const Demo = ({ load }: { load?: boolean }) => { + const [form] = Form.useForm(); + formCache = form; + + return ( + <> + {load && ( +
+ + + +
+ )} + + ); + }; + const { rerender } = render(); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + rerender(); + expect(formCache.getFieldsValue(true)).toEqual({}); + + // Rerender back should filled again + rerender(); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + }); + + it('change value', async () => { + let formCache: FormInstance | undefined; + const Demo = () => { + const [load, setLoad] = useState(true); + + const [form] = Form.useForm(); + formCache = form; + + return ( + <> + + {load && ( +
+ + + +
+ )} + + ); + }; + const { container, queryByText } = render(); + await changeValue(getInput(container), 'bamboo'); + expect(formCache.getFieldsValue(true)).toEqual({ count: 'bamboo' }); + fireEvent.click(queryByText('load')); + expect(formCache.getFieldsValue(true)).toEqual({}); + formCache.setFields([{ name: 'count', value: '1' }]); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + }); +}); diff --git a/tests/common/index.ts b/tests/common/index.ts index 36241a61..abac7dd5 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -1,7 +1,6 @@ -import { act } from 'react-dom/test-utils'; import timeout from './timeout'; import { matchNamePath } from '../../src/utils/valueUtil'; -import { fireEvent } from '@testing-library/react'; +import { fireEvent, act } from '@testing-library/react'; export function getInput( container: HTMLElement, diff --git a/tests/common/timeout.ts b/tests/common/timeout.ts index b3b7d911..1b2e52cb 100644 --- a/tests/common/timeout.ts +++ b/tests/common/timeout.ts @@ -1,5 +1,15 @@ +import { act } from '@testing-library/react'; + export default async (timeout: number = 10) => { return new Promise(resolve => { setTimeout(resolve, timeout); }); }; + +export async function waitFakeTime(timeout: number = 10) { + await act(async () => { + await Promise.resolve(); + jest.advanceTimersByTime(timeout); + await Promise.resolve(); + }); +} diff --git a/tests/dependencies.test.tsx b/tests/dependencies.test.tsx index 82d986a3..a06facd3 100644 --- a/tests/dependencies.test.tsx +++ b/tests/dependencies.test.tsx @@ -226,4 +226,43 @@ describe('Form.Dependencies', () => { // sync end expect(spy).toHaveBeenCalledTimes(3); }); + + it('shouldUpdate false should not update', () => { + let counter = 0; + const formRef = React.createRef(); + + const { container } = render( +
+ + + + + prev.little !== next.little}> + {(_, __, form) => { + // Fill to hide + if (!form.getFieldValue('little')) { + return ; + } + + return null; + }} + + + false}> + {() => { + console.log('render!'); + counter += 1; + return null; + }} + +
, + ); + expect(counter).toEqual(1); + expect(container.querySelectorAll('input')).toHaveLength(2); + + // hide should not re-render + fireEvent.change(getInput(container, 0), { target: { value: '1' } }); + expect(container.querySelectorAll('input')).toHaveLength(1); + expect(counter).toEqual(1); + }); }); diff --git a/tests/index.test.tsx b/tests/index.test.tsx index b1ca3833..7f7ef718 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -6,7 +6,7 @@ import Form, { Field, useForm } from '../src'; import { changeValue, getInput, matchError } from './common'; import InfoField, { Input } from './common/InfoField'; import timeout from './common/timeout'; -import type { Meta } from '@/interface'; +import type { FormRef, Meta } from '@/interface'; describe('Form.Basic', () => { describe('create form', () => { @@ -85,7 +85,7 @@ describe('Form.Basic', () => { }); it('fields touched', async () => { - const form = React.createRef(); + const form = React.createRef(); const { container } = render(
@@ -111,12 +111,15 @@ describe('Form.Basic', () => { expect(form.current?.isFieldsTouched(['username', 'password'])).toBeTruthy(); expect(form.current?.isFieldsTouched(true)).toBeTruthy(); expect(form.current?.isFieldsTouched(['username', 'password'], true)).toBeTruthy(); + + // nativeElementRef + expect(form.current?.nativeElement).toBeTruthy(); }); describe('reset form', () => { function resetTest(name: string, ...args) { it(name, async () => { - const form = React.createRef(); + const form = React.createRef(); const onReset = jest.fn(); const onMeta = jest.fn(); @@ -187,7 +190,7 @@ describe('Form.Basic', () => { resetTest('without field name'); it('not affect others', async () => { - const form = React.createRef(); + const form = React.createRef(); const { container } = render(
@@ -345,7 +348,7 @@ describe('Form.Basic', () => { it('getInternalHooks should not usable by user', () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const form = React.createRef(); + const form = React.createRef(); render(
@@ -362,7 +365,7 @@ describe('Form.Basic', () => { }); it('valuePropName', async () => { - const form = React.createRef(); + const form = React.createRef(); const { container } = render(
@@ -395,10 +398,42 @@ describe('Form.Basic', () => {
, ); - // expect((container.querySelector('.anything').props() as any).light).toEqual('bamboo'); expect(container.querySelector('.anything')).toHaveAttribute('data-light', 'bamboo'); }); + it('getValueProps should not throw if return empty', async () => { + const { container } = render( +
+ + null}> + + + +
, + ); + + expect(container.querySelector('.anything')).toBeTruthy(); + }); + + it('getValueProps should not be executed when name does not exist', async () => { + const getValueProps1 = jest.fn(); + const getValueProps2 = jest.fn(); + + render( +
+
+ + + + {() => } +
+
, + ); + + expect(getValueProps1).not.toHaveBeenCalled(); + expect(getValueProps2).not.toHaveBeenCalled(); + }); + describe('shouldUpdate', () => { it('work', async () => { let isAllTouched: boolean; @@ -477,7 +512,7 @@ describe('Form.Basic', () => { describe('setFields', () => { it('should work', () => { - const form = React.createRef(); + const form = React.createRef(); const { container } = render(
@@ -501,7 +536,7 @@ describe('Form.Basic', () => { it('should trigger by setField', () => { const triggerUpdate = jest.fn(); - const formRef = React.createRef(); + const formRef = React.createRef(); render(
@@ -562,7 +597,7 @@ describe('Form.Basic', () => { }); it('setFieldsValue should clean up status', async () => { - const form = React.createRef(); + const form = React.createRef(); let currentMeta: Meta = null; const { container } = render( @@ -656,7 +691,7 @@ describe('Form.Basic', () => { }); it('filtering fields by meta', async () => { - const form = React.createRef(); + const form = React.createRef(); const { container } = render(
@@ -823,7 +858,7 @@ describe('Form.Basic', () => { }); it('setFieldValue', () => { - const formRef = React.createRef(); + const formRef = React.createRef(); const Demo: React.FC = () => ( @@ -860,7 +895,7 @@ describe('Form.Basic', () => { it('onMetaChange should only trigger when meta changed', () => { const onMetaChange = jest.fn(); - const formRef = React.createRef(); + const formRef = React.createRef(); const Demo: React.FC = () => ( @@ -886,7 +921,7 @@ describe('Form.Basic', () => { describe('set to null value', () => { function test(name: string, callback: (form: FormInstance) => void) { it(name, async () => { - const form = React.createRef(); + const form = React.createRef(); const { container } = render(
@@ -916,4 +951,49 @@ describe('Form.Basic', () => { form.setFieldValue('user', null); }); }); + + it('setFieldValue should always set touched', async () => { + const EMPTY_VALUES = { light: '', bamboo: [] }; + const formRef = React.createRef(); + + const Demo: React.FC = () => ( + + + + + + + + + ); + + render(); + + await act(async () => { + await formRef.current?.validateFields().catch(() => {}); + }); + expect(formRef.current?.isFieldTouched('light')).toBeFalsy(); + expect(formRef.current?.isFieldTouched('bamboo')).toBeFalsy(); + expect(formRef.current?.getFieldError('light')).toHaveLength(1); + expect(formRef.current?.getFieldError('bamboo')).toHaveLength(1); + + act(() => { + formRef.current?.setFieldsValue(EMPTY_VALUES); + }); + expect(formRef.current?.isFieldTouched('light')).toBeFalsy(); + expect(formRef.current?.isFieldTouched('bamboo')).toBeFalsy(); + expect(formRef.current?.getFieldError('light')).toHaveLength(1); + expect(formRef.current?.getFieldError('bamboo')).toHaveLength(1); + + act(() => { + formRef.current?.setFieldsValue({ + light: 'Bamboo', + bamboo: ['Light'], + }); + }); + expect(formRef.current?.isFieldTouched('light')).toBeTruthy(); + expect(formRef.current?.isFieldTouched('bamboo')).toBeTruthy(); + expect(formRef.current?.getFieldError('light')).toHaveLength(0); + expect(formRef.current?.getFieldError('bamboo')).toHaveLength(0); + }); }); diff --git a/tests/list.test.tsx b/tests/list.test.tsx index 492bdf47..8662a8bb 100644 --- a/tests/list.test.tsx +++ b/tests/list.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, act } from '@testing-library/react'; import { resetWarned } from 'rc-util/lib/warning'; import Form, { Field, List } from '../src'; import type { FormProps } from '../src'; @@ -845,4 +844,52 @@ describe('Form.List', () => { little: 9, }); }); + + it('isFieldsTouched with params true', async () => { + const formRef = React.createRef(); + + const { container } = render( +
+ + + + + {(fields, { add }) => ( + <> + {fields.map(field => { + return ( + + + + + + + + + ); + })} + + + )} + +
, + ); + + expect(formRef.current.isFieldsTouched(true)).toBeFalsy(); + + await changeValue(getInput(container, 0), 'changed1'); + expect(formRef.current.isFieldsTouched(true)).toBeFalsy(); + + await changeValue(getInput(container, 1), 'changed2'); + expect(formRef.current.isFieldsTouched(true)).toBeFalsy(); + + await changeValue(getInput(container, 2), 'changed3'); + expect(formRef.current.isFieldsTouched(true)).toBeTruthy(); + }); }); diff --git a/tests/useWatch.test.tsx b/tests/useWatch.test.tsx index 2c035975..0c08ab19 100644 --- a/tests/useWatch.test.tsx +++ b/tests/useWatch.test.tsx @@ -1,10 +1,9 @@ import React, { useRef, useState } from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, act } from '@testing-library/react'; import type { FormInstance } from '../src'; import { List } from '../src'; import Form, { Field } from '../src'; import timeout from './common/timeout'; -import { act } from 'react-dom/test-utils'; import { Input } from './common/InfoField'; import { stringify } from '../src/useWatch'; import { changeValue } from './common'; @@ -288,8 +287,29 @@ describe('useWatch', () => { const demo5 = Form.useWatch(['demo1', 'demo2', 'demo3', 'demo4', 'demo5'], form); const more = Form.useWatch(['age', 'name', 'gender'], form); const demo = Form.useWatch(['demo']); + + const values2 = Form.useWatch(values => ({ newName: values.name, newAge: values.age }), form); + const values3 = Form.useWatch(values => ({ + newName: values.name, + })); + return ( - <>{JSON.stringify({ values, main, age, demo1, demo2, demo3, demo4, demo5, more, demo })} + <> + {JSON.stringify({ + values, + main, + age, + demo1, + demo2, + demo3, + demo4, + demo5, + more, + demo, + values2, + values3, + })} + ); }; @@ -457,4 +477,29 @@ describe('useWatch', () => { logSpy.mockRestore(); }); + it('selector', async () => { + const Demo: React.FC = () => { + const [form] = Form.useForm<{ name?: string }>(); + const nameValue = Form.useWatch(values => values.name, form); + return ( +
+
+ + + +
+
{nameValue}
+
+ ); + }; + + const { container } = render(); + await act(async () => { + await timeout(); + }); + expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); + const input = container.querySelectorAll('input'); + await changeValue(input[0], 'bamboo2'); + expect(container.querySelector('.values')?.textContent).toEqual('bamboo2'); + }); }); diff --git a/tests/validate.test.tsx b/tests/validate.test.tsx index 681df28b..2a9d9227 100644 --- a/tests/validate.test.tsx +++ b/tests/validate.test.tsx @@ -1,22 +1,17 @@ -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, act } from '@testing-library/react'; import React, { useEffect } from 'react'; -import { act } from 'react-dom/test-utils'; import Form, { Field, useForm } from '../src'; import type { FormInstance, ValidateMessages } from '../src/interface'; import { changeValue, getInput, matchError } from './common'; import InfoField, { Input } from './common/InfoField'; -import timeout from './common/timeout'; +import timeout, { waitFakeTime } from './common/timeout'; describe('Form.Validate', () => { it('required', async () => { - let form; + const form = React.createRef(); const { container } = render(
-
{ - form = instance; - }} - > +
, @@ -24,8 +19,8 @@ describe('Form.Validate', () => { await changeValue(getInput(container), ['bamboo', '']); matchError(container, true); - expect(form.getFieldError('username')).toEqual(["'username' is required"]); - expect(form.getFieldsError()).toEqual([ + expect(form.current?.getFieldError('username')).toEqual(["'username' is required"]); + expect(form.current?.getFieldsError()).toEqual([ { name: ['username'], errors: ["'username' is required"], @@ -34,7 +29,7 @@ describe('Form.Validate', () => { ]); // Contains not exists - expect(form.getFieldsError(['username', 'not-exist'])).toEqual([ + expect(form.current?.getFieldsError(['username', 'not-exist'])).toEqual([ { name: ['username'], errors: ["'username' is required"], @@ -1033,4 +1028,81 @@ describe('Form.Validate', () => { jest.useRealTimers(); }); + + it('dirty', async () => { + jest.useFakeTimers(); + + const formRef = React.createRef(); + + const Demo = ({ touchMessage, validateMessage }) => ( +
+ + + + + + + + + +
+ ); + + const { container, rerender } = render( + , + ); + + fireEvent.change(container.querySelectorAll('input')[0], { + target: { + value: 'light', + }, + }); + fireEvent.change(container.querySelectorAll('input')[0], { + target: { + value: '', + }, + }); + + formRef.current.validateFields(['validate']); + + await waitFakeTime(); + matchError(container.querySelectorAll('.field')[0], `touch`); + matchError(container.querySelectorAll('.field')[1], `validate`); + matchError(container.querySelectorAll('.field')[2], false); + + // Revalidate + rerender(); + formRef.current.validateFields({ dirty: true }); + + await waitFakeTime(); + matchError(container.querySelectorAll('.field')[0], `new_touch`); + matchError(container.querySelectorAll('.field')[1], `new_validate`); + matchError(container.querySelectorAll('.field')[2], false); + + jest.useRealTimers(); + }); + + it('should handle escaped and unescaped variables correctly', async () => { + const { container } = render( +
+ Promise.reject(new Error('\\${name} should be ${name}!')), + }, + ]} + > + + +
, + ); + + // Wrong value + await changeValue(getInput(container), 'light'); + matchError(container, '${name} should be bamboo!'); + }); });