Skip to content
Snippets Groups Projects
Unverified Commit cc41d39f authored by Anton Kulyk's avatar Anton Kulyk Committed by GitHub
Browse files

Clean up CustomForm, extract form types (#22373)

* Refactor CustomForm

* Extract common types to `metabase-types`

* Refactor StandardForm

* Fix form field rerenders

* Fix types
parent 5ea52ef7
No related merge requests found
Showing
with 648 additions and 328 deletions
export type FieldName = string;
export type DefaultFieldValue = unknown;
export type FieldValues = Record<FieldName, DefaultFieldValue>;
export type BaseFieldDefinition = {
name: string;
type?: string;
title?: string;
description?: string;
initial?: unknown;
validate?: () => void;
normalize?: () => void;
};
export type StandardFormFieldDefinition = BaseFieldDefinition & {
type: string;
};
export type CustomFormFieldDefinition = BaseFieldDefinition & {
widget: () => JSX.Element;
};
export type FormFieldDefinition =
| StandardFormFieldDefinition
| CustomFormFieldDefinition;
export type FormField<Value = DefaultFieldValue> = {
name: FieldName;
value: Value;
initialValue: Value;
active: boolean;
autofilled: boolean;
checked: boolean;
dirty: boolean;
invalid: boolean;
pristine: boolean;
touched: boolean;
valid: boolean;
visited: boolean;
autofill: () => void;
onBlur: () => void;
onChange: () => void;
onDragStart: () => void;
onDrop: () => void;
onFocus: () => void;
onUpdate: () => void;
};
export type FormObject = {
fields: (values: FieldValues) => FormFieldDefinition[];
fieldNames: (values: FieldValues) => FieldName[];
hidden: (obj: unknown) => void;
initial: (obj: unknown) => void;
normalize: (obj: unknown) => void;
validate: (obj: unknown) => void;
disablePristineSubmit?: boolean;
};
/* eslint-disable react/prop-types */
import React from "react";
import PropTypes from "prop-types";
import FormField from "metabase/components/form/FormField";
import FormWidget from "metabase/components/form/FormWidget";
import FormMessage from "metabase/components/form/FormMessage";
import DisclosureTriangle from "metabase/components/DisclosureTriangle";
import Button from "metabase/core/components/Button";
import ActionButton from "metabase/components/ActionButton";
import _ from "underscore";
import cx from "classnames";
import { t } from "ttag";
import { getIn } from "icepick";
class CustomForm extends React.Component {
static childContextTypes = {
handleSubmit: PropTypes.func,
submitTitle: PropTypes.string,
renderSubmit: PropTypes.func,
className: PropTypes.string,
style: PropTypes.object,
fields: PropTypes.object,
formFields: PropTypes.array,
formFieldsByName: PropTypes.object,
values: PropTypes.object,
submitting: PropTypes.bool,
invalid: PropTypes.bool,
pristine: PropTypes.bool,
error: PropTypes.string,
onChangeField: PropTypes.func,
disablePristineSubmit: PropTypes.bool,
};
getChildContext() {
const {
fields,
values,
formObject: form,
submitting,
invalid,
pristine,
error,
handleSubmit,
submitTitle,
renderSubmit,
className,
style,
onChangeField,
} = this.props;
const { disablePristineSubmit } = form;
const formFields = form.fields(values);
const formFieldsByName = _.indexBy(formFields, "name");
return {
handleSubmit,
submitTitle,
renderSubmit,
className,
style,
fields,
formFields,
formFieldsByName,
values,
submitting,
invalid,
pristine,
error,
onChangeField,
disablePristineSubmit,
};
}
render() {
const { formObject: form, values, children } = this.props;
if (typeof children === "function") {
return children({
...this.props,
form: form,
formFields: form.fields(values),
Form: Form,
FormField: CustomFormField,
FormSubmit: CustomFormSubmit,
FormMessage: CustomFormMessage,
FormFooter: CustomFormFooter,
});
} else {
return <Form formRef={form => (this._formRef = form)}>{children}</Form>;
}
}
}
const Form = ({ children, formRef }, { handleSubmit, className, style }) => (
<form
onSubmit={handleSubmit}
ref={formRef}
className={className}
style={style}
>
{children}
</form>
);
Form.contextTypes = {
handleSubmit: PropTypes.func,
className: PropTypes.string,
style: PropTypes.object,
};
class RawCustomFormField extends React.Component {
static contextTypes = {
fields: PropTypes.object,
formFieldsByName: PropTypes.object,
values: PropTypes.object,
onChangeField: PropTypes.func,
registerFormField: PropTypes.func,
unregisterFormField: PropTypes.func,
};
_getFieldDefinition() {
return _.pick(
this.props,
"name",
"type",
"title",
"description",
"initial",
"validate",
"normalize",
);
}
UNSAFE_componentWillMount() {
if (this.context.registerFormField) {
this.context.registerFormField(this._getFieldDefinition());
}
}
componentWillUnmount() {
if (this.context.unregisterFormField) {
this.context.unregisterFormField(this._getFieldDefinition());
}
}
onChange = (...args) => {
const { name, onChange } = this.props;
const { fields } = this.context;
const field = getIn(fields, name.split("."));
field.onChange(...args);
onChange(...args);
};
render() {
const { name, forwardedRef } = this.props;
const { fields, formFieldsByName, values, onChangeField } = this.context;
const field = getIn(fields, name.split("."));
const formField = formFieldsByName[name];
if (!field || !formField) {
return null;
}
const hasCustomOnChangeHandler = typeof this.props.onChange === "function";
const props = {
...this.props,
values,
onChangeField,
formField,
field: hasCustomOnChangeHandler
? {
...field,
onChange: this.onChange,
}
: field,
};
const hasCustomWidget =
!formField.type && typeof formField.widget === "function";
const Widget = hasCustomWidget ? formField.widget : FormWidget;
return (
<FormField {...props}>
<Widget {...props} ref={forwardedRef} />
</FormField>
);
}
}
export const CustomFormField = React.forwardRef((props, ref) => (
<RawCustomFormField {...props} forwardedRef={ref} />
));
export const CustomFormSubmit = (
{ children, ...props },
{
submitting,
invalid,
pristine,
handleSubmit,
submitTitle,
renderSubmit,
disablePristineSubmit,
},
) => {
const title = children || submitTitle || t`Submit`;
const canSubmit = !(
submitting ||
invalid ||
(pristine && disablePristineSubmit)
);
if (renderSubmit) {
return renderSubmit({ canSubmit, title, handleSubmit });
} else {
return (
<ActionButton
normalText={title}
activeText={title}
failedText={t`Failed`}
successText={t`Success`}
primary={canSubmit}
disabled={!canSubmit}
{...props}
type="submit"
actionFn={handleSubmit}
/>
);
}
};
CustomFormSubmit.contextTypes = {
values: PropTypes.object,
submitting: PropTypes.bool,
invalid: PropTypes.bool,
pristine: PropTypes.bool,
handleSubmit: PropTypes.func,
submitTitle: PropTypes.string,
renderSubmit: PropTypes.func,
disablePristineSubmit: PropTypes.bool,
};
export const CustomFormMessage = (props, { error }) =>
error ? <FormMessage {...props} message={error} formError /> : null;
CustomFormMessage.contextTypes = {
error: PropTypes.string,
};
export default CustomForm;
const StandardSection = ({ title, children }) => (
<section className="mb4">
{title && <h2 className="mb2">{title}</h2>}
{children}
</section>
);
class CollapsibleSection extends React.Component {
state = {
show: false,
};
handleToggle = () => {
this.setState(previousState => ({
show: !previousState.show,
}));
};
render() {
const { title, children } = this.props;
const { show } = this.state;
return (
<section className="mb4">
<div
className="mb2 flex align-center cursor-pointer text-brand-hover"
onClick={this.handleToggle}
>
<DisclosureTriangle className="mr1" open={show} />
<h3>{title}</h3>
</div>
<div className={show ? null : "hide"}>{children}</div>
</section>
);
}
}
export const CustomFormSection = ({ collapsible, ...props }) =>
collapsible ? (
<CollapsibleSection {...props} />
) : (
<StandardSection {...props} />
);
export const CustomFormFooter = (
{
submitTitle,
cancelTitle = t`Cancel`,
onCancel,
footerExtraButtons,
fullWidth,
isModal,
},
{ isModal: isContextModal },
) => {
return (
<div
className={cx("flex align-center", {
"flex-reverse": isModal || isContextModal,
})}
>
<CustomFormSubmit fullWidth={fullWidth}>{submitTitle}</CustomFormSubmit>
{onCancel && (
<Button className="mx1" type="button" onClick={onCancel}>
{cancelTitle}
</Button>
)}
<div className="flex-full" />
<CustomFormMessage className="ml1" noPadding />
{footerExtraButtons}
</div>
);
};
CustomFormFooter.contextTypes = {
isModal: PropTypes.bool,
};
import React from "react";
import _ from "underscore";
import { FormFieldDefinition, FormObject } from "metabase-types/forms";
import {
BaseFormProps,
OptionalFormViewProps,
FormLegacyContext,
LegacyContextTypes,
} from "./types";
import CustomFormField, { CustomFormFieldProps } from "./CustomFormField";
import CustomFormFooter, { CustomFormFooterProps } from "./CustomFormFooter";
import CustomFormMessage, { CustomFormMessageProps } from "./CustomFormMessage";
import CustomFormSubmit from "./CustomFormSubmit";
import Form from "./Form";
interface FormRenderProps extends BaseFormProps {
form: FormObject;
formFields: FormFieldDefinition[];
Form: React.ComponentType<{ children: React.ReactNode }>;
FormField: React.ComponentType<CustomFormFieldProps>;
FormSubmit: React.ComponentType<{ children: React.ReactNode }>;
FormMessage: React.ComponentType<CustomFormMessageProps>;
FormFooter: React.ComponentType<CustomFormFooterProps>;
}
interface CustomFormProps extends BaseFormProps, OptionalFormViewProps {
children: React.ReactNode | ((props: FormRenderProps) => JSX.Element);
}
function CustomForm(props: CustomFormProps) {
const { formObject: form, values, children } = props;
if (typeof children === "function") {
return children({
...props,
form,
formFields: form.fields(values),
Form: Form,
FormField: CustomFormField,
FormSubmit: CustomFormSubmit,
FormMessage: CustomFormMessage,
FormFooter: CustomFormFooter,
});
}
return <Form {...props} />;
}
class CustomFormWithLegacyContext extends React.Component<CustomFormProps> {
static childContextTypes = LegacyContextTypes;
getChildContext(): FormLegacyContext {
const {
fields,
values,
formObject: form,
submitting,
invalid,
pristine,
error,
handleSubmit,
submitTitle,
renderSubmit,
className,
style,
onChangeField,
} = this.props;
const { disablePristineSubmit } = form;
const formFields = form.fields(values);
const formFieldsByName = _.indexBy(formFields, "name");
return {
handleSubmit,
submitTitle,
renderSubmit,
className,
style,
fields,
formFields,
formFieldsByName,
values,
submitting,
invalid,
pristine,
error,
onChangeField,
disablePristineSubmit,
};
}
render() {
return <CustomForm {...this.props} />;
}
}
export default CustomFormWithLegacyContext;
import React, { useCallback, useMemo } from "react";
import PropTypes from "prop-types";
import { getIn } from "icepick";
import _ from "underscore";
import FormField from "metabase/components/form/FormField";
import FormWidget from "metabase/components/form/FormWidget";
import { useOnMount } from "metabase/hooks/use-on-mount";
import { useOnUnmount } from "metabase/hooks/use-on-unmount";
import {
BaseFieldDefinition,
StandardFormFieldDefinition,
CustomFormFieldDefinition,
FormFieldDefinition,
} from "metabase-types/forms";
import { FormLegacyContext, LegacyContextTypes } from "./types";
function isCustomWidget(
formField: FormFieldDefinition,
): formField is CustomFormFieldDefinition {
return (
!(formField as StandardFormFieldDefinition).type &&
typeof (formField as CustomFormFieldDefinition).widget === "function"
);
}
export interface CustomFormFieldProps extends BaseFieldDefinition {
onChange?: (e: unknown) => void;
}
interface LegacyContextProps extends FormLegacyContext {
registerFormField?: (field: BaseFieldDefinition) => void;
unregisterFormField?: (field: BaseFieldDefinition) => void;
}
function getFieldDefinition(props: CustomFormFieldProps): BaseFieldDefinition {
return _.pick(
props,
"name",
"type",
"title",
"description",
"initial",
"validate",
"normalize",
);
}
function RawCustomFormField({
fields,
formFieldsByName,
values,
onChangeField,
registerFormField,
unregisterFormField,
...props
}: CustomFormFieldProps & LegacyContextProps & { forwardedRef?: any }) {
const { name, onChange, forwardedRef } = props;
const field = getIn(fields, name.split("."));
const formField = formFieldsByName[name];
useOnMount(() => {
registerFormField?.(getFieldDefinition(props));
});
useOnUnmount(() => {
unregisterFormField?.(getFieldDefinition(props));
});
const handleChange = useCallback(
e => {
field.onChange(e);
onChange?.(e);
},
[field, onChange],
);
const fieldProps = useMemo(
() => ({
...props,
values,
onChangeField,
formField,
field:
typeof onChange === "function"
? {
...field,
onChange: handleChange,
}
: field,
}),
[props, values, formField, field, onChange, onChangeField, handleChange],
);
if (!field || !formField) {
return null;
}
const hasCustomWidget = isCustomWidget(formField);
const Widget = hasCustomWidget ? formField.widget : FormWidget;
return (
<FormField {...fieldProps}>
<Widget {...fieldProps} ref={forwardedRef} />
</FormField>
);
}
const CustomFormFieldLegacyContext = (
props: CustomFormFieldProps & { forwardedRef?: any },
context: FormLegacyContext,
) => <RawCustomFormField {...props} {...context} />;
CustomFormFieldLegacyContext.contextTypes = {
..._.pick(
LegacyContextTypes,
"fields",
"formFieldsByName",
"values",
"onChangeField",
),
registerFormField: PropTypes.func,
unregisterFormField: PropTypes.func,
};
export default React.forwardRef<HTMLInputElement, CustomFormFieldProps>(
function CustomFormField(props, ref) {
return <CustomFormFieldLegacyContext {...props} forwardedRef={ref} />;
},
);
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import { t } from "ttag";
import Button from "metabase/core/components/Button";
import CustomFormMessage from "./CustomFormMessage";
import CustomFormSubmit from "./CustomFormSubmit";
export interface CustomFormFooterProps {
submitTitle: string;
cancelTitle?: string;
fullWidth?: boolean;
isModal?: boolean;
footerExtraButtons: React.ReactElement[];
onCancel?: () => void;
}
interface LegacyContextProps {
isModal?: boolean;
}
function CustomFormFooter({
submitTitle,
cancelTitle = t`Cancel`,
onCancel,
footerExtraButtons,
fullWidth,
isModal,
isContextModal,
}: CustomFormFooterProps & { isContextModal?: boolean }) {
return (
<div
className={cx("flex align-center", {
"flex-reverse": isModal || isContextModal,
})}
>
<CustomFormSubmit fullWidth={fullWidth}>{submitTitle}</CustomFormSubmit>
{onCancel && (
<Button className="mx1" type="button" onClick={onCancel}>
{cancelTitle}
</Button>
)}
<div className="flex-full" />
<CustomFormMessage className="ml1" noPadding />
{footerExtraButtons}
</div>
);
}
const CustomFormFooterLegacyContext = (
props: CustomFormFooterProps,
{ isModal: isContextModal }: LegacyContextProps,
) => <CustomFormFooter {...props} isContextModal={isContextModal} />;
CustomFormFooterLegacyContext.contextTypes = {
isModal: PropTypes.bool,
};
export default CustomFormFooterLegacyContext;
import React from "react";
import _ from "underscore";
import FormMessage from "metabase/components/form/FormMessage";
import { FormLegacyContext, LegacyContextTypes } from "./types";
export interface CustomFormMessageProps {
className?: string;
noPadding?: boolean;
}
function CustomFormMessage({
error,
...props
}: CustomFormMessageProps & FormLegacyContext) {
if (error) {
return <FormMessage {...props} message={error} />;
}
return null;
}
const CustomFormMessageLegacyContext = (
props: CustomFormMessageProps,
context: FormLegacyContext,
) => <CustomFormMessage {...props} {...context} />;
CustomFormMessageLegacyContext.contextTypes = _.pick(
LegacyContextTypes,
"error",
);
export default CustomFormMessageLegacyContext;
import React from "react";
import DisclosureTriangle from "metabase/components/DisclosureTriangle";
import { useToggle } from "metabase/hooks/use-toggle";
interface SectionProps {
title?: string;
children: React.ReactNode;
}
function StandardSection({ title, children }: SectionProps) {
return (
<section className="mb4">
{title && <h2 className="mb2">{title}</h2>}
{children}
</section>
);
}
function CollapsibleSection({ title, children }: SectionProps) {
const [isExpanded, { toggle: handleToggle }] = useToggle(false);
return (
<section className="mb4">
<div
className="mb2 flex align-center cursor-pointer text-brand-hover"
onClick={handleToggle}
>
<DisclosureTriangle className="mr1" open={isExpanded} />
<h3>{title}</h3>
</div>
<div className={isExpanded ? undefined : "hide"}>{children}</div>
</section>
);
}
interface CustomFormSectionProps extends SectionProps {
collapsible?: boolean;
}
function CustomFormSection({ collapsible, ...props }: CustomFormSectionProps) {
const Section = collapsible ? CollapsibleSection : StandardSection;
return <Section {...props} />;
}
export default CustomFormSection;
import React from "react";
import { t } from "ttag";
import _ from "underscore";
import ActionButton from "metabase/components/ActionButton";
import { FormLegacyContext, LegacyContextTypes } from "./types";
interface CustomFormSubmitProps {
children: React.ReactNode;
// ActionButton props
fullWidth?: boolean;
}
function CustomFormSubmit({
submitting,
invalid,
pristine,
handleSubmit,
submitTitle,
renderSubmit,
disablePristineSubmit,
children,
...props
}: CustomFormSubmitProps & FormLegacyContext) {
const title = children || submitTitle || t`Submit`;
const canSubmit = !(
submitting ||
invalid ||
(pristine && disablePristineSubmit)
);
if (renderSubmit) {
return renderSubmit({ title, canSubmit, handleSubmit });
}
return (
<ActionButton
normalText={title}
activeText={title}
failedText={t`Failed`}
successText={t`Success`}
primary={canSubmit}
disabled={!canSubmit}
{...props}
type="submit"
actionFn={handleSubmit}
/>
);
}
const CustomFormSubmitLegacyContext = (
props: CustomFormSubmitProps,
context: FormLegacyContext,
) => <CustomFormSubmit {...props} {...context} />;
CustomFormSubmitLegacyContext.contextTypes = _.pick(
LegacyContextTypes,
"values",
"submitting",
"invalid",
"pristine",
"handleSubmit",
"submitTitle",
"renderSubmit",
"disablePristineSubmit",
);
export default CustomFormSubmitLegacyContext;
import React from "react";
import _ from "underscore";
import { FormLegacyContext, LegacyContextTypes } from "./types";
type Props = {
children: React.ReactNode;
};
function Form({
children,
handleSubmit,
className,
style,
}: Props & FormLegacyContext) {
return (
<form onSubmit={handleSubmit} className={className} style={style}>
{children}
</form>
);
}
const FormUsingLegacyContext = (props: Props, context: FormLegacyContext) => (
<Form {...props} {...context} />
);
FormUsingLegacyContext.contextTypes = _.pick(
LegacyContextTypes,
"handleSubmit",
"className",
"style",
);
export default FormUsingLegacyContext;
export { default as CustomFormField } from "./CustomFormField";
export { default as CustomFormFooter } from "./CustomFormFooter";
export { default as CustomFormMessage } from "./CustomFormMessage";
export { default as CustomFormSection } from "./CustomFormSection";
export { default as CustomFormSubmit } from "./CustomFormSubmit";
export { default } from "./CustomForm";
import PropTypes from "prop-types";
import {
FieldName,
DefaultFieldValue,
FieldValues,
FormFieldDefinition,
FormField,
FormObject,
} from "metabase-types/forms";
export interface BaseFormProps {
formKey?: string;
formName: string;
formObject: FormObject;
fields: FormField[];
values: FieldValues;
errors: Record<FieldName, string>;
active?: boolean;
asyncValidating: boolean;
dirty: boolean;
error?: string;
invalid: boolean;
overwriteOnInitialValuesChange?: boolean;
pristine: boolean;
readonly: boolean;
submitFailed: boolean;
submitting: boolean;
valid: boolean;
asyncValidate: () => void;
destroyForm: () => void;
handleSubmit: () => void;
initializeForm: () => void;
onChangeField: (fieldName: FieldName, value: DefaultFieldValue) => void;
onSubmitSuccess: () => void;
resetForm: () => void;
submitPassback: () => void;
touch: () => void;
touchAll: () => void;
untouch: () => void;
untouchAll: () => void;
}
type RenderSubmitProps = {
title: React.ReactNode;
canSubmit: boolean;
handleSubmit: () => void;
};
export interface OptionalFormViewProps {
submitTitle?: string;
renderSubmit?: (props: RenderSubmitProps) => JSX.Element;
className?: string;
style?: React.CSSProperties;
}
export interface FormLegacyContext
extends OptionalFormViewProps,
Pick<
BaseFormProps,
| "handleSubmit"
| "fields"
| "values"
| "submitting"
| "invalid"
| "pristine"
| "error"
| "onChangeField"
> {
formFields: FormFieldDefinition[];
formFieldsByName: Record<FieldName, FormFieldDefinition>;
disablePristineSubmit?: boolean;
}
export const LegacyContextTypes = {
handleSubmit: PropTypes.func,
submitTitle: PropTypes.string,
renderSubmit: PropTypes.func,
className: PropTypes.string,
style: PropTypes.object,
fields: PropTypes.object,
formFields: PropTypes.array,
formFieldsByName: PropTypes.object,
values: PropTypes.object,
submitting: PropTypes.bool,
invalid: PropTypes.bool,
pristine: PropTypes.bool,
error: PropTypes.string,
onChangeField: PropTypes.func,
disablePristineSubmit: PropTypes.bool,
};
/* eslint-disable react/prop-types */
import React from "react";
import { t } from "ttag";
import { BaseFormProps } from "./CustomForm/types";
import { CustomFormFooterProps } from "./CustomForm/CustomFormFooter";
import CustomForm from "./CustomForm";
import { t } from "ttag";
interface Props extends BaseFormProps, CustomFormFooterProps {
submitFullWidth?: boolean;
onClose?: () => void;
}
const StandardForm = ({ onClose, submitTitle, submitFullWidth, ...props }) => (
const StandardForm = ({
submitTitle,
submitFullWidth,
onClose,
...props
}: Props) => (
<CustomForm {...props}>
{({ values, formFields, Form, FormField, FormFooter }) => (
<Form>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment