From df7094106bac3123c22f2d0032b72ca653541e9e Mon Sep 17 00:00:00 2001 From: Dylan Kilgore Date: Tue, 2 Aug 2022 10:21:25 -0700 Subject: [PATCH 01/23] chore: form: initial commit of internal component --- package.json | 1 + src/components/Form/Internal/OcField.tsx | 720 ++++++++++++ .../Form/Internal/OcFieldContext.ts | 52 + src/components/Form/Internal/OcForm.tsx | 186 +++ src/components/Form/Internal/OcForm.types.ts | 331 ++++++ .../Form/Internal/OcFormContext.tsx | 110 ++ src/components/Form/Internal/OcList.tsx | 213 ++++ src/components/Form/Internal/OcListContext.ts | 10 + .../Form/Internal/Tests/Common/InfoField.tsx | 45 + .../Form/Internal/Tests/Common/index.ts | 94 ++ .../Form/Internal/Tests/Common/timeout.ts | 5 + .../Form/Internal/Tests/context.test.js | 155 +++ .../Form/Internal/Tests/control.test.js | 44 + .../Form/Internal/Tests/dependencies.test.js | 240 ++++ .../Form/Internal/Tests/field.test.tsx | 31 + .../Form/Internal/Tests/index.test.js | 965 +++++++++++++++ .../Form/Internal/Tests/initialValue.test.js | 427 +++++++ .../Tests/legacy/async-validation.test.js | 96 ++ .../Internal/Tests/legacy/basic-form.test.js | 131 +++ .../Internal/Tests/legacy/clean-field.test.js | 49 + .../Internal/Tests/legacy/dom-form.test.js | 5 + .../Tests/legacy/dynamic-binding.test.js | 278 +++++ .../Tests/legacy/dynamic-rule.test.js | 133 +++ .../Internal/Tests/legacy/field-props.test.js | 116 ++ .../Form/Internal/Tests/legacy/form.test.js | 40 + .../Tests/legacy/switch-field.test.js | 80 ++ .../Tests/legacy/validate-array.test.js | 94 ++ .../Form/Internal/Tests/list.test.tsx | 831 +++++++++++++ .../Form/Internal/Tests/preserve.test.tsx | 550 +++++++++ .../Form/Internal/Tests/setupAfterEnv.ts | 1 + .../Form/Internal/Tests/strict.test.tsx | 26 + .../Form/Internal/Tests/useWatch.test.tsx | 433 +++++++ .../Form/Internal/Tests/utils.test.js | 86 ++ .../Internal/Tests/validate-warning.test.tsx | 97 ++ .../Form/Internal/Tests/validate.test.tsx | 802 +++++++++++++ src/components/Form/Internal/Utils/NameMap.ts | 77 ++ .../Form/Internal/Utils/asyncUtil.ts | 36 + .../Form/Internal/Utils/cloneDeep.ts | 25 + .../Form/Internal/Utils/messages.ts | 49 + .../Form/Internal/Utils/typeUtil.ts | 7 + .../Form/Internal/Utils/validateUtil.ts | 294 +++++ .../Form/Internal/Utils/valueUtil.ts | 214 ++++ src/components/Form/Internal/index.tsx | 49 + src/components/Form/Internal/useForm.ts | 1033 +++++++++++++++++ src/components/Form/Internal/useWatch.ts | 132 +++ yarn.lock | 5 + 46 files changed, 9398 insertions(+) create mode 100644 src/components/Form/Internal/OcField.tsx create mode 100644 src/components/Form/Internal/OcFieldContext.ts create mode 100644 src/components/Form/Internal/OcForm.tsx create mode 100644 src/components/Form/Internal/OcForm.types.ts create mode 100644 src/components/Form/Internal/OcFormContext.tsx create mode 100644 src/components/Form/Internal/OcList.tsx create mode 100644 src/components/Form/Internal/OcListContext.ts create mode 100644 src/components/Form/Internal/Tests/Common/InfoField.tsx create mode 100644 src/components/Form/Internal/Tests/Common/index.ts create mode 100644 src/components/Form/Internal/Tests/Common/timeout.ts create mode 100644 src/components/Form/Internal/Tests/context.test.js create mode 100644 src/components/Form/Internal/Tests/control.test.js create mode 100644 src/components/Form/Internal/Tests/dependencies.test.js create mode 100644 src/components/Form/Internal/Tests/field.test.tsx create mode 100644 src/components/Form/Internal/Tests/index.test.js create mode 100644 src/components/Form/Internal/Tests/initialValue.test.js create mode 100644 src/components/Form/Internal/Tests/legacy/async-validation.test.js create mode 100644 src/components/Form/Internal/Tests/legacy/basic-form.test.js create mode 100644 src/components/Form/Internal/Tests/legacy/clean-field.test.js create mode 100644 src/components/Form/Internal/Tests/legacy/dom-form.test.js create mode 100644 src/components/Form/Internal/Tests/legacy/dynamic-binding.test.js create mode 100644 src/components/Form/Internal/Tests/legacy/dynamic-rule.test.js create mode 100644 src/components/Form/Internal/Tests/legacy/field-props.test.js create mode 100644 src/components/Form/Internal/Tests/legacy/form.test.js create mode 100644 src/components/Form/Internal/Tests/legacy/switch-field.test.js create mode 100644 src/components/Form/Internal/Tests/legacy/validate-array.test.js create mode 100644 src/components/Form/Internal/Tests/list.test.tsx create mode 100644 src/components/Form/Internal/Tests/preserve.test.tsx create mode 100644 src/components/Form/Internal/Tests/setupAfterEnv.ts create mode 100644 src/components/Form/Internal/Tests/strict.test.tsx create mode 100644 src/components/Form/Internal/Tests/useWatch.test.tsx create mode 100644 src/components/Form/Internal/Tests/utils.test.js create mode 100644 src/components/Form/Internal/Tests/validate-warning.test.tsx create mode 100644 src/components/Form/Internal/Tests/validate.test.tsx create mode 100644 src/components/Form/Internal/Utils/NameMap.ts create mode 100644 src/components/Form/Internal/Utils/asyncUtil.ts create mode 100644 src/components/Form/Internal/Utils/cloneDeep.ts create mode 100644 src/components/Form/Internal/Utils/messages.ts create mode 100644 src/components/Form/Internal/Utils/typeUtil.ts create mode 100644 src/components/Form/Internal/Utils/validateUtil.ts create mode 100644 src/components/Form/Internal/Utils/valueUtil.ts create mode 100644 src/components/Form/Internal/index.tsx create mode 100644 src/components/Form/Internal/useForm.ts create mode 100644 src/components/Form/Internal/useWatch.ts diff --git a/package.json b/package.json index 87b41b4ff..eb204152a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@types/lodash": "4.14.182", "@types/react-is": "17.0.3", "@types/shallowequal": "1.1.1", + "async-validator": "4.1.0", "bodymovin": "4.13.0", "date-fns": "2.28.0", "dayjs": "1.11.3", diff --git a/src/components/Form/Internal/OcField.tsx b/src/components/Form/Internal/OcField.tsx new file mode 100644 index 000000000..6630daa57 --- /dev/null +++ b/src/components/Form/Internal/OcField.tsx @@ -0,0 +1,720 @@ +import toChildrenArray from 'rc-util/lib/Children/toArray'; +import * as React from 'react'; +import type { + FieldEntity, + FormInstance, + InternalNamePath, + Meta, + NamePath, + NotifyInfo, + Rule, + Store, + ValidateOptions, + InternalFormInstance, + RuleObject, + StoreValue, + EventArgs, + RuleError, +} from './OcForm.types'; +import FieldContext, { HOOK_MARK } from './OcFieldContext'; +import { toArray } from './utils/typeUtil'; +import { validateRules } from './utils/validateUtil'; +import { + containsNamePath, + defaultGetValueFromEvent, + getNamePath, + getValue, +} from './utils/valueUtil'; + +const EMPTY_ERRORS: any[] = []; + +export type ShouldUpdate = + | boolean + | (( + prevValues: Values, + nextValues: Values, + info: { source?: string } + ) => boolean); + +function requireUpdate( + shouldUpdate: ShouldUpdate, + prev: StoreValue, + next: StoreValue, + prevValue: StoreValue, + nextValue: StoreValue, + info: NotifyInfo +): boolean { + if (typeof shouldUpdate === 'function') { + return shouldUpdate( + prev, + next, + 'source' in info ? { source: info.source } : {} + ); + } + return prevValue !== nextValue; +} + +// eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style +interface ChildProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [name: string]: any; +} + +export interface InternalFieldProps { + children?: + | React.ReactElement + | (( + control: ChildProps, + meta: Meta, + form: FormInstance + ) => React.ReactNode); + /** + * Set up `dependencies` field. + * When dependencies field update and current field is touched, + * will trigger validate rules and render. + */ + dependencies?: NamePath[]; + getValueFromEvent?: (...args: EventArgs) => StoreValue; + name?: InternalNamePath; + normalize?: ( + value: StoreValue, + prevValue: StoreValue, + allValues: Store + ) => StoreValue; + rules?: Rule[]; + shouldUpdate?: ShouldUpdate; + trigger?: string; + validateTrigger?: string | string[] | false; + validateFirst?: boolean | 'parallel'; + valuePropName?: string; + getValueProps?: (value: StoreValue) => Record; + messageVariables?: Record; + initialValue?: any; + onReset?: () => void; + onMetaChange?: (meta: Meta & { destroy?: boolean }) => void; + preserve?: boolean; + + /** @private Passed by Form.List props. Do not use since it will break by path check. */ + isListField?: boolean; + + /** @private Passed by Form.List props. Do not use since it will break by path check. */ + isList?: boolean; + + /** @private Pass context as prop instead of context api + * since class component can not get context in constructor */ + fieldContext?: InternalFormInstance; +} + +export interface FieldProps + extends Omit, 'name' | 'fieldContext'> { + name?: NamePath; +} + +export interface FieldState { + resetCount: number; +} + +// We use Class instead of Hooks here since it will cost much code by using Hooks. +class Field + extends React.Component + implements FieldEntity +{ + public static contextType = FieldContext; + + public static defaultProps = { + trigger: 'onChange', + valuePropName: 'value', + }; + + public state = { + resetCount: 0, + }; + + private cancelRegisterFunc: ( + isListField?: boolean, + preserve?: boolean, + namePath?: InternalNamePath + ) => void | null = null; + + private mounted = false; + + /** + * Follow state should not management in State since it will async update by React. + * This makes first render of form can not get correct state value. + */ + private touched: boolean = false; + + /** + * Mark when touched & validated. Currently only used for `dependencies`. + * Note that we do not think field with `initialValue` is dirty + * but this will be by `isFieldDirty` func. + */ + private dirty: boolean = false; + + private validatePromise: Promise | null = null; + + private prevValidating: boolean; + + private errors: string[] = EMPTY_ERRORS; + private warnings: string[] = EMPTY_ERRORS; + + // ============================== Subscriptions ============================== + constructor(props: InternalFieldProps) { + super(props); + + // Register on init + if (props.fieldContext) { + const { getInternalHooks }: InternalFormInstance = + props.fieldContext; + const { initEntityValue } = getInternalHooks(HOOK_MARK); + initEntityValue(this); + } + } + + public componentDidMount() { + const { shouldUpdate, fieldContext } = this.props; + + this.mounted = true; + + // Register on init + if (fieldContext) { + const { getInternalHooks }: InternalFormInstance = fieldContext; + const { registerField } = getInternalHooks(HOOK_MARK); + this.cancelRegisterFunc = registerField(this); + } + + // One more render for component in case fields not ready + if (shouldUpdate === true) { + this.reRender(); + } + } + + public componentWillUnmount() { + this.cancelRegister(); + this.triggerMetaEvent(true); + this.mounted = false; + } + + public cancelRegister = () => { + const { preserve, isListField, name } = this.props; + + if (this.cancelRegisterFunc) { + this.cancelRegisterFunc(isListField, preserve, getNamePath(name)); + } + this.cancelRegisterFunc = null; + }; + + // ================================== Utils ================================== + public getNamePath = (): InternalNamePath => { + const { name, fieldContext } = this.props; + const { prefixName = [] }: InternalFormInstance = fieldContext; + + return name !== undefined ? [...prefixName, ...name] : []; + }; + + public getRules = (): RuleObject[] => { + const { rules = [], fieldContext } = this.props; + + return rules.map((rule: Rule): RuleObject => { + if (typeof rule === 'function') { + return rule(fieldContext); + } + return rule; + }); + }; + + public reRender() { + if (!this.mounted) return; + this.forceUpdate(); + } + + public refresh = () => { + if (!this.mounted) return; + + /** + * Clean up current node. + */ + this.setState(({ resetCount }) => ({ + resetCount: resetCount + 1, + })); + }; + + public triggerMetaEvent = (destroy?: boolean) => { + const { onMetaChange } = this.props; + + onMetaChange?.({ ...this.getMeta(), destroy }); + }; + + // ========================= Field Entity Interfaces ========================= + // Trigger by store update. Check if need update the component + public onStoreChange: FieldEntity['onStoreChange'] = ( + prevStore, + namePathList, + info + ) => { + const { shouldUpdate, dependencies = [], onReset } = this.props; + const { store } = info; + const namePath = this.getNamePath(); + const prevValue = this.getValue(prevStore); + const curValue = this.getValue(store); + + const namePathMatch = + namePathList && containsNamePath(namePathList, namePath); + + // `setFieldsValue` is a quick access to update related status + if ( + info.type === 'valueUpdate' && + info.source === 'external' && + prevValue !== curValue + ) { + this.touched = true; + this.dirty = true; + this.validatePromise = null; + this.errors = EMPTY_ERRORS; + this.warnings = EMPTY_ERRORS; + this.triggerMetaEvent(); + } + + switch (info.type) { + case 'reset': + if (!namePathList || namePathMatch) { + // Clean up state + this.touched = false; + this.dirty = false; + this.validatePromise = null; + this.errors = EMPTY_ERRORS; + this.warnings = EMPTY_ERRORS; + this.triggerMetaEvent(); + + onReset?.(); + + this.refresh(); + return; + } + break; + + /** + * In case field with `preserve = false` nest deps like: + * - A = 1 => show B + * - B = 1 => show C + * - Reset A, need clean B, C + */ + case 'remove': { + if (shouldUpdate) { + this.reRender(); + return; + } + break; + } + + case 'setField': { + if (namePathMatch) { + const { data } = info; + + if ('touched' in data) { + this.touched = data.touched; + } + if ('validating' in data && !('originOcField' in data)) { + this.validatePromise = data.validating + ? Promise.resolve([]) + : null; + } + if ('errors' in data) { + this.errors = data.errors || EMPTY_ERRORS; + } + if ('warnings' in data) { + this.warnings = data.warnings || EMPTY_ERRORS; + } + this.dirty = true; + + this.triggerMetaEvent(); + + this.reRender(); + return; + } + + // Handle update by `setField` with `shouldUpdate` + if ( + shouldUpdate && + !namePath.length && + requireUpdate( + shouldUpdate, + prevStore, + store, + prevValue, + curValue, + info + ) + ) { + this.reRender(); + return; + } + break; + } + + case 'dependenciesUpdate': { + /** + * Trigger when marked `dependencies` updated. Related fields will all update + */ + const dependencyList = dependencies.map(getNamePath); + // No need for `namePathMath` check and `shouldUpdate` check, since `valueUpdate` will be + // emitted earlier and they will work there + // If set it may cause unnecessary twice rerendering + if ( + dependencyList.some((dependency) => + containsNamePath(info.relatedFields, dependency) + ) + ) { + this.reRender(); + return; + } + break; + } + + default: + // 1. If `namePath` exists in `namePathList`, means it's related value and should update + // For example + // If `namePathList` is [['list']] (List value update), Field should be updated + // If `namePathList` is [['list', 0]] (Field value update), List shouldn't be updated + // 2. + // 2.1 If `dependencies` is set, `name` is not set and `shouldUpdate` is not set, + // don't use `shouldUpdate`. `dependencies` is view as a shortcut if `shouldUpdate` + // is not provided + // 2.2 If `shouldUpdate` provided, use customize logic to update the field + // else to check if value changed + if ( + namePathMatch || + ((!dependencies.length || + namePath.length || + shouldUpdate) && + requireUpdate( + shouldUpdate, + prevStore, + store, + prevValue, + curValue, + info + )) + ) { + this.reRender(); + return; + } + break; + } + + if (shouldUpdate === true) { + this.reRender(); + } + }; + + public validateRules = ( + options?: ValidateOptions + ): Promise => { + // We should fixed namePath & value to avoid developer change then by form function + const namePath = this.getNamePath(); + const currentValue = this.getValue(); + + // Force change to async to avoid rule OOD under renderProps field + const rootPromise = Promise.resolve().then(() => { + if (!this.mounted) { + return []; + } + + const { validateFirst = false, messageVariables } = this.props; + const { triggerName } = (options || {}) as ValidateOptions; + + 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); + }); + } + + const promise = validateRules( + namePath, + currentValue, + filteredRules, + options, + validateFirst, + messageVariables + ); + + promise + .catch((e) => e) + .then((ruleErrors: RuleError[] = EMPTY_ERRORS) => { + if (this.validatePromise === rootPromise) { + this.validatePromise = null; + + // Get errors & warnings + const nextErrors: string[] = []; + const nextWarnings: string[] = []; + ruleErrors.forEach?.( + ({ + rule: { warningOnly }, + errors = EMPTY_ERRORS, + }) => { + if (warningOnly) { + nextWarnings.push(...errors); + } else { + nextErrors.push(...errors); + } + } + ); + + this.errors = nextErrors; + this.warnings = nextWarnings; + this.triggerMetaEvent(); + + this.reRender(); + } + }); + + return promise; + }); + + this.validatePromise = rootPromise; + this.dirty = true; + this.errors = EMPTY_ERRORS; + this.warnings = EMPTY_ERRORS; + this.triggerMetaEvent(); + + // Force trigger re-render since we need sync renderProps with new meta + this.reRender(); + + return rootPromise; + }; + + public isFieldValidating = () => !!this.validatePromise; + + public isFieldTouched = () => this.touched; + + public isFieldDirty = () => { + // Touched or validate or has initialValue + if (this.dirty || this.props.initialValue !== undefined) { + return true; + } + + // Form set initialValue + const { fieldContext } = this.props; + const { getInitialValue } = fieldContext.getInternalHooks(HOOK_MARK); + if (getInitialValue(this.getNamePath()) !== undefined) { + return true; + } + + return false; + }; + + public getErrors = () => this.errors; + + public getWarnings = () => this.warnings; + + public isListField = () => this.props.isListField; + + public isList = () => this.props.isList; + + public isPreserve = () => this.props.preserve; + + // ============================= Child Component ============================= + public getMeta = (): Meta => { + // Make error & validating in cache to save perf + this.prevValidating = this.isFieldValidating(); + + const meta: Meta = { + touched: this.isFieldTouched(), + validating: this.prevValidating, + errors: this.errors, + warnings: this.warnings, + name: this.getNamePath(), + }; + + return meta; + }; + + // Only return validate child node. If invalidate, will do nothing about field. + public getOnlyChild = ( + children: + | React.ReactNode + | (( + control: ChildProps, + meta: Meta, + context: FormInstance + ) => React.ReactNode) + ): { child: React.ReactNode | null; isFunction: boolean } => { + // Support render props + if (typeof children === 'function') { + const meta = this.getMeta(); + + return { + ...this.getOnlyChild( + children( + this.getControlled(), + meta, + this.props.fieldContext + ) + ), + isFunction: true, + }; + } + + // Filed element only + const childList = toChildrenArray(children); + if (childList.length !== 1 || !React.isValidElement(childList[0])) { + return { child: childList, isFunction: false }; + } + + return { child: childList[0], isFunction: false }; + }; + + // ============================== Field Control ============================== + public getValue = (store?: Store) => { + const { getFieldsValue }: FormInstance = this.props.fieldContext; + const namePath = this.getNamePath(); + return getValue(store || getFieldsValue(true), namePath); + }; + + public getControlled = (childProps: ChildProps = {}) => { + const { + trigger, + validateTrigger, + getValueFromEvent, + normalize, + valuePropName, + getValueProps, + fieldContext, + } = this.props; + + const mergedValidateTrigger = + validateTrigger !== undefined + ? validateTrigger + : fieldContext.validateTrigger; + + const namePath = this.getNamePath(); + const { getInternalHooks, getFieldsValue }: InternalFormInstance = + fieldContext; + const { dispatch } = getInternalHooks(HOOK_MARK); + const value = this.getValue(); + const mergedGetValueProps = + getValueProps || ((val: StoreValue) => ({ [valuePropName]: val })); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const originTriggerFunc: any = childProps[trigger]; + + const control = { + ...childProps, + ...mergedGetValueProps(value), + }; + + // Add trigger + control[trigger] = (...args: EventArgs) => { + // Mark as touched + this.touched = true; + this.dirty = true; + + this.triggerMetaEvent(); + + let newValue: StoreValue; + if (getValueFromEvent) { + newValue = getValueFromEvent(...args); + } else { + newValue = defaultGetValueFromEvent(valuePropName, ...args); + } + + if (normalize) { + newValue = normalize(newValue, value, getFieldsValue(true)); + } + + dispatch({ + type: 'updateValue', + namePath, + value: newValue, + }); + + if (originTriggerFunc) { + originTriggerFunc(...args); + } + }; + + // Add validateTrigger + const validateTriggerList: string[] = toArray( + mergedValidateTrigger || [] + ); + + validateTriggerList.forEach((triggerName: string) => { + // Wrap additional function of component, so that we can get latest value from store + const originTrigger = control[triggerName]; + control[triggerName] = (...args: EventArgs) => { + if (originTrigger) { + originTrigger(...args); + } + + // Always use latest rules + const { rules } = this.props; + if (rules && rules.length) { + // We dispatch validate to root, + // since it will update related data with other field with same name + dispatch({ + type: 'validateField', + namePath, + triggerName, + }); + } + }; + }); + + return control; + }; + + public render() { + const { resetCount } = this.state; + const { children } = this.props; + + const { child, isFunction } = this.getOnlyChild(children); + + // Not need to `cloneElement` since user can handle this in render function self + let returnChildNode: React.ReactNode; + if (isFunction) { + returnChildNode = child; + } else if (React.isValidElement(child)) { + returnChildNode = React.cloneElement( + child as React.ReactElement, + this.getControlled((child as React.ReactElement).props) + ); + } else { + returnChildNode = child; + } + + return ( + {returnChildNode} + ); + } +} + +function WrapperField({ + name, + ...restProps +}: FieldProps) { + const fieldContext = React.useContext(FieldContext); + + const namePath = name !== undefined ? getNamePath(name) : undefined; + + let key: string = 'keep'; + if (!restProps.isListField) { + key = `_${(namePath || []).join('_')}`; + } + + return ( + + ); +} + +export default WrapperField; diff --git a/src/components/Form/Internal/OcFieldContext.ts b/src/components/Form/Internal/OcFieldContext.ts new file mode 100644 index 000000000..7093a46e7 --- /dev/null +++ b/src/components/Form/Internal/OcFieldContext.ts @@ -0,0 +1,52 @@ +import warning from 'rc-util/lib/warning'; +import * as React from 'react'; +import type { InternalFormInstance } from './interface'; + +export const HOOK_MARK = 'RC_FORM_INTERNAL_HOOKS'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const warningFunc: any = () => { + warning( + false, + 'Can not find FormContext. Please make sure you wrap Field under Form.' + ); +}; + +const Context = React.createContext({ + getFieldValue: warningFunc, + getFieldsValue: warningFunc, + getFieldError: warningFunc, + getFieldWarning: warningFunc, + getFieldsError: warningFunc, + isFieldsTouched: warningFunc, + isFieldTouched: warningFunc, + isFieldValidating: warningFunc, + isFieldsValidating: warningFunc, + resetFields: warningFunc, + setFields: warningFunc, + setFieldValue: warningFunc, + setFieldsValue: warningFunc, + validateFields: warningFunc, + submit: warningFunc, + + getInternalHooks: () => { + warningFunc(); + + return { + dispatch: warningFunc, + initEntityValue: warningFunc, + registerField: warningFunc, + useSubscribe: warningFunc, + setInitialValues: warningFunc, + destroyForm: warningFunc, + setCallbacks: warningFunc, + registerWatch: warningFunc, + getFields: warningFunc, + setValidateMessages: warningFunc, + setPreserve: warningFunc, + getInitialValue: warningFunc, + }; + }, +}); + +export default Context; diff --git a/src/components/Form/Internal/OcForm.tsx b/src/components/Form/Internal/OcForm.tsx new file mode 100644 index 000000000..884a059db --- /dev/null +++ b/src/components/Form/Internal/OcForm.tsx @@ -0,0 +1,186 @@ +import * as React from 'react'; +import type { + Store, + FormInstance, + FieldData, + ValidateMessages, + Callbacks, + InternalFormInstance, +} from './interface'; +import useForm from './useForm'; +import FieldContext, { HOOK_MARK } from './FieldContext'; +import type { FormContextProps } from './FormContext'; +import FormContext from './FormContext'; +import { isSimilar } from './utils/valueUtil'; + +type BaseFormProps = Omit< + React.FormHTMLAttributes, + 'onSubmit' | 'children' +>; + +type RenderProps = ( + values: Store, + form: FormInstance +) => JSX.Element | React.ReactNode; + +export interface FormProps extends BaseFormProps { + initialValues?: Store; + form?: FormInstance; + children?: RenderProps | React.ReactNode; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component?: false | string | React.FC | React.ComponentClass; + fields?: FieldData[]; + name?: string; + validateMessages?: ValidateMessages; + onValuesChange?: Callbacks['onValuesChange']; + onFieldsChange?: Callbacks['onFieldsChange']; + onFinish?: Callbacks['onFinish']; + onFinishFailed?: Callbacks['onFinishFailed']; + validateTrigger?: string | string[] | false; + preserve?: boolean; +} + +const Form: React.ForwardRefRenderFunction = ( + { + name, + initialValues, + fields, + form, + preserve, + children, + component: Component = 'form', + validateMessages, + validateTrigger = 'onChange', + onValuesChange, + onFieldsChange, + onFinish, + onFinishFailed, + ...restProps + }: FormProps, + ref +) => { + const formContext: FormContextProps = React.useContext(FormContext); + + // We customize handle event since Context will makes all the consumer re-render: + // https://reactjs.org/docs/context.html#contextprovider + const [formInstance] = useForm(form); + const { + useSubscribe, + setInitialValues, + setCallbacks, + setValidateMessages, + setPreserve, + destroyForm, + } = (formInstance as InternalFormInstance).getInternalHooks(HOOK_MARK); + + // Pass ref with form instance + React.useImperativeHandle(ref, () => formInstance); + + // Register form into Context + React.useEffect(() => { + formContext.registerForm(name, formInstance); + return () => { + formContext.unregisterForm(name); + }; + }, [formContext, formInstance, name]); + + // Pass props to store + setValidateMessages({ + ...formContext.validateMessages, + ...validateMessages, + }); + setCallbacks({ + onValuesChange, + onFieldsChange: (changedFields: FieldData[], ...rest) => { + formContext.triggerFormChange(name, changedFields); + + if (onFieldsChange) { + onFieldsChange(changedFields, ...rest); + } + }, + onFinish: (values: Store) => { + formContext.triggerFormFinish(name, values); + + if (onFinish) { + onFinish(values); + } + }, + onFinishFailed, + }); + setPreserve(preserve); + + // Set initial value, init store value when first mount + const mountRef = React.useRef(null); + setInitialValues(initialValues, !mountRef.current); + if (!mountRef.current) { + mountRef.current = true; + } + + React.useEffect( + () => destroyForm, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + // Prepare children by `children` type + let childrenNode: React.ReactNode; + const childrenRenderProps = typeof children === 'function'; + if (childrenRenderProps) { + const values = formInstance.getFieldsValue(true); + childrenNode = (children as RenderProps)(values, formInstance); + } else { + childrenNode = children; + } + + // Not use subscribe when using render props + useSubscribe(!childrenRenderProps); + + // Listen if fields provided. We use ref to save prev data here to avoid additional render + const prevFieldsRef = React.useRef(); + React.useEffect(() => { + if (!isSimilar(prevFieldsRef.current || [], fields || [])) { + formInstance.setFields(fields || []); + } + prevFieldsRef.current = fields; + }, [fields, formInstance]); + + const formContextValue = React.useMemo( + () => ({ + ...(formInstance as InternalFormInstance), + validateTrigger, + }), + [formInstance, validateTrigger] + ); + + const wrapperNode = ( + + {childrenNode} + + ); + + if (Component === false) { + return wrapperNode; + } + + return ( + ) => { + event.preventDefault(); + event.stopPropagation(); + + formInstance.submit(); + }} + onReset={(event: React.FormEvent) => { + event.preventDefault(); + + formInstance.resetFields(); + restProps.onReset?.(event); + }} + > + {wrapperNode} + + ); +}; + +export default Form; diff --git a/src/components/Form/Internal/OcForm.types.ts b/src/components/Form/Internal/OcForm.types.ts new file mode 100644 index 000000000..68e42a817 --- /dev/null +++ b/src/components/Form/Internal/OcForm.types.ts @@ -0,0 +1,331 @@ +import type { ReactElement } from 'react'; +import type { ReducerAction } from './useForm'; + +export type InternalNamePath = (string | number)[]; +export type NamePath = string | number | InternalNamePath; + +export type StoreValue = any; +export type Store = Record; + +export interface Meta { + touched: boolean; + validating: boolean; + errors: string[]; + warnings: string[]; + name: InternalNamePath; +} + +export interface InternalFieldData extends Meta { + value: StoreValue; +} + +/** + * Used by `setFields` config + */ +export interface FieldData extends Partial> { + name: NamePath; +} + +export type RuleType = + | 'string' + | 'number' + | 'boolean' + | 'method' + | 'regexp' + | 'integer' + | 'float' + | 'object' + | 'enum' + | 'date' + | 'url' + | 'hex' + | 'email'; + +type Validator = ( + rule: RuleObject, + value: StoreValue, + callback: (error?: string) => void +) => Promise | void; + +export type RuleRender = (form: FormInstance) => RuleObject; + +export interface ValidatorRule { + warningOnly?: boolean; + message?: string | ReactElement; + validator: Validator; +} + +interface BaseRule { + warningOnly?: boolean; + enum?: StoreValue[]; + len?: number; + max?: number; + message?: string | ReactElement; + min?: number; + pattern?: RegExp; + required?: boolean; + transform?: (value: StoreValue) => StoreValue; + type?: RuleType; + whitespace?: boolean; + + /** Customize rule level `validateTrigger`. Must be subset of Field `validateTrigger` */ + validateTrigger?: string | string[]; +} + +type AggregationRule = BaseRule & Partial; + +interface ArrayRule extends Omit { + type: 'array'; + defaultField?: RuleObject; +} + +export type RuleObject = AggregationRule | ArrayRule; + +export type Rule = RuleObject | RuleRender; + +export interface ValidateErrorEntity { + values: Values; + errorFields: { name: InternalNamePath; errors: string[] }[]; + outOfDate: boolean; +} + +export interface FieldEntity { + onStoreChange: ( + store: Store, + namePathList: InternalNamePath[] | null, + info: ValuedNotifyInfo + ) => void; + isFieldTouched: () => boolean; + isFieldDirty: () => boolean; + isFieldValidating: () => boolean; + isListField: () => boolean; + isList: () => boolean; + isPreserve: () => boolean; + validateRules: (options?: ValidateOptions) => Promise; + getMeta: () => Meta; + getNamePath: () => InternalNamePath; + getErrors: () => string[]; + getWarnings: () => string[]; + props: { + name?: NamePath; + rules?: Rule[]; + dependencies?: NamePath[]; + initialValue?: any; + }; +} + +export interface FieldError { + name: InternalNamePath; + errors: string[]; + warnings: string[]; +} + +export interface RuleError { + errors: string[]; + rule: RuleObject; +} + +export interface ValidateOptions { + triggerName?: string; + validateMessages?: ValidateMessages; + /** + * 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]. + */ + recursive?: boolean; +} + +export type InternalValidateFields = ( + nameList?: NamePath[], + options?: ValidateOptions +) => Promise; +export type ValidateFields = ( + nameList?: NamePath[] +) => Promise; + +// >>>>>> Info +interface ValueUpdateInfo { + type: 'valueUpdate'; + source: 'internal' | 'external'; +} + +interface ValidateFinishInfo { + type: 'validateFinish'; +} + +interface ResetInfo { + type: 'reset'; +} + +interface RemoveInfo { + type: 'remove'; +} + +interface SetFieldInfo { + type: 'setField'; + data: FieldData; +} + +interface DependenciesUpdateInfo { + type: 'dependenciesUpdate'; + /** + * Contains all the related `InternalNamePath[]`. + * a <- b <- c : change `a` + * relatedFields=[a, b, c] + */ + relatedFields: InternalNamePath[]; +} + +export type NotifyInfo = + | ValueUpdateInfo + | ValidateFinishInfo + | ResetInfo + | RemoveInfo + | SetFieldInfo + | DependenciesUpdateInfo; + +export type ValuedNotifyInfo = NotifyInfo & { + store: Store; +}; + +export interface Callbacks { + onValuesChange?: (changedValues: any, values: Values) => void; + onFieldsChange?: ( + changedFields: FieldData[], + allFields: FieldData[] + ) => void; + onFinish?: (values: Values) => void; + onFinishFailed?: (errorInfo: ValidateErrorEntity) => void; +} + +export type WatchCallBack = ( + values: Store, + namePathList: InternalNamePath[] +) => void; + +export interface InternalHooks { + dispatch: (action: ReducerAction) => void; + initEntityValue: (entity: FieldEntity) => void; + registerField: (entity: FieldEntity) => () => void; + useSubscribe: (subscribable: boolean) => void; + setInitialValues: (values: Store, init: boolean) => void; + destroyForm: () => void; + setCallbacks: (callbacks: Callbacks) => void; + registerWatch: (callback: WatchCallBack) => () => void; + getFields: (namePathList?: InternalNamePath[]) => FieldData[]; + setValidateMessages: (validateMessages: ValidateMessages) => void; + setPreserve: (preserve?: boolean) => void; + getInitialValue: (namePath: InternalNamePath) => StoreValue; +} + +/** Only return partial when type is not any */ +type RecursivePartial = T extends object + ? { + [P in keyof T]?: T[P] extends (infer U)[] + ? RecursivePartial[] + : T[P] extends object + ? RecursivePartial + : T[P]; + } + : any; + +export interface FormInstance { + // Origin Form API + getFieldValue: (name: NamePath) => StoreValue; + getFieldsValue: (() => Values) & + (( + nameList: NamePath[] | true, + filterFunc?: (meta: Meta) => boolean + ) => any); + getFieldError: (name: NamePath) => string[]; + getFieldsError: (nameList?: NamePath[]) => FieldError[]; + getFieldWarning: (name: NamePath) => string[]; + isFieldsTouched: (( + nameList?: NamePath[], + allFieldsTouched?: boolean + ) => boolean) & + ((allFieldsTouched?: boolean) => boolean); + isFieldTouched: (name: NamePath) => boolean; + isFieldValidating: (name: NamePath) => boolean; + isFieldsValidating: (nameList: NamePath[]) => boolean; + resetFields: (fields?: NamePath[]) => void; + setFields: (fields: FieldData[]) => void; + setFieldValue: (name: NamePath, value: any) => void; + setFieldsValue: (values: RecursivePartial) => void; + validateFields: ValidateFields; + + // New API + submit: () => void; +} + +export type InternalFormInstance = Omit & { + validateFields: InternalValidateFields; + + /** + * Passed by field context props + */ + prefixName?: InternalNamePath; + + validateTrigger?: string | string[] | false; + + /** + * Form component should register some content into store. + * We pass the `HOOK_MARK` as key to avoid user call the function. + */ + getInternalHooks: (secret: string) => InternalHooks | null; + + /** @private Internal usage. Do not use it in your production */ + _init?: boolean; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type EventArgs = any[]; + +type ValidateMessage = string | (() => string); +export interface ValidateMessages { + default?: ValidateMessage; + required?: ValidateMessage; + enum?: ValidateMessage; + whitespace?: ValidateMessage; + date?: { + format?: ValidateMessage; + parse?: ValidateMessage; + invalid?: ValidateMessage; + }; + types?: { + string?: ValidateMessage; + method?: ValidateMessage; + array?: ValidateMessage; + object?: ValidateMessage; + number?: ValidateMessage; + date?: ValidateMessage; + boolean?: ValidateMessage; + integer?: ValidateMessage; + float?: ValidateMessage; + regexp?: ValidateMessage; + email?: ValidateMessage; + url?: ValidateMessage; + hex?: ValidateMessage; + }; + string?: { + len?: ValidateMessage; + min?: ValidateMessage; + max?: ValidateMessage; + range?: ValidateMessage; + }; + number?: { + len?: ValidateMessage; + min?: ValidateMessage; + max?: ValidateMessage; + range?: ValidateMessage; + }; + array?: { + len?: ValidateMessage; + min?: ValidateMessage; + max?: ValidateMessage; + range?: ValidateMessage; + }; + pattern?: { + mismatch?: ValidateMessage; + }; +} diff --git a/src/components/Form/Internal/OcFormContext.tsx b/src/components/Form/Internal/OcFormContext.tsx new file mode 100644 index 000000000..e0a7a727e --- /dev/null +++ b/src/components/Form/Internal/OcFormContext.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import type { + ValidateMessages, + FormInstance, + FieldData, + Store, +} from './interface'; + +export type Forms = Record; + +export interface FormChangeInfo { + changedFields: FieldData[]; + forms: Forms; +} + +export interface FormFinishInfo { + values: Store; + forms: Forms; +} + +export interface FormProviderProps { + validateMessages?: ValidateMessages; + onFormChange?: (name: string, info: FormChangeInfo) => void; + onFormFinish?: (name: string, info: FormFinishInfo) => void; + children?: React.ReactNode; +} + +export interface FormContextProps extends FormProviderProps { + triggerFormChange: (name: string, changedFields: FieldData[]) => void; + triggerFormFinish: (name: string, values: Store) => void; + registerForm: (name: string, form: FormInstance) => void; + unregisterForm: (name: string) => void; +} + +const FormContext = React.createContext({ + triggerFormChange: () => {}, + triggerFormFinish: () => {}, + registerForm: () => {}, + unregisterForm: () => {}, +}); + +const FormProvider: React.FunctionComponent = ({ + validateMessages, + onFormChange, + onFormFinish, + children, +}) => { + const formContext = React.useContext(FormContext); + + const formsRef = React.useRef({}); + + return ( + { + if (onFormChange) { + onFormChange(name, { + changedFields, + forms: formsRef.current, + }); + } + + formContext.triggerFormChange(name, changedFields); + }, + triggerFormFinish: (name, values) => { + if (onFormFinish) { + onFormFinish(name, { + values, + forms: formsRef.current, + }); + } + + formContext.triggerFormFinish(name, values); + }, + registerForm: (name, form) => { + if (name) { + formsRef.current = { + ...formsRef.current, + [name]: form, + }; + } + + formContext.registerForm(name, form); + }, + unregisterForm: (name) => { + const newForms = { ...formsRef.current }; + delete newForms[name]; + formsRef.current = newForms; + + formContext.unregisterForm(name); + }, + }} + > + {children} + + ); +}; + +export { FormProvider }; + +export default FormContext; diff --git a/src/components/Form/Internal/OcList.tsx b/src/components/Form/Internal/OcList.tsx new file mode 100644 index 000000000..77e08b727 --- /dev/null +++ b/src/components/Form/Internal/OcList.tsx @@ -0,0 +1,213 @@ +import * as React from 'react'; +import type { + InternalNamePath, + NamePath, + StoreValue, + ValidatorRule, + Meta, +} from './interface'; +import FieldContext from './FieldContext'; +import Field from './Field'; +import { move, getNamePath } from './utils/valueUtil'; +import type { ListContextProps } from './ListContext'; +import ListContext from './ListContext'; + +export interface ListField { + name: number; + key: number; + isListField: boolean; +} + +export interface ListOperations { + add: (defaultValue?: StoreValue, index?: number) => void; + remove: (index: number | number[]) => void; + move: (from: number, to: number) => void; +} + +export interface ListProps { + name: NamePath; + rules?: ValidatorRule[]; + validateTrigger?: string | string[] | false; + initialValue?: any[]; + children?: ( + fields: ListField[], + operations: ListOperations, + meta: Meta + ) => JSX.Element | React.ReactNode; +} + +const List: React.FunctionComponent = ({ + name, + initialValue, + children, + rules, + validateTrigger, +}) => { + const context = React.useContext(FieldContext); + const keyRef = React.useRef({ + keys: [], + id: 0, + }); + const keyManager = keyRef.current; + + const prefixName: InternalNamePath = React.useMemo(() => { + const parentPrefixName = getNamePath(context.prefixName) || []; + return [...parentPrefixName, ...getNamePath(name)]; + }, [context.prefixName, name]); + + const fieldContext = React.useMemo( + () => ({ ...context, prefixName }), + [context, prefixName] + ); + + // List context + const listContext = React.useMemo( + () => ({ + getKey: (namePath: InternalNamePath) => { + const len = prefixName.length; + const pathName = namePath[len]; + return [keyManager.keys[pathName], namePath.slice(len + 1)]; + }, + }), + [prefixName] + ); + + const shouldUpdate = ( + prevValue: StoreValue, + nextValue: StoreValue, + { source } + ) => { + if (source === 'internal') { + return false; + } + return prevValue !== nextValue; + }; + + return ( + + + + {({ value = [], onChange }, meta) => { + const { getFieldValue } = context; + const getNewValue = () => { + const values = getFieldValue( + prefixName || [] + ) as StoreValue[]; + return values || []; + }; + /** + * Always get latest value in case user update fields by `form` api. + */ + const operations: ListOperations = { + add: (defaultValue, index?: number) => { + // Mapping keys + const newValue = getNewValue(); + + if (index >= 0 && index <= newValue.length) { + keyManager.keys = [ + ...keyManager.keys.slice(0, index), + keyManager.id, + ...keyManager.keys.slice(index), + ]; + onChange([ + ...newValue.slice(0, index), + defaultValue, + ...newValue.slice(index), + ]); + } else { + keyManager.keys = [ + ...keyManager.keys, + keyManager.id, + ]; + onChange([...newValue, defaultValue]); + } + keyManager.id += 1; + }, + remove: (index: number | number[]) => { + const newValue = getNewValue(); + const indexSet = new Set( + Array.isArray(index) ? index : [index] + ); + + if (indexSet.size <= 0) { + return; + } + keyManager.keys = keyManager.keys.filter( + (_, keysIndex) => !indexSet.has(keysIndex) + ); + + // Trigger store change + onChange( + newValue.filter( + (_, valueIndex) => + !indexSet.has(valueIndex) + ) + ); + }, + move(from: number, to: number) { + if (from === to) { + return; + } + const newValue = getNewValue(); + + // Do not handle out of range + if ( + from < 0 || + from >= newValue.length || + to < 0 || + to >= newValue.length + ) { + return; + } + + keyManager.keys = move( + keyManager.keys, + from, + to + ); + + // Trigger store change + onChange(move(newValue, from, to)); + }, + }; + + let listValue = value || []; + if (!Array.isArray(listValue)) { + listValue = []; + } + + return children( + (listValue as StoreValue[]).map( + (__, index): ListField => { + let key = keyManager.keys[index]; + if (key === undefined) { + keyManager.keys[index] = keyManager.id; + key = keyManager.keys[index]; + keyManager.id += 1; + } + + return { + name: index, + key, + isListField: true, + }; + } + ), + operations, + meta + ); + }} + + + + ); +}; + +export default List; diff --git a/src/components/Form/Internal/OcListContext.ts b/src/components/Form/Internal/OcListContext.ts new file mode 100644 index 000000000..80fb193e1 --- /dev/null +++ b/src/components/Form/Internal/OcListContext.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; +import type { InternalNamePath } from './interface'; + +export interface ListContextProps { + getKey: (namePath: InternalNamePath) => [React.Key, InternalNamePath]; +} + +const ListContext = React.createContext(null); + +export default ListContext; diff --git a/src/components/Form/Internal/Tests/Common/InfoField.tsx b/src/components/Form/Internal/Tests/Common/InfoField.tsx new file mode 100644 index 000000000..1dbc6dd57 --- /dev/null +++ b/src/components/Form/Internal/Tests/Common/InfoField.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Field } from '../../src'; +import type { FieldProps } from '../../src/Field'; + +interface InfoFieldProps extends FieldProps { + children?: React.ReactElement; +} + +export const Input = ({ value = '', ...props }) => ( + +); + +/** + * Return a wrapped Field with meta info + */ +const InfoField: React.FC = ({ children, ...props }) => ( + + {(control, info) => { + const { errors, warnings, validating } = info; + + return ( +
+ {children ? ( + React.cloneElement(children, control) + ) : ( + + )} +
    + {errors.map((error, index) => ( +
  • {error}
  • + ))} +
+
    + {warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+ {validating && } +
+ ); + }} +
+); + +export default InfoField; diff --git a/src/components/Form/Internal/Tests/Common/index.ts b/src/components/Form/Internal/Tests/Common/index.ts new file mode 100644 index 000000000..f9f838499 --- /dev/null +++ b/src/components/Form/Internal/Tests/Common/index.ts @@ -0,0 +1,94 @@ +/* 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'; + +export async function changeValue(wrapper, value) { + wrapper.find('input').simulate('change', { target: { value } }); + await act(async () => { + await timeout(); + }); + wrapper.update(); +} + +export function matchError( + wrapper: ReactWrapper, + error?: boolean | string, + warning?: boolean | string +) { + // Error + if (error) { + expect(wrapper.find('.errors li').length).toBeTruthy(); + } else { + expect(wrapper.find('.errors li').length).toBeFalsy(); + } + + if (error && typeof error !== 'boolean') { + expect(wrapper.find('.errors li').text()).toBe(error); + } + + // Warning + if (warning) { + expect(wrapper.find('.warnings li').length).toBeTruthy(); + } else { + expect(wrapper.find('.warnings li').length).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); + } + + 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; +} + +export function matchArray(source, target, matchKey) { + expect(matchKey).toBeTruthy(); + + try { + expect(source.length).toBe(target.length); + } catch (err) { + throw new Error( + ` +Array length not match. +source(${source.length}): ${JSON.stringify(source)} +target(${target.length}): ${JSON.stringify(target)} +`.trim() + ); + } + + target.forEach((tgt) => { + const matchValue = tgt[matchKey]; + const src = source.find((item) => + matchNamePath(item[matchKey], matchValue) + ); + expect(src).toBeTruthy(); + expect(src).toMatchObject(tgt); + }); +} + +export async function validateFields(form, ...args) { + await act(async () => { + await form.validateFields(...args); + }); +} + +/* eslint-enable import/no-extraneous-dependencies */ diff --git a/src/components/Form/Internal/Tests/Common/timeout.ts b/src/components/Form/Internal/Tests/Common/timeout.ts new file mode 100644 index 000000000..999431d5a --- /dev/null +++ b/src/components/Form/Internal/Tests/Common/timeout.ts @@ -0,0 +1,5 @@ +export default (timeout: number = 0) => { + return new Promise((resolve) => { + setTimeout(resolve, timeout); + }); +}; diff --git a/src/components/Form/Internal/Tests/context.test.js b/src/components/Form/Internal/Tests/context.test.js new file mode 100644 index 000000000..30c553113 --- /dev/null +++ b/src/components/Form/Internal/Tests/context.test.js @@ -0,0 +1,155 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Form, { FormProvider } from '../src'; +import InfoField from './common/InfoField'; +import { changeValue, matchError, getField } from './common'; +import timeout from './common/timeout'; + +describe('Form.Context', () => { + it('validateMessages', async () => { + const wrapper = mount( + +
+ + +
+ ); + + await changeValue(wrapper, ''); + matchError(wrapper, "I'm global"); + }); + + it('change event', async () => { + const onFormChange = jest.fn(); + + const wrapper = mount( + +
+ + +
+ ); + + await changeValue(getField(wrapper), 'Light'); + expect(onFormChange).toHaveBeenCalledWith( + 'form1', + expect.objectContaining({ + changedFields: [ + { + errors: [], + warnings: [], + name: ['username'], + touched: true, + validating: false, + value: 'Light', + }, + ], + forms: { + form1: expect.objectContaining({}), + }, + }) + ); + }); + + describe('adjust sub form', () => { + it('basic', async () => { + const onFormChange = jest.fn(); + + const wrapper = mount( + +
+ + ); + + wrapper.setProps({ + children: ( + + + + ), + }); + + await changeValue(getField(wrapper), 'Bamboo'); + const { forms } = onFormChange.mock.calls[0][1]; + expect(Object.keys(forms)).toEqual(['form2']); + }); + + it('multiple context', async () => { + const onFormChange = jest.fn(); + + const Demo = (changed) => ( + + + {!changed ? ( +
+ ) : ( + + + + )} +
+
+ ); + + const wrapper = mount(); + + wrapper.setProps({ + changed: true, + }); + + await changeValue(getField(wrapper), 'Bamboo'); + const { forms } = onFormChange.mock.calls[0][1]; + expect(Object.keys(forms)).toEqual(['form2']); + }); + }); + + it('submit', async () => { + const onFormFinish = jest.fn(); + let form1; + + const wrapper = mount( +
+ +
{ + form1 = instance; + }} + > + + +
+ +
+ ); + + await changeValue(getField(wrapper), ''); + form1.submit(); + await timeout(); + expect(onFormFinish).not.toHaveBeenCalled(); + + await changeValue(getField(wrapper), 'Light'); + form1.submit(); + await timeout(); + expect(onFormFinish).toHaveBeenCalled(); + + expect(onFormFinish.mock.calls[0][0]).toEqual('form1'); + const info = onFormFinish.mock.calls[0][1]; + expect(info.values).toEqual({ name: 'Light' }); + expect(Object.keys(info.forms).sort()).toEqual( + ['form1', 'form2'].sort() + ); + }); + + it('do nothing if no Provider in use', () => { + const wrapper = mount( +
+ +
+ ); + + wrapper.setProps({ + children: null, + }); + }); +}); diff --git a/src/components/Form/Internal/Tests/control.test.js b/src/components/Form/Internal/Tests/control.test.js new file mode 100644 index 000000000..bb4d9717a --- /dev/null +++ b/src/components/Form/Internal/Tests/control.test.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Form from '../src'; +import InfoField from './common/InfoField'; +import { changeValue, matchError } from './common'; + +describe('Form.Control', () => { + it('fields', () => { + const wrapper = mount( + + + + ); + + wrapper.setProps({ + fields: [{ name: 'username', value: 'Bamboo' }], + }); + wrapper.update(); + + expect(wrapper.find('input').props().value).toEqual('Bamboo'); + }); + + it('fully test', async () => { + const Test = () => { + const [fields, setFields] = React.useState([]); + + return ( +
{ + setFields(allFields); + }} + > + + + ); + }; + + const wrapper = mount(); + + await changeValue(wrapper, ''); + matchError(wrapper, "'test' is required"); + }); +}); diff --git a/src/components/Form/Internal/Tests/dependencies.test.js b/src/components/Form/Internal/Tests/dependencies.test.js new file mode 100644 index 000000000..c8eeeaa67 --- /dev/null +++ b/src/components/Form/Internal/Tests/dependencies.test.js @@ -0,0 +1,240 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Form, { Field } from '../src'; +import timeout from './common/timeout'; +import InfoField, { Input } from './common/InfoField'; +import { changeValue, matchError, getField } from './common'; + +describe('Form.Dependencies', () => { + it('touched', async () => { + let form = null; + + const wrapper = mount( +
+
{ + form = instance; + }} + > + + + +
+ ); + + // Not trigger if not touched + await changeValue(getField(wrapper, 0), ''); + matchError(getField(wrapper, 1), false); + + // Trigger if touched + form.setFields([{ name: 'field_2', touched: true }]); + await changeValue(getField(wrapper, 0), ''); + matchError(getField(wrapper, 1), true); + }); + + describe('initialValue', () => { + function test(name, formProps, fieldProps) { + it(name, async () => { + let validated = false; + + const wrapper = mount( +
+
+ + { + validated = true; + }, + }, + ]} + dependencies={['field_1']} + {...fieldProps} + /> + +
+ ); + + // Not trigger if not touched + await changeValue(getField(wrapper, 0), ''); + expect(validated).toBeTruthy(); + }); + } + + test('form level', { initialValues: { field_2: 'bamboo' } }); + test('field level', null, { initialValue: 'little' }); + }); + + it('nest dependencies', async () => { + let form = null; + let rendered = false; + + const wrapper = mount( +
+
{ + form = instance; + }} + > + + + + + + + + {(control) => { + rendered = true; + return ; + }} + +
+
+ ); + + form.setFields([ + { name: 'field_1', touched: true }, + { name: 'field_2', touched: true }, + { name: 'field_3', touched: true }, + ]); + + rendered = false; + await changeValue(getField(wrapper), '1'); + + expect(rendered).toBeTruthy(); + }); + + it('should work when field is dirty', async () => { + let pass = false; + + const wrapper = mount( +
+ { + if (pass) { + return Promise.resolve(); + } + return Promise.reject('You should not pass'); + }, + }, + ]} + dependencies={['field_2']} + /> + + + + + {(_, __, { resetFields }) => ( + + + ); + 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/src/components/Form/Internal/Tests/initialValue.test.js b/src/components/Form/Internal/Tests/initialValue.test.js new file mode 100644 index 000000000..13d6b052c --- /dev/null +++ b/src/components/Form/Internal/Tests/initialValue.test.js @@ -0,0 +1,427 @@ +import React, { useState } from 'react'; +import { mount } from 'enzyme'; +import { resetWarned } from 'rc-util/lib/warning'; +import Form, { Field, useForm, List } from '../src'; +import { Input } from './common/InfoField'; +import { changeValue, getField } from './common'; + +describe('Form.InitialValues', () => { + it('works', () => { + let form; + + const wrapper = mount( +
+
{ + form = instance; + }} + initialValues={{ + username: 'Light', + path1: { path2: 'Bamboo' }, + }} + > + + + + + + +
+
+ ); + + expect(form.getFieldsValue()).toEqual({ + username: 'Light', + path1: { + path2: 'Bamboo', + }, + }); + expect(form.getFieldsValue(['username'])).toEqual({ + username: 'Light', + }); + expect(form.getFieldsValue(['path1'])).toEqual({ + path1: { + path2: 'Bamboo', + }, + }); + expect(form.getFieldsValue(['username', ['path1', 'path2']])).toEqual({ + username: 'Light', + path1: { + path2: 'Bamboo', + }, + }); + expect( + getField(wrapper, 'username').find('input').props().value + ).toEqual('Light'); + expect( + getField(wrapper, ['path1', 'path2']).find('input').props().value + ).toEqual('Bamboo'); + }); + + it('update and reset should use new initialValues', () => { + let form; + let mountCount = 0; + + const TestInput = (props) => { + React.useEffect(() => { + mountCount += 1; + }, []); + + return ; + }; + + const Test = ({ initialValues }) => ( +
{ + form = instance; + }} + initialValues={initialValues} + > + + + + + + +
+ ); + + const wrapper = mount(); + expect(form.getFieldsValue()).toEqual({ + username: 'Bamboo', + }); + expect( + getField(wrapper, 'username').find('input').props().value + ).toEqual('Bamboo'); + + // Should not change it + wrapper.setProps({ initialValues: { username: 'Light' } }); + wrapper.update(); + expect(form.getFieldsValue()).toEqual({ + username: 'Bamboo', + }); + expect( + getField(wrapper, 'username').find('input').props().value + ).toEqual('Bamboo'); + + // Should change it + form.resetFields(); + wrapper.update(); + expect(mountCount).toEqual(1); + expect(form.getFieldsValue()).toEqual({ + username: 'Light', + }); + expect( + getField(wrapper, 'username').find('input').props().value + ).toEqual('Light'); + }); + + it("initialValues shouldn't be modified if preserve is false", () => { + const formValue = { + test: 'test', + users: [{ first: 'aaa', last: 'bbb' }], + }; + + let refForm; + + const Demo = () => { + const [form] = Form.useForm(); + const [show, setShow] = useState(false); + + refForm = form; + + return ( + <> + + {show && ( +
+ + {() => ( + + + + )} + + + {(fields) => ( + <> + {fields.map( + ({ key, name, ...restField }) => ( + + + + + + + + + ) + )} + + )} + +
+ )} + + ); + }; + + const wrapper = mount(); + wrapper.find('button').simulate('click'); + expect(formValue.users[0].last).toEqual('bbb'); + + wrapper.find('button').simulate('click'); + expect(formValue.users[0].last).toEqual('bbb'); + console.log('Form Value:', refForm.getFieldsValue(true)); + + wrapper.find('button').simulate('click'); + wrapper.update(); + + expect( + wrapper + .find('.first-name-input') + .first() + .find('input') + .prop('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( +
+ + + +
+ ); + + expect(wrapper.find('input').props().value).toEqual('bamboo'); + + expect(errorSpy).toHaveBeenCalledWith( + "Warning: Form already set 'initialValues' with path 'conflict'. Field can not overwrite it." + ); + + errorSpy.mockRestore(); + }); + + it('warning if multiple Field with same name set `initialValue`', () => { + resetWarned(); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + mount( +
+ + + + + + +
+ ); + + expect(errorSpy).toHaveBeenCalledWith( + "Warning: Multiple Field with path 'conflict' set 'initialValue'. Can not decide which one to pick." + ); + + errorSpy.mockRestore(); + }); + + it('should not replace user input', async () => { + const Test = () => { + const [show, setShow] = React.useState(false); + + return ( +
+ {show && ( + + + + )} +
+ + ); + }; + + it('Preserve right fields when switch them', async () => { + const wrapper = mount(); + + wrapper + .find('.one') + .last() + .simulate('change', { target: { value: 'value1' } }); + expect(Object.keys(form.getFieldsValue())).toEqual( + expect.arrayContaining(['a']) + ); + expect(form.getFieldValue('a')).toBe('value1'); + expect(wrapper.find('.one').last().getDOMNode().value).toBe('value1'); + + wrapper.find('.sw').simulate('click'); + expect(Object.keys(form.getFieldsValue())).toEqual( + expect.arrayContaining(['a']) + ); + expect(form.getFieldValue('a')).toBe('value1'); + expect(wrapper.find('.two').last().getDOMNode().value).toBe('value1'); + }); +}); diff --git a/src/components/Form/Internal/Tests/legacy/validate-array.test.js b/src/components/Form/Internal/Tests/legacy/validate-array.test.js new file mode 100644 index 000000000..c2c6edc3f --- /dev/null +++ b/src/components/Form/Internal/Tests/legacy/validate-array.test.js @@ -0,0 +1,94 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Form, { Field } from '../../src'; +import { Input } from '../common/InfoField'; +import { changeValue, getField, matchArray } from '../common'; +import timeout from '../common/timeout'; + +describe('legacy.validate-array', () => { + const MyInput = ({ value = [''], onChange, ...props }) => ( + { + onChange(e.target.value.split(',')); + }} + value={value.join(',')} + /> + ); + + it('forceValidate works', async () => { + let form; + + mount( +
+
{ + form = instance; + }} + initialValues={{ url_array: ['test'] }} + > + + + +
+
+ ); + + try { + await form.validateFields(); + throw new Error('Should not pass!'); + } catch ({ errorFields }) { + matchArray( + errorFields, + [ + { + name: ['url_array'], + errors: ["'url_array.0' is not a valid url"], + }, + ], + 'name' + ); + } + }); + + // https://github.com/ant-design/ant-design/issues/36436 + it('antd issue #36436', async () => { + let form; + + mount( +
+
{ + form = instance; + }} + > + + + +
+
+ ); + + expect(async () => { + await form.validateFields(); + }).not.toThrow(); + }); +}); diff --git a/src/components/Form/Internal/Tests/list.test.tsx b/src/components/Form/Internal/Tests/list.test.tsx new file mode 100644 index 000000000..eb8bc08fe --- /dev/null +++ b/src/components/Form/Internal/Tests/list.test.tsx @@ -0,0 +1,831 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; +import type { ReactWrapper } from 'enzyme'; +import { resetWarned } from 'rc-util/lib/warning'; +import Form, { Field, List } from '../src'; +import type { FormProps } from '../src'; +import type { ListField, ListOperations, ListProps } from '../src/List'; +import type { FormInstance, Meta } from '../src/interface'; +import ListContext from '../src/ListContext'; +import { Input } from './common/InfoField'; +import { changeValue, getField } from './common'; +import timeout from './common/timeout'; + +describe('Form.List', () => { + let form; + + function generateForm( + renderList?: ( + fields: ListField[], + operations: ListOperations, + meta: Meta + ) => JSX.Element | React.ReactNode, + formProps?: FormProps, + listProps?: Partial + ): [ReactWrapper, () => ReactWrapper] { + const wrapper = mount( +
+
{ + form = instance; + }} + {...formProps} + > + + {renderList} + +
+
+ ); + + return [wrapper, () => getField(wrapper).find('div')]; + } + + it('basic', async () => { + const [, getList] = generateForm( + (fields) => ( +
+ {fields.map((field) => ( + + + + ))} +
+ ), + { + initialValues: { + list: ['', '', ''], + }, + } + ); + + function matchKey(index, key) { + expect(getList().find(Field).at(index).key()).toEqual(key); + } + + matchKey(0, '0'); + matchKey(1, '1'); + matchKey(2, '2'); + + const listNode = getList(); + + await changeValue(getField(listNode, 0), '111'); + await changeValue(getField(listNode, 1), '222'); + await changeValue(getField(listNode, 2), '333'); + + expect(form.getFieldsValue()).toEqual({ + list: ['111', '222', '333'], + }); + }); + + it('not crash', () => { + // Empty only + mount( +
+ {() => null} +
+ ); + + // Not a array + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + resetWarned(); + mount( +
+ {() => null} +
+ ); + expect(errorSpy).toHaveBeenCalledWith( + "Warning: Current value of 'list' is not an array type." + ); + errorSpy.mockRestore(); + }); + + it('operation', async () => { + let operation; + const [wrapper, getList] = generateForm((fields, opt) => { + operation = opt; + return ( +
+ {fields.map((field) => ( + + + + ))} +
+ ); + }); + + function matchKey(index, key) { + expect(getList().find(Field).at(index).key()).toEqual(key); + } + + // Add + act(() => { + operation.add(); + }); + // Add default value + act(() => { + operation.add('2'); + }); + + act(() => { + operation.add(); + }); + + wrapper.update(); + expect(getList().find(Field).length).toEqual(3); + expect(form.getFieldsValue()).toEqual({ + list: [undefined, '2', undefined], + }); + + matchKey(0, '0'); + matchKey(1, '1'); + matchKey(2, '2'); + + // Move + act(() => { + operation.move(2, 0); + }); + wrapper.update(); + matchKey(0, '2'); + matchKey(1, '0'); + matchKey(2, '1'); + + // noneffective move + act(() => { + operation.move(-1, 0); + }); + wrapper.update(); + matchKey(0, '2'); + matchKey(1, '0'); + matchKey(2, '1'); + + // noneffective move + act(() => { + operation.move(0, 10); + }); + + wrapper.update(); + matchKey(0, '2'); + matchKey(1, '0'); + matchKey(2, '1'); + + // noneffective move + act(() => { + operation.move(-1, 10); + }); + + wrapper.update(); + matchKey(0, '2'); + matchKey(1, '0'); + matchKey(2, '1'); + + // noneffective move + act(() => { + operation.move(0, 0); + }); + wrapper.update(); + matchKey(0, '2'); + matchKey(1, '0'); + matchKey(2, '1'); + + // Revert Move + act(() => { + operation.move(0, 2); + }); + wrapper.update(); + matchKey(0, '0'); + matchKey(1, '1'); + matchKey(2, '2'); + + // Modify + await changeValue(getField(getList(), 1), '222'); + expect(form.getFieldsValue()).toEqual({ + list: [undefined, '222', undefined], + }); + expect(form.isFieldTouched(['list', 0])).toBeFalsy(); + expect(form.isFieldTouched(['list', 1])).toBeTruthy(); + expect(form.isFieldTouched(['list', 2])).toBeFalsy(); + + matchKey(0, '0'); + matchKey(1, '1'); + matchKey(2, '2'); + + // Remove + act(() => { + operation.remove(1); + }); + wrapper.update(); + expect(getList().find(Field).length).toEqual(2); + expect(form.getFieldsValue()).toEqual({ + list: [undefined, undefined], + }); + expect(form.isFieldTouched(['list', 0])).toBeFalsy(); + expect(form.isFieldTouched(['list', 2])).toBeFalsy(); + + matchKey(0, '0'); + matchKey(1, '2'); + + // Remove not exist: less + act(() => { + operation.remove(-1); + }); + wrapper.update(); + + matchKey(0, '0'); + matchKey(1, '2'); + + // Remove not exist: more + act(() => { + operation.remove(99); + }); + wrapper.update(); + + matchKey(0, '0'); + matchKey(1, '2'); + }); + + it('remove when the param is Array', () => { + let operation; + const [wrapper, getList] = generateForm((fields, opt) => { + operation = opt; + return ( +
+ {fields.map((field) => ( + + + + ))} +
+ ); + }); + + function matchKey(index, key) { + expect(getList().find(Field).at(index).key()).toEqual(key); + } + + act(() => { + operation.add(); + }); + + act(() => { + operation.add(); + }); + + wrapper.update(); + expect(getList().find(Field).length).toEqual(2); + + // remove empty array + act(() => { + operation.remove([]); + }); + + wrapper.update(); + + matchKey(0, '0'); + matchKey(1, '1'); + + // remove not esist element in array + act(() => { + operation.remove([-1, 99]); + }); + wrapper.update(); + + matchKey(0, '0'); + matchKey(1, '1'); + + act(() => { + operation.remove([0]); + }); + + wrapper.update(); + expect(getList().find(Field).length).toEqual(1); + matchKey(0, '1'); + + act(() => { + operation.add(); + }); + + act(() => { + operation.add(); + }); + + wrapper.update(); + matchKey(0, '1'); + matchKey(1, '2'); + matchKey(2, '3'); + + act(() => { + operation.remove([0, 1]); + }); + + wrapper.update(); + matchKey(0, '3'); + }); + + it('add when the second param is number', () => { + let operation; + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const [wrapper, getList] = generateForm((fields, opt) => { + operation = opt; + return ( +
+ {fields.map((field) => ( + + + + ))} +
+ ); + }); + + act(() => { + operation.add(); + }); + act(() => { + operation.add('1', 2); + }); + + act(() => { + operation.add('2', -1); + }); + + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: The second parameter of the add function should be a valid positive number.' + ); + errorSpy.mockRestore(); + + wrapper.update(); + expect(getList().find(Field).length).toEqual(3); + expect(form.getFieldsValue()).toEqual({ + list: [undefined, '1', '2'], + }); + + act(() => { + operation.add('0', 0); + }); + act(() => { + operation.add('4', 3); + }); + + wrapper.update(); + expect(getList().find(Field).length).toEqual(5); + expect(form.getFieldsValue()).toEqual({ + list: ['0', undefined, '1', '4', '2'], + }); + }); + + describe('validate', () => { + it('basic', async () => { + const [, getList] = generateForm( + (fields) => ( +
+ {fields.map((field) => ( + + + + ))} +
+ ), + { + initialValues: { list: [''] }, + } + ); + + await changeValue(getField(getList()), ''); + + expect(form.getFieldError(['list', 0])).toEqual([ + "'list.0' is required", + ]); + }); + + it('remove should keep error', async () => { + const [wrapper, getList] = generateForm( + (fields, { remove }) => ( +
+ {fields.map((field) => ( + + + + ))} + +
+ ), + { + initialValues: { list: ['', ''] }, + } + ); + + expect(wrapper.find(Input)).toHaveLength(2); + await changeValue(getField(getList(), 1), ''); + expect(form.getFieldError(['list', 1])).toEqual([ + "'list.1' is required", + ]); + + wrapper.find('button').simulate('click'); + wrapper.update(); + + expect(wrapper.find(Input)).toHaveLength(1); + expect(form.getFieldError(['list', 0])).toEqual([ + "'list.1' is required", + ]); + }); + + it('when param of remove is array', async () => { + const [wrapper, getList] = generateForm( + (fields, { remove }) => ( +
+ {fields.map((field) => ( + + + + ))} + +
+ ), + { + initialValues: { list: ['', '', ''] }, + } + ); + + expect(wrapper.find(Input)).toHaveLength(3); + await changeValue(getField(getList(), 0), ''); + expect(form.getFieldError(['list', 0])).toEqual([ + "'list.0' is required", + ]); + + await changeValue(getField(getList(), 1), 'test'); + expect(form.getFieldError(['list', 1])).toEqual([ + "'list.1' must be at least 5 characters", + ]); + + await changeValue(getField(getList(), 2), ''); + expect(form.getFieldError(['list', 2])).toEqual([ + "'list.2' is required", + ]); + + wrapper.find('button').simulate('click'); + wrapper.update(); + + expect(wrapper.find(Input)).toHaveLength(1); + expect(form.getFieldError(['list', 0])).toEqual([ + "'list.1' must be at least 5 characters", + ]); + expect(wrapper.find('input').props().value).toEqual('test'); + }); + + it('when add() second param is number', async () => { + const [wrapper, getList] = generateForm( + (fields, { add }) => ( +
+ {fields.map((field) => ( + + + + ))} + +
+ ), + { + initialValues: { list: ['test1', 'test2', 'test3'] }, + } + ); + + expect(wrapper.find(Input)).toHaveLength(3); + await changeValue(getField(getList(), 0), ''); + expect(form.getFieldError(['list', 0])).toEqual([ + "'list.0' is required", + ]); + + wrapper.find('.button').simulate('click'); + wrapper.find('.button1').simulate('click'); + + expect(wrapper.find(Input)).toHaveLength(5); + expect(form.getFieldError(['list', 1])).toEqual([ + "'list.0' is required", + ]); + + await changeValue(getField(getList(), 1), 'test'); + expect(form.getFieldError(['list', 1])).toEqual([ + "'list.1' must be at least 5 characters", + ]); + }); + }); + + it('warning if children is not function', () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + generateForm((
) as any); + + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: Form.List only accepts function as children.' + ); + + errorSpy.mockRestore(); + }); + + // https://github.com/ant-design/ant-design/issues/25584 + it('preserve should not break list', async () => { + let operation; + const [wrapper] = generateForm( + (fields, opt) => { + operation = opt; + return ( +
+ {fields.map((field) => ( + + + + ))} +
+ ); + }, + { preserve: false } + ); + + // Add + act(() => { + operation.add(); + }); + wrapper.update(); + expect(wrapper.find(Input)).toHaveLength(1); + + // Remove + act(() => { + operation.remove(0); + }); + wrapper.update(); + expect(wrapper.find(Input)).toHaveLength(0); + + // Add + act(() => { + operation.add(); + }); + wrapper.update(); + expect(wrapper.find(Input)).toHaveLength(1); + }); + + it('list support validator', async () => { + let operation; + let currentMeta; + let currentValue; + + const [wrapper] = generateForm( + (_, opt, meta) => { + operation = opt; + currentMeta = meta; + return null; + }, + null, + { + rules: [ + { + validator(_, value) { + currentValue = value; + return Promise.reject(); + }, + message: 'Bamboo Light', + }, + ], + } + ); + + await act(async () => { + operation.add(); + await timeout(); + wrapper.update(); + }); + + expect(currentValue).toEqual([undefined]); + expect(currentMeta.errors).toEqual(['Bamboo Light']); + }); + + it('Nest list remove should trigger correct onValuesChange', () => { + const onValuesChange = jest.fn(); + + const [wrapper] = generateForm( + (fields, operation) => ( +
+ {fields.map((field) => ( + + + + ))} +
+ ), + { + onValuesChange, + initialValues: { + list: [{ first: 'light' }, { first: 'bamboo' }], + }, + } + ); + + wrapper.find('button').simulate('click'); + expect(onValuesChange).toHaveBeenCalledWith(expect.anything(), { + list: [{ first: 'light' }], + }); + }); + + describe('isFieldTouched edge case', () => { + it('virtual object', () => { + const formRef = React.createRef(); + const wrapper = mount( +
+ + + + + + +
+ ); + + // Not changed + 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: '' } }); + + 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( + (fields) => ( +
+ {fields.map((field) => ( + + + + ))} +
+ ), + { + initialValues: { list: ['light', 'bamboo'] }, + } + ); + + // Not changed yet + expect(form.isFieldTouched('list')).toBeFalsy(); + expect(form.isFieldsTouched(['list'], false)).toBeFalsy(); + expect(form.isFieldsTouched(['list'], true)).toBeFalsy(); + + // Change children value + wrapper + .find('input') + .first() + .simulate('change', { target: { value: 'little' } }); + + expect(form.isFieldTouched('list')).toBeTruthy(); + expect(form.isFieldsTouched(['list'], false)).toBeTruthy(); + expect(form.isFieldsTouched(['list'], true)).toBeTruthy(); + }); + + it('List self change', () => { + const [wrapper] = generateForm((fields, opt) => ( +
+ {fields.map((field) => ( + + + + ))} +
+ )); + + // Not changed yet + expect(form.isFieldTouched('list')).toBeFalsy(); + expect(form.isFieldsTouched(['list'], false)).toBeFalsy(); + expect(form.isFieldsTouched(['list'], true)).toBeFalsy(); + + // Change children value + wrapper.find('button').simulate('click'); + + expect(form.isFieldTouched('list')).toBeTruthy(); + expect(form.isFieldsTouched(['list'], false)).toBeTruthy(); + expect(form.isFieldsTouched(['list'], true)).toBeTruthy(); + }); + }); + + it('initialValue', () => { + generateForm( + (fields) => ( +
+ {fields.map((field) => ( + + + + ))} +
+ ), + null, + { initialValue: ['light', 'bamboo'] } + ); + + expect(form.getFieldsValue()).toEqual({ + list: ['light', 'bamboo'], + }); + }); + + it('ListContext', () => { + const Hooker = ({ field }: any) => { + const { getKey } = React.useContext(ListContext); + const [key, restPath] = getKey(['list', field.name, 'user']); + + return ( + <> + {key} + {restPath.join('_')} + + + + + + ); + }; + + const [wrapper] = generateForm( + (fields) => ( +
+ {fields.map((field) => { + return ; + })} +
+ ), + { + initialValues: { + list: [{ user: 'bamboo' }], + }, + } + ); + + expect(wrapper.find('.internal-key').text()).toEqual('0'); + expect(wrapper.find('.internal-rest').text()).toEqual('user'); + expect(wrapper.find('input').prop('value')).toEqual('bamboo'); + }); +}); diff --git a/src/components/Form/Internal/Tests/preserve.test.tsx b/src/components/Form/Internal/Tests/preserve.test.tsx new file mode 100644 index 000000000..307ccc44d --- /dev/null +++ b/src/components/Form/Internal/Tests/preserve.test.tsx @@ -0,0 +1,550 @@ +/* eslint-disable no-template-curly-in-string, arrow-body-style */ +import { mount } from 'enzyme'; +import React from '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; + }) => ( +
+ + {!removeField && ( + + )} + + ); + + it('field', async () => { + const onFinish = jest.fn(); + const wrapper = mount( + + ); + + async function matchTest(removeField: boolean, match: object) { + onFinish.mockReset(); + wrapper.setProps({ removeField }); + wrapper.find('form').simulate('submit'); + 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 }); + }); + + it('form', async () => { + const onFinish = jest.fn(); + const wrapper = mount( + + ); + + async function matchTest(removeField: boolean, match: object) { + onFinish.mockReset(); + wrapper.setProps({ removeField }); + wrapper.find('form').simulate('submit'); + 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 }); + }); + + 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 onFinish = jest.fn(); + const wrapper = mount(); + + // Change value + wrapper + .find('input') + .first() + .simulate('change', { target: { value: 'light' } }); + + formRef.current.submit(); + await timeout(); + expect(onFinish).toHaveBeenCalledWith({ test: 'light' }); + onFinish.mockReset(); + + // Remove preserve should not change the value + wrapper.setProps({ keep: false }); + await timeout(); + formRef.current.submit(); + await timeout(); + expect(onFinish).toHaveBeenCalledWith({ test: 'light' }); + }); + + it('form preserve but field !preserve', async () => { + const onFinish = jest.fn(); + const wrapper = mount( + + ); + + async function matchTest(removeField: boolean, match: object) { + onFinish.mockReset(); + wrapper.setProps({ removeField }); + wrapper.find('form').simulate('submit'); + 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; + }} + > + + {(fields, { remove }) => { + return ( +
+ {fields.map((field) => ( + + + + ))} +
+ ); + }} +
+
+ ); + + wrapper.find('button').simulate('click'); + wrapper.update(); + + expect(form.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'] }} + > + + {(fields, { remove }) => ( + <> + {fields.map((field) => ( + + + + ))} + + + )} + +
+ ); + + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: `preserve` should not apply on Form.List fields.' + ); + + errorSpy.mockRestore(); + + // Remove should not work + wrapper.find('button').simulate('click'); + expect(form.getFieldsValue()).toEqual({ list: [] }); + }); + + it('multiple level field can use preserve', async () => { + let form: FormInstance; + + const wrapper = mount( +
{ + form = instance; + }} + > + + {(fields, { remove }) => { + return ( + <> + {fields.map((field) => ( +
+ + + + + {(_, __, { getFieldValue }) => + getFieldValue([ + 'list', + field.name, + 'type', + ]) === 'light' ? ( + + + + ) : ( + + + + ) + } + +
+ ))} + + + ); + }} +
+
+ ); + + // Change light value + wrapper + .find('input') + .last() + .simulate('change', { target: { value: '1128' } }); + + // Change type + wrapper + .find('input') + .first() + .simulate('change', { target: { value: 'bamboo' } }); + + // Change bamboo value + wrapper + .find('input') + .last() + .simulate('change', { target: { value: '903' } }); + + expect(form.getFieldsValue()).toEqual({ + list: [{ type: 'bamboo', bamboo: '903' }], + }); + + // ============== Remove Test ============== + // Remove field + wrapper.find('button').simulate('click'); + expect(form.getFieldsValue()).toEqual({ list: [] }); + }); + }); + + it('nest render props should not clean full store', () => { + let form: FormInstance; + + const wrapper = mount( +
{ + form = instance; + }} + > + + + + + {(_, __, { getFieldValue }) => + getFieldValue('light') === 'bamboo' ? ( + {() => null} + ) : null + } + +
+ ); + + wrapper + .find('input') + .simulate('change', { target: { value: 'bamboo' } }); + expect(form.getFieldsValue()).toEqual({ light: 'bamboo' }); + + wrapper + .find('input') + .simulate('change', { target: { value: 'little' } }); + expect(form.getFieldsValue()).toEqual({ light: 'little' }); + + wrapper.unmount(); + }); + + // https://github.com/ant-design/ant-design/issues/31297 + describe('A -> B -> C should keep trigger refresh', () => { + it('shouldUpdate', () => { + const DepDemo = () => { + const [form] = Form.useForm(); + + return ( +
+ + + + + + {() => { + return form.getFieldValue('name') === '1' ? ( + + + + ) : null; + }} + + + + {() => { + const password = form.getFieldValue('password'); + return password ? ( + + + + ) : null; + }} + +
+ ); + }; + + const wrapper = mount(); + + // Input name to show password + wrapper + .find('#name') + .last() + .simulate('change', { target: { value: '1' } }); + expect(wrapper.exists('#password')).toBeTruthy(); + expect(wrapper.exists('#password2')).toBeFalsy(); + + // Input password to show password2 + wrapper + .find('#password') + .last() + .simulate('change', { target: { value: '1' } }); + expect(wrapper.exists('#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(); + }); + + it('dependencies', () => { + const DepDemo = () => { + const [form] = Form.useForm(); + + return ( +
+ + + + + + {() => { + return form.getFieldValue('name') === '1' ? ( + + + + ) : null; + }} + + + + {() => { + const password = form.getFieldValue('password'); + return password ? ( + + + + ) : null; + }} + +
+ ); + }; + + const wrapper = mount(); + + // Input name to show password + wrapper + .find('#name') + .last() + .simulate('change', { target: { value: '1' } }); + expect(wrapper.exists('#password')).toBeTruthy(); + expect(wrapper.exists('#password2')).toBeFalsy(); + + // Input password to show password2 + wrapper + .find('#password') + .last() + .simulate('change', { target: { value: '1' } }); + expect(wrapper.exists('#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(); + }); + }); + + it('should correct calculate preserve state', () => { + let instance: FormInstance; + + const VisibleDemo = ({ visible = true }: { visible?: boolean }) => { + const [form] = Form.useForm(); + instance = form; + + return visible ? ( +
+ + + +
+ ) : ( +
+ ); + }; + + const wrapper = mount(); + + wrapper.setProps({ + visible: false, + }); + + instance.setFieldsValue({ name: 'bamboo' }); + wrapper.setProps({ + visible: true, + }); + + expect(wrapper.find('input').prop('value')).toEqual('bamboo'); + }); +}); +/* eslint-enable no-template-curly-in-string */ diff --git a/src/components/Form/Internal/Tests/setupAfterEnv.ts b/src/components/Form/Internal/Tests/setupAfterEnv.ts new file mode 100644 index 000000000..7b0828bfa --- /dev/null +++ b/src/components/Form/Internal/Tests/setupAfterEnv.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/src/components/Form/Internal/Tests/strict.test.tsx b/src/components/Form/Internal/Tests/strict.test.tsx new file mode 100644 index 000000000..b9939cc67 --- /dev/null +++ b/src/components/Form/Internal/Tests/strict.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Form from '../src'; +import InfoField, { Input } from './common/InfoField'; +import { changeValue } from './common'; + +describe('Form.ReactStrict', () => { + it('should not register twice', async () => { + const onFieldsChange = jest.fn(); + + const wrapper = mount( + +
+ + + +
+
+ ); + + await changeValue(wrapper, 'bamboo'); + + expect(onFieldsChange).toHaveBeenCalledTimes(1); + expect(onFieldsChange.mock.calls[0][1]).toHaveLength(1); + }); +}); diff --git a/src/components/Form/Internal/Tests/useWatch.test.tsx b/src/components/Form/Internal/Tests/useWatch.test.tsx new file mode 100644 index 000000000..e90b4f1ea --- /dev/null +++ b/src/components/Form/Internal/Tests/useWatch.test.tsx @@ -0,0 +1,433 @@ +import React, { useState } from 'react'; +import { mount } from 'enzyme'; +import type { FormInstance } from '../src'; +import { List } from '../src'; +import Form, { Field } from '../src'; +import timeout from './common/timeout'; +import { act } from 'react-dom/test-utils'; +import { Input } from './common/InfoField'; +import { stringify } from '../src/useWatch'; + +describe('useWatch', () => { + let staticForm: FormInstance; + + it('field initialValue', async () => { + const Demo = () => { + const [form] = Form.useForm(); + const nameValue = Form.useWatch('name', form); + + return ( +
+
+ + + +
+
{nameValue}
+
+ ); + }; + await act(async () => { + const wrapper = mount(); + await timeout(); + expect(wrapper.find('.values').text()).toEqual('bamboo'); + }); + }); + + it('form initialValue', async () => { + const Demo = () => { + const [form] = Form.useForm(); + const nameValue = Form.useWatch(['name'], form); + + return ( +
+
+ + + +
+
{nameValue}
+
+ ); + }; + await act(async () => { + const wrapper = mount(); + await timeout(); + expect(wrapper.find('.values').text()).toEqual('bamboo'); + }); + }); + + it('change value with form api', async () => { + const Demo = () => { + const [form] = Form.useForm(); + const nameValue = Form.useWatch(['name'], form); + + return ( +
+
{ + staticForm = instance; + }} + > + + + +
+
{nameValue}
+
+ ); + }; + 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'); + + staticForm.resetFields(); + expect(wrapper.find('.values').text()).toEqual(''); + }); + }); + + describe('unmount', () => { + it('basic', async () => { + const Demo = ({ visible }: { visible: boolean }) => { + const [form] = Form.useForm(); + const nameValue = Form.useWatch(['name'], form); + + return ( +
+
+ {visible && ( + + + + )} +
+
{nameValue}
+
+ ); + }; + + await act(async () => { + const wrapper = mount(); + await timeout(); + + expect(wrapper.find('.values').text()).toEqual('bamboo'); + + wrapper.setProps({ visible: false }); + expect(wrapper.find('.values').text()).toEqual(''); + + wrapper.setProps({ visible: true }); + expect(wrapper.find('.values').text()).toEqual('bamboo'); + }); + }); + + it('nest children component', async () => { + const DemoWatch = () => { + Form.useWatch(['name']); + + return ( + + + + ); + }; + + const Demo = ({ visible }: { visible: boolean }) => { + const [form] = Form.useForm(); + const nameValue = Form.useWatch(['name'], form); + + return ( +
+
+ {visible && } + +
{nameValue}
+
+ ); + }; + + await act(async () => { + const wrapper = mount(); + await timeout(); + + expect(wrapper.find('.values').text()).toEqual('bamboo'); + + wrapper.setProps({ visible: false }); + expect(wrapper.find('.values').text()).toEqual(''); + + wrapper.setProps({ visible: true }); + expect(wrapper.find('.values').text()).toEqual('bamboo'); + }); + }); + }); + + it('list', async () => { + const Demo = () => { + const [form] = Form.useForm(); + const users = Form.useWatch(['users'], form) || []; + + return ( +
+
{JSON.stringify(users)}
+ + {(fields, { remove }) => { + return ( +
+ {fields.map((field, index) => ( + + {(control) => ( + + )} + + ))} +
+ ); + }} +
+ + ); + }; + 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'); + await timeout(); + expect(wrapper.find('.values').text()).toEqual( + JSON.stringify(['light']) + ); + }); + }); + + it('warning if not provide form', () => { + const errorSpy = jest.spyOn(console, 'error'); + + const Demo = () => { + Form.useWatch([]); + return null; + }; + + mount(); + + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: useWatch requires a form instance since it can not auto detect from context.' + ); + }); + + it('no more render time', () => { + let renderTime = 0; + + const Demo = () => { + const [form] = Form.useForm(); + const name = Form.useWatch('name', form); + + renderTime += 1; + + return ( +
+ + + + + + +
{name}
+
+ ); + }; + + const wrapper = mount(); + expect(renderTime).toEqual(1); + + wrapper + .find('input') + .first() + .simulate('change', { + target: { + value: 'bamboo', + }, + }); + expect(renderTime).toEqual(2); + + wrapper + .find('input') + .last() + .simulate('change', { + target: { + value: '123', + }, + }); + expect(renderTime).toEqual(2); + + wrapper + .find('input') + .last() + .simulate('change', { + target: { + value: '123456', + }, + }); + expect(renderTime).toEqual(2); + }); + + it('typescript', () => { + type FieldType = { + main?: string; + name?: string; + age?: number; + gender?: boolean; + demo?: string; + demo2?: string; + id?: number; + demo1?: { demo2?: { demo3?: { demo4?: string } } }; + }; + + const Demo = () => { + const [form] = Form.useForm(); + const values = Form.useWatch([], form); + const main = Form.useWatch('main', form); + const age = Form.useWatch(['age'], form); + const demo1 = Form.useWatch(['demo1'], form); + const demo2 = Form.useWatch(['demo1', 'demo2'], form); + const demo3 = Form.useWatch(['demo1', 'demo2', 'demo3'], form); + 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); + const demo = Form.useWatch(['demo']); + + return ( + <> + {JSON.stringify({ + values, + main, + age, + demo1, + demo2, + demo3, + demo4, + demo5, + more, + demo, + })} + + ); + }; + + mount(); + }); + + // https://github.com/react-component/field-form/issues/431 + it('not trigger effect', () => { + let updateA = 0; + let updateB = 0; + + const Demo = () => { + 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 ( +
+ + + + + + +
+ ); + }; + + const wrapper = mount(); + + console.log('Change!'); + wrapper + .find('input') + .first() + .simulate('change', { target: { value: 'bamboo' } }); + + expect(updateA > updateB).toBeTruthy(); + }); + + it('mount while unmount', () => { + const Demo = () => { + const [form] = Form.useForm(); + const [type, setType] = useState(true); + const name = Form.useWatch('name', form); + + return ( +
+ + {type && ( + + + + )} + {!type && ( + + + + )} +
{name}
+
+ ); + }; + + 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'); + }); + it('stringify error', () => { + const obj: any = {}; + obj.name = obj; + const str = stringify(obj); + expect(typeof str === 'number').toBeTruthy(); + }); +}); diff --git a/src/components/Form/Internal/Tests/utils.test.js b/src/components/Form/Internal/Tests/utils.test.js new file mode 100644 index 000000000..db7d43f09 --- /dev/null +++ b/src/components/Form/Internal/Tests/utils.test.js @@ -0,0 +1,86 @@ +import { move, isSimilar, setValues } from '../src/utils/valueUtil'; +import NameMap from '../src/utils/NameMap'; +import cloneDeep from '../src/utils/cloneDeep'; + +describe('utils', () => { + describe('arrayMove', () => { + it('move', () => { + expect(move([0, 1, 2, 3], 0, 2)).toEqual([1, 2, 0, 3]); + expect(move([0, 1, 2, 3], 3, 1)).toEqual([0, 3, 1, 2]); + expect(move([0, 1, 2, 3], 1, 1)).toEqual([0, 1, 2, 3]); + expect(move([0, 1, 2, 3], -1, 3)).toEqual([0, 1, 2, 3]); + expect(move([0, 1, 2, 3], -1, 5)).toEqual([0, 1, 2, 3]); + expect(move([0, 1, 2, 3], 1, 5)).toEqual([0, 1, 2, 3]); + expect(move([0, 1, 2, 3], 0, 0)).toEqual([0, 1, 2, 3]); + expect(move([0, 1, 2, 3], 0, 1)).toEqual([1, 0, 2, 3]); + expect(move([0, 1, 2, 3], 1, 0)).toEqual([1, 0, 2, 3]); + expect(move([0, 1, 2, 3], 2, 3)).toEqual([0, 1, 3, 2]); + expect(move([0, 1, 2, 3], 3, 3)).toEqual([0, 1, 2, 3]); + expect(move([0, 1, 2, 3], 3, 2)).toEqual([0, 1, 3, 2]); + }); + }); + describe('valueUtil', () => { + it('isSimilar', () => { + expect(isSimilar(1, 1)).toBeTruthy(); + expect(isSimilar(1, 2)).toBeFalsy(); + expect(isSimilar({}, {})).toBeTruthy(); + expect(isSimilar({ a: 1 }, { a: 2 })).toBeFalsy(); + expect(isSimilar({ a() {} }, { a() {} })).toBeTruthy(); + expect(isSimilar({ a: 1 }, {})).toBeFalsy(); + expect(isSimilar({}, { a: 1 })).toBeFalsy(); + 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', () => { + it('update should clean if empty', () => { + const map = new NameMap(); + map.set(['user', 'name'], 'Bamboo'); + map.set(['user', 'age'], 14); + + expect(map.toJSON()).toEqual({ + 'user.name': 'Bamboo', + 'user.age': 14, + }); + + map.update(['user', 'age'], (prevValue) => { + expect(prevValue).toEqual(14); + return null; + }); + + expect(map.toJSON()).toEqual({ + 'user.name': 'Bamboo', + }); + + map.set(['user', 'name'], 'Light'); + expect(map.toJSON()).toEqual({ + 'user.name': 'Light', + }); + }); + }); + + 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/src/components/Form/Internal/Tests/validate-warning.test.tsx b/src/components/Form/Internal/Tests/validate-warning.test.tsx new file mode 100644 index 000000000..429ad75f4 --- /dev/null +++ b/src/components/Form/Internal/Tests/validate-warning.test.tsx @@ -0,0 +1,97 @@ +/* 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 type { FormInstance, Rule } from '../src/interface'; + +describe('Form.WarningValidate', () => { + it('required', async () => { + let form: FormInstance; + + const wrapper = mount( +
{ + form = f; + }} + > + + + +
+ ); + + await changeValue(wrapper, ''); + matchError(wrapper, false, "'name' is required"); + expect(form.getFieldWarning('name')).toEqual(["'name' is required"]); + }); + + describe('validateFirst should not block error', () => { + function testValidateFirst( + name: string, + validateFirst: boolean | 'parallel', + additionalRule?: Rule, + errorMessage?: string + ) { + it(name, async () => { + const rules = [ + additionalRule, + { + type: 'string', + len: 10, + warningOnly: true, + }, + { + type: 'url', + }, + { + type: 'string', + len: 20, + warningOnly: true, + }, + ]; + + const wrapper = mount( +
+ r) as any} + > + + +
+ ); + + await changeValue(wrapper, 'bamboo'); + matchError( + wrapper, + errorMessage || "'name' is not a valid url", + false + ); + }); + } + + testValidateFirst('default', true); + testValidateFirst( + 'default', + true, + { + type: 'string', + len: 3, + }, + "'name' must be exactly 3 characters" + ); + testValidateFirst('parallel', 'parallel'); + }); +}); +/* eslint-enable no-template-curly-in-string */ diff --git a/src/components/Form/Internal/Tests/validate.test.tsx b/src/components/Form/Internal/Tests/validate.test.tsx new file mode 100644 index 000000000..4d5acdc2b --- /dev/null +++ b/src/components/Form/Internal/Tests/validate.test.tsx @@ -0,0 +1,802 @@ +/* eslint-disable no-template-curly-in-string */ +import React from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import Form, { Field, useForm } from '../src'; +import InfoField, { Input } from './common/InfoField'; +import { changeValue, matchError, getField } from './common'; +import timeout from './common/timeout'; + +describe('Form.Validate', () => { + it('required', async () => { + let form; + const wrapper = mount( +
+
{ + form = instance; + }} + > + + +
+ ); + + await changeValue(wrapper, ''); + matchError(wrapper, true); + expect(form.getFieldError('username')).toEqual([ + "'username' is required", + ]); + expect(form.getFieldsError()).toEqual([ + { + name: ['username'], + errors: ["'username' is required"], + warnings: [], + }, + ]); + + // Contains not exists + expect(form.getFieldsError(['username', 'not-exist'])).toEqual([ + { + name: ['username'], + errors: ["'username' is required"], + warnings: [], + }, + { + name: ['not-exist'], + errors: [], + warnings: [], + }, + ]); + }); + + describe('validateMessages', () => { + function renderForm(messages, fieldProps = {}) { + return mount( +
+ + + ); + } + + it('template message', async () => { + const wrapper = renderForm({ required: "You miss '${name}'!" }); + + await changeValue(wrapper, ''); + matchError(wrapper, "You miss 'username'!"); + }); + + it('function message', async () => { + const wrapper = renderForm({ required: () => 'Bamboo & Light' }); + + await changeValue(wrapper, ''); + matchError(wrapper, 'Bamboo & Light'); + }); + + it('messageVariables', async () => { + const wrapper = renderForm( + { required: "You miss '${label}'!" }, + { + messageVariables: { + label: 'Light&Bamboo', + }, + } + ); + + await changeValue(wrapper, ''); + matchError(wrapper, "You miss 'Light&Bamboo'!"); + }); + }); + + describe('customize validator', () => { + it('work', async () => { + const wrapper = mount( +
+ + + ); + + // Wrong value + await changeValue(wrapper, 'light'); + matchError(wrapper, 'should be bamboo!'); + + // Correct value + await changeValue(wrapper, 'bamboo'); + matchError(wrapper, false); + }); + + it('should error if throw in validate', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const wrapper = mount( +
+ + + ); + + await changeValue(wrapper, 'light'); + matchError(wrapper, "Validation error on field 'username'"); + + const consoleErr = String(errorSpy.mock.calls[0][0]); + expect(consoleErr).toBe('Error: without thinking'); + + errorSpy.mockRestore(); + }); + }); + + it('fail validate if throw', async () => { + const wrapper = mount( +
+ + + ); + + // Wrong value + await changeValue(wrapper, 'light'); + matchError(wrapper, "Validation error on field 'username'"); + }); + + describe('callback', () => { + it('warning if not return promise', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const wrapper = mount( +
+ + + ); + + await changeValue(wrapper, 'light'); + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: `callback` is deprecated. Please return a promise instead.' + ); + + errorSpy.mockRestore(); + }); + + it('warning if both promise & callback exist', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const wrapper = mount( +
+ {}); + }, + }, + ]} + /> + + ); + + await changeValue(wrapper, 'light'); + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: Your validator function has already return a promise. `callback` will be ignored.' + ); + + errorSpy.mockRestore(); + }); + }); + + describe('validateTrigger', () => { + it('normal', async () => { + let form; + const wrapper = mount( +
+
{ + form = instance; + }} + > + { + throw new Error('Not pass'); + }, + validateTrigger: 'onChange', + }, + ]} + > + + +
+
+ ); + + await changeValue(getField(wrapper, 'test'), ''); + expect(form.getFieldError('test')).toEqual(['Not pass']); + + wrapper.find('input').simulate('blur'); + await timeout(); + expect(form.getFieldError('test')).toEqual(["'test' is required"]); + }); + + it('change validateTrigger', async () => { + let form; + + const Test = ({ init = false }) => ( +
{ + form = instance; + }} + > + + + +
+ ); + + const wrapper = mount(); + + getField(wrapper).simulate('blur'); + await timeout(); + expect(form.getFieldError('title')).toEqual(['Title is required']); + + wrapper.setProps({ init: true }); + await changeValue(getField(wrapper), '1'); + expect(form.getFieldValue('title')).toBe('1'); + expect(form.getFieldError('title')).toEqual([ + 'Title should be 3+ characters', + ]); + }); + + it('form context', async () => { + const wrapper = mount( +
+ + + ); + + // Not trigger validate since Form set `onBlur` + await changeValue(getField(wrapper), ''); + matchError(wrapper, false); + + // Trigger onBlur + wrapper.find('input').simulate('blur'); + await timeout(); + wrapper.update(); + matchError(wrapper, true); + + // Update Form context + wrapper.setProps({ validateTrigger: 'onChange' }); + await changeValue(getField(wrapper), '1'); + matchError(wrapper, false); + }); + }); + + describe('validate only accept exist fields', () => { + it('skip init value', async () => { + let form; + const onFinish = jest.fn(); + + const wrapper = mount( +
+
{ + form = instance; + }} + initialValues={{ user: 'light', pass: 'bamboo' }} + > + + + + +
+
+ ); + + // Validate callback + expect(await form.validateFields(['user'])).toEqual({ + user: 'light', + }); + expect(await form.validateFields()).toEqual({ user: 'light' }); + + // Submit callback + wrapper.find('button').simulate('submit'); + await timeout(); + expect(onFinish).toHaveBeenCalledWith({ user: 'light' }); + }); + + it('remove from fields', async () => { + const onFinish = jest.fn(); + const wrapper = mount( +
+ + + + + {(_, __, { getFieldValue }) => + getFieldValue('switch') && ( + + + + ) + } + + +
+ ); + + // Submit callback + wrapper.find('button').simulate('submit'); + 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'); + await timeout(); + expect(onFinish).toHaveBeenCalledWith({ switch: false }); + }); + + it('validateFields should not pass when validateFirst is set', async () => { + let form; + + mount( +
+
{ + form = instance; + }} + > + + + +
+
+ ); + + // Validate callback + await new Promise((resolve) => { + let failed = false; + form.validateFields() + .catch(() => { + failed = true; + }) + .then(() => { + expect(failed).toBeTruthy(); + resolve(''); + }); + }); + }); + }); + + it('should error in console if user script failed', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const wrapper = mount( +
{ + throw new Error('should console this'); + }} + initialValues={{ user: 'light' }} + > + + + +
+ ); + + wrapper.find('form').simulate('submit'); + await timeout(); + expect(errorSpy.mock.calls[0][0].message).toEqual( + 'should console this' + ); + + errorSpy.mockRestore(); + }); + + describe('validateFirst', () => { + it('work', async () => { + let form; + let canEnd = false; + const onFinish = jest.fn(); + + const wrapper = mount( +
+
{ + form = instance; + }} + onFinish={onFinish} + > + + new Promise((resolve) => { + if (canEnd) { + resolve(''); + } + }), + }, + ]} + /> + +
+ ); + + // Not pass + await changeValue(wrapper, ''); + matchError(wrapper, true); + expect(form.getFieldError('username')).toEqual([ + "'username' is required", + ]); + expect(form.getFieldsError()).toEqual([ + { + name: ['username'], + errors: ["'username' is required"], + warnings: [], + }, + ]); + expect(onFinish).not.toHaveBeenCalled(); + + // Should pass + canEnd = true; + await changeValue(wrapper, 'test'); + wrapper.find('form').simulate('submit'); + await timeout(); + + matchError(wrapper, false); + expect(onFinish).toHaveBeenCalledWith({ username: 'test' }); + }); + + [ + { + name: 'serialization', + first: true, + second: false, + validateFirst: true, + }, + { + name: 'parallel', + first: true, + second: true, + validateFirst: 'parallel' as const, + }, + ].forEach(({ name, first, second, validateFirst }) => { + it(name, async () => { + let ruleFirst = false; + let ruleSecond = false; + + const wrapper = mount( +
+ { + ruleFirst = true; + await timeout(); + throw new Error('failed first'); + }, + }, + { + validator: async () => { + ruleSecond = true; + await timeout(); + throw new Error('failed second'); + }, + }, + ]} + /> + + ); + + await changeValue(wrapper, 'test'); + await timeout(); + + wrapper.update(); + matchError(wrapper, 'failed first'); + + expect(ruleFirst).toEqual(first); + expect(ruleSecond).toEqual(second); + }); + }); + }); + + it('switch to remove errors', async () => { + const Demo = () => { + const [checked, setChecked] = React.useState(true); + + return ( +
+
- + ); - await changeValue(getField(wrapper), 'Bamboo'); + await changeValue(getField(wrapper), 'Mia'); wrapper.find('button').simulate('reset'); await timeout(); expect(resetFn).toHaveBeenCalledTimes(1); const { value } = wrapper.find('input').props(); expect(value).toEqual(''); }); - it('submit', async () => { + test('submit', async () => { const onFinish = jest.fn(); const onFinishFailed = jest.fn(); const wrapper = mount( -
+ - +
); // Not trigger @@ -373,52 +375,27 @@ describe('Form.Basic', () => { onFinishFailed.mockReset(); // Trigger - await changeValue(getField(wrapper), 'Bamboo'); + await changeValue(getField(wrapper), 'Mia'); wrapper.find('button').simulate('submit'); await timeout(); matchError(wrapper, false); - expect(onFinish).toHaveBeenCalledWith({ user: 'Bamboo' }); + expect(onFinish).toHaveBeenCalledWith({ user: 'Mia' }); 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 () => { + test('valuePropName', async () => { let form; const wrapper = mount(
- { form = instance; }} > - + - - + +
); @@ -435,37 +412,37 @@ describe('Form.Basic', () => { expect(form.getFieldsValue()).toEqual({ check: false }); }); - it('getValueProps', async () => { + test('getValueProps', async () => { const wrapper = mount(
-
- + ({ light: val })} + getValueProps={(val) => ({ lola: val })} > - -
+ +
); - expect(wrapper.find('.anything').props().light).toEqual('bamboo'); + expect(wrapper.find('.anything').props().lola).toEqual('mia'); }); describe('shouldUpdate', () => { - it('work', async () => { + test('work', async () => { let isAllTouched; let hasError; const wrapper = mount( -
- + + - - + + - - + + {(_, __, { getFieldsError, isFieldsTouched }) => { isAllTouched = isFieldsTouched(true); hasError = getFieldsError().filter( @@ -474,15 +451,15 @@ describe('Form.Basic', () => { return null; }} - -
+ + ); await changeValue(getField(wrapper, 'username'), ''); expect(isAllTouched).toBeFalsy(); expect(hasError).toBeTruthy(); - await changeValue(getField(wrapper, 'username'), 'Bamboo'); + await changeValue(getField(wrapper, 'username'), 'Mia'); expect(isAllTouched).toBeFalsy(); expect(hasError).toBeFalsy(); @@ -495,15 +472,15 @@ describe('Form.Basic', () => { expect(hasError).toBeTruthy(); }); - it('true will force one more update', async () => { + test('true will force one more update', async () => { let renderPhase = 0; const wrapper = mount( -
- + + - - + + {(_, __, form) => { renderPhase += 1; return ( @@ -514,23 +491,23 @@ describe('Form.Basic', () => { /> ); }} - -
+ + ); const props = wrapper.find('#holder').props(); expect(renderPhase).toEqual(2); expect(props['data-touched']).toBeFalsy(); - expect(props['data-value']).toEqual({ username: 'light' }); + expect(props['data-value']).toEqual({ username: 'lola' }); }); }); describe('setFields', () => { - it('should work', () => { + test('should work', () => { let form; const wrapper = mount(
-
{ form = instance; }} @@ -538,7 +515,7 @@ describe('Form.Basic', () => { -
+
); @@ -557,14 +534,14 @@ describe('Form.Basic', () => { expect(form.isFieldsTouched()).toBeFalsy(); }); - it('should trigger by setField', () => { + test('should trigger by setField', () => { const triggerUpdate = jest.fn(); const formRef = React.createRef(); const wrapper = mount(
-
- + prev.value !== next.value } @@ -573,8 +550,8 @@ describe('Form.Basic', () => { triggerUpdate(); return ; }} - -
+ +
); wrapper.update(); @@ -596,45 +573,45 @@ describe('Form.Basic', () => { }); }); - it('render props get meta', () => { + test('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']); + expect(meta.name).toEqual(['Mia', 'Best']); called2 = true; return null; }} - -
+ + ); expect(called1).toBeTruthy(); expect(called2).toBeTruthy(); }); - it('setFieldsValue should clean up status', async () => { + test('setFieldsValue should clean up status', async () => { let form; let currentMeta; const wrapper = mount(
-
{ form = instance; }} > - new Promise(() => {}) }]} > @@ -642,8 +619,8 @@ describe('Form.Basic', () => { currentMeta = meta; return ; }} - -
+ +
); @@ -664,9 +641,9 @@ describe('Form.Basic', () => { expect(currentMeta.validating).toBeFalsy(); // Input it - await changeValue(getField(wrapper), 'Bamboo'); + await changeValue(getField(wrapper), 'Mia'); - expect(form.getFieldValue('normal')).toBe('Bamboo'); + expect(form.getFieldValue('normal')).toBe('Mia'); expect(form.isFieldTouched('normal')).toBeTruthy(); expect(form.getFieldError('normal')).toEqual([]); expect(currentMeta.validating).toBeTruthy(); @@ -682,65 +659,20 @@ describe('Form.Basic', () => { 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 () => { + test('filtering fields by meta', async () => { let form; const wrapper = mount(
- { form = instance; }} > - {() => null} - + {() => null} +
); @@ -762,24 +694,24 @@ describe('Form.Basic', () => { ); expect(form.getFieldsValue(null, (meta) => meta.touched)).toEqual({}); - await changeValue(getField(wrapper, 0), 'Bamboo'); + await changeValue(getField(wrapper, 0), 'Mia'); expect(form.getFieldsValue(null, () => true)).toEqual( form.getFieldsValue() ); expect(form.getFieldsValue(null, (meta) => meta.touched)).toEqual({ - username: 'Bamboo', + username: 'Mia', }); expect( form.getFieldsValue(['username'], (meta) => meta.touched) ).toEqual({ - username: 'Bamboo', + username: 'Mia', }); expect( form.getFieldsValue(['password'], (meta) => meta.touched) ).toEqual({}); }); - it('should not crash when return value contains target field', async () => { + test('should not crash when return value contains target field', async () => { const CustomInput = ({ value, onChange }) => { const onInputChange = (e) => { onChange({ @@ -790,11 +722,11 @@ describe('Form.Basic', () => { return ; }; const wrapper = mount( -
- + + - -
+ + ); expect(() => { wrapper @@ -803,7 +735,7 @@ describe('Form.Basic', () => { }).not.toThrowError(); }); - it('setFieldsValue for List should work', () => { + test('setFieldsValue for List should work', () => { const Demo = () => { const [form] = useForm(); @@ -818,17 +750,17 @@ describe('Form.Basic', () => { }; return ( -
- + {(fields, { add, remove }) => ( <> {fields.map(({ key, name, ...restField }) => ( - { ]} > - + ))} )} - - + + - -
+ + ); }; @@ -860,7 +792,7 @@ describe('Form.Basic', () => { expect(wrapper.find('input').length).toBe(0); }); - it('setFieldsValue should work for multiple Select', () => { + test('setFieldsValue should work for multiple Select', () => { const Select = ({ value, defaultValue }) => { return (
@@ -870,18 +802,18 @@ describe('Form.Basic', () => { }; const Demo = () => { - const [formInstance] = Form.useForm(); + const [formInstance] = OcForm.useForm(); React.useEffect(() => { formInstance.setFieldsValue({ selector: ['K1', 'K2'] }); }, [formInstance]); return ( -
- + + - -
+ + ); if (remount) { @@ -913,45 +844,45 @@ describe('Form.Basic', () => { }; const wrapper = mount(); - refForm.setFieldsValue({ name: 'bamboo' }); + refForm.setFieldsValue({ name: 'mia' }); wrapper.update(); - expect(wrapper.find('input').prop('value')).toEqual('bamboo'); + expect(wrapper.find('input').prop('value')).toEqual('mia'); wrapper.setProps({ remount: true }); wrapper.update(); - expect(wrapper.find('input').prop('value')).toEqual('bamboo'); + expect(wrapper.find('input').prop('value')).toEqual('mia'); }); - it('setFieldValue', () => { + test('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']); + ).toEqual(['mia', 'little', 'lola', 'nested']); // Set formRef.current.setFieldValue(['list', 1], 'tiny'); @@ -960,6 +891,6 @@ describe('Form.Basic', () => { expect( wrapper.find('input').map((input) => input.prop('value')) - ).toEqual(['bamboo', 'tiny', 'light', 'match']); + ).toEqual(['mia', 'tiny', 'lola', 'match']); }); }); diff --git a/src/components/Form/Internal/Tests/initialValue.test.js b/src/components/Form/Internal/Tests/initialValue.test.js index 13d6b052c..c18303e72 100644 --- a/src/components/Form/Internal/Tests/initialValue.test.js +++ b/src/components/Form/Internal/Tests/initialValue.test.js @@ -1,64 +1,66 @@ import React, { useState } from 'react'; -import { mount } from 'enzyme'; -import { resetWarned } from 'rc-util/lib/warning'; -import Form, { Field, useForm, List } from '../src'; +import Enzyme, { mount } from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import OcForm, { OcField, useForm, OcList } from '../'; import { Input } from './common/InfoField'; import { changeValue, getField } from './common'; -describe('Form.InitialValues', () => { - it('works', () => { +Enzyme.configure({ adapter: new Adapter() }); + +describe('OcForm.InitialValues', () => { + test('works', () => { let form; const wrapper = mount(
-
{ form = instance; }} initialValues={{ - username: 'Light', - path1: { path2: 'Bamboo' }, + username: 'Lola', + path1: { path2: 'Mia' }, }} > - + - - + + - -
+ +
); expect(form.getFieldsValue()).toEqual({ - username: 'Light', + username: 'Lola', path1: { - path2: 'Bamboo', + path2: 'Mia', }, }); expect(form.getFieldsValue(['username'])).toEqual({ - username: 'Light', + username: 'Lola', }); expect(form.getFieldsValue(['path1'])).toEqual({ path1: { - path2: 'Bamboo', + path2: 'Mia', }, }); expect(form.getFieldsValue(['username', ['path1', 'path2']])).toEqual({ - username: 'Light', + username: 'Lola', path1: { - path2: 'Bamboo', + path2: 'Mia', }, }); expect( getField(wrapper, 'username').find('input').props().value - ).toEqual('Light'); + ).toEqual('Lola'); expect( getField(wrapper, ['path1', 'path2']).find('input').props().value - ).toEqual('Bamboo'); + ).toEqual('Mia'); }); - it('update and reset should use new initialValues', () => { + test('update and reset should use new initialValues', () => { let form; let mountCount = 0; @@ -71,52 +73,52 @@ describe('Form.InitialValues', () => { }; const Test = ({ initialValues }) => ( -
{ form = instance; }} initialValues={initialValues} > - + - - + + - -
+ + ); - const wrapper = mount(); + const wrapper = mount(); expect(form.getFieldsValue()).toEqual({ - username: 'Bamboo', + username: 'Mia', }); expect( getField(wrapper, 'username').find('input').props().value - ).toEqual('Bamboo'); + ).toEqual('Mia'); // Should not change it - wrapper.setProps({ initialValues: { username: 'Light' } }); + wrapper.setProps({ initialValues: { username: 'Lola' } }); wrapper.update(); expect(form.getFieldsValue()).toEqual({ - username: 'Bamboo', + username: 'Mia', }); expect( getField(wrapper, 'username').find('input').props().value - ).toEqual('Bamboo'); + ).toEqual('Mia'); // Should change it form.resetFields(); wrapper.update(); expect(mountCount).toEqual(1); expect(form.getFieldsValue()).toEqual({ - username: 'Light', + username: 'Lola', }); expect( getField(wrapper, 'username').find('input').props().value - ).toEqual('Light'); + ).toEqual('Lola'); }); - it("initialValues shouldn't be modified if preserve is false", () => { + test("initialValues shouldn't be modified if preserve is false", () => { const formValue = { test: 'test', users: [{ first: 'aaa', last: 'bbb' }], @@ -125,36 +127,39 @@ describe('Form.InitialValues', () => { let refForm; const Demo = () => { - const [form] = Form.useForm(); + const [form] = OcForm.useForm(); const [show, setShow] = useState(false); refForm = form; return ( <> - {show && ( -
- + {() => ( - + - + )} - - + + {(fields) => ( <> {fields.map( ({ key, name, ...restField }) => ( - { className="first-name-input" placeholder="First Name" /> - - + { ]} > - + ) )} )} - -
+ + )} ); @@ -206,71 +211,20 @@ describe('Form.InitialValues', () => { wrapper.find('button').simulate('click'); wrapper.update(); - expect( - wrapper - .find('.first-name-input') - .first() - .find('input') - .prop('value') - ).toEqual('aaa'); + expect(formValue.users[0].first).toEqual('aaa'); }); - describe('Field with initialValue', () => { - it('warning if Form already has initialValues', () => { - resetWarned(); - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - const wrapper = mount( -
- - - -
- ); - - expect(wrapper.find('input').props().value).toEqual('bamboo'); - - expect(errorSpy).toHaveBeenCalledWith( - "Warning: Form already set 'initialValues' with path 'conflict'. Field can not overwrite it." - ); - - errorSpy.mockRestore(); - }); - - it('warning if multiple Field with same name set `initialValue`', () => { - resetWarned(); - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - mount( -
- - - - - - -
- ); - - expect(errorSpy).toHaveBeenCalledWith( - "Warning: Multiple Field with path 'conflict' set 'initialValue'. Can not decide which one to pick." - ); - - errorSpy.mockRestore(); - }); - - it('should not replace user input', async () => { + describe('OcField with initialValue', () => { + test('should not replace user input', async () => { const Test = () => { const [show, setShow] = React.useState(false); return ( -
+ {show && ( - + - + )} - - ); - }; - - it('Preserve right fields when switch them', async () => { - const wrapper = mount(); - - wrapper - .find('.one') - .last() - .simulate('change', { target: { value: 'value1' } }); - expect(Object.keys(form.getFieldsValue())).toEqual( - expect.arrayContaining(['a']) - ); - expect(form.getFieldValue('a')).toBe('value1'); - expect(wrapper.find('.one').last().getDOMNode().value).toBe('value1'); - - wrapper.find('.sw').simulate('click'); - expect(Object.keys(form.getFieldsValue())).toEqual( - expect.arrayContaining(['a']) - ); - expect(form.getFieldValue('a')).toBe('value1'); - expect(wrapper.find('.two').last().getDOMNode().value).toBe('value1'); - }); -}); diff --git a/src/components/Form/Internal/Tests/legacy/validate-array.test.js b/src/components/Form/Internal/Tests/legacy/validate-array.test.js deleted file mode 100644 index c2c6edc3f..000000000 --- a/src/components/Form/Internal/Tests/legacy/validate-array.test.js +++ /dev/null @@ -1,94 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import Form, { Field } from '../../src'; -import { Input } from '../common/InfoField'; -import { changeValue, getField, matchArray } from '../common'; -import timeout from '../common/timeout'; - -describe('legacy.validate-array', () => { - const MyInput = ({ value = [''], onChange, ...props }) => ( - { - onChange(e.target.value.split(',')); - }} - value={value.join(',')} - /> - ); - - it('forceValidate works', async () => { - let form; - - mount( -
-
{ - form = instance; - }} - initialValues={{ url_array: ['test'] }} - > - - - -
-
- ); - - try { - await form.validateFields(); - throw new Error('Should not pass!'); - } catch ({ errorFields }) { - matchArray( - errorFields, - [ - { - name: ['url_array'], - errors: ["'url_array.0' is not a valid url"], - }, - ], - 'name' - ); - } - }); - - // https://github.com/ant-design/ant-design/issues/36436 - it('antd issue #36436', async () => { - let form; - - mount( -
-
{ - form = instance; - }} - > - - - -
-
- ); - - expect(async () => { - await form.validateFields(); - }).not.toThrow(); - }); -}); diff --git a/src/components/Form/Internal/Tests/list.test.tsx b/src/components/Form/Internal/Tests/list.test.tsx index eb8bc08fe..fa7df64b8 100644 --- a/src/components/Form/Internal/Tests/list.test.tsx +++ b/src/components/Form/Internal/Tests/list.test.tsx @@ -1,55 +1,62 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mount } from 'enzyme'; import type { ReactWrapper } from 'enzyme'; -import { resetWarned } from 'rc-util/lib/warning'; -import Form, { Field, List } from '../src'; -import type { FormProps } from '../src'; -import type { ListField, ListOperations, ListProps } from '../src/List'; -import type { FormInstance, Meta } from '../src/interface'; -import ListContext from '../src/ListContext'; +import Enzyme, { mount } from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import OcForm, { OcField, OcList } from '../'; +import type { + OcFormInstance, + OcFormProps, + OcListField, + OcListOperations, + OcListProps, + OcMeta, +} from '../OcForm.types'; +import OcListContext from '../OcListContext'; import { Input } from './common/InfoField'; import { changeValue, getField } from './common'; import timeout from './common/timeout'; -describe('Form.List', () => { - let form; +Enzyme.configure({ adapter: new Adapter() }); + +describe('OcForm.OcList', () => { + let form: OcFormInstance; function generateForm( renderList?: ( - fields: ListField[], - operations: ListOperations, - meta: Meta + fields: OcListField[], + operations: OcListOperations, + meta: OcMeta ) => JSX.Element | React.ReactNode, - formProps?: FormProps, - listProps?: Partial + formProps?: OcFormProps, + listProps?: Partial ): [ReactWrapper, () => ReactWrapper] { const wrapper = mount(
-
{ form = instance; }} {...formProps} > - + {renderList} - -
+ +
); return [wrapper, () => getField(wrapper).find('div')]; } - it('basic', async () => { + test('basic', async () => { const [, getList] = generateForm( (fields) => (
{fields.map((field) => ( - + - + ))}
), @@ -60,8 +67,8 @@ describe('Form.List', () => { } ); - function matchKey(index, key) { - expect(getList().find(Field).at(index).key()).toEqual(key); + function matchKey(index: number, key: string) { + expect(getList().find(OcField).at(index).key()).toEqual(key); } matchKey(0, '0'); @@ -79,47 +86,37 @@ describe('Form.List', () => { }); }); - it('not crash', () => { + test('not crash', () => { // Empty only mount( -
- {() => null} -
+ + {() => null} + ); - - // Not a array - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - resetWarned(); mount( -
- {() => null} -
- ); - expect(errorSpy).toHaveBeenCalledWith( - "Warning: Current value of 'list' is not an array type." + + {() => null} + ); - errorSpy.mockRestore(); }); - it('operation', async () => { - let operation; + test('operation', async () => { + let operation: OcListOperations; const [wrapper, getList] = generateForm((fields, opt) => { operation = opt; return (
{fields.map((field) => ( - + - + ))}
); }); - function matchKey(index, key) { - expect(getList().find(Field).at(index).key()).toEqual(key); + function matchKey(index: number, key: string) { + expect(getList().find(OcField).at(index).key()).toEqual(key); } // Add @@ -136,7 +133,7 @@ describe('Form.List', () => { }); wrapper.update(); - expect(getList().find(Field).length).toEqual(3); + expect(getList().find(OcField).length).toEqual(3); expect(form.getFieldsValue()).toEqual({ list: [undefined, '2', undefined], }); @@ -219,7 +216,7 @@ describe('Form.List', () => { operation.remove(1); }); wrapper.update(); - expect(getList().find(Field).length).toEqual(2); + expect(getList().find(OcField).length).toEqual(2); expect(form.getFieldsValue()).toEqual({ list: [undefined, undefined], }); @@ -248,23 +245,23 @@ describe('Form.List', () => { matchKey(1, '2'); }); - it('remove when the param is Array', () => { - let operation; + test('remove when the param is Array', () => { + let operation: OcListOperations; const [wrapper, getList] = generateForm((fields, opt) => { operation = opt; return (
{fields.map((field) => ( - + - + ))}
); }); - function matchKey(index, key) { - expect(getList().find(Field).at(index).key()).toEqual(key); + function matchKey(index: number, key: string) { + expect(getList().find(OcField).at(index).key()).toEqual(key); } act(() => { @@ -276,7 +273,7 @@ describe('Form.List', () => { }); wrapper.update(); - expect(getList().find(Field).length).toEqual(2); + expect(getList().find(OcField).length).toEqual(2); // remove empty array act(() => { @@ -302,7 +299,7 @@ describe('Form.List', () => { }); wrapper.update(); - expect(getList().find(Field).length).toEqual(1); + expect(getList().find(OcField).length).toEqual(1); matchKey(0, '1'); act(() => { @@ -326,19 +323,16 @@ describe('Form.List', () => { matchKey(0, '3'); }); - it('add when the second param is number', () => { - let operation; - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); + test('add when the second param is number', () => { + let operation: OcListOperations; const [wrapper, getList] = generateForm((fields, opt) => { operation = opt; return (
{fields.map((field) => ( - + - + ))}
); @@ -355,13 +349,8 @@ describe('Form.List', () => { operation.add('2', -1); }); - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: The second parameter of the add function should be a valid positive number.' - ); - errorSpy.mockRestore(); - wrapper.update(); - expect(getList().find(Field).length).toEqual(3); + expect(getList().find(OcField).length).toEqual(3); expect(form.getFieldsValue()).toEqual({ list: [undefined, '1', '2'], }); @@ -374,21 +363,21 @@ describe('Form.List', () => { }); wrapper.update(); - expect(getList().find(Field).length).toEqual(5); + expect(getList().find(OcField).length).toEqual(5); expect(form.getFieldsValue()).toEqual({ list: ['0', undefined, '1', '4', '2'], }); }); describe('validate', () => { - it('basic', async () => { + test('basic', async () => { const [, getList] = generateForm( (fields) => (
{fields.map((field) => ( - + - + ))}
), @@ -404,14 +393,14 @@ describe('Form.List', () => { ]); }); - it('remove should keep error', async () => { + test('remove should keep error', async () => { const [wrapper, getList] = generateForm( (fields, { remove }) => (
{fields.map((field) => ( - + - + ))}
); }} - + ); @@ -194,30 +197,27 @@ describe('Form.Preserve', () => { wrapper.update(); expect(form.getFieldsValue()).toEqual({ - list: ['bamboo', 'little'], + list: ['mia', 'little'], }); }); - it('warning when Form.List use preserve', () => { - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - let form: FormInstance; + test('when Form.List use preserve', () => { + let form: OcFormInstance; const wrapper = mount(
{ form = instance; }} - initialValues={{ list: ['bamboo'] }} + initialValues={{ list: ['mia'] }} > - + {(fields, { remove }) => ( <> {fields.map((field) => ( - + - + ))} )} - +
); - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: `preserve` should not apply on Form.List fields.' - ); - - errorSpy.mockRestore(); - // Remove should not work wrapper.find('button').simulate('click'); expect(form.getFieldsValue()).toEqual({ list: [] }); }); - it('multiple level field can use preserve', async () => { - let form: FormInstance; + test('multiple level field can use preserve', async () => { + let form: OcFormInstance; const wrapper = mount(
{ form = instance; }} > - + {(fields, { remove }) => { return ( <> {fields.map((field) => (
- - - + + {(_, __, { getFieldValue }) => getFieldValue([ 'list', field.name, 'type', - ]) === 'light' ? ( - - + ) : ( - - + ) } - +
))} {type && ( - + - + )} {!type && ( - + - + )}
{name}
- +
); }; @@ -420,11 +407,11 @@ describe('useWatch', () => { wrapper .find('input') .first() - .simulate('change', { target: { value: 'bamboo' } }); + .simulate('change', { target: { value: 'mia' } }); wrapper.find('button').at(0).simulate('click'); - expect(wrapper.find('.value').text()).toEqual('bamboo'); + expect(wrapper.find('.value').text()).toEqual('mia'); }); - it('stringify error', () => { + test('stringify error', () => { const obj: any = {}; obj.name = obj; const str = stringify(obj); diff --git a/src/components/Form/Internal/Tests/utils.test.js b/src/components/Form/Internal/Tests/utils.test.js index db7d43f09..f08b7d204 100644 --- a/src/components/Form/Internal/Tests/utils.test.js +++ b/src/components/Form/Internal/Tests/utils.test.js @@ -1,10 +1,10 @@ -import { move, isSimilar, setValues } from '../src/utils/valueUtil'; -import NameMap from '../src/utils/NameMap'; -import cloneDeep from '../src/utils/cloneDeep'; +import { move, isSimilar, setValues } from '../Utils/valueUtil'; +import NameMap from '../Utils/NameMap'; +import cloneDeep from '../Utils/cloneDeep'; -describe('utils', () => { +describe('Utils', () => { describe('arrayMove', () => { - it('move', () => { + test('move', () => { expect(move([0, 1, 2, 3], 0, 2)).toEqual([1, 2, 0, 3]); expect(move([0, 1, 2, 3], 3, 1)).toEqual([0, 3, 1, 2]); expect(move([0, 1, 2, 3], 1, 1)).toEqual([0, 1, 2, 3]); @@ -20,7 +20,7 @@ describe('utils', () => { }); }); describe('valueUtil', () => { - it('isSimilar', () => { + test('isSimilar', () => { expect(isSimilar(1, 1)).toBeTruthy(); expect(isSimilar(1, 2)).toBeFalsy(); expect(isSimilar({}, {})).toBeTruthy(); @@ -33,7 +33,7 @@ describe('utils', () => { }); describe('setValues', () => { - it('basic', () => { + test('basic', () => { expect(setValues({}, { a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2, @@ -41,7 +41,7 @@ describe('utils', () => { expect(setValues([], [123])).toEqual([123]); }); - it('Correct handle class instance', () => { + test('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(); @@ -50,13 +50,13 @@ describe('utils', () => { }); describe('NameMap', () => { - it('update should clean if empty', () => { + test('update should clean if empty', () => { const map = new NameMap(); - map.set(['user', 'name'], 'Bamboo'); + map.set(['user', 'name'], 'Mia'); map.set(['user', 'age'], 14); expect(map.toJSON()).toEqual({ - 'user.name': 'Bamboo', + 'user.name': 'Mia', 'user.age': 14, }); @@ -66,18 +66,18 @@ describe('utils', () => { }); expect(map.toJSON()).toEqual({ - 'user.name': 'Bamboo', + 'user.name': 'Mia', }); - map.set(['user', 'name'], 'Light'); + map.set(['user', 'name'], 'Lola'); expect(map.toJSON()).toEqual({ - 'user.name': 'Light', + 'user.name': 'Lola', }); }); }); describe('clone deep', () => { - it('should not deep clone Class', () => { + test('should not deep clone Class', () => { const data = { a: new Date() }; const clonedData = cloneDeep(data); expect(data.a === clonedData.a).toBeTruthy(); diff --git a/src/components/Form/Internal/Tests/validate-warning.test.tsx b/src/components/Form/Internal/Tests/validate-warning.test.tsx index 429ad75f4..4d479d665 100644 --- a/src/components/Form/Internal/Tests/validate-warning.test.tsx +++ b/src/components/Form/Internal/Tests/validate-warning.test.tsx @@ -1,17 +1,20 @@ /* eslint-disable no-template-curly-in-string */ import React from 'react'; -import { mount } from 'enzyme'; -import Form from '../src'; +import Enzyme, { mount } from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import OcForm from '../'; import InfoField, { Input } from './common/InfoField'; import { changeValue, matchError } from './common'; -import type { FormInstance, Rule } from '../src/interface'; +import type { OcFormInstance, OcRule } from '../OcForm.types'; -describe('Form.WarningValidate', () => { - it('required', async () => { - let form: FormInstance; +Enzyme.configure({ adapter: new Adapter() }); + +describe('OcForm.WarningValidate', () => { + test('required', async () => { + let form: OcFormInstance; const wrapper = mount( -
{ form = f; }} @@ -27,7 +30,7 @@ describe('Form.WarningValidate', () => { > -
+ ); await changeValue(wrapper, ''); @@ -39,10 +42,10 @@ describe('Form.WarningValidate', () => { function testValidateFirst( name: string, validateFirst: boolean | 'parallel', - additionalRule?: Rule, + additionalRule?: OcRule, errorMessage?: string ) { - it(name, async () => { + test(name, async () => { const rules = [ additionalRule, { @@ -61,7 +64,7 @@ describe('Form.WarningValidate', () => { ]; const wrapper = mount( -
+ { > - +
); await changeValue(wrapper, 'bamboo'); diff --git a/src/components/Form/Internal/Tests/validate.test.tsx b/src/components/Form/Internal/Tests/validate.test.tsx index 4d5acdc2b..4c592950b 100644 --- a/src/components/Form/Internal/Tests/validate.test.tsx +++ b/src/components/Form/Internal/Tests/validate.test.tsx @@ -1,24 +1,28 @@ /* eslint-disable no-template-curly-in-string */ import React from 'react'; -import { mount } from 'enzyme'; +import Enzyme, { mount } from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; import { act } from 'react-dom/test-utils'; -import Form, { Field, useForm } from '../src'; +import OcForm, { OcField, useForm } from '../'; import InfoField, { Input } from './common/InfoField'; import { changeValue, matchError, getField } from './common'; import timeout from './common/timeout'; +import { OcFormInstance, ValidateMessages } from '../OcForm.types'; -describe('Form.Validate', () => { - it('required', async () => { - let form; +Enzyme.configure({ adapter: new Adapter() }); + +describe('OcForm.Validate', () => { + test('required', async () => { + let form: OcFormInstance; const wrapper = mount(
-
{ form = instance; }} > - +
); @@ -51,59 +55,59 @@ describe('Form.Validate', () => { }); describe('validateMessages', () => { - function renderForm(messages, fieldProps = {}) { + function renderForm(messages: ValidateMessages, fieldProps = {}) { return mount( -
+ - +
); } - it('template message', async () => { + test('template message', async () => { const wrapper = renderForm({ required: "You miss '${name}'!" }); await changeValue(wrapper, ''); matchError(wrapper, "You miss 'username'!"); }); - it('function message', async () => { - const wrapper = renderForm({ required: () => 'Bamboo & Light' }); + test('function message', async () => { + const wrapper = renderForm({ required: () => 'Mia & Lola' }); await changeValue(wrapper, ''); - matchError(wrapper, 'Bamboo & Light'); + matchError(wrapper, 'Mia & Lola'); }); - it('messageVariables', async () => { + test('messageVariables', async () => { const wrapper = renderForm( { required: "You miss '${label}'!" }, { messageVariables: { - label: 'Light&Bamboo', + label: 'Lola&Mia', }, } ); await changeValue(wrapper, ''); - matchError(wrapper, "You miss 'Light&Bamboo'!"); + matchError(wrapper, "You miss 'Lola&Mia'!"); }); }); describe('customize validator', () => { - it('work', async () => { + test('work', async () => { const wrapper = mount( -
+ { }, ]} /> - +
); // Wrong value - await changeValue(wrapper, 'light'); - matchError(wrapper, 'should be bamboo!'); + await changeValue(wrapper, 'lola'); + matchError(wrapper, 'should be mia!'); // Correct value - await changeValue(wrapper, 'bamboo'); + await changeValue(wrapper, 'mia'); matchError(wrapper, false); }); - it('should error if throw in validate', async () => { + test('should error if throw in validate', async () => { const errorSpy = jest .spyOn(console, 'error') .mockImplementation(() => {}); const wrapper = mount( -
+ { }, ]} /> - +
); - await changeValue(wrapper, 'light'); + await changeValue(wrapper, 'lola'); matchError(wrapper, "Validation error on field 'username'"); const consoleErr = String(errorSpy.mock.calls[0][0]); @@ -152,9 +156,9 @@ describe('Form.Validate', () => { }); }); - it('fail validate if throw', async () => { + test('fail validate if throw', async () => { const wrapper = mount( -
+ { }, ]} /> - +
); // Wrong value - await changeValue(wrapper, 'light'); + await changeValue(wrapper, 'lola'); matchError(wrapper, "Validation error on field 'username'"); }); describe('callback', () => { - it('warning if not return promise', async () => { - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - + test('when if not return promise', async () => { const wrapper = mount( -
+ - - ); - - await changeValue(wrapper, 'light'); - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: `callback` is deprecated. Please return a promise instead.' +
); - errorSpy.mockRestore(); + await changeValue(wrapper, 'lola'); }); - it('warning if both promise & callback exist', async () => { - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - + test('when both promise & callback exist', async () => { const wrapper = mount( -
+ { }, ]} /> - +
); - await changeValue(wrapper, 'light'); - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: Your validator function has already return a promise. `callback` will be ignored.' - ); - - errorSpy.mockRestore(); + await changeValue(wrapper, 'lola'); }); }); describe('validateTrigger', () => { - it('normal', async () => { - let form; + test('normal', async () => { + let form: OcFormInstance; const wrapper = mount(
-
{ form = instance; }} @@ -257,7 +243,7 @@ describe('Form.Validate', () => { > -
+
); @@ -269,16 +255,16 @@ describe('Form.Validate', () => { expect(form.getFieldError('test')).toEqual(["'test' is required"]); }); - it('change validateTrigger', async () => { - let form; + test('change validateTrigger', async () => { + let form: OcFormInstance; const Test = ({ init = false }) => ( -
{ form = instance; }} > - { ]} > - -
+ + ); const wrapper = mount(); @@ -308,14 +294,14 @@ describe('Form.Validate', () => { ]); }); - it('form context', async () => { + test('form context', async () => { const wrapper = mount( -
+ - +
); - // Not trigger validate since Form set `onBlur` + // Not trigger validate since OcForm set `onBlur` await changeValue(getField(wrapper), ''); matchError(wrapper, false); @@ -325,7 +311,7 @@ describe('Form.Validate', () => { wrapper.update(); matchError(wrapper, true); - // Update Form context + // Update OcForm context wrapper.setProps({ validateTrigger: 'onChange' }); await changeValue(getField(wrapper), '1'); matchError(wrapper, false); @@ -333,43 +319,43 @@ describe('Form.Validate', () => { }); describe('validate only accept exist fields', () => { - it('skip init value', async () => { - let form; + test('skip init value', async () => { + let form: OcFormInstance; const onFinish = jest.fn(); const wrapper = mount(
-
{ form = instance; }} - initialValues={{ user: 'light', pass: 'bamboo' }} + initialValues={{ user: 'lola', pass: 'mia' }} > -
+
); // Validate callback expect(await form.validateFields(['user'])).toEqual({ - user: 'light', + user: 'lola', }); - expect(await form.validateFields()).toEqual({ user: 'light' }); + expect(await form.validateFields()).toEqual({ user: 'lola' }); // Submit callback wrapper.find('button').simulate('submit'); await timeout(); - expect(onFinish).toHaveBeenCalledWith({ user: 'light' }); + expect(onFinish).toHaveBeenCalledWith({ user: 'lola' }); }); - it('remove from fields', async () => { + test('remove from fields', async () => { const onFinish = jest.fn(); const wrapper = mount( -
{ - + {(_, __, { getFieldValue }) => getFieldValue('switch') && ( @@ -387,9 +373,9 @@ describe('Form.Validate', () => { ) } - + -
+ ); // Submit callback @@ -412,12 +398,12 @@ describe('Form.Validate', () => { expect(onFinish).toHaveBeenCalledWith({ switch: false }); }); - it('validateFields should not pass when validateFirst is set', async () => { - let form; + test('validateFields should not pass when validateFirst is set', async () => { + let form: OcFormInstance; mount(
-
{ form = instance; }} @@ -429,7 +415,7 @@ describe('Form.Validate', () => { > -
+
); @@ -448,22 +434,22 @@ describe('Form.Validate', () => { }); }); - it('should error in console if user script failed', async () => { + test('should error in console if user script failed', async () => { const errorSpy = jest .spyOn(console, 'error') .mockImplementation(() => {}); const wrapper = mount( -
{ throw new Error('should console this'); }} - initialValues={{ user: 'light' }} + initialValues={{ user: 'lola' }} > -
+ ); wrapper.find('form').simulate('submit'); @@ -476,14 +462,14 @@ describe('Form.Validate', () => { }); describe('validateFirst', () => { - it('work', async () => { - let form; + test('work', async () => { + let form: OcFormInstance; let canEnd = false; const onFinish = jest.fn(); const wrapper = mount(
-
{ form = instance; }} @@ -505,7 +491,7 @@ describe('Form.Validate', () => { }, ]} /> -
+
); @@ -548,12 +534,12 @@ describe('Form.Validate', () => { validateFirst: 'parallel' as const, }, ].forEach(({ name, first, second, validateFirst }) => { - it(name, async () => { + test(name, async () => { let ruleFirst = false; let ruleSecond = false; const wrapper = mount( -
+ { }, ]} /> - +
); await changeValue(wrapper, 'test'); @@ -589,12 +575,12 @@ describe('Form.Validate', () => { }); }); - it('switch to remove errors', async () => { + test('switch to remove errors', async () => { const Demo = () => { const [checked, setChecked] = React.useState(true); return ( -
+