diff --git a/.dumirc.ts b/.dumirc.ts new file mode 100644 index 00000000..d94ccdc4 --- /dev/null +++ b/.dumirc.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'dumi'; +import path from 'path'; + +const isProdSite = + // 不是预览模式 同时是生产环境 + process.env.PREVIEW !== 'true' && process.env.NODE_ENV === 'production'; + +const name = 'field-form'; + +export default defineConfig({ + alias: { + 'rc-field-form$': path.resolve('src'), + 'rc-field-form/es': path.resolve('src'), + }, + mfsu: false, + favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], + themeConfig: { + name: 'FieldForm', + logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', + }, + base: isProdSite ? `/${name}/` : '/', + publicPath: isProdSite ? `/${name}/` : '/', +}); \ No newline at end of file diff --git a/.fatherrc.ts b/.fatherrc.ts index d02c60aa..96268ae1 100644 --- a/.fatherrc.ts +++ b/.fatherrc.ts @@ -1,9 +1,5 @@ -export default { - cjs: 'babel', - esm: { type: 'babel', importLibToEs: true }, - runtimeHelpers: true, - preCommit: { - eslint: true, - prettier: true, - }, -}; +import { defineConfig } from 'father'; + +export default defineConfig({ + plugins: ['@rc-component/father-plugin'], +}); diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3571ed27..85650de4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-node@v1 with: - node-version: '12' + node-version: '16' - name: cache package-lock.json uses: actions/cache@v2 @@ -41,7 +41,7 @@ jobs: - name: install if: steps.node_modules_cache_id.outputs.cache-hit != 'true' - run: npm ci + run: npm i lint: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 38e890c0..58f701c7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,11 @@ .DS_Store .vscode .idea +~* # umi .umi .umi-production .umi-test .env.local +.dumi/ diff --git a/.umirc.ts b/.umirc.ts deleted file mode 100644 index 88ff7af0..00000000 --- a/.umirc.ts +++ /dev/null @@ -1,19 +0,0 @@ -// more config: https://d.umijs.org/config -import { defineConfig } from 'dumi'; - -export default defineConfig({ - title: 'rc-field-form', - favicon: - 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', - logo: - 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', - outputPath: '.doc', - exportStatic: {}, - styles: [ - ` - .markdown table { - width: auto !important; - } - `, - ] -}); diff --git a/docs/demo/basic.md b/docs/demo/basic.md index ad6f032c..04e2e0e8 100644 --- a/docs/demo/basic.md +++ b/docs/demo/basic.md @@ -1,4 +1,4 @@ ## basic - + diff --git a/docs/demo/component.md b/docs/demo/component.md index 144e3d79..5b085d7a 100644 --- a/docs/demo/component.md +++ b/docs/demo/component.md @@ -1,3 +1,3 @@ ## component - + diff --git a/docs/demo/context.md b/docs/demo/context.md index 91ad6f99..c18805bf 100644 --- a/docs/demo/context.md +++ b/docs/demo/context.md @@ -1,3 +1,3 @@ ## context - + diff --git a/docs/demo/deps.md b/docs/demo/deps.md index 8bce3c21..2233a9fa 100644 --- a/docs/demo/deps.md +++ b/docs/demo/deps.md @@ -1,4 +1,4 @@ ## deps - + diff --git a/docs/demo/initialValues.md b/docs/demo/initialValues.md index 575c4ca9..671db850 100644 --- a/docs/demo/initialValues.md +++ b/docs/demo/initialValues.md @@ -1,4 +1,4 @@ ## initialValues - + diff --git a/docs/demo/layout.md b/docs/demo/layout.md index 3ea2171a..447af4d9 100644 --- a/docs/demo/layout.md +++ b/docs/demo/layout.md @@ -1,3 +1,3 @@ ## layout - + diff --git a/docs/demo/list.md b/docs/demo/list.md index c2bbd28c..4d7b093f 100644 --- a/docs/demo/list.md +++ b/docs/demo/list.md @@ -1,3 +1,3 @@ ## list - + diff --git a/docs/demo/preserve.md b/docs/demo/preserve.md index 36d3c2c3..655b30c1 100644 --- a/docs/demo/preserve.md +++ b/docs/demo/preserve.md @@ -1,4 +1,4 @@ ## preserve - + diff --git a/docs/demo/redux.md b/docs/demo/redux.md index ff6243b1..b7cc731f 100644 --- a/docs/demo/redux.md +++ b/docs/demo/redux.md @@ -1,3 +1,3 @@ ## redux - + diff --git a/docs/demo/renderProps.md b/docs/demo/renderProps.md index a7955e94..497cb2e9 100644 --- a/docs/demo/renderProps.md +++ b/docs/demo/renderProps.md @@ -1,3 +1,3 @@ ## renderProps - + diff --git a/docs/demo/reset.md b/docs/demo/reset.md index 90bbf4d7..c95458fb 100644 --- a/docs/demo/reset.md +++ b/docs/demo/reset.md @@ -1,3 +1,3 @@ ## reset - + diff --git a/docs/demo/stateForm-list-draggable.md b/docs/demo/stateForm-list-draggable.md index e376d85e..73402537 100644 --- a/docs/demo/stateForm-list-draggable.md +++ b/docs/demo/stateForm-list-draggable.md @@ -1,3 +1,3 @@ ## stateForm-list-draggable - + diff --git a/docs/demo/useForm.md b/docs/demo/useForm.md index 757af1e7..d3a61cfc 100644 --- a/docs/demo/useForm.md +++ b/docs/demo/useForm.md @@ -1,3 +1,3 @@ ## useForm - + diff --git a/docs/demo/useWatch-list.md b/docs/demo/useWatch-list.md index 1a14f737..b118b10a 100644 --- a/docs/demo/useWatch-list.md +++ b/docs/demo/useWatch-list.md @@ -1,3 +1,3 @@ ## useWatch-list - + diff --git a/docs/demo/useWatch.md b/docs/demo/useWatch.md index 04e70536..96b3ab1d 100644 --- a/docs/demo/useWatch.md +++ b/docs/demo/useWatch.md @@ -1,3 +1,3 @@ ## useWatch - + diff --git a/docs/demo/validate-perf.md b/docs/demo/validate-perf.md index c99a0ab1..fcec6883 100644 --- a/docs/demo/validate-perf.md +++ b/docs/demo/validate-perf.md @@ -1,3 +1,3 @@ ## validate-perf - + diff --git a/docs/demo/validate.md b/docs/demo/validate.md index ab665b6c..4bab53f2 100644 --- a/docs/demo/validate.md +++ b/docs/demo/validate.md @@ -1,3 +1,3 @@ ## validate - + diff --git a/docs/demo/validateOnly.md b/docs/demo/validateOnly.md new file mode 100644 index 00000000..4b1aaf29 --- /dev/null +++ b/docs/demo/validateOnly.md @@ -0,0 +1,3 @@ +## validateOnly + + diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx index 6e170fbf..48a46fc1 100644 --- a/docs/examples/basic.tsx +++ b/docs/examples/basic.tsx @@ -2,16 +2,28 @@ import Form, { Field } from 'rc-field-form'; import React from 'react'; import Input from './components/Input'; +type FormData = { + name?: string; + password?: string; + password2?: string; +}; + export default () => { const [form] = Form.useForm(); return ( -
- + { + console.error('fields:', fields); + }} + > + name="name"> - + dependencies={['name']}> {() => { return form.getFieldValue('name') === '1' ? ( @@ -26,12 +38,14 @@ export default () => { const password = form.getFieldValue('password'); console.log('>>>', password); return password ? ( - + name={['password2']}> ) : null; }} + + ); }; diff --git a/docs/examples/components/LabelField.tsx b/docs/examples/components/LabelField.tsx index 2608f2bd..1169d465 100644 --- a/docs/examples/components/LabelField.tsx +++ b/docs/examples/components/LabelField.tsx @@ -9,7 +9,7 @@ interface ErrorProps { children?: React.ReactNode[]; } -const Error = ({ children, warning }: ErrorProps) => ( +const Error: React.FC = ({ children, warning }) => (
    {children.map((error: React.ReactNode, index: number) => (
  • {error}
  • diff --git a/docs/examples/components/useDraggable.ts b/docs/examples/components/useDraggable.ts index 020bdec4..0dde2ffb 100644 --- a/docs/examples/components/useDraggable.ts +++ b/docs/examples/components/useDraggable.ts @@ -1,5 +1,6 @@ import { useRef } from 'react'; -import { DragObjectWithType, useDrag, useDrop } from 'react-dnd'; +import type { DragObjectWithType } from 'react-dnd'; +import { useDrag, useDrop } from 'react-dnd'; type DragWithIndex = DragObjectWithType & { index: number; diff --git a/docs/examples/renderProps.tsx b/docs/examples/renderProps.tsx index 13f6c399..4aa8dbcd 100644 --- a/docs/examples/renderProps.tsx +++ b/docs/examples/renderProps.tsx @@ -1,4 +1,3 @@ -/* eslint-disable jsx-a11y/label-has-associated-control */ import React from 'react'; import Form from 'rc-field-form'; import Input from './components/Input'; @@ -45,7 +44,6 @@ export default class Demo extends React.Component { )} - {list.map((_, index) => ( diff --git a/docs/examples/useWatch.tsx b/docs/examples/useWatch.tsx index 3a3d3a88..1475b789 100644 --- a/docs/examples/useWatch.tsx +++ b/docs/examples/useWatch.tsx @@ -13,6 +13,7 @@ type FieldType = { demo2?: string; id?: number; demo1?: { demo2?: { demo3?: { demo4?: string } } }; + hidden?: string; }; const Demo = React.memo(() => { @@ -48,12 +49,13 @@ export default () => { const demo4 = Form.useWatch(['demo1', 'demo2', 'demo3', 'demo4'], form); const demo5 = Form.useWatch(['demo1', 'demo2', 'demo3', 'demo4', 'demo5'], form); const more = Form.useWatch(['age', 'name', 'gender'], form); - console.log('main watch', values, demo1, demo2, main, age, demo3, demo4, demo5, more); + const hidden = Form.useWatch(['hidden'], { form, preserve: true }); + console.log('main watch', values, demo1, demo2, main, age, demo3, demo4, demo5, more, hidden); return ( <>
    console.log('submit values', v)} > no render @@ -115,6 +117,7 @@ export default () => { + ); }; diff --git a/docs/examples/validate.tsx b/docs/examples/validate.tsx index 059ebf1b..3a3b29f9 100644 --- a/docs/examples/validate.tsx +++ b/docs/examples/validate.tsx @@ -6,7 +6,11 @@ import Input from './components/Input'; const { Field } = Form; -const Error = ({ children }) => ( +interface ErrorProps { + children?: React.ReactNode[]; +} + +const Error: React.FC = ({ children }) => (
      {children.map((error, i) => (
    • {error}
    • @@ -19,14 +23,7 @@ const FieldState = ({ form, name }) => { const validating = form.isFieldValidating(name); return ( -
      +
      {touched ? Touched! : null} {validating ? Validating! : null}
      diff --git a/docs/examples/validateOnly.tsx b/docs/examples/validateOnly.tsx new file mode 100644 index 00000000..df561830 --- /dev/null +++ b/docs/examples/validateOnly.tsx @@ -0,0 +1,76 @@ +/* eslint-disable react/prop-types, @typescript-eslint/consistent-type-imports */ + +import React from 'react'; +import Form from 'rc-field-form'; +import type { FormInstance } from 'rc-field-form'; +import Input from './components/Input'; +import LabelField from './components/LabelField'; + +function useSubmittable(form: FormInstance) { + const [submittable, setSubmittable] = React.useState(false); + const store = Form.useWatch([], form); + + React.useEffect(() => { + form + .validateFields({ + validateOnly: true, + }) + .then( + () => { + setSubmittable(true); + }, + () => { + setSubmittable(false); + }, + ); + }, [store]); + + return submittable; +} + +export default () => { + const [form] = Form.useForm(); + + const canSubmit = useSubmittable(form); + + const onValidateOnly = async () => { + const result = await form.validateFields({ + validateOnly: true, + }); + console.log('Validate:', result); + }; + + return ( + <> + + Promise.reject('Warn Name!') }, + ]} + > + + + Promise.reject('Warn Age!') }, + ]} + > + + + + + + + + ); +}; diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 865b283d..00000000 --- a/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - setupFilesAfterEnv: ['/tests/setupAfterEnv.ts'] -}; \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 00000000..84f2eee7 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,3 @@ +export default { + setupFilesAfterEnv: ['/tests/setupAfterEnv.ts'], +}; diff --git a/now.json b/now.json index a0d5ebdf..17cb30ca 100644 --- a/now.json +++ b/now.json @@ -5,7 +5,7 @@ { "src": "package.json", "use": "@now/static-build", - "config": { "distDir": ".doc" } + "config": { "distDir": "dist" } } ], "routes": [ diff --git a/package.json b/package.json index cdc014af..b57a484c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-field-form", - "version": "1.27.0", + "version": "1.40.0", "description": "React Form Component", "typings": "es/index.d.ts", "engines": { @@ -34,10 +34,10 @@ "start": "dumi dev", "docs:build": "dumi build", "docs:deploy": "gh-pages -d docs-dist", - "compile": "father-build", + "compile": "father build", "deploy": "npm run docs:build && npm run docs:deploy", "prettier": "prettier --write \"**/*.{js,jsx,tsx,ts,less,md,json}\"", - "test": "father test", + "test": "rc-test", "test:coverage": "umi-test --coverage", "prepublishOnly": "npm run compile && np --no-cleanup --yolo --no-publish", "lint": "eslint src/ --ext .tsx,.ts", @@ -51,34 +51,34 @@ "dependencies": { "@babel/runtime": "^7.18.0", "async-validator": "^4.1.0", - "rc-util": "^5.8.0" + "rc-util": "^5.32.2" }, "devDependencies": { - "@testing-library/jest-dom": "^5.16.4", - "@testing-library/react": "^13.0.0", - "@types/enzyme": "^3.10.5", - "@types/jest": "^26.0.20", + "@rc-component/father-plugin": "^1.0.0", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^14.0.0", + "@types/jest": "^29.2.5", "@types/lodash": "^4.14.135", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@umijs/fabric": "^2.5.2", - "@umijs/test": "^3.2.27", - "dumi": "^1.1.0", - "enzyme": "^3.1.0", - "enzyme-adapter-react-16": "^1.0.2", - "enzyme-to-json": "^3.1.4", - "eslint": "^7.18.0", - "father": "^2.13.6", - "father-build": "^1.18.6", - "gh-pages": "^3.1.0", + "dumi": "^2.0.0", + "eslint": "^8.54.0", + "eslint-plugin-jest": "^27.6.0", + "eslint-plugin-unicorn": "^49.0.0", + "father": "^4.0.0", + "gh-pages": "^5.0.0", + "jest": "^29.0.0", "np": "^5.0.3", - "prettier": "^2.1.2", - "react": "^16.14.0", - "react-dnd": "^8.0.3", "react-dnd-html5-backend": "^16.0.1", - "react-dom": "^16.14.0", - "react-redux": "^4.4.10", - "redux": "^3.7.2", - "typescript": "^4.6.3" + "prettier": "^3.1.0", + "rc-test": "^7.0.15", + "react": "^18.0.0", + "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", + "typescript": "^5.1.6" } } diff --git a/src/Field.tsx b/src/Field.tsx index b848c738..6b01ef14 100644 --- a/src/Field.tsx +++ b/src/Field.tsx @@ -1,23 +1,25 @@ import toChildrenArray from 'rc-util/lib/Children/toArray'; +import isEqual from 'rc-util/lib/isEqual'; import warning from 'rc-util/lib/warning'; import * as React from 'react'; +import FieldContext, { HOOK_MARK } from './FieldContext'; import type { + EventArgs, FieldEntity, FormInstance, + InternalFormInstance, InternalNamePath, + InternalValidateOptions, Meta, NamePath, NotifyInfo, Rule, - Store, - ValidateOptions, - InternalFormInstance, + RuleError, RuleObject, + Store, StoreValue, - EventArgs, - RuleError, } from './interface'; -import FieldContext, { HOOK_MARK } from './FieldContext'; +import ListContext from './ListContext'; import { toArray } from './utils/typeUtil'; import { validateRules } from './utils/validateUtil'; import { @@ -53,6 +55,8 @@ interface ChildProps { [name: string]: any; } +export type MetaEvent = Meta & { destroy?: boolean }; + export interface InternalFieldProps { children?: | React.ReactElement @@ -70,13 +74,17 @@ export interface InternalFieldProps { shouldUpdate?: ShouldUpdate; trigger?: string; validateTrigger?: string | string[] | false; + /** + * Trigger will after configured milliseconds. + */ + validateDebounce?: number; validateFirst?: boolean | 'parallel'; valuePropName?: string; getValueProps?: (value: StoreValue) => Record; messageVariables?: Record; initialValue?: any; onReset?: () => void; - onMetaChange?: (meta: Meta & { destroy?: boolean }) => void; + onMetaChange?: (meta: MetaEvent) => void; preserve?: boolean; /** @private Passed by Form.List props. Do not use since it will break by path check. */ @@ -92,7 +100,7 @@ export interface InternalFieldProps { export interface FieldProps extends Omit, 'name' | 'fieldContext'> { - name?: NamePath; + name?: NamePath; } export interface FieldState { @@ -133,7 +141,7 @@ class Field extends React.Component implements F */ private dirty: boolean = false; - private validatePromise: Promise | null = null; + private validatePromise: Promise | null; private prevValidating: boolean; @@ -220,10 +228,23 @@ class Field extends React.Component implements F })); }; + // Event should only trigger when meta changed + private metaCache: MetaEvent = null; + public triggerMetaEvent = (destroy?: boolean) => { const { onMetaChange } = this.props; - onMetaChange?.({ ...this.getMeta(), destroy }); + if (onMetaChange) { + const meta = { ...this.getMeta(), destroy }; + + if (!isEqual(this.metaCache, meta)) { + onMetaChange(meta); + } + + this.metaCache = meta; + } else { + this.metaCache = null; + } }; // ========================= Field Entity Interfaces ========================= @@ -253,7 +274,7 @@ class Field extends React.Component implements F // Clean up state this.touched = false; this.dirty = false; - this.validatePromise = null; + this.validatePromise = undefined; this.errors = EMPTY_ERRORS; this.warnings = EMPTY_ERRORS; this.triggerMetaEvent(); @@ -280,9 +301,8 @@ class Field extends React.Component implements F } case 'setField': { + const { data } = info; if (namePathMatch) { - const { data } = info; - if ('touched' in data) { this.touched = data.touched; } @@ -299,6 +319,10 @@ class Field extends React.Component implements F this.triggerMetaEvent(); + this.reRender(); + return; + } else if ('value' in data && containsNamePath(namePathList, namePath, true)) { + // Contains path with value should also check this.reRender(); return; } @@ -357,30 +381,46 @@ class Field extends React.Component implements F } }; - public validateRules = (options?: ValidateOptions): Promise => { + public validateRules = (options?: InternalValidateOptions): Promise => { // We should fixed namePath & value to avoid developer change then by form function const namePath = this.getNamePath(); const currentValue = this.getValue(); + const { triggerName, validateOnly = false } = options || {}; + // Force change to async to avoid rule OOD under renderProps field - const rootPromise = Promise.resolve().then(() => { + const rootPromise = Promise.resolve().then(async (): Promise => { if (!this.mounted) { return []; } - const { validateFirst = false, messageVariables } = this.props; - const { triggerName } = (options || {}) as ValidateOptions; + const { validateFirst = false, messageVariables, validateDebounce } = this.props; + // Start validate let filteredRules = this.getRules(); if (triggerName) { - filteredRules = filteredRules.filter((rule: RuleObject) => { - const { validateTrigger } = rule; - if (!validateTrigger) { - return true; - } - const triggerList = toArray(validateTrigger); - return triggerList.includes(triggerName); + filteredRules = filteredRules + .filter(rule => rule) + .filter((rule: RuleObject) => { + const { validateTrigger } = rule; + if (!validateTrigger) { + return true; + } + const triggerList = toArray(validateTrigger); + return triggerList.includes(triggerName); + }); + } + + // Wait for debounce. Skip if no `triggerName` since its from `validateFields / submit` + if (validateDebounce && triggerName) { + await new Promise(resolve => { + setTimeout(resolve, validateDebounce); }); + + // Skip since out of date + if (this.validatePromise !== rootPromise) { + return []; + } } const promise = validateRules( @@ -420,6 +460,10 @@ class Field extends React.Component implements F return promise; }); + if (validateOnly) { + return rootPromise; + } + this.validatePromise = rootPromise; this.dirty = true; this.errors = EMPTY_ERRORS; @@ -473,6 +517,7 @@ class Field extends React.Component implements F errors: this.errors, warnings: this.warnings, name: this.getNamePath(), + validated: this.validatePromise === null, }; return meta; @@ -622,7 +667,7 @@ class Field extends React.Component implements F function WrapperField({ name, ...restProps }: FieldProps) { const fieldContext = React.useContext(FieldContext); - + const listContext = React.useContext(ListContext); const namePath = name !== undefined ? getNamePath(name) : undefined; let key: string = 'keep'; @@ -641,7 +686,15 @@ function WrapperField({ name, ...restProps }: FieldProps) warning(false, '`preserve` should not apply on Form.List fields.'); } - return ; + return ( + + ); } export default WrapperField; diff --git a/src/Form.tsx b/src/Form.tsx index de30b6f9..370185cb 100644 --- a/src/Form.tsx +++ b/src/Form.tsx @@ -12,6 +12,7 @@ import FieldContext, { HOOK_MARK } from './FieldContext'; import type { FormContextProps } from './FormContext'; import FormContext from './FormContext'; import { isSimilar } from './utils/valueUtil'; +import ListContext from './ListContext'; type BaseFormProps = Omit, 'onSubmit' | 'children'>; @@ -147,7 +148,9 @@ const Form: React.ForwardRefRenderFunction = ( ); const wrapperNode = ( - {childrenNode} + + {childrenNode} + ); if (Component === false) { diff --git a/src/List.tsx b/src/List.tsx index 7a2ae9a5..99548bfa 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -19,8 +19,8 @@ export interface ListOperations { move: (from: number, to: number) => void; } -export interface ListProps { - name: NamePath; +export interface ListProps { + name: NamePath; rules?: ValidatorRule[]; validateTrigger?: string | string[] | false; initialValue?: any[]; @@ -29,16 +29,21 @@ export interface ListProps { operations: ListOperations, meta: Meta, ) => JSX.Element | React.ReactNode; + + /** @private Passed by Form.List props. Do not use since it will break by path check. */ + isListField?: boolean; } -const List: React.FunctionComponent = ({ +function List({ name, initialValue, children, rules, validateTrigger, -}) => { + isListField, +}: ListProps) { const context = React.useContext(FieldContext); + const wrapperListContext = React.useContext(ListContext); const keyRef = React.useRef({ keys: [], id: 0, @@ -87,6 +92,7 @@ const List: React.FunctionComponent = ({ validateTrigger={validateTrigger} initialValue={initialValue} isList + isListField={isListField ?? !!wrapperListContext} > {({ value = [], onChange }, meta) => { const { getFieldValue } = context; @@ -191,6 +197,6 @@ const List: React.FunctionComponent = ({ ); -}; +} export default List; diff --git a/src/ListContext.ts b/src/ListContext.ts index cb5d0791..0c272cf5 100644 --- a/src/ListContext.ts +++ b/src/ListContext.ts @@ -2,7 +2,7 @@ import * as React from 'react'; import type { InternalNamePath } from './interface'; export interface ListContextProps { - getKey: (namePath: InternalNamePath) => [React.Key, InternalNamePath]; + getKey: (namePath: InternalNamePath) => [InternalNamePath[number], InternalNamePath]; } const ListContext = React.createContext(null); diff --git a/src/index.tsx b/src/index.tsx index a726fb8f..3c6c6e5d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -31,8 +31,8 @@ RefForm.List = List; RefForm.useForm = useForm; RefForm.useWatch = useWatch; -export { FormInstance, Field, List, useForm, FormProvider, FieldContext, ListContext, useWatch }; +export { Field, List, useForm, FormProvider, FieldContext, ListContext, useWatch }; -export type { FormProps }; +export type { FormProps, FormInstance }; export default RefForm; diff --git a/src/interface.ts b/src/interface.ts index c01f0839..723998a6 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,8 +1,9 @@ import type { ReactElement } from 'react'; +import type { DeepNamePath } from './namePathType'; import type { ReducerAction } from './useForm'; export type InternalNamePath = (string | number)[]; -export type NamePath = string | number | InternalNamePath; +export type NamePath = DeepNamePath; export type StoreValue = any; export type Store = Record; @@ -13,6 +14,7 @@ export interface Meta { errors: string[]; warnings: string[]; name: InternalNamePath; + validated: boolean; } export interface InternalFieldData extends Meta { @@ -101,7 +103,7 @@ export interface FieldEntity { isListField: () => boolean; isList: () => boolean; isPreserve: () => boolean; - validateRules: (options?: ValidateOptions) => Promise; + validateRules: (options?: InternalValidateOptions) => Promise; getMeta: () => Meta; getNamePath: () => InternalNamePath; getErrors: () => string[]; @@ -126,20 +128,33 @@ export interface RuleError { } export interface ValidateOptions { - triggerName?: string; - validateMessages?: ValidateMessages; + /** + * Validate only and not trigger UI and Field status update + */ + validateOnly?: boolean; /** * Recursive validate. It will validate all the name path that contains the provided one. - * e.g. ['a'] will validate ['a'] , ['a', 'b'] and ['a', 1]. + * 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 InternalValidateFields = ( - nameList?: NamePath[], - options?: ValidateOptions, -) => Promise; -export type ValidateFields = (nameList?: NamePath[]) => Promise; +export type ValidateFields = { + (opt?: ValidateOptions): Promise; + (nameList?: NamePath[], opt?: ValidateOptions): Promise; +}; + +export interface InternalValidateOptions extends ValidateOptions { + triggerName?: string; + validateMessages?: ValidateMessages; +} + +export type InternalValidateFields = { + (options?: InternalValidateOptions): Promise; + (nameList?: NamePath[], options?: InternalValidateOptions): Promise; +}; // >>>>>> Info interface ValueUpdateInfo { @@ -193,7 +208,16 @@ export interface Callbacks { onFinishFailed?: (errorInfo: ValidateErrorEntity) => void; } -export type WatchCallBack = (values: Store, namePathList: InternalNamePath[]) => void; +export type WatchCallBack = ( + values: Store, + allValues: Store, + namePathList: InternalNamePath[], +) => void; + +export interface WatchOptions
      { + form?: Form; + preserve?: boolean; +} export interface InternalHooks { dispatch: (action: ReducerAction) => void; @@ -211,21 +235,26 @@ export interface InternalHooks { } /** Only return partial when type is not any */ -type RecursivePartial = T extends object +type RecursivePartial = NonNullable extends object ? { - [P in keyof T]?: T[P] extends (infer U)[] + [P in keyof T]?: NonNullable extends (infer U)[] ? RecursivePartial[] - : T[P] extends object + : NonNullable extends object ? RecursivePartial : T[P]; } - : any; + : T; + +export type FilterFunc = (meta: Meta) => boolean; + +export type GetFieldsValueConfig = { strict?: boolean; filter?: FilterFunc }; export interface FormInstance { // Origin Form API getFieldValue: (name: NamePath) => StoreValue; getFieldsValue: (() => Values) & - ((nameList: NamePath[] | true, filterFunc?: (meta: Meta) => boolean) => any); + ((nameList: NamePath[] | true, filterFunc?: FilterFunc) => any) & + ((config: GetFieldsValueConfig) => any); getFieldError: (name: NamePath) => string[]; getFieldsError: (nameList?: NamePath[]) => FieldError[]; getFieldWarning: (name: NamePath) => string[]; @@ -233,7 +262,7 @@ export interface FormInstance { ((allFieldsTouched?: boolean) => boolean); isFieldTouched: (name: NamePath) => boolean; isFieldValidating: (name: NamePath) => boolean; - isFieldsValidating: (nameList: NamePath[]) => boolean; + isFieldsValidating: (nameList?: NamePath[]) => boolean; resetFields: (fields?: NamePath[]) => void; setFields: (fields: FieldData[]) => void; setFieldValue: (name: NamePath, value: any) => void; diff --git a/src/namePathType.ts b/src/namePathType.ts new file mode 100644 index 00000000..8e54c6f1 --- /dev/null +++ b/src/namePathType.ts @@ -0,0 +1,32 @@ +type BaseNamePath = string | number | boolean | (string | number | boolean)[]; +/** + * Store: The store type from `FormInstance` + * ParentNamePath: Auto generate by nest logic. Do not fill manually. + */ +export type DeepNamePath< + Store = any, + ParentNamePath extends any[] = [], +> = ParentNamePath['length'] extends 10 + ? 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]; diff --git a/src/useForm.ts b/src/useForm.ts index 1adf9967..29ed0c3e 100644 --- a/src/useForm.ts +++ b/src/useForm.ts @@ -1,3 +1,4 @@ +import { merge } from 'rc-util/lib/utils/set'; import warning from 'rc-util/lib/warning'; import * as React from 'react'; import { HOOK_MARK } from './FieldContext'; @@ -6,12 +7,15 @@ import type { FieldData, FieldEntity, FieldError, + FilterFunc, FormInstance, + GetFieldsValueConfig, InternalFieldData, InternalFormInstance, InternalHooks, InternalNamePath, InternalValidateFields, + InternalValidateOptions, Meta, NamePath, NotifyInfo, @@ -20,12 +24,10 @@ import type { StoreValue, ValidateErrorEntity, ValidateMessages, - ValidateOptions, ValuedNotifyInfo, WatchCallBack, } from './interface'; import { allPromiseFinish } from './utils/asyncUtil'; -import cloneDeep from './utils/cloneDeep'; import { defaultValidateMessages } from './utils/messages'; import NameMap from './utils/NameMap'; import { @@ -35,7 +37,6 @@ import { getValue, matchNamePath, setValue, - setValues, } from './utils/valueUtil'; type InvalidateFieldEntity = { INVALIDATE_NAME_PATH: InternalNamePath }; @@ -141,7 +142,7 @@ export class FormStore { private setInitialValues = (initialValues: Store, init: boolean) => { this.initialValues = initialValues || {}; if (init) { - let nextStore = setValues({}, initialValues, this.store); + let nextStore = merge(initialValues, this.store); // We will take consider prev form unmount fields. // When the field is not `preserve`, we need fill this with initialValues instead of store. @@ -170,7 +171,7 @@ export class FormStore { const initValue = getValue(this.initialValues, namePath); // Not cloneDeep when without `namePath` - return namePath.length ? cloneDeep(initValue) : initValue; + return namePath.length ? merge(initValue) : initValue; }; private setCallbacks = (callbacks: Callbacks) => { @@ -200,9 +201,10 @@ export class FormStore { // No need to cost perf when nothing need to watch if (this.watchList.length) { const values = this.getFieldsValue(); + const allValues = this.getFieldsValue(true); this.watchList.forEach(callback => { - callback(values, namePath); + callback(values, allValues, namePath); }); } }; @@ -265,15 +267,31 @@ export class FormStore { }); }; - private getFieldsValue = (nameList?: NamePath[] | true, filterFunc?: (meta: Meta) => boolean) => { + private getFieldsValue = ( + nameList?: NamePath[] | true | GetFieldsValueConfig, + filterFunc?: FilterFunc, + ) => { this.warningUnhooked(); - if (nameList === true && !filterFunc) { + // Fill args + let mergedNameList: NamePath[] | true; + let mergedFilterFunc: FilterFunc; + let mergedStrict: boolean; + + if (nameList === true || Array.isArray(nameList)) { + mergedNameList = nameList; + mergedFilterFunc = filterFunc; + } else if (nameList && typeof nameList === 'object') { + mergedStrict = nameList.strict; + mergedFilterFunc = nameList.filter; + } + + if (mergedNameList === true && !mergedFilterFunc) { return this.store; } const fieldEntities = this.getFieldEntitiesForNamePathList( - Array.isArray(nameList) ? nameList : null, + Array.isArray(mergedNameList) ? mergedNameList : null, ); const filteredNameList: NamePath[] = []; @@ -283,15 +301,19 @@ export class FormStore { // Ignore when it's a list item and not specific the namePath, // since parent field is already take in count - if (!nameList && (entity as FieldEntity).isListField?.()) { + if (mergedStrict) { + if ((entity as FieldEntity).isList?.()) { + return; + } + } else if (!mergedNameList && (entity as FieldEntity).isListField?.()) { return; } - if (!filterFunc) { + if (!mergedFilterFunc) { filteredNameList.push(namePath); } else { const meta: Meta = 'getMeta' in entity ? entity.getMeta() : null; - if (filterFunc(meta)) { + if (mergedFilterFunc(meta)) { filteredNameList.push(namePath); } } @@ -488,8 +510,10 @@ export class FormStore { ); } else if (records) { const originValue = this.getFieldValue(namePath); + const isListField = field.isListField(); + // Set `initialValue` - if (!info.skipExist || originValue === undefined) { + if (!isListField && (!info.skipExist || originValue === undefined)) { this.updateStore(setValue(this.store, namePath, [...records][0].value)); } } @@ -522,7 +546,7 @@ export class FormStore { const prevStore = this.store; if (!nameList) { - this.updateStore(setValues({}, this.initialValues)); + this.updateStore(merge(this.initialValues)); this.resetWithFieldInitialValue(); this.notifyObservers(prevStore, null, { type: 'reset' }); this.notifyWatch(); @@ -548,7 +572,7 @@ export class FormStore { const namePathList: InternalNamePath[] = []; fields.forEach((fieldData: FieldData) => { - const { name, errors, ...data } = fieldData; + const { name, ...data } = fieldData; const namePath = getNamePath(name); namePathList.push(namePath); @@ -742,7 +766,7 @@ export class FormStore { const prevStore = this.store; if (store) { - const nextStore = setValues(this.store, store); + const nextStore = merge(this.store, store); this.updateStore(nextStore); } @@ -830,17 +854,27 @@ export class FormStore { const changedFields = fields.filter(({ name: fieldName }) => containsNamePath(namePathList, fieldName as InternalNamePath), ); - onFieldsChange(changedFields, fields); + + if (changedFields.length) { + onFieldsChange(changedFields, fields); + } } }; // =========================== Validate =========================== - private validateFields: InternalValidateFields = ( - nameList?: NamePath[], - options?: ValidateOptions, - ) => { + private validateFields: InternalValidateFields = (arg1?: any, arg2?: any) => { this.warningUnhooked(); + let nameList: NamePath[]; + let options: InternalValidateOptions; + + if (Array.isArray(arg1) || typeof arg1 === 'string' || typeof arg2 === 'string') { + nameList = arg1; + options = arg2; + } else { + options = arg1; + } + const provideNameList = !!nameList; const namePathList: InternalNamePath[] | undefined = provideNameList ? nameList.map(getNamePath) @@ -849,35 +883,33 @@ export class FormStore { // Collect result in promise list const promiseList: Promise[] = []; + // We temp save the path which need trigger for `onFieldsChange` + const TMP_SPLIT = String(Date.now()); + const validateNamePathList = new Set(); + + const { recursive, dirty } = options || {}; + this.getFieldEntities(true).forEach((field: FieldEntity) => { // Add field if not provide `nameList` if (!provideNameList) { namePathList.push(field.getNamePath()); } - /** - * Recursive validate if configured. - * TODO: perf improvement @zombieJ - */ - if (options?.recursive && provideNameList) { - const namePath = field.getNamePath(); - if ( - // nameList[i] === undefined 说明是以 nameList 开头的 - // ['name'] -> ['name','list'] - namePath.every((nameUnit, i) => nameList[i] === nameUnit || nameList[i] === undefined) - ) { - namePathList.push(namePath); - } - } - // Skip if without rule if (!field.props.rules || !field.props.rules.length) { return; } + // Skip if only validate dirty field + if (dirty && !field.isFieldDirty()) { + return; + } + const fieldNamePath = field.getNamePath(); + validateNamePathList.add(fieldNamePath.join(TMP_SPLIT)); + // Add field validate rule in to promise list - if (!provideNameList || containsNamePath(namePathList, fieldNamePath)) { + if (!provideNameList || containsNamePath(namePathList, fieldNamePath, recursive)) { const promise = field.validateRules({ validateMessages: { ...defaultValidateMessages, @@ -953,6 +985,12 @@ export class FormStore { // Do not throw in console returnPromise.catch(e => e); + // `validating` changed. Trigger `onFieldsChange` + const triggerNamePathList = namePathList.filter(namePath => + validateNamePathList.has(namePath.join(TMP_SPLIT)), + ); + this.triggerOnFieldsChange(triggerNamePathList); + return returnPromise as Promise; }; diff --git a/src/useWatch.ts b/src/useWatch.ts index a3f47462..e5144083 100644 --- a/src/useWatch.ts +++ b/src/useWatch.ts @@ -1,9 +1,15 @@ -import type { FormInstance } from '.'; -import { FieldContext } from '.'; import warning from 'rc-util/lib/warning'; -import { HOOK_MARK } from './FieldContext'; -import type { InternalFormInstance, NamePath, Store } from './interface'; -import { useState, useContext, useEffect, useRef, useMemo } from 'react'; +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import FieldContext, { HOOK_MARK } from './FieldContext'; +import type { + FormInstance, + InternalFormInstance, + InternalNamePath, + NamePath, + Store, + WatchOptions, +} from './interface'; +import { isFormInstance } from './utils/typeUtil'; import { getNamePath, getValue } from './utils/valueUtil'; type ReturnPromise = T extends Promise ? ValueType : never; @@ -17,6 +23,19 @@ export function stringify(value: any) { } } +const useWatchWarning = + process.env.NODE_ENV !== 'production' + ? (namePath: InternalNamePath) => { + const fullyStr = namePath.join('__RC_FIELD_FORM_SPLIT__'); + const nameStrRef = useRef(fullyStr); + + warning( + nameStrRef.current === fullyStr, + '`useWatch` is not support dynamic `namePath`. Please provide static instead.', + ); + } + : () => {}; + function useWatch< TDependencies1 extends keyof GetGeneric, TForm extends FormInstance, @@ -25,7 +44,7 @@ function useWatch< TDependencies4 extends keyof GetGeneric[TDependencies1][TDependencies2][TDependencies3], >( dependencies: [TDependencies1, TDependencies2, TDependencies3, TDependencies4], - form?: TForm, + form?: TForm | WatchOptions, ): GetGeneric[TDependencies1][TDependencies2][TDependencies3][TDependencies4]; function useWatch< @@ -35,7 +54,7 @@ function useWatch< TDependencies3 extends keyof GetGeneric[TDependencies1][TDependencies2], >( dependencies: [TDependencies1, TDependencies2, TDependencies3], - form?: TForm, + form?: TForm | WatchOptions, ): GetGeneric[TDependencies1][TDependencies2][TDependencies3]; function useWatch< @@ -44,21 +63,34 @@ function useWatch< TDependencies2 extends keyof GetGeneric[TDependencies1], >( dependencies: [TDependencies1, TDependencies2], - form?: TForm, + form?: TForm | WatchOptions, ): GetGeneric[TDependencies1][TDependencies2]; function useWatch, TForm extends FormInstance>( dependencies: TDependencies | [TDependencies], - form?: TForm, + form?: TForm | WatchOptions, ): GetGeneric[TDependencies]; -function useWatch(dependencies: [], form?: TForm): GetGeneric; +function useWatch( + dependencies: [], + form?: TForm | WatchOptions, +): GetGeneric; -function useWatch(dependencies: NamePath, form?: TForm): any; +function useWatch( + dependencies: NamePath, + form?: TForm | WatchOptions, +): any; -function useWatch(dependencies: NamePath, form?: FormInstance): ValueType; +function useWatch( + dependencies: NamePath, + form?: FormInstance | WatchOptions, +): ValueType; + +function useWatch(...args: [NamePath, FormInstance | WatchOptions]) { + const [dependencies = [], _form = {}] = args; + const options = isFormInstance(_form) ? { form: _form } : _form; + const form = options.form; -function useWatch(dependencies: NamePath = [], form?: FormInstance) { const [value, setValue] = useState(); const valueStr = useMemo(() => stringify(value), [value]); @@ -72,7 +104,7 @@ function useWatch(dependencies: NamePath = [], form?: FormInstance) { // Warning if not exist form instance if (process.env.NODE_ENV !== 'production') { warning( - isValidForm, + args.length === 2 ? (form ? isValidForm : true) : isValidForm, 'useWatch requires a form instance since it can not auto detect from context.', ); } @@ -81,6 +113,8 @@ function useWatch(dependencies: NamePath = [], form?: FormInstance) { const namePathRef = useRef(namePath); namePathRef.current = namePath; + useWatchWarning(namePath); + useEffect( () => { // Skip if not exist form instance @@ -91,8 +125,8 @@ function useWatch(dependencies: NamePath = [], form?: FormInstance) { const { getFieldsValue, getInternalHooks } = formInstance; const { registerWatch } = getInternalHooks(HOOK_MARK); - const cancelRegister = registerWatch(store => { - const newValue = getValue(store, namePathRef.current); + const cancelRegister = registerWatch((values, allValues) => { + const newValue = getValue(options.preserve ? allValues : values, namePathRef.current); const nextValueStr = stringify(newValue); // Compare stringify in case it's nest object @@ -103,15 +137,23 @@ function useWatch(dependencies: NamePath = [], form?: FormInstance) { }); // TODO: We can improve this perf in future - const initialValue = getValue(getFieldsValue(), namePathRef.current); - setValue(initialValue); + const initialValue = getValue( + options.preserve ? getFieldsValue(true) : getFieldsValue(), + namePathRef.current, + ); + + // React 18 has the bug that will queue update twice even the value is not changed + // ref: https://github.com/facebook/react/issues/27213 + if (value !== initialValue) { + setValue(initialValue); + } return cancelRegister; }, // We do not need re-register since namePath content is the same // eslint-disable-next-line react-hooks/exhaustive-deps - [], + [isValidForm], ); return value; diff --git a/src/utils/cloneDeep.ts b/src/utils/cloneDeep.ts deleted file mode 100644 index d12467b2..00000000 --- a/src/utils/cloneDeep.ts +++ /dev/null @@ -1,25 +0,0 @@ -function cloneDeep(val) { - if (Array.isArray(val)) { - return cloneArrayDeep(val); - } else if (typeof val === 'object' && val !== null) { - return cloneObjectDeep(val); - } - return val; -} - -function cloneObjectDeep(val) { - if (Object.getPrototypeOf(val) === Object.prototype) { - const res = {}; - for (const key in val) { - res[key] = cloneDeep(val[key]); - } - return res; - } - return val; -} - -function cloneArrayDeep(val) { - return val.map(item => cloneDeep(item)); -} - -export default cloneDeep; diff --git a/src/utils/typeUtil.ts b/src/utils/typeUtil.ts index bb319571..49128367 100644 --- a/src/utils/typeUtil.ts +++ b/src/utils/typeUtil.ts @@ -1,3 +1,5 @@ +import type { FormInstance, InternalFormInstance } from '../interface'; + export function toArray(value?: T | T[] | null): T[] { if (value === undefined || value === null) { return []; @@ -5,3 +7,7 @@ export function toArray(value?: T | T[] | null): T[] { return Array.isArray(value) ? value : [value]; } + +export function isFormInstance(form: T | FormInstance): form is FormInstance { + return form && !!(form as InternalFormInstance)._init; +} diff --git a/src/utils/validateUtil.ts b/src/utils/validateUtil.ts index 36071eaa..866e0e85 100644 --- a/src/utils/validateUtil.ts +++ b/src/utils/validateUtil.ts @@ -3,13 +3,13 @@ import * as React from 'react'; import warning from 'rc-util/lib/warning'; import type { InternalNamePath, - ValidateOptions, + InternalValidateOptions, RuleObject, StoreValue, RuleError, } from '../interface'; import { defaultValidateMessages } from './messages'; -import { setValues } from './valueUtil'; +import { merge } from 'rc-util/lib/utils/set'; // Remove incorrect original ts define const AsyncValidator: any = RawAsyncValidator; @@ -31,7 +31,7 @@ async function validateRule( name: string, value: StoreValue, rule: RuleObject, - options: ValidateOptions, + options: InternalValidateOptions, messageVariables?: Record, ): Promise { const cloneRule = { ...rule }; @@ -41,6 +41,9 @@ async function validateRule( // https://github.com/react-component/field-form/issues/313 delete (cloneRule as any).ruleIndex; + // https://github.com/ant-design/ant-design/issues/40497#issuecomment-1422282378 + AsyncValidator.warning = () => void 0; + if (cloneRule.validator) { const originValidator = cloneRule.validator; cloneRule.validator = (...args) => { @@ -64,7 +67,7 @@ async function validateRule( [name]: [cloneRule], }); - const messages = setValues({}, defaultValidateMessages, options.validateMessages); + const messages = merge(defaultValidateMessages, options.validateMessages); validator.messages(messages); let result = []; @@ -120,7 +123,7 @@ export function validateRules( namePath: InternalNamePath, value: StoreValue, rules: RuleObject[], - options: ValidateOptions, + options: InternalValidateOptions, validateFirst: boolean | 'parallel', messageVariables?: Record, ) { diff --git a/src/utils/valueUtil.ts b/src/utils/valueUtil.ts index 25527bd6..eb64966a 100644 --- a/src/utils/valueUtil.ts +++ b/src/utils/valueUtil.ts @@ -1,8 +1,9 @@ -import get from 'rc-util/lib/utils/get'; -import set from 'rc-util/lib/utils/set'; -import type { InternalNamePath, NamePath, Store, StoreValue, EventArgs } from '../interface'; +import getValue from 'rc-util/lib/utils/get'; +import setValue from 'rc-util/lib/utils/set'; +import type { InternalNamePath, NamePath, Store, EventArgs } from '../interface'; import { toArray } from './typeUtil'; -import cloneDeep from '../utils/cloneDeep'; + +export { getValue, setValue }; /** * Convert name to internal supported format. @@ -15,21 +16,6 @@ export function getNamePath(path: NamePath | null): InternalNamePath { return toArray(path); } -export function getValue(store: Store, namePath: InternalNamePath) { - const value = get(store, namePath); - return value; -} - -export function setValue( - store: Store, - namePath: InternalNamePath, - value: StoreValue, - removeIfUndefined = false, -): Store { - const newStore = set(store, namePath, value, removeIfUndefined); - return newStore; -} - export function cloneByNamePathList(store: Store, namePathList: InternalNamePath[]): Store { let newStore = {}; namePathList.forEach(namePath => { @@ -40,57 +26,44 @@ export function cloneByNamePathList(store: Store, namePathList: InternalNamePath return newStore; } -export function containsNamePath(namePathList: InternalNamePath[], namePath: InternalNamePath) { - return namePathList && namePathList.some(path => matchNamePath(path, namePath)); -} - -function isObject(obj: StoreValue) { - return typeof obj === 'object' && obj !== null && Object.getPrototypeOf(obj) === Object.prototype; -} - /** - * Copy values into store and return a new values object - * ({ a: 1, b: { c: 2 } }, { a: 4, b: { d: 5 } }) => { a: 4, b: { c: 2, d: 5 } } + * Check if `namePathList` includes `namePath`. + * @param namePathList A list of `InternalNamePath[]` + * @param namePath Compare `InternalNamePath` + * @param partialMatch True will make `[a, b]` match `[a, b, c]` */ -function internalSetValues(store: T, values: T): T { - const newStore: T = (Array.isArray(store) ? [...store] : { ...store }) as T; - - if (!values) { - return newStore; - } - - Object.keys(values).forEach(key => { - const prevValue = newStore[key]; - const value = values[key]; - - // If both are object (but target is not array), we use recursion to set deep value - const recursive = isObject(prevValue) && isObject(value); - - newStore[key] = recursive ? internalSetValues(prevValue, value || {}) : cloneDeep(value); // Clone deep for arrays - }); - - return newStore; -} - -export function setValues(store: T, ...restValues: T[]): T { - return restValues.reduce( - (current: T, newStore: T): T => internalSetValues(current, newStore), - store, - ); +export function containsNamePath( + namePathList: InternalNamePath[], + namePath: InternalNamePath, + partialMatch = false, +) { + return namePathList && namePathList.some(path => matchNamePath(namePath, path, partialMatch)); } +/** + * Check if `namePath` is super set or equal of `subNamePath`. + * @param namePath A list of `InternalNamePath[]` + * @param subNamePath Compare `InternalNamePath` + * @param partialMatch True will make `[a, b]` match `[a, b, c]` + */ export function matchNamePath( namePath: InternalNamePath, - changedNamePath: InternalNamePath | null, + subNamePath: InternalNamePath | null, + partialMatch = false, ) { - if (!namePath || !changedNamePath || namePath.length !== changedNamePath.length) { + if (!namePath || !subNamePath) { return false; } - return namePath.every((nameUnit, i) => changedNamePath[i] === nameUnit); + + if (!partialMatch && namePath.length !== subNamePath.length) { + return false; + } + + return subNamePath.every((nameUnit, i) => namePath[i] === nameUnit); } // Like `shallowEqual`, but we not check the data which may cause re-render -type SimilarObject = string | number | {}; +type SimilarObject = string | number | object; export function isSimilar(source: SimilarObject, target: SimilarObject) { if (source === target) { return true; diff --git a/tests/common/InfoField.tsx b/tests/common/InfoField.tsx index ecf75ecd..e4faa52f 100644 --- a/tests/common/InfoField.tsx +++ b/tests/common/InfoField.tsx @@ -6,7 +6,9 @@ interface InfoFieldProps extends FieldProps { children?: React.ReactElement; } -export const Input = ({ value = '', ...props }) => ; +export const Input: React.FC = ({ value = '', ...props }) => ( + +); /** * Return a wrapped Field with meta info @@ -17,7 +19,7 @@ const InfoField: React.FC = ({ children, ...props }) => ( const { errors, warnings, validating } = info; return ( -
      +
      {children ? React.cloneElement(children, control) : }
        {errors.map((error, index) => ( diff --git a/tests/common/index.ts b/tests/common/index.ts index 54dc9073..36241a61 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -1,66 +1,75 @@ -/* eslint-disable import/no-extraneous-dependencies */ - import { act } from 'react-dom/test-utils'; -import type { ReactWrapper } from 'enzyme'; import timeout from './timeout'; -import { Field } from '../../src'; -import { getNamePath, matchNamePath } from '../../src/utils/valueUtil'; +import { matchNamePath } from '../../src/utils/valueUtil'; +import { fireEvent } from '@testing-library/react'; -export async function changeValue(wrapper, value) { - wrapper.find('input').simulate('change', { target: { value } }); - await act(async () => { - await timeout(); - }); - wrapper.update(); +export function getInput( + container: HTMLElement, + dataNameOrIndex?: string | number, + parentField = false, +): HTMLInputElement { + let ele: HTMLInputElement | null = null; + + if (!dataNameOrIndex) { + ele = container.querySelector('input'); + } else if (typeof dataNameOrIndex === 'number') { + ele = container.querySelectorAll('input')[dataNameOrIndex]; + } else { + ele = container.querySelector(`[data-name="${dataNameOrIndex}"]`); + } + + if (parentField) { + return ele.closest('.field'); + } + + return ele!; +} + +export async function changeValue(wrapper: HTMLElement, value: string | string[]) { + const values = Array.isArray(value) ? value : [value]; + + for (let i = 0; i < values.length; i += 1) { + fireEvent.change(wrapper, { target: { value: values[i] } }); + + await act(async () => { + await timeout(); + }); + } + + return; } export function matchError( - wrapper: ReactWrapper, + wrapper: HTMLElement, error?: boolean | string, warning?: boolean | string, ) { // Error if (error) { - expect(wrapper.find('.errors li').length).toBeTruthy(); + expect(wrapper.querySelector('.errors li')).toBeTruthy(); } else { - expect(wrapper.find('.errors li').length).toBeFalsy(); + expect(wrapper.querySelector('.errors li')).toBeFalsy(); } if (error && typeof error !== 'boolean') { - expect(wrapper.find('.errors li').text()).toBe(error); + expect(wrapper.querySelector('.errors li').textContent).toBe(error); } // Warning if (warning) { - expect(wrapper.find('.warnings li').length).toBeTruthy(); + expect(wrapper.querySelector('.warnings li')).toBeTruthy(); } else { - expect(wrapper.find('.warnings li').length).toBeFalsy(); + expect(wrapper.querySelector('.warnings li')).toBeFalsy(); } if (warning && typeof warning !== 'boolean') { - expect(wrapper.find('.warnings li').text()).toBe(warning); - } -} - -export function getField(wrapper, index: string | number = 0) { - if (typeof index === 'number') { - return wrapper.find(Field).at(index); + expect(wrapper.querySelector('.warnings li').textContent).toBe(warning); } - const name = getNamePath(index); - const fields = wrapper.find(Field); - for (let i = 0; i < fields.length; i += 1) { - const field = fields.at(i); - const fieldName = getNamePath(field.props().name); - - if (matchNamePath(name, fieldName)) { - return field; - } - } - return null; + return; } -export function matchArray(source, target, matchKey) { +export function matchArray(source: any[], target: any[], matchKey: React.Key) { expect(matchKey).toBeTruthy(); try { @@ -68,10 +77,10 @@ export function matchArray(source, target, matchKey) { } catch (err) { throw new Error( ` -Array length not match. -source(${source.length}): ${JSON.stringify(source)} -target(${target.length}): ${JSON.stringify(target)} -`.trim(), + Array length not match. + source(${source.length}): ${JSON.stringify(source)} + target(${target.length}): ${JSON.stringify(target)} + `.trim(), ); } @@ -88,5 +97,3 @@ export async function validateFields(form, ...args) { await form.validateFields(...args); }); } - -/* eslint-enable import/no-extraneous-dependencies */ diff --git a/tests/common/timeout.ts b/tests/common/timeout.ts index 9eecacec..1b2e52cb 100644 --- a/tests/common/timeout.ts +++ b/tests/common/timeout.ts @@ -1,5 +1,15 @@ -export default (timeout: number = 0) => { - return new Promise(resolve => { +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/context.test.js b/tests/context.test.tsx similarity index 63% rename from tests/context.test.js rename to tests/context.test.tsx index d4a5c193..4a587c9f 100644 --- a/tests/context.test.js +++ b/tests/context.test.tsx @@ -1,13 +1,14 @@ import React from 'react'; -import { mount } from 'enzyme'; +import { render } from '@testing-library/react'; +import type { FormInstance } from '../src'; import Form, { FormProvider } from '../src'; import InfoField from './common/InfoField'; -import { changeValue, matchError, getField } from './common'; +import { changeValue, matchError, getInput } from './common'; import timeout from './common/timeout'; describe('Form.Context', () => { it('validateMessages', async () => { - const wrapper = mount( + const { container } = render( @@ -15,14 +16,14 @@ describe('Form.Context', () => { , ); - await changeValue(wrapper, ''); - matchError(wrapper, "I'm global"); + await changeValue(getInput(container), ['bamboo', '']); + matchError(container, "I'm global"); }); it('change event', async () => { const onFormChange = jest.fn(); - const wrapper = mount( + const { container } = render( @@ -30,7 +31,7 @@ describe('Form.Context', () => { , ); - await changeValue(getField(wrapper), 'Light'); + await changeValue(getInput(container), 'Light'); expect(onFormChange).toHaveBeenCalledWith( 'form1', expect.objectContaining({ @@ -42,6 +43,26 @@ describe('Form.Context', () => { touched: true, validating: false, value: 'Light', + validated: false, + }, + ], + forms: { + form1: expect.objectContaining({}), + }, + }), + ); + expect(onFormChange).toHaveBeenCalledWith( + 'form1', + expect.objectContaining({ + changedFields: [ + { + errors: [], + warnings: [], + name: ['username'], + touched: true, + validating: false, + value: 'Light', + validated: true, }, ], forms: { @@ -55,21 +76,21 @@ describe('Form.Context', () => { it('basic', async () => { const onFormChange = jest.fn(); - const wrapper = mount( + const { container, rerender } = render( , ); - wrapper.setProps({ - children: ( + rerender( + - ), - }); + , + ); - await changeValue(getField(wrapper), 'Bamboo'); + await changeValue(getInput(container), 'Bamboo'); const { forms } = onFormChange.mock.calls[0][1]; expect(Object.keys(forms)).toEqual(['form2']); }); @@ -77,7 +98,7 @@ describe('Form.Context', () => { it('multiple context', async () => { const onFormChange = jest.fn(); - const Demo = changed => ( + const Demo: React.FC<{ changed?: boolean }> = ({ changed }) => ( {!changed ? ( @@ -91,13 +112,11 @@ describe('Form.Context', () => { ); - const wrapper = mount(); + const { container, rerender } = render(); - wrapper.setProps({ - changed: true, - }); + rerender(); - await changeValue(getField(wrapper), 'Bamboo'); + await changeValue(getInput(container), 'Bamboo'); const { forms } = onFormChange.mock.calls[0][1]; expect(Object.keys(forms)).toEqual(['form2']); }); @@ -105,17 +124,12 @@ describe('Form.Context', () => { it('submit', async () => { const onFormFinish = jest.fn(); - let form1; + const form = React.createRef(); - const wrapper = mount( + const { container } = render(
        -
        { - form1 = instance; - }} - > +
        @@ -123,13 +137,13 @@ describe('Form.Context', () => {
        , ); - await changeValue(getField(wrapper), ''); - form1.submit(); + await changeValue(getInput(container), ['bamboo', '']); + form.current?.submit(); await timeout(); expect(onFormFinish).not.toHaveBeenCalled(); - await changeValue(getField(wrapper), 'Light'); - form1.submit(); + await changeValue(getInput(container), 'Light'); + form.current?.submit(); await timeout(); expect(onFormFinish).toHaveBeenCalled(); @@ -140,14 +154,11 @@ describe('Form.Context', () => { }); it('do nothing if no Provider in use', () => { - const wrapper = mount( + const { rerender } = render(
        , ); - - wrapper.setProps({ - children: null, - }); + rerender(
        {null}
        ); }); }); diff --git a/tests/control.test.js b/tests/control.test.tsx similarity index 51% rename from tests/control.test.js rename to tests/control.test.tsx index 751fdd8a..88614292 100644 --- a/tests/control.test.js +++ b/tests/control.test.tsx @@ -1,27 +1,26 @@ import React from 'react'; -import { mount } from 'enzyme'; import Form from '../src'; import InfoField from './common/InfoField'; -import { changeValue, matchError } from './common'; +import { changeValue, getInput, matchError } from './common'; +import { render } from '@testing-library/react'; describe('Form.Control', () => { it('fields', () => { - const wrapper = mount( + const { container, rerender } = render( , ); - - wrapper.setProps({ - fields: [{ name: 'username', value: 'Bamboo' }], - }); - wrapper.update(); - - expect(wrapper.find('input').props().value).toEqual('Bamboo'); + rerender( +
        + + , + ); + expect(container.querySelector('input')?.value).toBe('Bamboo'); }); it('fully test', async () => { - const Test = () => { + const Test: React.FC = () => { const [fields, setFields] = React.useState([]); return ( @@ -36,9 +35,9 @@ describe('Form.Control', () => { ); }; - const wrapper = mount(); + const { container } = render(); - await changeValue(wrapper, ''); - matchError(wrapper, "'test' is required"); + await changeValue(getInput(container), ['bamboo', '']); + matchError(container, "'test' is required"); }); }); diff --git a/tests/dependencies.test.js b/tests/dependencies.test.tsx similarity index 73% rename from tests/dependencies.test.js rename to tests/dependencies.test.tsx index 779da32b..82d986a3 100644 --- a/tests/dependencies.test.js +++ b/tests/dependencies.test.tsx @@ -1,21 +1,18 @@ import React from 'react'; -import { mount } from 'enzyme'; +import type { FormInstance } from '../src'; import Form, { Field } from '../src'; import timeout from './common/timeout'; import InfoField, { Input } from './common/InfoField'; -import { changeValue, matchError, getField } from './common'; +import { changeValue, matchError, getInput } from './common'; +import { fireEvent, render } from '@testing-library/react'; describe('Form.Dependencies', () => { it('touched', async () => { - let form = null; + const form = React.createRef(); - const wrapper = mount( + const { container } = render(
        -
        { - form = instance; - }} - > + @@ -23,21 +20,21 @@ describe('Form.Dependencies', () => { ); // Not trigger if not touched - await changeValue(getField(wrapper, 0), ''); - matchError(getField(wrapper, 1), false); + await changeValue(getInput(container, 0), ['bamboo', '']); + matchError(getInput(container, 1, true), false); // Trigger if touched - form.setFields([{ name: 'field_2', touched: true }]); - await changeValue(getField(wrapper, 0), ''); - matchError(getField(wrapper, 1), true); + form.current?.setFields([{ name: 'field_2', touched: true }]); + await changeValue(getInput(container, 0), ['bamboo', '']); + matchError(getInput(container, 1, true), true); }); describe('initialValue', () => { - function test(name, formProps, fieldProps) { + function test(name: string, formProps = {}, fieldProps = {}) { it(name, async () => { let validated = false; - const wrapper = mount( + const { container } = render(
        @@ -58,7 +55,7 @@ describe('Form.Dependencies', () => { ); // Not trigger if not touched - await changeValue(getField(wrapper, 0), ''); + await changeValue(getInput(container, 0), 'bamboo'); expect(validated).toBeTruthy(); }); } @@ -68,16 +65,12 @@ describe('Form.Dependencies', () => { }); it('nest dependencies', async () => { - let form = null; + const form = React.createRef(); let rendered = false; - const wrapper = mount( + const { container } = render(
        - { - form = instance; - }} - > + @@ -94,14 +87,14 @@ describe('Form.Dependencies', () => {
        , ); - form.setFields([ + form.current?.setFields([ { name: 'field_1', touched: true }, { name: 'field_2', touched: true }, { name: 'field_3', touched: true }, ]); rendered = false; - await changeValue(getField(wrapper), '1'); + await changeValue(getInput(container), '1'); expect(rendered).toBeTruthy(); }); @@ -109,7 +102,7 @@ describe('Form.Dependencies', () => { it('should work when field is dirty', async () => { let pass = false; - const wrapper = mount( + const { container } = render( { , ); - wrapper.find('form').simulate('submit'); + fireEvent.submit(container.querySelector('form')!); await timeout(); - wrapper.update(); - matchError(getField(wrapper, 0), 'You should not pass'); + // wrapper.update(); + matchError(getInput(container, 0, true), 'You should not pass'); // Mock new validate pass = true; - await changeValue(getField(wrapper, 1), 'bamboo'); - matchError(getField(wrapper, 0), false); + await changeValue(getInput(container, 1), 'bamboo'); + matchError(getInput(container, 0, true), false); // Should not validate after reset pass = false; - wrapper.find('button').simulate('click'); - await changeValue(getField(wrapper, 1), 'light'); - matchError(getField(wrapper, 0), false); + fireEvent.click(container.querySelector('button')!); + await changeValue(getInput(container, 1), 'light'); + matchError(getInput(container, 0, true), false); }); it('should work as a shortcut when name is not provided', async () => { const spy = jest.fn(); - const wrapper = mount( + const { container } = render(
        {() => { @@ -177,7 +170,7 @@ describe('Form.Dependencies', () => { , ); expect(spy).toHaveBeenCalledTimes(1); - await changeValue(getField(wrapper, 2), 'value2'); + await changeValue(getInput(container, 1), 'value2'); // sync start // valueUpdate -> not rerender // depsUpdate -> not rerender @@ -186,7 +179,7 @@ describe('Form.Dependencies', () => { // validateFinish -> not rerender // async end expect(spy).toHaveBeenCalledTimes(1); - await changeValue(getField(wrapper, 1), 'value1'); + await changeValue(getInput(container, 0), 'value1'); // sync start // valueUpdate -> not rerender // depsUpdate -> rerender by deps @@ -200,7 +193,7 @@ describe('Form.Dependencies', () => { it("shouldn't work when shouldUpdate is set", async () => { const spy = jest.fn(); - const wrapper = mount( + const { container } = render(
        true}> {() => { @@ -217,7 +210,7 @@ describe('Form.Dependencies', () => { , ); expect(spy).toHaveBeenCalledTimes(1); - await changeValue(getField(wrapper, 1), 'value1'); + await changeValue(getInput(container, 0), 'value1'); // sync start // valueUpdate -> rerender by shouldUpdate // depsUpdate -> rerender by deps @@ -225,7 +218,7 @@ describe('Form.Dependencies', () => { // sync end expect(spy).toHaveBeenCalledTimes(2); - await changeValue(getField(wrapper, 2), 'value2'); + await changeValue(getInput(container, 1), 'value2'); // sync start // valueUpdate -> rerender by shouldUpdate // depsUpdate -> rerender by deps diff --git a/tests/field.test.tsx b/tests/field.test.tsx index 5cdf9a54..737c345d 100644 --- a/tests/field.test.tsx +++ b/tests/field.test.tsx @@ -1,11 +1,15 @@ import React from 'react'; -import { mount } from 'enzyme'; import Form, { Field } from '../src'; +import type { FormInstance } from '../src'; +import { render } from '@testing-library/react'; describe('Form.Field', () => { it('field remount should trigger constructor again', () => { + let formRef: FormInstance; + const Demo = ({ visible }: { visible: boolean }) => { const [form] = Form.useForm(); + formRef = form; const fieldNode = ; @@ -13,17 +17,24 @@ describe('Form.Field', () => { }; // First mount - const wrapper = mount(); - const instance = wrapper.find('Field').instance() as any; - expect(instance.cancelRegisterFunc).toBeTruthy(); + const { rerender } = render(); + + // ZombieJ: testing lib can not access instance + // const instance = wrapper.find('Field').instance() as any; + // expect(instance.cancelRegisterFunc).toBeTruthy(); + expect(formRef.getFieldsValue()).toEqual({ light: 'bamboo' }); // Hide - wrapper.setProps({ visible: false }); - expect(instance.cancelRegisterFunc).toBeFalsy(); + // wrapper.setProps({ visible: false }); + // expect(instance.cancelRegisterFunc).toBeFalsy(); + rerender(); + expect(formRef.getFieldsValue()).toEqual({}); // Mount again - wrapper.setProps({ visible: true }); - expect(instance.cancelRegisterFunc).toBeFalsy(); - expect((wrapper.find('Field').instance() as any).cancelRegisterFunc).toBeTruthy(); + // wrapper.setProps({ visible: true }); + rerender(); + // expect(instance.cancelRegisterFunc).toBeFalsy(); + // expect((wrapper.find('Field').instance() as any).cancelRegisterFunc).toBeTruthy(); + expect(formRef.getFieldsValue()).toEqual({ light: 'bamboo' }); }); }); diff --git a/tests/index.test.js b/tests/index.test.js deleted file mode 100644 index d24a6d20..00000000 --- a/tests/index.test.js +++ /dev/null @@ -1,883 +0,0 @@ -import { mount } from 'enzyme'; -import { resetWarned } from 'rc-util/lib/warning'; -import React from 'react'; -import Form, { Field, useForm } from '../src'; -import { changeValue, getField, matchError } from './common'; -import InfoField, { Input } from './common/InfoField'; -import timeout from './common/timeout'; - -describe('Form.Basic', () => { - describe('create form', () => { - function renderContent() { - return ( -
        - - - - {() => null} - -
        - ); - } - - it('sub component', () => { - const wrapper = mount(
        {renderContent()}
        ); - expect(wrapper.find('form')).toBeTruthy(); - expect(wrapper.find('input').length).toBe(2); - }); - - describe('component', () => { - it('without dom', () => { - const wrapper = mount(
        {renderContent()}
        ); - expect(wrapper.find('form').length).toBe(0); - expect(wrapper.find('input').length).toBe(2); - }); - - it('use string', () => { - const wrapper = mount(
        {renderContent()}
        ); - expect(wrapper.find('form').length).toBe(0); - expect(wrapper.find('pre').length).toBe(1); - expect(wrapper.find('input').length).toBe(2); - }); - - it('use component', () => { - const MyComponent = ({ children }) =>
        {children}
        ; - const wrapper = mount(
        {renderContent()}
        ); - expect(wrapper.find('form').length).toBe(0); - expect(wrapper.find(MyComponent).length).toBe(1); - expect(wrapper.find('input').length).toBe(2); - }); - }); - - describe('render props', () => { - it('normal', () => { - const wrapper = mount(
        {renderContent}
        ); - expect(wrapper.find('form')).toBeTruthy(); - expect(wrapper.find('input').length).toBe(2); - }); - - it('empty', () => { - const wrapper = mount(
        {() => null}
        ); - expect(wrapper.find('form')).toBeTruthy(); - }); - }); - }); - - it('fields touched', async () => { - let form; - - const wrapper = mount( -
        -
        { - form = instance; - }} - > - - - {() => null} - -
        , - ); - - expect(form.isFieldsTouched()).toBeFalsy(); - expect(form.isFieldsTouched(['username', 'password'])).toBeFalsy(); - - await changeValue(getField(wrapper, 0), 'Bamboo'); - expect(form.isFieldsTouched()).toBeTruthy(); - expect(form.isFieldsTouched(['username', 'password'])).toBeTruthy(); - expect(form.isFieldsTouched(true)).toBeFalsy(); - expect(form.isFieldsTouched(['username', 'password'], true)).toBeFalsy(); - - await changeValue(getField(wrapper, 1), 'Light'); - expect(form.isFieldsTouched()).toBeTruthy(); - expect(form.isFieldsTouched(['username', 'password'])).toBeTruthy(); - expect(form.isFieldsTouched(true)).toBeTruthy(); - expect(form.isFieldsTouched(['username', 'password'], true)).toBeTruthy(); - }); - - describe('reset form', () => { - function resetTest(name, ...args) { - it(name, async () => { - let form; - const onReset = jest.fn(); - const onMeta = jest.fn(); - - const wrapper = mount( -
        -
        { - form = instance; - }} - > - - - -
        -
        , - ); - - await changeValue(getField(wrapper, 'username'), 'Bamboo'); - expect(form.getFieldValue('username')).toEqual('Bamboo'); - expect(form.getFieldError('username')).toEqual([]); - expect(form.isFieldTouched('username')).toBeTruthy(); - expect(onMeta).toHaveBeenCalledWith( - expect.objectContaining({ - touched: true, - errors: [], - warnings: [], - }), - ); - expect(onReset).not.toHaveBeenCalled(); - onMeta.mockRestore(); - onReset.mockRestore(); - - form.resetFields(...args); - expect(form.getFieldValue('username')).toEqual(undefined); - expect(form.getFieldError('username')).toEqual([]); - expect(form.isFieldTouched('username')).toBeFalsy(); - expect(onMeta).toHaveBeenCalledWith( - expect.objectContaining({ - touched: false, - errors: [], - warnings: [], - }), - ); - expect(onReset).toHaveBeenCalled(); - onMeta.mockRestore(); - onReset.mockRestore(); - - await changeValue(getField(wrapper, 'username'), ''); - expect(form.getFieldValue('username')).toEqual(''); - expect(form.getFieldError('username')).toEqual(["'username' is required"]); - expect(form.isFieldTouched('username')).toBeTruthy(); - expect(onMeta).toHaveBeenCalledWith( - expect.objectContaining({ - touched: true, - errors: ["'username' is required"], - warnings: [], - }), - ); - expect(onReset).not.toHaveBeenCalled(); - onMeta.mockRestore(); - onReset.mockRestore(); - - form.resetFields(...args); - expect(form.getFieldValue('username')).toEqual(undefined); - expect(form.getFieldError('username')).toEqual([]); - expect(form.isFieldTouched('username')).toBeFalsy(); - expect(onMeta).toHaveBeenCalledWith( - expect.objectContaining({ - touched: false, - errors: [], - warnings: [], - }), - ); - expect(onReset).toHaveBeenCalled(); - }); - } - - resetTest('with field name', ['username']); - resetTest('without field name'); - - it('not affect others', async () => { - let form; - - const wrapper = mount( -
        -
        { - form = instance; - }} - > - - - - - - - -
        -
        , - ); - - await changeValue(getField(wrapper, 'username'), 'Bamboo'); - await changeValue(getField(wrapper, 'password'), ''); - form.resetFields(['username']); - - expect(form.getFieldValue('username')).toEqual(undefined); - expect(form.getFieldError('username')).toEqual([]); - expect(form.isFieldTouched('username')).toBeFalsy(); - expect(form.getFieldValue('password')).toEqual(''); - expect(form.getFieldError('password')).toEqual(["'password' is required"]); - expect(form.isFieldTouched('password')).toBeTruthy(); - }); - - it('remove Field should trigger onMetaChange', () => { - const onMetaChange = jest.fn(); - const wrapper = mount( -
        - - - -
        , - ); - - wrapper.unmount(); - expect(onMetaChange).toHaveBeenCalledWith(expect.objectContaining({ destroy: true })); - }); - }); - - it('should throw if no Form in use', () => { - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - mount( - - - , - ); - - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: Can not find FormContext. Please make sure you wrap Field under Form.', - ); - - errorSpy.mockRestore(); - }); - - it('keep origin input function', async () => { - const onChange = jest.fn(); - const onValuesChange = jest.fn(); - const wrapper = mount( -
        - - - -
        , - ); - - await changeValue(getField(wrapper), 'Bamboo'); - expect(onValuesChange).toHaveBeenCalledWith({ username: 'Bamboo' }, { username: 'Bamboo' }); - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ target: { value: 'Bamboo' } })); - }); - - it('onValuesChange should not return fully value', async () => { - const onValuesChange = jest.fn(); - - const Demo = ({ showField = true }) => ( -
        - {showField && ( - - - - )} - - - -
        - ); - - const wrapper = mount(); - await changeValue(getField(wrapper, 'bamboo'), 'cute'); - expect(onValuesChange).toHaveBeenCalledWith(expect.anything(), { - light: 'little', - bamboo: 'cute', - }); - - onValuesChange.mockReset(); - wrapper.setProps({ showField: false }); - await changeValue(getField(wrapper, 'bamboo'), 'beauty'); - expect(onValuesChange).toHaveBeenCalledWith(expect.anything(), { bamboo: 'beauty' }); - }); - it('should call onReset fn, when the button is clicked', async () => { - const resetFn = jest.fn(); - const wrapper = mount( -
        - - - - -
        , - ); - await changeValue(getField(wrapper), 'Bamboo'); - wrapper.find('button').simulate('reset'); - await timeout(); - expect(resetFn).toHaveBeenCalledTimes(1); - const { value } = wrapper.find('input').props(); - expect(value).toEqual(''); - }); - it('submit', async () => { - const onFinish = jest.fn(); - const onFinishFailed = jest.fn(); - - const wrapper = mount( -
        - - - - -
        , - ); - - // Not trigger - wrapper.find('button').simulate('submit'); - await timeout(); - wrapper.update(); - matchError(wrapper, "'user' is required"); - expect(onFinish).not.toHaveBeenCalled(); - expect(onFinishFailed).toHaveBeenCalledWith({ - errorFields: [{ name: ['user'], errors: ["'user' is required"], warnings: [] }], - outOfDate: false, - values: {}, - }); - - onFinish.mockReset(); - onFinishFailed.mockReset(); - - // Trigger - await changeValue(getField(wrapper), 'Bamboo'); - wrapper.find('button').simulate('submit'); - await timeout(); - matchError(wrapper, false); - expect(onFinish).toHaveBeenCalledWith({ user: 'Bamboo' }); - expect(onFinishFailed).not.toHaveBeenCalled(); - }); - - it('getInternalHooks should not usable by user', () => { - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - let form; - mount( -
        -
        { - form = instance; - }} - /> -
        , - ); - - expect(form.getInternalHooks()).toEqual(null); - - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: `getInternalHooks` is internal usage. Should not call directly.', - ); - - errorSpy.mockRestore(); - }); - - it('valuePropName', async () => { - let form; - const wrapper = mount( -
        - { - form = instance; - }} - > - - - - -
        , - ); - - wrapper.find('input[type="checkbox"]').simulate('change', { target: { checked: true } }); - await timeout(); - expect(form.getFieldsValue()).toEqual({ check: true }); - - wrapper.find('input[type="checkbox"]').simulate('change', { target: { checked: false } }); - await timeout(); - expect(form.getFieldsValue()).toEqual({ check: false }); - }); - - it('getValueProps', async () => { - const wrapper = mount( -
        -
        - ({ light: val })}> - - -
        -
        , - ); - - expect(wrapper.find('.anything').props().light).toEqual('bamboo'); - }); - - describe('shouldUpdate', () => { - it('work', async () => { - let isAllTouched; - let hasError; - - const wrapper = mount( -
        - - - - - - - - {(_, __, { getFieldsError, isFieldsTouched }) => { - isAllTouched = isFieldsTouched(true); - hasError = getFieldsError().filter(({ errors }) => errors.length).length; - - return null; - }} - -
        , - ); - - await changeValue(getField(wrapper, 'username'), ''); - expect(isAllTouched).toBeFalsy(); - expect(hasError).toBeTruthy(); - - await changeValue(getField(wrapper, 'username'), 'Bamboo'); - expect(isAllTouched).toBeFalsy(); - expect(hasError).toBeFalsy(); - - await changeValue(getField(wrapper, 'password'), 'Light'); - expect(isAllTouched).toBeTruthy(); - expect(hasError).toBeFalsy(); - - await changeValue(getField(wrapper, 'password'), ''); - expect(isAllTouched).toBeTruthy(); - expect(hasError).toBeTruthy(); - }); - - it('true will force one more update', async () => { - let renderPhase = 0; - - const wrapper = mount( -
        - - - - - {(_, __, form) => { - renderPhase += 1; - return ( - - ); - }} - -
        , - ); - - const props = wrapper.find('#holder').props(); - expect(renderPhase).toEqual(2); - expect(props['data-touched']).toBeFalsy(); - expect(props['data-value']).toEqual({ username: 'light' }); - }); - }); - - describe('setFields', () => { - it('should work', () => { - let form; - const wrapper = mount( -
        -
        { - form = instance; - }} - > - - - -
        -
        , - ); - - form.setFields([ - { - name: 'username', - touched: false, - validating: true, - errors: ['Set It!'], - }, - ]); - wrapper.update(); - - matchError(wrapper, 'Set It!'); - expect(wrapper.find('.validating').length).toBeTruthy(); - expect(form.isFieldsTouched()).toBeFalsy(); - }); - - it('should trigger by setField', () => { - const triggerUpdate = jest.fn(); - const formRef = React.createRef(); - - const wrapper = mount( -
        -
        - prev.value !== next.value}> - {() => { - triggerUpdate(); - return ; - }} - -
        -
        , - ); - wrapper.update(); - triggerUpdate.mockReset(); - - // Not trigger render - formRef.current.setFields([{ name: 'others', value: 'no need to update' }]); - wrapper.update(); - expect(triggerUpdate).not.toHaveBeenCalled(); - - // Trigger render - formRef.current.setFields([{ name: 'value', value: 'should update' }]); - wrapper.update(); - expect(triggerUpdate).toHaveBeenCalled(); - }); - }); - - it('render props get meta', () => { - let called1 = false; - let called2 = false; - - mount( -
        - - {(_, meta) => { - expect(meta.name).toEqual(['Light']); - called1 = true; - return null; - }} - - - {(_, meta) => { - expect(meta.name).toEqual(['Bamboo', 'Best']); - called2 = true; - return null; - }} - -
        , - ); - - expect(called1).toBeTruthy(); - expect(called2).toBeTruthy(); - }); - - it('setFieldsValue should clean up status', async () => { - let form; - let currentMeta; - - const wrapper = mount( -
        -
        { - form = instance; - }} - > - new Promise(() => {}) }]}> - {(control, meta) => { - currentMeta = meta; - return ; - }} - -
        -
        , - ); - - // Init - expect(form.getFieldValue('normal')).toBe(undefined); - expect(form.isFieldTouched('normal')).toBeFalsy(); - expect(form.getFieldError('normal')).toEqual([]); - expect(currentMeta.validating).toBeFalsy(); - - // Set it - form.setFieldsValue({ - normal: 'Light', - }); - - expect(form.getFieldValue('normal')).toBe('Light'); - expect(form.isFieldTouched('normal')).toBeTruthy(); - expect(form.getFieldError('normal')).toEqual([]); - expect(currentMeta.validating).toBeFalsy(); - - // Input it - await changeValue(getField(wrapper), 'Bamboo'); - - expect(form.getFieldValue('normal')).toBe('Bamboo'); - expect(form.isFieldTouched('normal')).toBeTruthy(); - expect(form.getFieldError('normal')).toEqual([]); - expect(currentMeta.validating).toBeTruthy(); - - // Set it again - form.setFieldsValue({ - normal: 'Light', - }); - - expect(form.getFieldValue('normal')).toBe('Light'); - expect(form.isFieldTouched('normal')).toBeTruthy(); - expect(form.getFieldError('normal')).toEqual([]); - expect(currentMeta.validating).toBeFalsy(); - }); - - it('warning if invalidate element', () => { - resetWarned(); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - mount( -
        -
        - -

        Light

        -

        Bamboo

        -
        -
        -
        , - ); - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: `children` of Field is not validate ReactElement.', - ); - errorSpy.mockRestore(); - }); - - it('warning if call function before set prop', () => { - jest.useFakeTimers(); - resetWarned(); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - const Test = () => { - const [form] = useForm(); - form.getFieldsValue(); - - return
        ; - }; - - mount(); - - jest.runAllTimers(); - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: Instance created by `useForm` is not connected to any Form element. Forget to pass `form` prop?', - ); - errorSpy.mockRestore(); - jest.useRealTimers(); - }); - - it('filtering fields by meta', async () => { - let form; - - const wrapper = mount( -
        - { - form = instance; - }} - > - - - {() => null} - -
        , - ); - - expect( - form.getFieldsValue(null, meta => { - expect(Object.keys(meta)).toEqual(['touched', 'validating', 'errors', 'warnings', 'name']); - return false; - }), - ).toEqual({}); - - expect(form.getFieldsValue(null, () => true)).toEqual(form.getFieldsValue()); - expect(form.getFieldsValue(null, meta => meta.touched)).toEqual({}); - - await changeValue(getField(wrapper, 0), 'Bamboo'); - expect(form.getFieldsValue(null, () => true)).toEqual(form.getFieldsValue()); - expect(form.getFieldsValue(null, meta => meta.touched)).toEqual({ - username: 'Bamboo', - }); - expect(form.getFieldsValue(['username'], meta => meta.touched)).toEqual({ - username: 'Bamboo', - }); - expect(form.getFieldsValue(['password'], meta => meta.touched)).toEqual({}); - }); - - it('should not crash when return value contains target field', async () => { - const CustomInput = ({ value, onChange }) => { - const onInputChange = e => { - onChange({ - value: e.target.value, - target: 'string', - }); - }; - return ; - }; - const wrapper = mount( -
        - - - -
        , - ); - expect(() => { - wrapper.find('Input').simulate('change', { event: { target: { value: 'Light' } } }); - }).not.toThrowError(); - }); - - it('setFieldsValue for List should work', () => { - const Demo = () => { - const [form] = useForm(); - - const handelReset = () => { - form.setFieldsValue({ - users: [], - }); - }; - - const initialValues = { - users: [{ name: '11' }, { name: '22' }], - }; - - return ( -
        - - {(fields, { add, remove }) => ( - <> - {fields.map(({ key, name, ...restField }) => ( - - - - ))} - - )} - - - - -
        - ); - }; - - const wrapper = mount(); - expect(wrapper.find('input').first().getDOMNode().value).toBe('11'); - wrapper.find('.reset-btn').first().simulate('click'); - expect(wrapper.find('input').length).toBe(0); - }); - - it('setFieldsValue should work for multiple Select', () => { - const Select = ({ value, defaultValue }) => { - return
        {(value || defaultValue || []).toString()}
        ; - }; - - const Demo = () => { - const [formInstance] = Form.useForm(); - - React.useEffect(() => { - formInstance.setFieldsValue({ selector: ['K1', 'K2'] }); - }, [formInstance]); - - return ( -
        - - - -
        - ); - - if (remount) { - node =
        {node}
        ; - } - - return node; - }; - - const wrapper = mount(); - refForm.setFieldsValue({ name: 'bamboo' }); - wrapper.update(); - - expect(wrapper.find('input').prop('value')).toEqual('bamboo'); - - wrapper.setProps({ remount: true }); - wrapper.update(); - - expect(wrapper.find('input').prop('value')).toEqual('bamboo'); - }); - - it('setFieldValue', () => { - const formRef = React.createRef(); - - const Demo = () => ( -
        - - {fields => - fields.map(field => ( - - - - )) - } - - - - - -
        - ); - - const wrapper = mount(); - expect(wrapper.find('input').map(input => input.prop('value'))).toEqual([ - 'bamboo', - 'little', - 'light', - 'nested', - ]); - - // Set - formRef.current.setFieldValue(['list', 1], 'tiny'); - formRef.current.setFieldValue(['nest', 'target'], 'match'); - wrapper.update(); - - expect(wrapper.find('input').map(input => input.prop('value'))).toEqual([ - 'bamboo', - 'tiny', - 'light', - 'match', - ]); - }); -}); diff --git a/tests/index.test.tsx b/tests/index.test.tsx new file mode 100644 index 00000000..b1ca3833 --- /dev/null +++ b/tests/index.test.tsx @@ -0,0 +1,919 @@ +import { act, fireEvent, render } from '@testing-library/react'; +import { resetWarned } from 'rc-util/lib/warning'; +import React from 'react'; +import type { FormInstance } from '../src'; +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'; + +describe('Form.Basic', () => { + describe('create form', () => { + const Content: React.FC = () => ( +
        + + + + {() => null} + +
        + ); + + it('sub component', () => { + const { container } = render( +
        + + , + ); + expect(container.querySelector('form')).toBeTruthy(); + expect(container.querySelectorAll('input').length).toBe(2); + }); + + describe('component', () => { + it('without dom', () => { + const { container } = render( +
        + + , + ); + expect(container.querySelectorAll('form').length).toBe(0); + expect(container.querySelectorAll('input').length).toBe(2); + }); + + it('use string', () => { + const { container } = render( +
        + + , + ); + expect(container.querySelectorAll('form').length).toBe(0); + expect(container.querySelectorAll('pre').length).toBe(1); + expect(container.querySelectorAll('input').length).toBe(2); + }); + + it('use component', () => { + const Component: React.FC = ({ children }) => ( +
        {children}
        + ); + const { container } = render( +
        + + , + ); + expect(container.querySelectorAll('form').length).toBe(0); + expect(container.querySelectorAll('.customize').length).toBe(1); + expect(container.querySelectorAll('input').length).toBe(2); + }); + }); + + describe('render props', () => { + it('normal', () => { + const { container } = render( +
        + + , + ); + expect(container.querySelector('form')).toBeTruthy(); + expect(container.querySelectorAll('input').length).toBe(2); + }); + it('empty', () => { + const { container } = render(
        {() => null}
        ); + expect(container.querySelector('form')).toBeTruthy(); + }); + }); + }); + + it('fields touched', async () => { + const form = React.createRef(); + + const { container } = render( +
        +
        + + + {() => null} + +
        , + ); + + expect(form.current?.isFieldsTouched()).toBeFalsy(); + expect(form.current?.isFieldsTouched(['username', 'password'])).toBeFalsy(); + + await changeValue(getInput(container, 0), 'Bamboo'); + expect(form.current?.isFieldsTouched()).toBeTruthy(); + expect(form.current?.isFieldsTouched(['username', 'password'])).toBeTruthy(); + expect(form.current?.isFieldsTouched(true)).toBeFalsy(); + expect(form.current?.isFieldsTouched(['username', 'password'], true)).toBeFalsy(); + + await changeValue(getInput(container, 1), 'Light'); + expect(form.current?.isFieldsTouched()).toBeTruthy(); + expect(form.current?.isFieldsTouched(['username', 'password'])).toBeTruthy(); + expect(form.current?.isFieldsTouched(true)).toBeTruthy(); + expect(form.current?.isFieldsTouched(['username', 'password'], true)).toBeTruthy(); + }); + + describe('reset form', () => { + function resetTest(name: string, ...args) { + it(name, async () => { + const form = React.createRef(); + const onReset = jest.fn(); + const onMeta = jest.fn(); + + const { container } = render( +
        +
        + + + +
        +
        , + ); + + await changeValue(getInput(container, 'username'), 'Bamboo'); + expect(form.current?.getFieldValue('username')).toEqual('Bamboo'); + expect(form.current?.getFieldError('username')).toEqual([]); + expect(form.current?.isFieldTouched('username')).toBeTruthy(); + expect(onMeta).toHaveBeenCalledWith( + expect.objectContaining({ touched: true, errors: [], warnings: [] }), + ); + expect(onReset).not.toHaveBeenCalled(); + onMeta.mockRestore(); + onReset.mockRestore(); + + form.current?.resetFields(...args); + expect(form.current?.getFieldValue('username')).toEqual(undefined); + expect(form.current?.getFieldError('username')).toEqual([]); + expect(form.current?.isFieldTouched('username')).toBeFalsy(); + expect(onMeta).toHaveBeenCalledWith( + expect.objectContaining({ touched: false, errors: [], warnings: [] }), + ); + expect(onReset).toHaveBeenCalled(); + onMeta.mockRestore(); + onReset.mockRestore(); + + await changeValue(getInput(container, 'username'), ''); + expect(form.current?.getFieldValue('username')).toEqual(''); + expect(form.current?.getFieldError('username')).toEqual(["'username' is required"]); + expect(form.current?.isFieldTouched('username')).toBeTruthy(); + expect(onMeta).toHaveBeenCalledWith( + expect.objectContaining({ + touched: true, + errors: ["'username' is required"], + warnings: [], + }), + ); + expect(onReset).not.toHaveBeenCalled(); + onMeta.mockRestore(); + onReset.mockRestore(); + + form.current?.resetFields(...args); + expect(form.current?.getFieldValue('username')).toEqual(undefined); + expect(form.current?.getFieldError('username')).toEqual([]); + expect(form.current?.isFieldTouched('username')).toBeFalsy(); + expect(onMeta).toHaveBeenCalledWith( + expect.objectContaining({ touched: false, errors: [], warnings: [] }), + ); + expect(onReset).toHaveBeenCalled(); + }); + } + + resetTest('with field name', ['username']); + resetTest('without field name'); + + it('not affect others', async () => { + const form = React.createRef(); + + const { container } = render( +
        +
        + + + + + + + +
        +
        , + ); + + await changeValue(getInput(container, 'username'), 'Bamboo'); + await changeValue(getInput(container, 'password'), ['bamboo', '']); + + form.current?.resetFields(['username']); + + expect(form.current?.getFieldValue('username')).toEqual(undefined); + expect(form.current?.getFieldError('username')).toEqual([]); + expect(form.current?.isFieldTouched('username')).toBeFalsy(); + expect(form.current?.getFieldValue('password')).toEqual(''); + expect(form.current?.getFieldError('password')).toEqual(["'password' is required"]); + expect(form.current?.isFieldTouched('password')).toBeTruthy(); + }); + + it('remove Field should trigger onMetaChange', () => { + const onMetaChange = jest.fn(); + const { unmount } = render( +
        + + + +
        , + ); + unmount(); + expect(onMetaChange).toHaveBeenCalledWith(expect.objectContaining({ destroy: true })); + }); + }); + + it('should throw if no Form in use', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + + , + ); + + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: Can not find FormContext. Please make sure you wrap Field under Form.', + ); + + errorSpy.mockRestore(); + }); + + it('keep origin input function', async () => { + const onChange = jest.fn(); + const onValuesChange = jest.fn(); + const { container } = render( +
        + + + +
        , + ); + + await changeValue(getInput(container), 'Bamboo'); + expect(onValuesChange).toHaveBeenCalledWith({ username: 'Bamboo' }, { username: 'Bamboo' }); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ target: getInput(container) })); + }); + + it('onValuesChange should not return fully value', async () => { + const onValuesChange = jest.fn(); + + const Demo: React.FC = ({ showField = true }) => ( +
        + {showField && ( + + + + )} + + + +
        + ); + + const { container, rerender } = render(); + await changeValue(getInput(container, 'bamboo'), 'cute'); + expect(onValuesChange).toHaveBeenCalledWith(expect.anything(), { + light: 'little', + bamboo: 'cute', + }); + + onValuesChange.mockReset(); + // wrapper.setProps({ showField: false }); + rerender(); + await changeValue(getInput(container, 'bamboo'), 'beauty'); + expect(onValuesChange).toHaveBeenCalledWith(expect.anything(), { bamboo: 'beauty' }); + }); + it('should call onReset fn, when the button is clicked', async () => { + const resetFn = jest.fn(); + const { container } = render( +
        + + + + +
        , + ); + await changeValue(getInput(container), 'Bamboo'); + fireEvent.reset(container.querySelector('form')); + await timeout(); + expect(resetFn).toHaveBeenCalledTimes(1); + expect(getInput(container).value).toEqual(''); + }); + it('submit', async () => { + const onFinish = jest.fn(); + const onFinishFailed = jest.fn(); + + const { container } = render( +
        + + + +
        , + ); + + // Not trigger + fireEvent.submit(container.querySelector('form')); + await timeout(); + matchError(container, "'user' is required"); + expect(onFinish).not.toHaveBeenCalled(); + expect(onFinishFailed).toHaveBeenCalledWith({ + errorFields: [{ name: ['user'], errors: ["'user' is required"], warnings: [] }], + outOfDate: false, + values: {}, + }); + + onFinish.mockReset(); + onFinishFailed.mockReset(); + + // Trigger + await changeValue(getInput(container), 'Bamboo'); + fireEvent.submit(container.querySelector('form')); + await timeout(); + matchError(container, false); + expect(onFinish).toHaveBeenCalledWith({ user: 'Bamboo' }); + expect(onFinishFailed).not.toHaveBeenCalled(); + }); + + it('getInternalHooks should not usable by user', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const form = React.createRef(); + render( +
        +
        +
        , + ); + + expect((form.current as any)?.getInternalHooks()).toEqual(null); + + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: `getInternalHooks` is internal usage. Should not call directly.', + ); + + errorSpy.mockRestore(); + }); + + it('valuePropName', async () => { + const form = React.createRef(); + const { container } = render( +
        + + + + + +
        , + ); + + // wrapper.find('input[type="checkbox"]').simulate('change', { target: { checked: true } }); + fireEvent.click(container.querySelector('input[type="checkbox"]')); + await timeout(); + expect(form.current?.getFieldsValue()).toEqual({ check: true }); + + // wrapper.find('input[type="checkbox"]').simulate('change', { target: { checked: false } }); + fireEvent.click(container.querySelector('input[type="checkbox"]')); + await timeout(); + expect(form.current?.getFieldsValue()).toEqual({ check: false }); + }); + + it('getValueProps', async () => { + const { container } = render( +
        +
        + ({ 'data-light': val })}> + + +
        +
        , + ); + + // expect((container.querySelector('.anything').props() as any).light).toEqual('bamboo'); + expect(container.querySelector('.anything')).toHaveAttribute('data-light', 'bamboo'); + }); + + describe('shouldUpdate', () => { + it('work', async () => { + let isAllTouched: boolean; + let hasError: number; + + const { container } = render( +
        + + + + + + + + {(_, __, { getFieldsError, isFieldsTouched }) => { + isAllTouched = isFieldsTouched(true); + hasError = getFieldsError().filter(({ errors }) => errors.length).length; + + return null; + }} + +
        , + ); + + await changeValue(getInput(container, 'username'), ['bamboo', '']); + expect(isAllTouched).toBeFalsy(); + expect(hasError).toBeTruthy(); + + await changeValue(getInput(container, 'username'), 'Bamboo'); + expect(isAllTouched).toBeFalsy(); + expect(hasError).toBeFalsy(); + + await changeValue(getInput(container, 'password'), 'Light'); + expect(isAllTouched).toBeTruthy(); + expect(hasError).toBeFalsy(); + + await changeValue(getInput(container, 'password'), ''); + expect(isAllTouched).toBeTruthy(); + expect(hasError).toBeTruthy(); + }); + + it('true will force one more update', async () => { + let renderPhase = 0; + + const { container } = render( +
        + + + + + {(_, __, form) => { + renderPhase += 1; + return ( + + ); + }} + +
        , + ); + + // const props = wrapper.find('#holder').props(); + expect(renderPhase).toEqual(2); + // expect(props['data-touched']).toBeFalsy(); + // expect(props['data-value']).toEqual({ username: 'light' }); + expect(container.querySelector('#holder')).toHaveAttribute('data-touched', 'false'); + expect(container.querySelector('#holder')).toHaveAttribute( + 'data-value', + '{"username":"light"}', + ); + }); + }); + + describe('setFields', () => { + it('should work', () => { + const form = React.createRef(); + const { container } = render( +
        +
        + + + +
        +
        , + ); + + act(() => { + form.current?.setFields([ + { name: 'username', touched: false, validating: true, errors: ['Set It!'] }, + ]); + }); + + matchError(container, 'Set It!'); + expect(container.querySelector('.validating')).toBeTruthy(); + expect(form.current?.isFieldsTouched()).toBeFalsy(); + }); + + it('should trigger by setField', () => { + const triggerUpdate = jest.fn(); + const formRef = React.createRef(); + + render( +
        +
        + prev.value !== next.value}> + {() => { + triggerUpdate(); + return ; + }} + +
        +
        , + ); + + triggerUpdate.mockReset(); + + // Not trigger render + act(() => { + formRef.current.setFields([{ name: 'others', value: 'no need to update' }]); + }); + + expect(triggerUpdate).not.toHaveBeenCalled(); + + // Trigger render + act(() => { + formRef.current.setFields([{ name: 'value', value: 'should update' }]); + }); + + expect(triggerUpdate).toHaveBeenCalled(); + }); + }); + + it('render props get meta', () => { + let called1 = false; + let called2 = false; + + render( +
        + + {(_, meta) => { + expect(meta.name).toEqual(['Light']); + called1 = true; + return null; + }} + + + {(_, meta) => { + expect(meta.name).toEqual(['Bamboo', 'Best']); + called2 = true; + return null; + }} + +
        , + ); + + expect(called1).toBeTruthy(); + expect(called2).toBeTruthy(); + }); + + it('setFieldsValue should clean up status', async () => { + const form = React.createRef(); + let currentMeta: Meta = null; + + const { container } = render( +
        +
        + new Promise(() => {}) }]}> + {(control, meta) => { + currentMeta = meta; + return ; + }} + +
        +
        , + ); + + // Init + expect(form.current?.getFieldValue('normal')).toBe(undefined); + expect(form.current?.isFieldTouched('normal')).toBeFalsy(); + expect(form.current?.getFieldError('normal')).toEqual([]); + expect(currentMeta.validating).toBeFalsy(); + + // Set it + act(() => { + form.current?.setFieldsValue({ normal: 'Light' }); + }); + + expect(form.current?.getFieldValue('normal')).toBe('Light'); + expect(form.current?.isFieldTouched('normal')).toBeTruthy(); + expect(form.current?.getFieldError('normal')).toEqual([]); + expect(currentMeta.validating).toBeFalsy(); + + // Input it + await changeValue(getInput(container), 'Bamboo'); + + expect(form.current?.getFieldValue('normal')).toBe('Bamboo'); + expect(form.current?.isFieldTouched('normal')).toBeTruthy(); + expect(form.current?.getFieldError('normal')).toEqual([]); + expect(currentMeta.validating).toBeTruthy(); + + // Set it again + act(() => { + form.current?.setFieldsValue({ normal: 'Light' }); + }); + + expect(form.current?.getFieldValue('normal')).toBe('Light'); + expect(form.current?.isFieldTouched('normal')).toBeTruthy(); + expect(form.current?.getFieldError('normal')).toEqual([]); + expect(currentMeta.validating).toBeFalsy(); + }); + + it('warning if invalidate element', () => { + resetWarned(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + render( +
        +
        + {/* @ts-ignore */} + +

        Light

        +

        Bamboo

        +
        +
        +
        , + ); + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: `children` of Field is not validate ReactElement.', + ); + errorSpy.mockRestore(); + }); + + it('warning if call function before set prop', () => { + jest.useFakeTimers(); + resetWarned(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const Test: React.FC = () => { + const [form] = useForm(); + form.getFieldsValue(); + + return
        ; + }; + + render(); + + jest.runAllTimers(); + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: Instance created by `useForm` is not connected to any Form element. Forget to pass `form` prop?', + ); + errorSpy.mockRestore(); + jest.useRealTimers(); + }); + + it('filtering fields by meta', async () => { + const form = React.createRef(); + + const { container } = render( +
        + + + + {() => null} + +
        , + ); + + expect( + form.current?.getFieldsValue(null, meta => { + expect(Object.keys(meta)).toEqual([ + 'touched', + 'validating', + 'errors', + 'warnings', + 'name', + 'validated', + ]); + return false; + }), + ).toEqual({}); + + expect(form.current?.getFieldsValue(null, () => true)).toEqual(form.current?.getFieldsValue()); + expect(form.current?.getFieldsValue(null, meta => meta.touched)).toEqual({}); + + await changeValue(getInput(container, 0), 'Bamboo'); + expect(form.current?.getFieldsValue(null, () => true)).toEqual(form.current?.getFieldsValue()); + expect(form.current?.getFieldsValue(null, meta => meta.touched)).toEqual({ + username: 'Bamboo', + }); + expect(form.current?.getFieldsValue(['username'], meta => meta.touched)).toEqual({ + username: 'Bamboo', + }); + expect(form.current?.getFieldsValue(['password'], meta => meta.touched)).toEqual({}); + }); + + it('should not crash when return value contains target field', async () => { + const CustomInput: React.FC = ({ value, onChange }) => { + const onInputChange = (e: React.ChangeEvent) => { + onChange({ value: e.target.value, target: 'string' }); + }; + return ; + }; + const { container } = render( +
        + + + +
        , + ); + expect(() => { + fireEvent.change(container.querySelector('input'), { target: { value: 'Light' } }); + }).not.toThrowError(); + }); + + it('setFieldsValue for List should work', () => { + const Demo: React.FC = () => { + const [form] = useForm(); + + const handelReset = () => { + form.setFieldsValue({ + users: [], + }); + }; + + const initialValues = { + users: [{ name: '11' }, { name: '22' }], + }; + + return ( +
        + + {fields => ( + <> + {fields.map(({ key, name, ...restField }) => ( + + + + ))} + + )} + + + + +
        + ); + }; + const { container } = render(); + expect(container.querySelector('input')?.value).toBe('11'); + fireEvent.click(container.querySelector('.reset-btn')); + expect(container.querySelectorAll('input').length).toBe(0); + }); + + it('setFieldsValue should work for multiple Select', () => { + const Select: React.FC = ({ value, defaultValue }) => { + return
        {(value || defaultValue || []).toString()}
        ; + }; + + const Demo: React.FC = () => { + const [formInstance] = Form.useForm(); + + React.useEffect(() => { + formInstance.setFieldsValue({ selector: ['K1', 'K2'] }); + }, [formInstance]); + + return ( +
        + + + +
        + ); + + if (remount) { + node =
        {node}
        ; + } + + return node; + }; + + const { container, rerender } = render(); + act(() => { + refForm.setFieldsValue({ name: 'bamboo' }); + }); + expect(container.querySelector('input').value).toBe('bamboo'); + rerender(); + expect(container.querySelector('input').value).toBe('bamboo'); + }); + + it('setFieldValue', () => { + const formRef = React.createRef(); + + const Demo: React.FC = () => ( +
        + + {fields => + fields.map(field => ( + + + + )) + } + + + + +
        + ); + + const { container } = render(); + expect( + Array.from(container.querySelectorAll('input')).map(input => input?.value), + ).toEqual(['bamboo', 'little', 'light', 'nested']); + + // Set + act(() => { + formRef.current.setFieldValue(['list', 1], 'tiny'); + formRef.current.setFieldValue(['nest', 'target'], 'match'); + }); + + expect( + Array.from(container.querySelectorAll('input')).map(input => input?.value), + ).toEqual(['bamboo', 'tiny', 'light', 'match']); + }); + + it('onMetaChange should only trigger when meta changed', () => { + const onMetaChange = jest.fn(); + const formRef = React.createRef(); + + const Demo: React.FC = () => ( +
        + false}> + {() => null} + +
        + ); + + render(); + + formRef.current?.setFieldsValue({}); + onMetaChange.mockReset(); + + // Re-render should not trigger `onMetaChange` + for (let i = 0; i < 10; i += 1) { + formRef.current?.setFieldsValue({}); + } + + expect(onMetaChange).toHaveBeenCalledTimes(0); + }); + + describe('set to null value', () => { + function test(name: string, callback: (form: FormInstance) => void) { + it(name, async () => { + const form = React.createRef(); + + const { container } = render( +
        +
        + + +
        , + ); + + expect(container.querySelector('input').value).toBe('bamboo'); + expect(form.current.getFieldsValue()).toEqual({ user: { name: 'bamboo' } }); + + // Set it + act(() => { + callback(form.current!); + }); + expect(form.current.getFieldValue(['user', 'name'])).toBeFalsy(); + expect(container.querySelector('input').value).toBe(''); + }); + } + + test('by setFieldsValue', form => { + form.setFieldsValue({ user: null }); + }); + + test('by setFieldValue', form => { + form.setFieldValue('user', null); + }); + }); +}); diff --git a/tests/initialValue.test.js b/tests/initialValue.test.tsx similarity index 66% rename from tests/initialValue.test.js rename to tests/initialValue.test.tsx index 36a8f656..cfb736c6 100644 --- a/tests/initialValue.test.js +++ b/tests/initialValue.test.tsx @@ -1,15 +1,15 @@ -import React, { useState } from 'react'; -import { mount } from 'enzyme'; +import { act, fireEvent, render } from '@testing-library/react'; import { resetWarned } from 'rc-util/lib/warning'; -import Form, { Field, useForm, List } from '../src'; +import React, { useState } from 'react'; +import Form, { Field, List, useForm, type FormInstance } from '../src'; +import { changeValue, getInput } from './common'; import { Input } from './common/InfoField'; -import { changeValue, getField } from './common'; describe('Form.InitialValues', () => { it('works', () => { let form; - const wrapper = mount( + const { container } = render(
        { @@ -18,10 +18,10 @@ describe('Form.InitialValues', () => { initialValues={{ username: 'Light', path1: { path2: 'Bamboo' } }} > - + - +
        , @@ -47,12 +47,12 @@ describe('Form.InitialValues', () => { path2: 'Bamboo', }, }); - expect(getField(wrapper, 'username').find('input').props().value).toEqual('Light'); - expect(getField(wrapper, ['path1', 'path2']).find('input').props().value).toEqual('Bamboo'); + expect(getInput(container, 'username').value).toEqual('Light'); + expect(getInput(container, 'path1.path2').value).toEqual('Bamboo'); }); it('update and reset should use new initialValues', () => { - let form; + let form: FormInstance; let mountCount = 0; const TestInput = props => { @@ -71,45 +71,47 @@ describe('Form.InitialValues', () => { initialValues={initialValues} > - + - + ); - const wrapper = mount(); + const { container, rerender } = render(); expect(form.getFieldsValue()).toEqual({ username: 'Bamboo', }); - expect(getField(wrapper, 'username').find('input').props().value).toEqual('Bamboo'); + expect(getInput(container, 'username').value).toEqual('Bamboo'); + expect(mountCount).toEqual(1); // Should not change it - wrapper.setProps({ initialValues: { username: 'Light' } }); - wrapper.update(); + rerender(); expect(form.getFieldsValue()).toEqual({ username: 'Bamboo', }); - expect(getField(wrapper, 'username').find('input').props().value).toEqual('Bamboo'); + expect(getInput(container, 'username').value).toEqual('Bamboo'); // Should change it - form.resetFields(); - wrapper.update(); - expect(mountCount).toEqual(1); + act(() => { + form.resetFields(); + }); + expect(mountCount).toEqual(2); expect(form.getFieldsValue()).toEqual({ username: 'Light', }); - expect(getField(wrapper, 'username').find('input').props().value).toEqual('Light'); + expect(getInput(container, 'username').value).toEqual('Light'); }); - it("initialValues shouldn't be modified if preserve is false", () => { + // FIXME: Not work in React 18 + it.skip("initialValues shouldn't be modified if preserve is false", () => { const formValue = { test: 'test', users: [{ first: 'aaa', last: 'bbb' }], }; - let refForm; + let refForm: FormInstance; const Demo = () => { const [form] = Form.useForm(); @@ -159,25 +161,26 @@ describe('Form.InitialValues', () => { ); }; - const wrapper = mount(); - wrapper.find('button').simulate('click'); + const { container } = render(); + + fireEvent.click(container.querySelector('button')); expect(formValue.users[0].last).toEqual('bbb'); + console.log('Form Value:', refForm.getFieldsValue(true)); - wrapper.find('button').simulate('click'); + fireEvent.click(container.querySelector('button')); expect(formValue.users[0].last).toEqual('bbb'); console.log('Form Value:', refForm.getFieldsValue(true)); - wrapper.find('button').simulate('click'); - wrapper.update(); + fireEvent.click(container.querySelector('button')); - expect(wrapper.find('.first-name-input').first().find('input').prop('value')).toEqual('aaa'); + expect(container.querySelector('.first-name-input').value).toEqual('aaa'); }); describe('Field with initialValue', () => { it('warning if Form already has initialValues', () => { resetWarned(); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const wrapper = mount( + const { container } = render(
        @@ -185,7 +188,7 @@ describe('Form.InitialValues', () => { , ); - expect(wrapper.find('input').props().value).toEqual('bamboo'); + expect(getInput(container).value).toEqual('bamboo'); expect(errorSpy).toHaveBeenCalledWith( "Warning: Form already set 'initialValues' with path 'conflict'. Field can not overwrite it.", @@ -197,7 +200,7 @@ describe('Form.InitialValues', () => { it('warning if multiple Field with same name set `initialValue`', () => { resetWarned(); const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - mount( + render(
        @@ -236,21 +239,20 @@ describe('Form.InitialValues', () => { ); }; - const wrapper = mount(); - wrapper.find('button').simulate('click'); - wrapper.update(); + const { container } = render(); + fireEvent.click(container.querySelector('button')); // First mount should reset value - expect(wrapper.find('input').props().value).toEqual('light'); + expect(getInput(container).value).toEqual('light'); // Do not reset value when value already exist - await changeValue(wrapper, 'bamboo'); - expect(wrapper.find('input').props().value).toEqual('bamboo'); + await changeValue(getInput(container), 'bamboo'); + expect(getInput(container).value).toEqual('bamboo'); + + fireEvent.click(container.querySelector('button')); + fireEvent.click(container.querySelector('button')); - wrapper.find('button').simulate('click'); - wrapper.find('button').simulate('click'); - wrapper.update(); - expect(wrapper.find('input').props().value).toEqual('bamboo'); + expect(getInput(container).value).toEqual('bamboo'); }); it('form reset should work', async () => { @@ -265,12 +267,14 @@ describe('Form.InitialValues', () => {
        + ), + { + onValuesChange, + initialValues: { + list: [{ first: 'light' }], + }, + }, + ); + fireEvent.click(container.querySelector('.add-btn')!); + expect(onValuesChange).toHaveBeenCalledWith(expect.anything(), { + list: [{ first: 'light' }, undefined], + }); + fireEvent.click(container.querySelector('.remove-btn')!); expect(onValuesChange).toHaveBeenCalledWith(expect.anything(), { list: [{ first: 'light' }] }); }); describe('isFieldTouched edge case', () => { - it('virtual object', () => { + it('virtual object', async () => { const formRef = React.createRef(); - const wrapper = mount( + const { container } = render(
        @@ -650,58 +663,50 @@ describe('Form.List', () => { ); // Not changed - expect(formRef.current.isFieldTouched('user')).toBeFalsy(); - expect(formRef.current.isFieldsTouched(['user'], false)).toBeFalsy(); - expect(formRef.current.isFieldsTouched(['user'], true)).toBeFalsy(); + expect(formRef.current?.isFieldTouched('user')).toBeFalsy(); + expect(formRef.current?.isFieldsTouched(['user'], false)).toBeFalsy(); + expect(formRef.current?.isFieldsTouched(['user'], true)).toBeFalsy(); // Changed - wrapper - .find('input') - .first() - .simulate('change', { target: { value: '' } }); + await changeValue(getInput(container, 0), ['bamboo', '']); - expect(formRef.current.isFieldTouched('user')).toBeTruthy(); - expect(formRef.current.isFieldsTouched(['user'], false)).toBeTruthy(); - expect(formRef.current.isFieldsTouched(['user'], true)).toBeTruthy(); + expect(formRef.current?.isFieldTouched('user')).toBeTruthy(); + expect(formRef.current?.isFieldsTouched(['user'], false)).toBeTruthy(); + expect(formRef.current?.isFieldsTouched(['user'], true)).toBeTruthy(); }); - it('List children change', () => { - const [wrapper] = generateForm( + it('List children change', async () => { + const [container] = generateForm( fields => (
        {fields.map(field => ( - + ))}
        ), - { - initialValues: { list: ['light', 'bamboo'] }, - }, + { initialValues: { list: ['light', 'bamboo'] } }, ); // Not changed yet - expect(form.isFieldTouched('list')).toBeFalsy(); - expect(form.isFieldsTouched(['list'], false)).toBeFalsy(); - expect(form.isFieldsTouched(['list'], true)).toBeFalsy(); + expect(form.current?.isFieldTouched('list')).toBeFalsy(); + expect(form.current?.isFieldsTouched(['list'], false)).toBeFalsy(); + expect(form.current?.isFieldsTouched(['list'], true)).toBeFalsy(); // Change children value - wrapper - .find('input') - .first() - .simulate('change', { target: { value: 'little' } }); + await changeValue(getInput(container, 0), 'little'); - expect(form.isFieldTouched('list')).toBeTruthy(); - expect(form.isFieldsTouched(['list'], false)).toBeTruthy(); - expect(form.isFieldsTouched(['list'], true)).toBeTruthy(); + expect(form.current?.isFieldTouched('list')).toBeTruthy(); + expect(form.current?.isFieldsTouched(['list'], false)).toBeTruthy(); + expect(form.current?.isFieldsTouched(['list'], true)).toBeTruthy(); }); it('List self change', () => { - const [wrapper] = generateForm((fields, opt) => ( + const [container] = generateForm((fields, opt) => (
        {fields.map(field => ( - + ))} @@ -715,16 +720,16 @@ describe('Form.List', () => { )); // Not changed yet - expect(form.isFieldTouched('list')).toBeFalsy(); - expect(form.isFieldsTouched(['list'], false)).toBeFalsy(); - expect(form.isFieldsTouched(['list'], true)).toBeFalsy(); + expect(form.current?.isFieldTouched('list')).toBeFalsy(); + expect(form.current?.isFieldsTouched(['list'], false)).toBeFalsy(); + expect(form.current?.isFieldsTouched(['list'], true)).toBeFalsy(); // Change children value - wrapper.find('button').simulate('click'); + fireEvent.click(container.querySelector('button')!); - expect(form.isFieldTouched('list')).toBeTruthy(); - expect(form.isFieldsTouched(['list'], false)).toBeTruthy(); - expect(form.isFieldsTouched(['list'], true)).toBeTruthy(); + expect(form.current?.isFieldTouched('list')).toBeTruthy(); + expect(form.current?.isFieldsTouched(['list'], false)).toBeTruthy(); + expect(form.current?.isFieldsTouched(['list'], true)).toBeTruthy(); }); }); @@ -733,7 +738,7 @@ describe('Form.List', () => { fields => (
        {fields.map(field => ( - + ))} @@ -743,7 +748,7 @@ describe('Form.List', () => { { initialValue: ['light', 'bamboo'] }, ); - expect(form.getFieldsValue()).toEqual({ + expect(form.current?.getFieldsValue()).toEqual({ list: ['light', 'bamboo'], }); }); @@ -765,7 +770,7 @@ describe('Form.List', () => { ); }; - const [wrapper] = generateForm( + const [container] = generateForm( fields => (
        {fields.map(field => { @@ -780,8 +785,64 @@ describe('Form.List', () => { }, ); - expect(wrapper.find('.internal-key').text()).toEqual('0'); - expect(wrapper.find('.internal-rest').text()).toEqual('user'); - expect(wrapper.find('input').prop('value')).toEqual('bamboo'); + expect(container.querySelector('.internal-key')!.textContent).toEqual('0'); + expect(container.querySelector('.internal-rest')!.textContent).toEqual('user'); + expect(getInput(container).value).toEqual('bamboo'); + }); + + it('list should not pass context', async () => { + const onValuesChange = jest.fn(); + + const InnerForm = () => ( + + + + + + + + + ); + + const { container } = render( +
        + {() => } +
        , + ); + + await changeValue(getInput(container, 0), 'little'); + + expect(onValuesChange).toHaveBeenCalledWith({ name: 'little' }, { name: 'little', age: 2 }); + }); + + it('getFieldsValue with Strict mode', () => { + const formRef = React.createRef(); + + const initialValues = { list: [{ bamboo: 1, light: 3 }], little: 9 }; + + render( +
        +
        + + + + + {fields => + fields.map(field => ( + + + + )) + } + +
        +
        , + ); + + // Strict only return field not list + expect(formRef.current.getFieldsValue({ strict: true })).toEqual({ + list: [{ bamboo: 1 }], + little: 9, + }); }); }); diff --git a/tests/nameTypeCheck.test.tsx b/tests/nameTypeCheck.test.tsx new file mode 100644 index 00000000..9179a9ac --- /dev/null +++ b/tests/nameTypeCheck.test.tsx @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { useMemo } from 'react'; +import { render } from '@testing-library/react'; +import Form, { Field, List } from '../src'; +import type { NamePath } from '../src/interface'; + +describe('nameTypeCheck', () => { + it('typescript', () => { + type FieldType = { + a: string; + b?: string[]; + c?: { c1?: string; c2?: string[]; c3?: boolean[] }[]; + d?: { d1?: string[]; d2?: string }; + e?: { e1?: { e2?: string; e3?: string[]; e4: { e5: { e6: string } } } }; + list?: { age?: string }[]; + }; + + type fieldType = NamePath; + + const Demo: React.FC = () => { + return ( +
        + + + {/* 无类型 */} + + + + + + + + + + {/* */} + {/* 有类型 */} + name={'a'} /> + name={'b'} /> + name={'c'} /> + name={'d'} /> + name={'e'} /> + name={['a']} /> + name={['b']} /> + name={['c']} /> + name={['d']} /> + name={['e']} /> + name={['b', 1]} /> + name={['c', 1]} /> + name={['c', 1, 'c1']} /> + name={['c', 1, 'c2']} /> + name={['c', 1, 'c2', 1]} /> + name={['c', 1, 'c3']} /> + name={['c', 1, 'c3', 1]} /> + name={['d', 'd1']} /> + name={['d', 'd1', 1]} /> + name={['d', 'd2']} /> + name={['e', 'e1']} /> + name={['e', 'e1', 'e2']} /> + name={['e', 'e1', 'e3']} /> + name={['e', 'e1', 'e3', 1]} /> + name={['e', 'e1', 'e4']} /> + name={['e', 'e1', 'e4', 'e5']} /> + name={['e', 'e1', 'e4', 'e5', 'e6']} /> + {/* list */} + name={'list'}> + {fields => { + return fields.map(field => ( + {...field} name={[1, 'age']} key={field.key} /> + )); + }} + + + ); + }; + render(); + }); + it('type inference', () => { + interface Props { + data?: T[]; + list?: { name?: NamePath }[]; + } + function func(props: Props) { + return props; + } + func({ data: [{ a: { b: 'c' } }], list: [{ name: ['a', 'b'] }] }); + }); + it('more type', () => { + // Moment + type t1 = NamePath<{ a: { b: string; func: Moment } }>; + // Function + type t2 = NamePath<{ a: { b: string; func: () => { c: string } } }>; + // known + const t3: NamePath = 'a'; + + interface Moment { + func2: Function; + format: (format?: string) => string; + } + }); + it('tree', () => { + type t1 = NamePath<{ a: TreeNode }>; + + interface TreeNode { + child: TreeNode[]; + } + }); +}); diff --git a/tests/preserve.test.tsx b/tests/preserve.test.tsx index 042c14dc..e4951f2d 100644 --- a/tests/preserve.test.tsx +++ b/tests/preserve.test.tsx @@ -1,23 +1,12 @@ -/* eslint-disable no-template-curly-in-string, arrow-body-style */ -import { mount } from 'enzyme'; import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; import type { FormInstance } from '../src'; import Form from '../src'; import InfoField, { Input } from './common/InfoField'; import timeout from './common/timeout'; describe('Form.Preserve', () => { - const Demo = ({ - removeField, - formPreserve, - fieldPreserve, - onFinish, - }: { - removeField: boolean; - formPreserve?: boolean; - fieldPreserve?: boolean; - onFinish: (values: object) => void; - }) => ( + const Demo: React.FC = ({ removeField, formPreserve, fieldPreserve, onFinish }) => (
        {!removeField && } @@ -26,16 +15,16 @@ describe('Form.Preserve', () => { it('field', async () => { const onFinish = jest.fn(); - const wrapper = mount(); - - async function matchTest(removeField: boolean, match: object) { + const { container, rerender } = render( + , + ); + const matchTest = async (removeField: boolean, match: object) => { onFinish.mockReset(); - wrapper.setProps({ removeField }); - wrapper.find('form').simulate('submit'); + rerender(); + fireEvent.submit(container.querySelector('form')); await timeout(); expect(onFinish).toHaveBeenCalledWith(match); - } - + }; await matchTest(false, { keep: 233, remove: 666 }); await matchTest(true, { keep: 233 }); await matchTest(false, { keep: 233, remove: 666 }); @@ -43,16 +32,16 @@ describe('Form.Preserve', () => { it('form', async () => { const onFinish = jest.fn(); - const wrapper = mount(); - - async function matchTest(removeField: boolean, match: object) { + const { container, rerender } = render( + , + ); + const matchTest = async (removeField: boolean, match: object) => { onFinish.mockReset(); - wrapper.setProps({ removeField }); - wrapper.find('form').simulate('submit'); + rerender(); + fireEvent.submit(container.querySelector('form')); await timeout(); expect(onFinish).toHaveBeenCalledWith(match); - } - + }; await matchTest(false, { keep: 233, remove: 666 }); await matchTest(true, { keep: 233 }); await matchTest(false, { keep: 233, remove: 666 }); @@ -61,119 +50,94 @@ describe('Form.Preserve', () => { it('keep preserve when other field exist the name', async () => { const formRef = React.createRef(); - const KeepDemo = ({ onFinish, keep }: { onFinish: (values: any) => void; keep: boolean }) => { - return ( - - - {() => { - return ( - <> - {keep && } - - - ); - }} - - - ); - }; + const KeepDemo: React.FC = ({ onFinish, keep }) => ( +
        + + {() => ( + <> + {keep && } + + + )} + +
        + ); const onFinish = jest.fn(); - const wrapper = mount(); + const { container, rerender } = render(); // Change value - wrapper - .find('input') - .first() - .simulate('change', { target: { value: 'light' } }); + fireEvent.change(container.querySelector('input'), { + target: { value: 'light' }, + }); - formRef.current.submit(); + formRef.current?.submit(); await timeout(); expect(onFinish).toHaveBeenCalledWith({ test: 'light' }); onFinish.mockReset(); // Remove preserve should not change the value - wrapper.setProps({ keep: false }); + rerender(); await timeout(); - formRef.current.submit(); + formRef.current?.submit(); await timeout(); expect(onFinish).toHaveBeenCalledWith({ test: 'light' }); }); it('form preserve but field !preserve', async () => { const onFinish = jest.fn(); - const wrapper = mount( + const { container, rerender } = render( , ); - - async function matchTest(removeField: boolean, match: object) { + const matchTest = async (removeField: boolean, match: object) => { onFinish.mockReset(); - wrapper.setProps({ removeField }); - wrapper.find('form').simulate('submit'); + rerender( + , + ); + fireEvent.submit(container.querySelector('form')); await timeout(); expect(onFinish).toHaveBeenCalledWith(match); - } - + }; await matchTest(true, { keep: 233 }); await matchTest(false, { keep: 233, remove: 666 }); }); describe('Form.List', () => { it('form preserve should not crash', async () => { - let form: FormInstance; - - const wrapper = mount( -
        { - form = instance; - }} - > + const form = React.createRef(); + + const { container } = render( + {(fields, { remove }) => { return (
        {fields.map(field => ( - + ))} -
        ); }}
        , ); - - wrapper.find('button').simulate('click'); - wrapper.update(); - - expect(form.getFieldsValue()).toEqual({ list: ['bamboo', 'little'] }); + fireEvent.click(container.querySelector('button')); + expect(form.current?.getFieldsValue()).toEqual({ list: ['bamboo', 'little'] }); }); it('warning when Form.List use preserve', () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - let form: FormInstance; - - const wrapper = mount( -
        { - form = instance; - }} - initialValues={{ list: ['bamboo'] }} - > + const form = React.createRef(); + const { container } = render( + {(fields, { remove }) => ( <> {fields.map(field => ( - + ))} @@ -197,106 +161,84 @@ describe('Form.Preserve', () => { errorSpy.mockRestore(); // Remove should not work - wrapper.find('button').simulate('click'); - expect(form.getFieldsValue()).toEqual({ list: [] }); + fireEvent.click(container.querySelector('button')); + expect(form.current?.getFieldsValue()).toEqual({ list: [] }); }); it('multiple level field can use preserve', async () => { - let form: FormInstance; - - const wrapper = mount( - { - form = instance; - }} - > + const form = React.createRef(); + + const { container } = render( + - {(fields, { remove }) => { - return ( - <> - {fields.map(field => ( -
        - - - - - {(_, __, { getFieldValue }) => - getFieldValue(['list', field.name, 'type']) === 'light' ? ( - - - - ) : ( - - - - ) - } - -
        - ))} - - - ); - }} + {(fields, { remove }) => ( + <> + {fields.map(field => ( +
        + + + + + {(_, __, { getFieldValue }) => + getFieldValue(['list', field.name, 'type']) === 'light' ? ( + + + + ) : ( + + + + ) + } + +
        + ))} + + + )}
        , ); // Change light value - wrapper - .find('input') - .last() - .simulate('change', { target: { value: '1128' } }); + fireEvent.change(container.querySelectorAll('input')[1], { + target: { value: '1128' }, + }); // Change type - wrapper - .find('input') - .first() - .simulate('change', { target: { value: 'bamboo' } }); + fireEvent.change(container.querySelectorAll('input')[0], { + target: { value: 'bamboo' }, + }); - // Change bamboo value - wrapper - .find('input') - .last() - .simulate('change', { target: { value: '903' } }); + // Change type + fireEvent.change(container.querySelectorAll('input')[1], { + target: { value: '903' }, + }); - expect(form.getFieldsValue()).toEqual({ list: [{ type: 'bamboo', bamboo: '903' }] }); + expect(form.current?.getFieldsValue()).toEqual({ list: [{ type: 'bamboo', bamboo: '903' }] }); // ============== Remove Test ============== // Remove field - wrapper.find('button').simulate('click'); - expect(form.getFieldsValue()).toEqual({ list: [] }); + fireEvent.click(container.querySelector('button')); + expect(form.current?.getFieldsValue()).toEqual({ list: [] }); }); }); it('nest render props should not clean full store', () => { - let form: FormInstance; + const form = React.createRef(); - const wrapper = mount( -
        { - form = instance; - }} - > + const { container, unmount } = render( + @@ -308,79 +250,76 @@ describe('Form.Preserve', () => {
        , ); - wrapper.find('input').simulate('change', { target: { value: 'bamboo' } }); - expect(form.getFieldsValue()).toEqual({ light: 'bamboo' }); + fireEvent.change(container.querySelector('input'), { + target: { value: 'bamboo' }, + }); + expect(form.current?.getFieldsValue()).toEqual({ light: 'bamboo' }); - wrapper.find('input').simulate('change', { target: { value: 'little' } }); - expect(form.getFieldsValue()).toEqual({ light: 'little' }); + fireEvent.change(container.querySelector('input'), { + target: { value: 'little' }, + }); + expect(form.current?.getFieldsValue()).toEqual({ light: 'little' }); - wrapper.unmount(); + unmount(); }); // https://github.com/ant-design/ant-design/issues/31297 describe('A -> B -> C should keep trigger refresh', () => { it('shouldUpdate', () => { - const DepDemo = () => { + const DepDemo: React.FC = () => { const [form] = Form.useForm(); - return (
        - - {() => { - return form.getFieldValue('name') === '1' ? ( + {() => + form.getFieldValue('name') === '1' ? ( - ) : null; - }} + ) : null + } - - {() => { - const password = form.getFieldValue('password'); - return password ? ( + {() => + form.getFieldValue('password') ? ( - ) : null; - }} + ) : null + }
        ); }; - const wrapper = mount(); + const { container } = render(); // Input name to show password - wrapper - .find('#name') - .last() - .simulate('change', { target: { value: '1' } }); - expect(wrapper.exists('#password')).toBeTruthy(); - expect(wrapper.exists('#password2')).toBeFalsy(); + fireEvent.change(container.querySelector('#name'), { + target: { value: '1' }, + }); + expect(container.querySelector('#password')).toBeTruthy(); + expect(container.querySelector('#password2')).toBeFalsy(); // Input password to show password2 - wrapper - .find('#password') - .last() - .simulate('change', { target: { value: '1' } }); - expect(wrapper.exists('#password2')).toBeTruthy(); + fireEvent.change(container.querySelector('#password'), { + target: { value: '1' }, + }); + expect(container.querySelector('#password2')).toBeTruthy(); // Change name to hide password - wrapper - .find('#name') - .last() - .simulate('change', { target: { value: '2' } }); - expect(wrapper.exists('#password')).toBeFalsy(); - expect(wrapper.exists('#password2')).toBeFalsy(); + fireEvent.change(container.querySelector('#name'), { + target: { value: '2' }, + }); + expect(container.querySelector('#password')).toBeFalsy(); + expect(container.querySelector('#password2')).toBeFalsy(); }); it('dependencies', () => { - const DepDemo = () => { + const DepDemo: React.FC = () => { const [form] = Form.useForm(); return ( @@ -413,37 +352,34 @@ describe('Form.Preserve', () => { ); }; - const wrapper = mount(); + const { container } = render(); // Input name to show password - wrapper - .find('#name') - .last() - .simulate('change', { target: { value: '1' } }); - expect(wrapper.exists('#password')).toBeTruthy(); - expect(wrapper.exists('#password2')).toBeFalsy(); + fireEvent.change(container.querySelector('#name'), { + target: { value: '1' }, + }); + expect(container.querySelector('#password')).toBeTruthy(); + expect(container.querySelector('#password2')).toBeFalsy(); // Input password to show password2 - wrapper - .find('#password') - .last() - .simulate('change', { target: { value: '1' } }); - expect(wrapper.exists('#password2')).toBeTruthy(); + fireEvent.change(container.querySelector('#password'), { + target: { value: '1' }, + }); + expect(container.querySelector('#password2')).toBeTruthy(); // Change name to hide password - wrapper - .find('#name') - .last() - .simulate('change', { target: { value: '2' } }); - expect(wrapper.exists('#password')).toBeFalsy(); - expect(wrapper.exists('#password2')).toBeFalsy(); + fireEvent.change(container.querySelector('#name'), { + target: { value: '2' }, + }); + expect(container.querySelector('#password')).toBeFalsy(); + expect(container.querySelector('#password2')).toBeFalsy(); }); }); it('should correct calculate preserve state', () => { let instance: FormInstance; - const VisibleDemo = ({ visible = true }: { visible?: boolean }) => { + const VisibleDemo: React.FC<{ visible?: boolean }> = ({ visible = true }) => { const [form] = Form.useForm(); instance = form; @@ -458,18 +394,55 @@ describe('Form.Preserve', () => { ); }; - const wrapper = mount(); + const { container, rerender } = render(); - wrapper.setProps({ - visible: false, - }); + rerender(); instance.setFieldsValue({ name: 'bamboo' }); - wrapper.setProps({ - visible: true, - }); + rerender(); + + expect(container.querySelector('input')?.value).toEqual('bamboo'); + }); + + it('nest Form.List should clear correctly', async () => { + const { container } = render( +
        + + {(fields, { remove }) => { + return fields.map(field => ( +
        +
        + )); + }} +
        +
        , + ); + + expect(container.querySelector('input').value).toEqual('bamboo'); - expect(wrapper.find('input').prop('value')).toEqual('bamboo'); + // Clean + fireEvent.click(container.querySelector('button')); + expect(container.querySelector('input')).toBeFalsy(); }); }); -/* eslint-enable no-template-curly-in-string */ diff --git a/tests/strict.test.tsx b/tests/strict.test.tsx index d4685477..1510af86 100644 --- a/tests/strict.test.tsx +++ b/tests/strict.test.tsx @@ -1,14 +1,14 @@ import React from 'react'; -import { mount } from 'enzyme'; import Form from '../src'; import InfoField, { Input } from './common/InfoField'; -import { changeValue } from './common'; +import { changeValue, getInput } from './common'; +import { render } from '@testing-library/react'; describe('Form.ReactStrict', () => { it('should not register twice', async () => { const onFieldsChange = jest.fn(); - const wrapper = mount( + const { container } = render(
        @@ -18,7 +18,7 @@ describe('Form.ReactStrict', () => { , ); - await changeValue(wrapper, 'bamboo'); + await changeValue(getInput(container), 'bamboo'); expect(onFieldsChange).toHaveBeenCalledTimes(1); expect(onFieldsChange.mock.calls[0][1]).toHaveLength(1); diff --git a/tests/useWatch.test.tsx b/tests/useWatch.test.tsx index 88f3afb7..2c035975 100644 --- a/tests/useWatch.test.tsx +++ b/tests/useWatch.test.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { mount } from 'enzyme'; +import React, { useRef, useState } from 'react'; +import { render, fireEvent } from '@testing-library/react'; import type { FormInstance } from '../src'; import { List } from '../src'; import Form, { Field } from '../src'; @@ -7,15 +7,13 @@ 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'; describe('useWatch', () => { - let staticForm: FormInstance; - it('field initialValue', async () => { - const Demo = () => { + const Demo: React.FC = () => { const [form] = Form.useForm(); const nameValue = Form.useWatch('name', form); - return (
        @@ -27,18 +25,18 @@ describe('useWatch', () => {
        ); }; + + const { container } = render(); await act(async () => { - const wrapper = mount(); await timeout(); - expect(wrapper.find('.values').text()).toEqual('bamboo'); }); + expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); it('form initialValue', async () => { - const Demo = () => { + const Demo: React.FC = () => { const [form] = Form.useForm(); const nameValue = Form.useWatch(['name'], form); - return (
        @@ -50,26 +48,22 @@ describe('useWatch', () => {
        ); }; + + const { container } = render(); await act(async () => { - const wrapper = mount(); await timeout(); - expect(wrapper.find('.values').text()).toEqual('bamboo'); }); + expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); it('change value with form api', async () => { - const Demo = () => { + const staticForm = React.createRef(); + const Demo: React.FC = () => { const [form] = Form.useForm(); const nameValue = Form.useWatch(['name'], form); - return (
        - { - staticForm = instance; - }} - > + @@ -78,26 +72,33 @@ describe('useWatch', () => {
        ); }; + + const { container } = render(); await act(async () => { - const wrapper = mount(); await timeout(); - staticForm.setFields([{ name: 'name', value: 'little' }]); - expect(wrapper.find('.values').text()).toEqual('little'); + }); - staticForm.setFieldsValue({ name: 'light' }); - expect(wrapper.find('.values').text()).toEqual('light'); + await act(async () => { + staticForm.current?.setFields([{ name: 'name', value: 'little' }]); + }); + expect(container.querySelector('.values').textContent)?.toEqual('little'); - staticForm.resetFields(); - expect(wrapper.find('.values').text()).toEqual(''); + await act(async () => { + staticForm.current?.setFieldsValue({ name: 'light' }); }); + expect(container.querySelector('.values').textContent)?.toEqual('light'); + + await act(async () => { + staticForm.current?.resetFields(); + }); + expect(container.querySelector('.values').textContent)?.toEqual(''); }); describe('unmount', () => { it('basic', async () => { - const Demo = ({ visible }: { visible: boolean }) => { + const Demo: React.FC<{ visible?: boolean }> = ({ visible }) => { const [form] = Form.useForm(); const nameValue = Form.useWatch(['name'], form); - return (
        @@ -112,24 +113,24 @@ describe('useWatch', () => { ); }; + const { container, rerender } = render(); + await act(async () => { - const wrapper = mount(); await timeout(); + }); - expect(wrapper.find('.values').text()).toEqual('bamboo'); + expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); - wrapper.setProps({ visible: false }); - expect(wrapper.find('.values').text()).toEqual(''); + rerender(); + expect(container.querySelector('.values')?.textContent).toEqual(''); - wrapper.setProps({ visible: true }); - expect(wrapper.find('.values').text()).toEqual('bamboo'); - }); + rerender(); + expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); it('nest children component', async () => { - const DemoWatch = () => { + const DemoWatch: React.FC = () => { Form.useWatch(['name']); - return ( @@ -137,7 +138,7 @@ describe('useWatch', () => { ); }; - const Demo = ({ visible }: { visible: boolean }) => { + const Demo: React.FC<{ visible?: boolean }> = ({ visible }) => { const [form] = Form.useForm(); const nameValue = Form.useWatch(['name'], form); @@ -151,26 +152,24 @@ describe('useWatch', () => { ); }; + const { container, rerender } = render(); await act(async () => { - const wrapper = mount(); await timeout(); + }); + expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); - expect(wrapper.find('.values').text()).toEqual('bamboo'); - - wrapper.setProps({ visible: false }); - expect(wrapper.find('.values').text()).toEqual(''); + rerender(); + expect(container.querySelector('.values')?.textContent).toEqual(''); - wrapper.setProps({ visible: true }); - expect(wrapper.find('.values').text()).toEqual('bamboo'); - }); + rerender(); + expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); }); it('list', async () => { - const Demo = () => { + const Demo: React.FC = () => { const [form] = Form.useForm(); const users = Form.useWatch(['users'], form) || []; - return (
        {JSON.stringify(users)}
        @@ -195,36 +194,43 @@ describe('useWatch', () => { ); }; + + const { container } = render(); await act(async () => { - const wrapper = mount(); await timeout(); - expect(wrapper.find('.values').text()).toEqual(JSON.stringify(['bamboo', 'light'])); - - wrapper.find('.remove').at(0).simulate('click'); + }); + expect(container.querySelector('.values')?.textContent).toEqual( + JSON.stringify(['bamboo', 'light']), + ); + fireEvent.click(container.querySelector('.remove')); + await act(async () => { await timeout(); - expect(wrapper.find('.values').text()).toEqual(JSON.stringify(['light'])); }); + expect(container.querySelector('.values')?.textContent).toEqual( + JSON.stringify(['light']), + ); }); it('warning if not provide form', () => { - const errorSpy = jest.spyOn(console, 'error'); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const Demo = () => { + const Demo: React.FC = () => { Form.useWatch([]); return null; }; - mount(); + render(); expect(errorSpy).toHaveBeenCalledWith( 'Warning: useWatch requires a form instance since it can not auto detect from context.', ); + errorSpy.mockRestore(); }); - it('no more render time', () => { + it('no more render time', async () => { let renderTime = 0; - const Demo = () => { + const Demo: React.FC = () => { const [form] = Form.useForm(); const name = Form.useWatch('name', form); @@ -243,37 +249,18 @@ describe('useWatch', () => { ); }; - const wrapper = mount(); + const { container } = render(); expect(renderTime).toEqual(1); - wrapper - .find('input') - .first() - .simulate('change', { - target: { - value: 'bamboo', - }, - }); + const input = container.querySelectorAll('input'); + + await changeValue(input[0], 'bamboo'); expect(renderTime).toEqual(2); - wrapper - .find('input') - .last() - .simulate('change', { - target: { - value: '123', - }, - }); + await changeValue(input[1], '123'); expect(renderTime).toEqual(2); - wrapper - .find('input') - .last() - .simulate('change', { - target: { - value: '123456', - }, - }); + await changeValue(input[1], '123456'); expect(renderTime).toEqual(2); }); @@ -289,7 +276,7 @@ describe('useWatch', () => { demo1?: { demo2?: { demo3?: { demo4?: string } } }; }; - const Demo = () => { + const Demo: React.FC = () => { const [form] = Form.useForm(); const values = Form.useWatch([], form); const main = Form.useWatch('main', form); @@ -301,13 +288,12 @@ 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']); - return ( <>{JSON.stringify({ values, main, age, demo1, demo2, demo3, demo4, demo5, more, demo })} ); }; - mount(); + render(); }); // https://github.com/react-component/field-form/issues/431 @@ -315,18 +301,16 @@ describe('useWatch', () => { let updateA = 0; let updateB = 0; - const Demo = () => { + const Demo: React.FC = () => { const [form] = Form.useForm(); const userA = Form.useWatch(['a'], form); const userB = Form.useWatch(['b'], form); React.useEffect(() => { updateA += 1; - console.log('Update A', userA); }, [userA]); React.useEffect(() => { updateB += 1; - console.log('Update B', userB); }, [userB]); return ( @@ -341,19 +325,15 @@ describe('useWatch', () => { ); }; - const wrapper = mount(); - - console.log('Change!'); - wrapper - .find('input') - .first() - .simulate('change', { target: { value: 'bamboo' } }); - + const { container } = render(); + fireEvent.change(container.querySelector('input'), { + target: { value: 'bamboo' }, + }); expect(updateA > updateB).toBeTruthy(); }); it('mount while unmount', () => { - const Demo = () => { + const Demo: React.FC = () => { const [form] = Form.useForm(); const [type, setType] = useState(true); const name = Form.useWatch('name', form); @@ -377,14 +357,12 @@ describe('useWatch', () => { ); }; - - const wrapper = mount(); - wrapper - .find('input') - .first() - .simulate('change', { target: { value: 'bamboo' } }); - wrapper.find('button').at(0).simulate('click'); - expect(wrapper.find('.value').text()).toEqual('bamboo'); + const { container } = render(); + fireEvent.change(container.querySelector('input'), { + target: { value: 'bamboo' }, + }); + container.querySelector('button').click(); + expect(container.querySelector('.value')?.textContent).toEqual('bamboo'); }); it('stringify error', () => { const obj: any = {}; @@ -392,4 +370,91 @@ describe('useWatch', () => { const str = stringify(obj); expect(typeof str === 'number').toBeTruthy(); }); + it('first undefined', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const Demo: React.FC = () => { + const formRef = useRef(); + const name = Form.useWatch('name', formRef.current); + const [, setUpdate] = useState({}); + return ( + <> +
        setUpdate({})} /> +
        {name}
        +
        + + + +
        + + ); + }; + const { container } = render(); + expect(container.querySelector('.value')?.textContent).toEqual(''); + fireEvent.click(container.querySelector('.setUpdate')); + expect(container.querySelector('.value')?.textContent).toEqual('default'); + fireEvent.change(container.querySelector('input'), { + target: { value: 'bamboo' }, + }); + expect(container.querySelector('.value')?.textContent).toEqual('bamboo'); + expect(errorSpy).not.toHaveBeenCalledWith( + 'Warning: useWatch requires a form instance since it can not auto detect from context.', + ); + errorSpy.mockRestore(); + }); + + it('dynamic change warning', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const Demo: React.FC = () => { + const [form] = Form.useForm(); + const [watchPath, setWatchPath] = React.useState('light'); + Form.useWatch(watchPath, form); + + React.useEffect(() => { + setWatchPath('bamboo'); + }, []); + + return
        ; + }; + render(); + + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: `useWatch` is not support dynamic `namePath`. Please provide static instead.', + ); + errorSpy.mockRestore(); + }); + + it('useWatch with preserve option', async () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const Demo: React.FC = () => { + const [form] = Form.useForm(); + const nameValuePreserve = Form.useWatch('name', { + form, + preserve: true, + }); + const nameValue = Form.useWatch('name', form); + React.useEffect(() => { + console.log(nameValuePreserve, nameValue); + }, [nameValuePreserve, nameValue]); + return ( +
        + +
        {nameValuePreserve}
        +
        + ); + }; + + const { container } = render(); + await act(async () => { + await timeout(); + }); + expect(logSpy).toHaveBeenCalledWith('bamboo', undefined); // initialValue + fireEvent.click(container.querySelector('.test-btn')); + await act(async () => { + await timeout(); + }); + expect(logSpy).toHaveBeenCalledWith('light', undefined); // after setFieldValue + + logSpy.mockRestore(); + }); }); diff --git a/tests/utils.test.js b/tests/utils.test.ts similarity index 72% rename from tests/utils.test.js rename to tests/utils.test.ts index 7bc47894..d40b2dc8 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.ts @@ -1,6 +1,5 @@ -import { move, isSimilar, setValues } from '../src/utils/valueUtil'; +import { move, isSimilar } from '../src/utils/valueUtil'; import NameMap from '../src/utils/NameMap'; -import cloneDeep from '../src/utils/cloneDeep'; describe('utils', () => { describe('arrayMove', () => { @@ -31,19 +30,6 @@ describe('utils', () => { expect(isSimilar({}, null)).toBeFalsy(); expect(isSimilar(null, {})).toBeFalsy(); }); - - describe('setValues', () => { - it('basic', () => { - expect(setValues({}, { a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); - expect(setValues([], [123])).toEqual([123]); - }); - - it('Correct handle class instance', () => { - const out = setValues({}, { a: 1, b: { c: new Date() } }); - expect(out.a).toEqual(1); - expect(out.b.c instanceof Date).toBeTruthy(); - }); - }); }); describe('NameMap', () => { @@ -72,12 +58,4 @@ describe('utils', () => { }); }); }); - - describe('clone deep', () => { - it('should not deep clone Class', () => { - const data = { a: new Date() }; - const clonedData = cloneDeep(data); - expect(data.a === clonedData.a).toBeTruthy(); - }); - }); }); diff --git a/tests/validate-warning.test.tsx b/tests/validate-warning.test.tsx index 447153c3..84b0b6a8 100644 --- a/tests/validate-warning.test.tsx +++ b/tests/validate-warning.test.tsx @@ -1,93 +1,57 @@ -/* eslint-disable no-template-curly-in-string */ import React from 'react'; -import { mount } from 'enzyme'; import Form from '../src'; import InfoField, { Input } from './common/InfoField'; -import { changeValue, matchError } from './common'; +import { changeValue, getInput, matchError } from './common'; import type { FormInstance, Rule } from '../src/interface'; +import { render } from '@testing-library/react'; describe('Form.WarningValidate', () => { it('required', async () => { - let form: FormInstance; - - const wrapper = mount( - { - form = f; - }} - > - + const form = React.createRef(); + const { container } = render( + + , ); - - await changeValue(wrapper, ''); - matchError(wrapper, false, "'name' is required"); - expect(form.getFieldWarning('name')).toEqual(["'name' is required"]); + await changeValue(getInput(container), ['bamboo', '']); + matchError(container, false, "'name' is required"); + expect(form.current?.getFieldWarning('name')).toEqual(["'name' is required"]); }); describe('validateFirst should not block error', () => { - function testValidateFirst( + const testValidateFirst = ( name: string, validateFirst: boolean | 'parallel', additionalRule?: Rule, errorMessage?: string, - ) { + ) => { it(name, async () => { - const rules = [ + const rules: Rule[] = [ additionalRule, - { - type: 'string', - len: 10, - warningOnly: true, - }, - { - type: 'url', - }, - { - type: 'string', - len: 20, - warningOnly: true, - }, + { type: 'string', len: 10, warningOnly: true }, + { type: 'url' }, + { type: 'string', len: 20, warningOnly: true }, ]; - - const wrapper = mount( + const { container } = render(
        - r) as any} - > + , ); - - await changeValue(wrapper, 'bamboo'); - matchError(wrapper, errorMessage || "'name' is not a valid url", false); + await changeValue(getInput(container), 'bamboo'); + matchError(container, errorMessage || "'name' is not a valid url", false); }); - } - + }; testValidateFirst('default', true); testValidateFirst( 'default', true, - { - type: 'string', - len: 3, - }, + { type: 'string', len: 3 }, "'name' must be exactly 3 characters", ); testValidateFirst('parallel', 'parallel'); }); }); -/* eslint-enable no-template-curly-in-string */ diff --git a/tests/validate.test.tsx b/tests/validate.test.tsx index dc759ac3..20cf7c01 100644 --- a/tests/validate.test.tsx +++ b/tests/validate.test.tsx @@ -1,16 +1,16 @@ -/* eslint-disable no-template-curly-in-string */ -import React from 'react'; -import { mount } from 'enzyme'; +import { fireEvent, render } 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 { changeValue, matchError, getField } from './common'; -import timeout from './common/timeout'; +import timeout, { waitFakeTime } from './common/timeout'; describe('Form.Validate', () => { it('required', async () => { let form; - const wrapper = mount( + const { container } = render(
        { @@ -22,8 +22,8 @@ describe('Form.Validate', () => {
        , ); - await changeValue(wrapper, ''); - matchError(wrapper, true); + await changeValue(getInput(container), ['bamboo', '']); + matchError(container, true); expect(form.getFieldError('username')).toEqual(["'username' is required"]); expect(form.getFieldsError()).toEqual([ { @@ -49,8 +49,8 @@ describe('Form.Validate', () => { }); describe('validateMessages', () => { - function renderForm(messages, fieldProps = {}) { - return mount( + function renderForm(messages: ValidateMessages, fieldProps = {}) { + return render( , @@ -58,21 +58,21 @@ describe('Form.Validate', () => { } it('template message', async () => { - const wrapper = renderForm({ required: "You miss '${name}'!" }); + const { container } = renderForm({ required: "You miss '${name}'!" }); - await changeValue(wrapper, ''); - matchError(wrapper, "You miss 'username'!"); + await changeValue(getInput(container), ['bamboo', '']); + matchError(container, "You miss 'username'!"); }); it('function message', async () => { - const wrapper = renderForm({ required: () => 'Bamboo & Light' }); + const { container } = renderForm({ required: () => 'Bamboo & Light' }); - await changeValue(wrapper, ''); - matchError(wrapper, 'Bamboo & Light'); + await changeValue(getInput(container), ['bamboo', '']); + matchError(container, 'Bamboo & Light'); }); it('messageVariables', async () => { - const wrapper = renderForm( + const { container } = renderForm( { required: "You miss '${label}'!" }, { messageVariables: { @@ -81,14 +81,14 @@ describe('Form.Validate', () => { }, ); - await changeValue(wrapper, ''); - matchError(wrapper, "You miss 'Light&Bamboo'!"); + await changeValue(getInput(container), ['bamboo', '']); + matchError(container, "You miss 'Light&Bamboo'!"); }); }); describe('customize validator', () => { it('work', async () => { - const wrapper = mount( + const { container } = render(
        { ); // Wrong value - await changeValue(wrapper, 'light'); - matchError(wrapper, 'should be bamboo!'); + await changeValue(getInput(container), 'light'); + matchError(container, 'should be bamboo!'); // Correct value - await changeValue(wrapper, 'bamboo'); - matchError(wrapper, false); + await changeValue(getInput(container), 'bamboo'); + matchError(container, false); }); it('should error if throw in validate', async () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const wrapper = mount( + const { container } = render( { , ); - await changeValue(wrapper, 'light'); - matchError(wrapper, "Validation error on field 'username'"); + await changeValue(getInput(container), 'light'); + matchError(container, "Validation error on field 'username'"); const consoleErr = String(errorSpy.mock.calls[0][0]); expect(consoleErr).toBe('Error: without thinking'); @@ -143,7 +143,7 @@ describe('Form.Validate', () => { }); it('fail validate if throw', async () => { - const wrapper = mount( + const { container } = render(
        { ); // Wrong value - await changeValue(wrapper, 'light'); - matchError(wrapper, "Validation error on field 'username'"); + await changeValue(getInput(container), 'light'); + matchError(container, "Validation error on field 'username'"); }); describe('callback', () => { it('warning if not return promise', async () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const wrapper = mount( + const { container } = render( { , ); - await changeValue(wrapper, 'light'); + await changeValue(getInput(container), 'light'); expect(errorSpy).toHaveBeenCalledWith( 'Warning: `callback` is deprecated. Please return a promise instead.', ); @@ -193,7 +193,7 @@ describe('Form.Validate', () => { it('warning if both promise & callback exist', async () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const wrapper = mount( + const { container } = render(
        { , ); - await changeValue(wrapper, 'light'); + await changeValue(getInput(container), 'light'); expect(errorSpy).toHaveBeenCalledWith( 'Warning: Your validator function has already return a promise. `callback` will be ignored.', ); @@ -221,7 +221,7 @@ describe('Form.Validate', () => { describe('validateTrigger', () => { it('normal', async () => { let form; - const wrapper = mount( + const { container } = render(
        { @@ -241,16 +241,17 @@ describe('Form.Validate', () => { }, ]} > - +
        , ); - await changeValue(getField(wrapper, 'test'), ''); + await changeValue(getInput(container, 'test'), ['bamboo', '']); expect(form.getFieldError('test')).toEqual(['Not pass']); - wrapper.find('input').simulate('blur'); + // wrapper.find('input').simulate('blur'); + fireEvent.blur(getInput(container, 'test')); await timeout(); expect(form.getFieldError('test')).toEqual(["'test' is required"]); }); @@ -277,39 +278,47 @@ describe('Form.Validate', () => { ); - const wrapper = mount(); + const { container, rerender } = render(); - getField(wrapper).simulate('blur'); + // getInput(container).simulate('blur'); + fireEvent.blur(getInput(container)); await timeout(); expect(form.getFieldError('title')).toEqual(['Title is required']); - wrapper.setProps({ init: true }); - await changeValue(getField(wrapper), '1'); + // wrapper.setProps({ init: true }); + rerender(); + await changeValue(getInput(container), '1'); expect(form.getFieldValue('title')).toBe('1'); expect(form.getFieldError('title')).toEqual(['Title should be 3+ characters']); }); it('form context', async () => { - const wrapper = mount( + const { container, rerender } = render(
        , ); // Not trigger validate since Form set `onBlur` - await changeValue(getField(wrapper), ''); - matchError(wrapper, false); + await changeValue(getInput(container), ['bamboo', '']); + matchError(container, false); // Trigger onBlur - wrapper.find('input').simulate('blur'); + // wrapper.find('input').simulate('blur'); + fireEvent.blur(getInput(container)); await timeout(); - wrapper.update(); - matchError(wrapper, true); + // wrapper.update(); + matchError(container, true); // Update Form context - wrapper.setProps({ validateTrigger: 'onChange' }); - await changeValue(getField(wrapper), '1'); - matchError(wrapper, false); + // wrapper.setProps({ validateTrigger: 'onChange' }); + rerender( +
        + + , + ); + await changeValue(getInput(container), '1'); + matchError(container, false); }); }); @@ -318,7 +327,7 @@ describe('Form.Validate', () => { let form; const onFinish = jest.fn(); - const wrapper = mount( + const { container } = render(
        { -
        , ); @@ -340,14 +348,15 @@ describe('Form.Validate', () => { expect(await form.validateFields()).toEqual({ user: 'light' }); // Submit callback - wrapper.find('button').simulate('submit'); + // wrapper.find('button').simulate('submit'); + fireEvent.submit(container.querySelector('form')); await timeout(); expect(onFinish).toHaveBeenCalledWith({ user: 'light' }); }); it('remove from fields', async () => { const onFinish = jest.fn(); - const wrapper = mount( + const { container } = render(
        { ) } -
        , ); // Submit callback - wrapper.find('button').simulate('submit'); + // wrapper.find('button').simulate('submit'); + fireEvent.submit(container.querySelector('form')); await timeout(); expect(onFinish).toHaveBeenCalledWith({ switch: true, ignore: 'test' }); onFinish.mockReset(); // Hide one - wrapper.find('input.switch').simulate('change', { - target: { - checked: false, - }, - }); - wrapper.find('button').simulate('submit'); + // wrapper.find('input.switch').simulate('change', { + // target: { + // checked: false, + // }, + // }); + fireEvent.click(container.querySelector('input.switch')); + // wrapper.find('button').simulate('submit'); + fireEvent.submit(container.querySelector('form')); await timeout(); expect(onFinish).toHaveBeenCalledWith({ switch: false }); }); @@ -391,7 +402,7 @@ describe('Form.Validate', () => { it('validateFields should not pass when validateFirst is set', async () => { let form; - mount( + render(
        { @@ -424,7 +435,7 @@ describe('Form.Validate', () => { it('should error in console if user script failed', async () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const wrapper = mount( + const { container } = render( { throw new Error('should console this'); @@ -437,7 +448,8 @@ describe('Form.Validate', () => {
        , ); - wrapper.find('form').simulate('submit'); + // wrapper.find('form').simulate('submit'); + fireEvent.submit(container.querySelector('form')); await timeout(); expect(errorSpy.mock.calls[0][0].message).toEqual('should console this'); @@ -450,7 +462,7 @@ describe('Form.Validate', () => { let canEnd = false; const onFinish = jest.fn(); - const wrapper = mount( + const { container } = render(
        { @@ -479,8 +491,8 @@ describe('Form.Validate', () => { ); // Not pass - await changeValue(wrapper, ''); - matchError(wrapper, true); + await changeValue(getInput(container), ['bamboo', '']); + matchError(container, true); expect(form.getFieldError('username')).toEqual(["'username' is required"]); expect(form.getFieldsError()).toEqual([ { @@ -493,11 +505,12 @@ describe('Form.Validate', () => { // Should pass canEnd = true; - await changeValue(wrapper, 'test'); - wrapper.find('form').simulate('submit'); + await changeValue(getInput(container), 'test'); + // wrapper.find('form').simulate('submit'); + fireEvent.submit(container.querySelector('form')); await timeout(); - matchError(wrapper, false); + matchError(container, false); expect(onFinish).toHaveBeenCalledWith({ username: 'test' }); }); @@ -509,7 +522,7 @@ describe('Form.Validate', () => { let ruleFirst = false; let ruleSecond = false; - const wrapper = mount( + const { container } = render( { , ); - await changeValue(wrapper, 'test'); - await timeout(); + await changeValue(getInput(container), 'test'); + await timeout(100); - wrapper.update(); - matchError(wrapper, 'failed first'); + // wrapper.update(); + matchError(container, 'failed first'); expect(ruleFirst).toEqual(first); expect(ruleSecond).toEqual(second); @@ -575,14 +588,15 @@ describe('Form.Validate', () => { ); }; - const wrapper = mount(); + const { container } = render(); - await changeValue(wrapper, '233'); - matchError(wrapper, true); + await changeValue(getInput(container), '233'); + matchError(container, true); - wrapper.find('button').simulate('click'); - wrapper.update(); - matchError(wrapper, false); + // wrapper.find('button').simulate('click'); + fireEvent.click(container.querySelector('button')); + // wrapper.update(); + matchError(container, false); }); it('submit should trigger Field re-render', () => { @@ -609,11 +623,12 @@ describe('Form.Validate', () => { ); }; - const wrapper = mount(); + const { container } = render(); renderProps.mockReset(); // Should trigger validating - wrapper.find('button').simulate('click'); + // wrapper.find('button').simulate('click'); + fireEvent.click(container.querySelector('button')); expect(renderProps.mock.calls[0][1]).toEqual(expect.objectContaining({ validating: true })); }); @@ -641,6 +656,7 @@ describe('Form.Validate', () => { { ); - const wrapper = mount(); + const { container } = render(); expect(failedTriggerTimes).toEqual(0); expect(passedTriggerTimes).toEqual(0); // Failed of second input - await changeValue(getField(wrapper, 1), ''); - matchError(getField(wrapper, 2), true); + await changeValue(getInput(container, 1), ''); + matchError(getInput(container, 1, true), true); expect(failedTriggerTimes).toEqual(1); expect(passedTriggerTimes).toEqual(0); // Changed first to trigger update - await changeValue(getField(wrapper, 0), 'light'); - matchError(getField(wrapper, 2), false); + await changeValue(getInput(container, 0), 'light'); + matchError(getInput(container, 1, true), false); expect(failedTriggerTimes).toEqual(1); expect(passedTriggerTimes).toEqual(1); // Remove should not trigger validate - await changeValue(getField(wrapper, 0), 'removed'); + await changeValue(getInput(container, 0), 'removed'); expect(failedTriggerTimes).toEqual(1); expect(passedTriggerTimes).toEqual(1); }); it('validate support recursive', async () => { - let form; - const wrapper = mount( + let form: FormInstance; + const { container } = render(
        { @@ -707,36 +723,370 @@ describe('Form.Validate', () => {
        , ); - wrapper - .find('input') - .at(0) - .simulate('change', { target: { value: '' } }); - await act(async () => { - await timeout(); - }); - wrapper.update(); + async function changeInputValue(input: HTMLElement, value = '') { + fireEvent.change(input, { + target: { + value: '2', + }, + }); + fireEvent.change(input, { + target: { + value, + }, + }); + + await act(async () => { + await timeout(); + }); + } + + await changeInputValue(container.querySelector('input')); try { - const values = await form.validateFields(['username'], { recursive: true }); - expect(values.username.do).toBe(''); + await form.validateFields([['username']], { recursive: true }); + + // Should not reach this + expect(false).toBeTruthy(); } catch (error) { expect(error.errorFields.length).toBe(2); + expect(error.errorFields[0].errors).toEqual(["'username.do' is required"]); + expect(error.errorFields[1].errors).toEqual(["'username.list' is required"]); } - const values = await form.validateFields(['username']); - expect(values.username.do).toBe(''); + await act(async () => { + await timeout(); + }); + + // Passed + await changeInputValue(container.querySelectorAll('input')[0], 'do'); + await changeInputValue(container.querySelectorAll('input')[1], 'list'); + + const passedValues = await form.validateFields([['username']], { recursive: true }); + expect(passedValues).toEqual({ username: { do: 'do', list: 'list' } }); }); it('not trigger validator', async () => { - const wrapper = mount( + const { container } = render(
        , ); - await changeValue(getField(wrapper, 0), ['light']); - matchError(wrapper, false); + await changeValue(getInput(container, 0), ['light']); + matchError(container, false); + }); + + it('filter empty rule', async () => { + const { container } = render( +
        +
        + + +
        , + ); + await changeValue(getInput(container), ['bamboo', '']); + matchError(container, true); + }); + it('validated status should be true when trigger validate', async () => { + const validateTrigger = jest.fn(); + const validateNoTrigger = jest.fn(); + const App = ({ trigger = true }) => { + const ref = React.useRef(null); + useEffect(() => { + if (!trigger) return; + ref.current!.validateFields(); + }, [trigger]); + return ( +
        +
        + { + if (trigger) { + validateTrigger(meta.validated); + } else { + validateNoTrigger(meta.validated); + } + }} + rules={[ + { + type: 'email', + message: 'Please input your e-mail', + }, + { + required: true, + message: 'Please input your value', + }, + ]} + /> + +
        + ); + }; + const { rerender } = render(); + await timeout(); + expect(validateNoTrigger).not.toHaveBeenCalled(); + // wrapper.setProps({ trigger: true }); + rerender(); + await timeout(); + expect(validateTrigger).toBeCalledWith(true); + }); + + it('should trigger onFieldsChange 3 times', async () => { + const onFieldsChange = jest.fn(); + const onMetaChange = jest.fn(); + + const App = () => { + const ref = React.useRef(null); + return ( +
        + { + onMetaChange(meta.validated); + }} + > + + +
        + ); + }; + const { container } = render(); + + await changeValue(getInput(container), 'bamboo'); + + await timeout(); + + // `validated: false` -> `validated: false` -> `validated: true` + // `validating: false` -> `validating: true` -> `validating: false` + expect(onFieldsChange).toHaveBeenCalledTimes(3); + + expect(onFieldsChange).toHaveBeenNthCalledWith( + 1, + [ + expect.objectContaining({ + name: ['test'], + validated: false, + validating: false, + }), + ], + expect.anything(), + ); + expect(onFieldsChange).toHaveBeenNthCalledWith( + 2, + [ + expect.objectContaining({ + name: ['test'], + validated: false, + validating: true, + }), + ], + expect.anything(), + ); + expect(onFieldsChange).toHaveBeenNthCalledWith( + 3, + [ + expect.objectContaining({ + name: ['test'], + validated: true, + validating: false, + }), + ], + expect.anything(), + ); + // should reset validated and validating when reset btn had been clicked + // wrapper.find('#reset').simulate('reset'); + fireEvent.reset(container.querySelector('form')); + await timeout(); + expect(onMetaChange).toHaveBeenNthCalledWith(3, true); + expect(onMetaChange).toHaveBeenNthCalledWith(4, false); + }); + + it('should not trigger onFieldsChange if no rules', async () => { + const onFieldsChange = jest.fn(); + const onFinish = jest.fn(); + + const App = () => { + return ( +
        + + {fields => + fields.map(field => ( + + + + )) + } + +
        + ); + }; + const { container } = render(); + + // wrapper.find('form').simulate('submit'); + fireEvent.submit(container.querySelector('form')); + + await timeout(); + + expect(onFieldsChange).not.toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalledWith({ + list: ['hello'], + }); + }); + + it('validateOnly', async () => { + const formRef = React.createRef(); + const { container } = render( +
        + + + +
        , + ); + + // Validate only + const result = await formRef.current.validateFields({ validateOnly: true }).catch(e => e); + await timeout(); + expect(result.errorFields).toHaveLength(1); + expect(container.querySelector('.errors').textContent).toBeFalsy(); + + // Normal validate + await formRef.current.validateFields().catch(e => e); + await timeout(); + expect(container.querySelector('.errors').textContent).toEqual(`'test' is required`); + }); + + it('validateDebounce', async () => { + jest.useFakeTimers(); + + const validator = jest.fn( + () => + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('Not Correct')); + }, 100); + }), + ); + + const formRef = React.createRef(); + + const { container } = render( +
        + + + +
        , + ); + + fireEvent.change(container.querySelector('input'), { + target: { + value: 'light', + }, + }); + + // Debounce should wait + await act(async () => { + await Promise.resolve(); + jest.advanceTimersByTime(900); + await Promise.resolve(); + }); + expect(validator).not.toHaveBeenCalled(); + + // Debounce should work + await act(async () => { + await Promise.resolve(); + jest.advanceTimersByTime(1000); + await Promise.resolve(); + }); + expect(validator).toHaveBeenCalled(); + + // `validateFields` should ignore `validateDebounce` + validator.mockReset(); + formRef.current.validateFields(); + + await act(async () => { + await Promise.resolve(); + jest.advanceTimersByTime(200); + await Promise.resolve(); + }); + expect(validator).toHaveBeenCalled(); + + // `submit` should ignore `validateDebounce` + validator.mockReset(); + fireEvent.submit(container.querySelector('form')); + + await act(async () => { + await Promise.resolve(); + jest.advanceTimersByTime(200); + await Promise.resolve(); + }); + expect(validator).toHaveBeenCalled(); + + 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(); }); }); -/* eslint-enable no-template-curly-in-string */