withField.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. /* argus-disable unPkgSensitiveInfo */
  2. /* eslint-disable max-lines-per-function, react-hooks/rules-of-hooks, prefer-const, max-len */
  3. import React, { useState, useLayoutEffect, useMemo, useRef, forwardRef } from 'react';
  4. import classNames from 'classnames';
  5. import { cssClasses } from '@douyinfe/semi-foundation/form/constants';
  6. import { isValid, generateValidatesFromRules, mergeOptions, mergeProps, getDisplayName } from '@douyinfe/semi-foundation/form/utils';
  7. import * as ObjectUtil from '@douyinfe/semi-foundation/utils/object';
  8. import isPromise from '@douyinfe/semi-foundation/utils/isPromise';
  9. import warning from '@douyinfe/semi-foundation/utils/warning';
  10. import { useFormState, useStateWithGetter, useFormUpdater, useArrayFieldState } from '../hooks/index';
  11. import ErrorMessage from '../errorMessage';
  12. import { isElement } from '../../_base/reactUtils';
  13. import Label from '../label';
  14. import { Col } from '../../grid';
  15. import { CallOpts, WithFieldOption } from '@douyinfe/semi-foundation/form/interface';
  16. import { CommonFieldProps, CommonexcludeType } from '../interface';
  17. import { Subtract } from 'utility-types';
  18. const prefix = cssClasses.PREFIX;
  19. /**
  20. * withFiled is used to inject components
  21. * 1. Takes over the value and onChange of the component and synchronizes them to Form Foundation
  22. * 2. Insert <Label>
  23. * 3. Insert <ErrorMessage>
  24. */
  25. function withField<
  26. C extends React.ComponentType<React.ComponentProps<C>>,
  27. T extends React.ComponentType<Subtract<React.ComponentProps<C>, CommonexcludeType> & CommonFieldProps>
  28. >(Component: C, opts?: WithFieldOption): T {
  29. let SemiField = (props: any, ref: React.MutableRefObject<any>) => {
  30. let {
  31. // condition,
  32. field,
  33. label,
  34. labelPosition,
  35. labelWidth,
  36. labelAlign,
  37. labelCol,
  38. wrapperCol,
  39. noLabel,
  40. noErrorMessage,
  41. isInInputGroup,
  42. initValue,
  43. validate,
  44. validateStatus,
  45. trigger,
  46. allowEmptyString,
  47. allowEmpty,
  48. emptyValue,
  49. rules,
  50. required,
  51. keepState,
  52. transform,
  53. name,
  54. fieldClassName,
  55. fieldStyle,
  56. convert,
  57. stopValidateWithError,
  58. helpText,
  59. extraText,
  60. extraTextPosition,
  61. pure,
  62. rest,
  63. } = mergeProps(props);
  64. let { options, shouldInject } = mergeOptions(opts, props);
  65. warning(
  66. typeof field === 'undefined' && options.shouldInject,
  67. "[Semi Form]: 'field' is required, please check your props of Field Component"
  68. );
  69. // 无需注入的直接返回,eg:Group内的checkbox、radio
  70. // Return without injection, eg: <Checkbox> / <Radio> inside CheckboxGroup/RadioGroup
  71. if (!shouldInject) {
  72. return <Component {...rest} />;
  73. }
  74. // grab formState from context
  75. const formState = useFormState();
  76. // grab formUpdater (the api for field to read/modify FormState) from context
  77. const updater = useFormUpdater();
  78. if (!updater.getFormProps) {
  79. warning(
  80. true,
  81. '[Semi Form]: Field Component must be use inside the Form, please check your dom declaration'
  82. );
  83. return null;
  84. }
  85. // To prevent user forgetting to pass the field, use undefined as the key, and updater.getValue will get the wrong value.
  86. let initValueInFormOpts = typeof field !== 'undefined' ? updater.getValue(field) : undefined; // Get the init value of form from formP rops.init Values Get the initial value set in the initValues of Form
  87. let initVal = typeof initValue !== 'undefined' ? initValue : initValueInFormOpts;
  88. // use arrayFieldState to fix issue 615
  89. let arrayFieldState;
  90. try {
  91. arrayFieldState = useArrayFieldState();
  92. if (arrayFieldState) {
  93. initVal =
  94. arrayFieldState.shouldUseInitValue && typeof initValue !== 'undefined'
  95. ? initValue
  96. : initValueInFormOpts;
  97. }
  98. } catch (err) {}
  99. const [value, setValue, getVal] = useStateWithGetter(typeof initVal !== undefined ? initVal : null);
  100. const validateOnMount = trigger.includes('mount');
  101. allowEmpty = allowEmpty || updater.getFormProps().allowEmpty;
  102. // Error information: Array, String, undefined
  103. const [error, setError, getError] = useStateWithGetter();
  104. const [touched, setTouched] = useState<boolean | undefined>();
  105. const [cursor, setCursor, getCursor] = useStateWithGetter(0);
  106. const [status, setStatus] = useState(validateStatus); // use props.validateStatus to init
  107. const rulesRef = useRef(rules);
  108. // notNotify is true means that the onChange of the Form does not need to be triggered
  109. // notUpdate is true means that this operation does not need to trigger the forceUpdate
  110. const updateTouched = (isTouched: boolean, callOpts?: CallOpts) => {
  111. setTouched(isTouched);
  112. updater.updateStateTouched(field, isTouched, callOpts);
  113. };
  114. const updateError = (errors: any, callOpts?: CallOpts) => {
  115. if (errors === getError()) {
  116. // When the inspection result is unchanged, no need to update, saving a forceUpdate overhead
  117. // When errors is an array, deepEqual is not used, and it is always treated as a need to update
  118. // 检验结果不变时,无需更新,节省一次forceUpdate开销
  119. // errors为数组时,不做deepEqual,始终当做需要更新处理
  120. return;
  121. }
  122. setError(errors);
  123. updater.updateStateError(field, errors, callOpts);
  124. if (!isValid(errors)) {
  125. setStatus('error');
  126. } else {
  127. setStatus('success');
  128. }
  129. };
  130. const updateValue = (val: any, callOpts?: CallOpts) => {
  131. setValue(val);
  132. let newOpts = {
  133. ...callOpts,
  134. allowEmpty,
  135. };
  136. updater.updateStateValue(field, val, newOpts);
  137. };
  138. const reset = () => {
  139. let callOpts = {
  140. notNotify: true,
  141. notUpdate: true,
  142. };
  143. // reset is called by the FormFoundaion uniformly. The field level does not need to trigger notify and update.
  144. updateValue(initVal !== null ? initVal : undefined, callOpts);
  145. updateError(undefined, callOpts);
  146. updateTouched(undefined, callOpts);
  147. setStatus('default');
  148. };
  149. // Execute the validation rules specified by rules
  150. const _validateInternal = (val: any, callOpts: CallOpts) => {
  151. let latestRules = rulesRef.current || [];
  152. const validator = generateValidatesFromRules(field, latestRules);
  153. const model = {
  154. [field]: val,
  155. };
  156. return new Promise((resolve, reject) => {
  157. validator
  158. .validate(
  159. model,
  160. {
  161. first: stopValidateWithError,
  162. },
  163. // eslint-disable-next-line @typescript-eslint/no-empty-function
  164. (errors, fields) => {}
  165. )
  166. .then(res => {
  167. // validation passed
  168. setStatus('success');
  169. updateError(undefined, callOpts);
  170. resolve({});
  171. })
  172. .catch(err => {
  173. let { errors, fields } = err;
  174. if (errors && fields) {
  175. let messages = errors.map((e: any) => e.message);
  176. if (messages.length === 1) {
  177. // eslint-disable-next-line prefer-destructuring
  178. messages = messages[0];
  179. }
  180. updateError(messages, callOpts);
  181. if (!isValid(messages)) {
  182. setStatus('error');
  183. resolve(errors);
  184. }
  185. } else {
  186. // Some grammatical errors in rules
  187. setStatus('error');
  188. updateError(err.message, callOpts);
  189. resolve(err.message);
  190. throw err;
  191. }
  192. });
  193. });
  194. };
  195. // execute custom validate function
  196. const _validate = (val: any, values: any, callOpts: CallOpts) =>
  197. new Promise(resolve => {
  198. let maybePromisedErrors;
  199. // let errorThrowSync;
  200. try {
  201. maybePromisedErrors = validate(val, values);
  202. } catch (err) {
  203. // error throw by syncValidate
  204. maybePromisedErrors = err;
  205. }
  206. if (maybePromisedErrors === undefined) {
  207. resolve({});
  208. updateError(undefined, callOpts);
  209. } else if (isPromise(maybePromisedErrors)) {
  210. maybePromisedErrors.then((result: any) => {
  211. if (isValid(result)) {
  212. // validate success,no need to do anything with result
  213. updateError(undefined, callOpts);
  214. resolve(null);
  215. } else {
  216. // validate failed
  217. updateError(result, callOpts);
  218. resolve(result);
  219. }
  220. });
  221. } else {
  222. if (isValid(maybePromisedErrors)) {
  223. updateError(undefined, callOpts);
  224. resolve(null);
  225. } else {
  226. updateError(maybePromisedErrors, callOpts);
  227. resolve(maybePromisedErrors);
  228. }
  229. }
  230. });
  231. const fieldValidate = (val: any, callOpts?: CallOpts) => {
  232. let finalVal = val;
  233. let latestRules = rulesRef.current;
  234. if (transform) {
  235. finalVal = transform(val);
  236. }
  237. if (validate) {
  238. return _validate(finalVal, updater.getValue(), callOpts);
  239. } else if (latestRules) {
  240. return _validateInternal(finalVal, callOpts);
  241. }
  242. return null;
  243. };
  244. /**
  245. * parse / format
  246. * validate when trigger
  247. *
  248. */
  249. const handleChange = (newValue: any, e: any, ...other: any[]) => {
  250. let fnKey = options.onKeyChangeFnName;
  251. if (fnKey in props && typeof props[options.onKeyChangeFnName] === 'function') {
  252. props[options.onKeyChangeFnName](newValue, e, ...other);
  253. }
  254. // support various type component
  255. let val;
  256. if (!options.valuePath) {
  257. val = newValue;
  258. } else {
  259. val = ObjectUtil.get(newValue, options.valuePath);
  260. }
  261. // User can use convert function to updateValue before Component UI render
  262. if (typeof convert === 'function') {
  263. val = convert(val);
  264. }
  265. // TODO: allowEmptyString split into allowEmpty, emptyValue
  266. // Added abandonment warning
  267. // if (process.env.NODE_ENV !== 'production') {
  268. // warning(allowEmptyString, `'allowEmptyString' will be de deprecated in next version, please replace with 'allowEmpty' & 'emptyValue'
  269. // `)
  270. // }
  271. // set value to undefined if it's an empty string
  272. // allowEmptyString={true} is equivalent to allowEmpty = {true} emptyValue = "
  273. if (allowEmptyString || allowEmpty) {
  274. if (val === '') {
  275. // do nothing
  276. }
  277. } else {
  278. if (val === emptyValue) {
  279. val = undefined;
  280. }
  281. }
  282. // maintain compoent cursor if needed
  283. try {
  284. if (e && e.target && e.target.selectionStart) {
  285. setCursor(e.target.selectionStart);
  286. }
  287. } catch (err) {}
  288. updateTouched(true, { notNotify: true, notUpdate: true });
  289. updateValue(val);
  290. // only validate when trigger includes change
  291. if (trigger.includes('change')) {
  292. fieldValidate(val);
  293. }
  294. };
  295. const handleBlur = (...e: any[]) => {
  296. if (props.onBlur) {
  297. props.onBlur(...e);
  298. }
  299. if (!touched) {
  300. updateTouched(true);
  301. }
  302. if (trigger.includes('blur')) {
  303. let val = getVal();
  304. fieldValidate(val);
  305. }
  306. };
  307. /** Field level maintains a separate layer of data, which is convenient for Form to control Field to update the UI */
  308. // The field level maintains a separate layer of data, which is convenient for the Form to control the Field for UI updates.
  309. const fieldApi = {
  310. setValue: updateValue,
  311. setTouched: updateTouched,
  312. setError: updateError,
  313. reset,
  314. validate: fieldValidate,
  315. };
  316. const fieldState = {
  317. value,
  318. error,
  319. touched,
  320. status,
  321. };
  322. // avoid hooks capture value, fixed issue 346
  323. useLayoutEffect(() => {
  324. rulesRef.current = rules;
  325. }, [rules]);
  326. // exec validate once when trigger inlcude 'mount'
  327. useLayoutEffect(() => {
  328. if (validateOnMount) {
  329. fieldValidate(value);
  330. }
  331. // eslint-disable-next-line react-hooks/exhaustive-deps
  332. }, []);
  333. // register when mounted,unregister when unmounted
  334. // register again when field change
  335. useLayoutEffect(() => {
  336. // register
  337. if (typeof field === 'undefined') {
  338. // eslint-disable-next-line @typescript-eslint/no-empty-function
  339. return () => {};
  340. }
  341. // log('register: ' + field);
  342. updater.register(field, fieldState, {
  343. field,
  344. fieldApi,
  345. keepState,
  346. allowEmpty: allowEmpty || allowEmptyString,
  347. });
  348. // return unRegister cb
  349. return () => {
  350. updater.unRegister(field);
  351. // log('unRegister: ' + field);
  352. };
  353. // eslint-disable-next-line react-hooks/exhaustive-deps
  354. }, [field]);
  355. let formProps = updater.getFormProps([
  356. 'labelPosition',
  357. 'labelWidth',
  358. 'labelAlign',
  359. 'labelCol',
  360. 'wrapperCol',
  361. 'disabled',
  362. 'showValidateIcon',
  363. 'extraTextPosition',
  364. ]);
  365. let mergeLabelPos = labelPosition || formProps.labelPosition;
  366. let mergeLabelWidth = labelWidth || formProps.labelWidth;
  367. let mergeLabelAlign = labelAlign || formProps.labelAlign;
  368. let mergeLabelCol = labelCol || formProps.labelCol;
  369. let mergeWrapperCol = wrapperCol || formProps.wrapperCol;
  370. let mergeExtraPos = extraTextPosition || formProps.extraTextPosition || 'bottom';
  371. let FieldComponent = (() => {
  372. // prefer to use validateStatus which pass by user throught props
  373. let blockStatus = validateStatus ? validateStatus : status;
  374. let newProps: Record<string, any> = {
  375. disabled: formProps.disabled,
  376. ...rest,
  377. ref,
  378. onBlur: handleBlur,
  379. [options.onKeyChangeFnName]: handleChange,
  380. [options.valueKey]: value,
  381. validateStatus: blockStatus,
  382. };
  383. const fieldCls = classNames({
  384. [`${prefix}-field`]: true,
  385. [`${prefix}-field-${name}`]: Boolean(name),
  386. [fieldClassName]: Boolean(fieldClassName),
  387. });
  388. const fieldMaincls = classNames({
  389. [`${prefix}-field-main`]: true,
  390. });
  391. if (mergeLabelPos === 'inset' && !noLabel) {
  392. newProps.insetLabel = label || field;
  393. if (typeof label === 'object' && !isElement(label)) {
  394. newProps.insetLabel = label.text;
  395. }
  396. }
  397. const com = <Component {...(newProps as any)} />;
  398. // when use in InputGroup, no need to insert <Label>、<ErrorMessage> inside Field, just add it at Group
  399. if (isInInputGroup) {
  400. return com;
  401. }
  402. if (pure) {
  403. let pureCls = classNames(rest.className, {
  404. [`${prefix}-field-pure`]: true,
  405. [`${prefix}-field-${name}`]: Boolean(name),
  406. [fieldClassName]: Boolean(fieldClassName),
  407. });
  408. newProps.className = pureCls;
  409. return <Component {...(newProps as any)} />;
  410. }
  411. let withCol = mergeLabelCol && mergeWrapperCol;
  412. const labelColCls = mergeLabelAlign ? `${prefix}-col-${mergeLabelAlign}` : '';
  413. // get label
  414. let labelContent = null;
  415. if (!noLabel && mergeLabelPos !== 'inset') {
  416. let needSpread = typeof label === 'object' && !isElement(label) ? label : {};
  417. labelContent = (
  418. <Label
  419. text={label || field}
  420. required={required}
  421. name={name || field}
  422. width={mergeLabelWidth}
  423. align={mergeLabelAlign}
  424. {...needSpread}
  425. />
  426. );
  427. }
  428. const extraCls = classNames(`${prefix}-field-extra`, {
  429. [`${prefix}-field-extra-string`]: typeof extraText === 'string',
  430. [`${prefix}-field-extra-middle`]: mergeExtraPos === 'middle',
  431. [`${prefix}-field-extra-botttom`]: mergeExtraPos === 'bottom',
  432. });
  433. const extraContent = extraText ? <div className={extraCls}>{extraText}</div> : null;
  434. const fieldMainContent = (
  435. <div className={fieldMaincls}>
  436. {mergeExtraPos === 'middle' ? extraContent : null}
  437. {com}
  438. {!noErrorMessage ? (
  439. <ErrorMessage
  440. error={error}
  441. validateStatus={blockStatus}
  442. helpText={helpText}
  443. showValidateIcon={formProps.showValidateIcon}
  444. />
  445. ) : null}
  446. {mergeExtraPos === 'bottom' ? extraContent : null}
  447. </div>
  448. );
  449. const withColContent = (
  450. <>
  451. {mergeLabelPos === 'top' ? (
  452. <div style={{ overflow: 'hidden' }}>
  453. <Col {...mergeLabelCol} className={labelColCls}>
  454. {labelContent}
  455. </Col>
  456. </div>
  457. ) : (
  458. <Col {...mergeLabelCol} className={labelColCls}>
  459. {labelContent}
  460. </Col>
  461. )}
  462. <Col {...mergeWrapperCol}>{fieldMainContent}</Col>
  463. </>
  464. );
  465. return (
  466. <div
  467. className={fieldCls}
  468. style={fieldStyle}
  469. x-label-pos={mergeLabelPos}
  470. x-field-id={field}
  471. x-extra-pos={mergeExtraPos}
  472. >
  473. {withCol ? (
  474. withColContent
  475. ) : (
  476. <>
  477. {labelContent}
  478. {fieldMainContent}
  479. </>
  480. )}
  481. </div>
  482. );
  483. })();
  484. // !important optimization
  485. const shouldUpdate = [
  486. ...Object.values(fieldState),
  487. ...Object.values(props),
  488. field,
  489. mergeLabelPos,
  490. mergeLabelAlign,
  491. formProps.disabled,
  492. ];
  493. if (options.shouldMemo) {
  494. // eslint-disable-next-line react-hooks/exhaustive-deps
  495. return useMemo(() => FieldComponent, [...shouldUpdate]);
  496. } else {
  497. // Some Custom Component with inner state shouldn't be memo, otherwise the component will not updated when the internal state is updated
  498. // Fixed issue 328
  499. return FieldComponent;
  500. }
  501. };
  502. SemiField = forwardRef(SemiField);
  503. (SemiField as React.SFC).displayName = getDisplayName(Component);
  504. return SemiField as any;
  505. }
  506. // eslint-disable-next-line
  507. export default withField;