import React from 'react'; import clsx from 'clsx'; import logger from 'app/services/logger'; import FormModel from './FormModel'; import styles from './form.scss'; interface BaseProps { id: string; isLoading: boolean; onInvalid: (errors: Record) => void; children: React.ReactNode; } interface PropsWithoutForm extends BaseProps { onSubmit?: (form: FormData) => Promise | void; } interface PropsWithForm extends BaseProps { form: FormModel; onSubmit?: (form: FormModel) => Promise | void; } type Props = PropsWithoutForm | PropsWithForm; function hasForm(props: Props): props is PropsWithForm { return 'form' in props; } interface State { id: string; // just to track value for derived updates isTouched: boolean; isLoading: boolean; } type InputElement = HTMLInputElement | HTMLTextAreaElement; export default class Form extends React.Component { static defaultProps = { id: 'default', isLoading: false, onSubmit() {}, onInvalid() {}, }; state: State = { id: this.props.id, isTouched: false, isLoading: this.props.isLoading || false, }; formEl: HTMLFormElement | null; mounted = false; componentDidMount() { if (hasForm(this.props)) { this.props.form.addLoadingListener(this.onLoading); } this.mounted = true; } static getDerivedStateFromProps(props: Props, state: State) { const patch: Partial = {}; if (typeof props.isLoading !== 'undefined' && props.isLoading !== state.isLoading) { patch.isLoading = props.isLoading; } if (props.id !== state.id) { patch.id = props.id; patch.isTouched = true; } return patch; } componentDidUpdate(prevProps: Props) { const nextForm = hasForm(this.props) ? this.props.form : undefined; const prevForm = hasForm(prevProps) ? prevProps.form : undefined; if (nextForm !== prevForm) { if (prevForm) { prevForm.removeLoadingListener(this.onLoading); } if (nextForm) { nextForm.addLoadingListener(this.onLoading); } } } componentWillUnmount() { if (hasForm(this.props)) { this.props.form.removeLoadingListener(this.onLoading); } this.mounted = false; } render() { const { isLoading } = this.state; return (
(this.formEl = el)} noValidate > {this.props.children}
); } submit() { if (!this.state.isTouched) { this.setState({ isTouched: true, }); } const form = this.formEl; if (!form) { return; } if (form.checkValidity()) { this.clearErrors(); let result: Promise | void; if (hasForm(this.props)) { // @ts-ignore this prop has default value result = this.props.onSubmit(this.props.form); } else { // @ts-ignore this prop has default value result = this.props.onSubmit(new FormData(form)); } if (result && result.then) { this.setState({ isLoading: true }); result .catch((errors: Record) => { this.setErrors(errors); }) .finally(() => this.mounted && this.setState({ isLoading: false })); } } else { const invalidEls: NodeListOf = form.querySelectorAll(':invalid'); const errors: Record = {}; invalidEls[0].focus(); // focus on first error Array.from(invalidEls).reduce((acc, el) => { if (!el.name) { logger.warn('Found an element without name', { el }); return acc; } let errorMessage = el.validationMessage; if (el.validity.valueMissing) { errorMessage = `error.${el.name}_required`; } else if (el.validity.typeMismatch) { errorMessage = `error.${el.name}_invalid`; } acc[el.name] = errorMessage; return acc; }, errors); this.setErrors(errors); } } setErrors(errors: { [key: string]: string }) { if (hasForm(this.props)) { this.props.form.setErrors(errors); } this.props.onInvalid(errors); } clearErrors = () => hasForm(this.props) && this.props.form.clearErrors(); onFormSubmit = (event: React.FormEvent) => { event.preventDefault(); this.submit(); }; onLoading = (isLoading: boolean) => this.setState({ isLoading }); }