client/app/components/dynamic-form/DynamicForm.jsx (275 lines of code) (raw):
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Form from "antd/lib/form";
import Input from "antd/lib/input";
import InputNumber from "antd/lib/input-number";
import Checkbox from "antd/lib/checkbox";
import Button from "antd/lib/button";
import Upload from "antd/lib/upload";
import Icon from "antd/lib/icon";
import { includes, isFunction, filter, difference, isEmpty } from "lodash";
import Select from "antd/lib/select";
import notification from "@/services/notification";
import Collapse from "@/components/Collapse";
import AceEditorInput from "@/components/AceEditorInput";
import { toHuman } from "@/lib/utils";
import { Field, Action, AntdForm } from "../proptypes";
import helper from "./dynamicFormHelper";
import "./DynamicForm.less";
const fieldRules = ({ type, required, minLength }) => {
const requiredRule = required;
const minLengthRule = minLength && includes(["text", "email", "password"], type);
const emailTypeRule = type === "email";
return [
requiredRule && { required, message: "This field is required." },
minLengthRule && { min: minLength, message: "This field is too short." },
emailTypeRule && { type: "email", message: "This field must be a valid email." },
].filter(rule => rule);
};
class DynamicForm extends React.Component {
static propTypes = {
id: PropTypes.string,
fields: PropTypes.arrayOf(Field),
actions: PropTypes.arrayOf(Action),
feedbackIcons: PropTypes.bool,
hideSubmitButton: PropTypes.bool,
defaultShowExtraFields: PropTypes.bool,
saveText: PropTypes.string,
onSubmit: PropTypes.func,
form: AntdForm.isRequired,
};
static defaultProps = {
id: null,
fields: [],
actions: [],
feedbackIcons: false,
hideSubmitButton: false,
defaultShowExtraFields: false,
saveText: "Save",
onSubmit: () => {},
};
constructor(props) {
super(props);
const inProgressActions = {};
props.actions.forEach(action => (inProgressActions[action.name] = false));
this.state = {
isSubmitting: false,
showExtraFields: props.defaultShowExtraFields,
inProgressActions,
};
this.actionCallbacks = this.props.actions.reduce(
(acc, cur) => ({
...acc,
[cur.name]: cur.callback,
}),
null
);
}
setActionInProgress = (actionName, inProgress) => {
this.setState(prevState => ({
inProgressActions: {
...prevState.inProgressActions,
[actionName]: inProgress,
},
}));
};
handleSubmit = e => {
this.setState({ isSubmitting: true });
e.preventDefault();
this.props.form.validateFieldsAndScroll((err, values) => {
Object.entries(values).forEach(([key, value]) => {
const initialValue = this.props.fields.find(f => f.name === key).initialValue;
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
values[key] = null;
}
});
if (!err) {
this.props.onSubmit(
values,
msg => {
const { setFieldsValue, getFieldsValue } = this.props.form;
this.setState({ isSubmitting: false });
setFieldsValue(getFieldsValue()); // reset form touched state
notification.success(msg);
},
msg => {
this.setState({ isSubmitting: false });
notification.error(msg);
}
);
} else this.setState({ isSubmitting: false });
});
};
handleAction = e => {
const actionName = e.target.dataset.action;
this.setActionInProgress(actionName, true);
this.actionCallbacks[actionName](() => {
this.setActionInProgress(actionName, false);
});
};
base64File = (fieldName, e) => {
if (e && e.fileList[0]) {
helper.getBase64(e.file).then(value => {
this.props.form.setFieldsValue({ [fieldName]: value });
});
}
};
renderUpload(field, props) {
const { getFieldDecorator, getFieldValue } = this.props.form;
const { name, initialValue } = field;
const fileOptions = {
rules: fieldRules(field),
initialValue,
getValueFromEvent: this.base64File.bind(this, name),
};
const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue;
const upload = (
<Upload {...props} beforeUpload={() => false}>
<Button disabled={disabled}>
<Icon type="upload" /> Click to upload
</Button>
</Upload>
);
return getFieldDecorator(name, fileOptions)(upload);
}
renderSelect(field, props) {
const { getFieldDecorator } = this.props.form;
const { name, options, mode, initialValue, readOnly, loading } = field;
const { Option } = Select;
const decoratorOptions = {
rules: fieldRules(field),
initialValue,
};
return getFieldDecorator(
name,
decoratorOptions
)(
<Select
{...props}
optionFilterProp="children"
loading={loading || false}
mode={mode}
getPopupContainer={trigger => trigger.parentNode}>
{options &&
options.map(option => (
<Option key={`${option.value}`} value={option.value} disabled={readOnly}>
{option.name || option.value}
</Option>
))}
</Select>
);
}
renderField(field, props) {
const { getFieldDecorator } = this.props.form;
const { name, type, initialValue } = field;
const fieldLabel = field.title || toHuman(name);
const options = {
rules: fieldRules(field),
valuePropName: type === "checkbox" ? "checked" : "value",
initialValue,
};
if (type === "checkbox") {
return getFieldDecorator(name, options)(<Checkbox {...props}>{fieldLabel}</Checkbox>);
} else if (type === "file") {
return this.renderUpload(field, props);
} else if (type === "select") {
return this.renderSelect(field, props);
} else if (type === "content") {
return field.content;
} else if (type === "number") {
return getFieldDecorator(name, options)(<InputNumber {...props} />);
} else if (type === "textarea") {
return getFieldDecorator(name, options)(<Input.TextArea {...props} />);
} else if (type === "ace") {
return getFieldDecorator(name, options)(<AceEditorInput {...props} />);
}
return getFieldDecorator(name, options)(<Input {...props} />);
}
renderFields(fields) {
return fields.map(field => {
const FormItem = Form.Item;
const { name, title, type, readOnly, autoFocus, contentAfter } = field;
const fieldLabel = title || toHuman(name);
const { feedbackIcons, form } = this.props;
const formItemProps = {
className: "m-b-10",
hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons,
label: type === "checkbox" ? "" : fieldLabel,
};
const fieldProps = {
...field.props,
className: "w-100",
name,
type,
readOnly,
autoFocus,
placeholder: field.placeholder,
"data-test": fieldLabel,
};
return (
<React.Fragment key={name}>
<FormItem {...formItemProps}>{this.renderField(field, fieldProps)}</FormItem>
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
</React.Fragment>
);
});
}
renderActions() {
return this.props.actions.map(action => {
const inProgress = this.state.inProgressActions[action.name];
const { isFieldsTouched } = this.props.form;
const actionProps = {
key: action.name,
htmlType: "button",
className: action.pullRight ? "pull-right m-t-10" : "m-t-10",
type: action.type,
disabled: isFieldsTouched() && action.disableWhenDirty,
loading: inProgress,
onClick: this.handleAction,
};
return (
<Button {...actionProps} data-action={action.name}>
{action.name}
</Button>
);
});
}
render() {
const submitProps = {
type: "primary",
htmlType: "submit",
className: "w-100 m-t-20",
disabled: this.state.isSubmitting,
loading: this.state.isSubmitting,
};
const { id, hideSubmitButton, saveText, fields } = this.props;
const { showExtraFields } = this.state;
const saveButton = !hideSubmitButton;
const extraFields = filter(fields, { extra: true });
const regularFields = difference(fields, extraFields);
return (
<Form id={id} className="dynamic-form" layout="vertical" onSubmit={this.handleSubmit}>
{this.renderFields(regularFields)}
{!isEmpty(extraFields) && (
<div className="extra-options">
<Button
type="dashed"
block
className="extra-options-button"
onClick={() => this.setState({ showExtraFields: !showExtraFields })}>
Additional Settings
<i className={cx("fa m-l-5", { "fa-caret-up": showExtraFields, "fa-caret-down": !showExtraFields })} />
</Button>
<Collapse collapsed={!showExtraFields} className="extra-options-content">
{this.renderFields(extraFields)}
</Collapse>
</div>
)}
{saveButton && <Button {...submitProps}>{saveText}</Button>}
{this.renderActions()}
</Form>
);
}
}
export default Form.create()(DynamicForm);