diff --git a/frontend/src/metabase-types/forms/index.ts b/frontend/src/metabase-types/forms/index.ts index a64b178a213ab9f2ab00f3da88ff7cd10cc8a978..5affc99e8b331e6e5dc1de765870b5ad7ff926c2 100644 --- a/frontend/src/metabase-types/forms/index.ts +++ b/frontend/src/metabase-types/forms/index.ts @@ -3,14 +3,31 @@ export type DefaultFieldValue = unknown; export type FieldValues = Record<FieldName, DefaultFieldValue>; +type FieldValidateResultOK = undefined; +type FieldValidateResultError = string; + export type BaseFieldDefinition = { name: string; type?: string; title?: string; description?: string; - initial?: unknown; - validate?: () => void; - normalize?: () => void; + placeholder?: string; + hidden?: boolean; + + info?: string; + infoLabel?: string; + infoLabelTooltip?: string; + + align?: "left" | "right"; + horizontal?: boolean; + descriptionPosition?: "top" | "bottom"; + visibleIf?: Record<FieldName, unknown>; + + initial?: (value: unknown) => DefaultFieldValue; + validate?: ( + value: DefaultFieldValue, + ) => FieldValidateResultOK | FieldValidateResultError; + normalize?: (value: unknown) => DefaultFieldValue; }; export type StandardFormFieldDefinition = BaseFieldDefinition & { @@ -28,6 +45,7 @@ export type FormFieldDefinition = export type FormField<Value = DefaultFieldValue> = { name: FieldName; value: Value; + error?: string; initialValue: Value; active: boolean; diff --git a/frontend/src/metabase/components/form/FormField.jsx b/frontend/src/metabase/components/form/FormField.jsx deleted file mode 100644 index 7bc3a0662958f170226614c8c49023cb9777b550..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/components/form/FormField.jsx +++ /dev/null @@ -1,134 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import cx from "classnames"; - -import Tooltip from "metabase/components/Tooltip"; - -import { - FieldRow, - Label, - InfoIcon, - InputContainer, - FieldContainer, - InfoLabel, -} from "./FormField.styled"; -import { FormFieldDescription } from "./FormFieldDescription"; - -const formFieldCommon = { - title: PropTypes.string, - description: PropTypes.string, - descriptionPosition: PropTypes.oneOf(["top", "bottom"]), - info: PropTypes.string, - hidden: PropTypes.bool, - horizontal: PropTypes.bool, -}; - -const propTypes = { - ...formFieldCommon, - - field: PropTypes.object, - formField: PropTypes.shape({ - ...formFieldCommon, - type: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), - }), - - // redux-form compatible: - name: PropTypes.string, - error: PropTypes.any, - visited: PropTypes.bool, - active: PropTypes.bool, - - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node, - ]), - className: PropTypes.string, -}; - -const ALL_DOT_CHARS = /\./g; - -function FormField(props) { - const { - className, - formField, - title = formField && formField.title, - description = formField && formField.description, - descriptionPosition = descriptionPosition || - (formField && formField.descriptionPosition) || - "top", - info = formField && formField.info, - infoLabel = formField && formField.infoLabel, - infoLabelTooltip = formField && formField.infoLabelTooltip, - hidden = formField && (formField.hidden || formField.type === "hidden"), - horizontal = formField && - (formField.horizontal || formField.type === "boolean"), - align = formField?.align || "right", - children, - } = props; - - if (hidden) { - return null; - } - - let { name, error, visited, active } = { - ...(props.field || {}), - ...props, - }; - - const formFieldId = `formField-${name.replace(ALL_DOT_CHARS, "-")}`; - const isToggle = formField?.type === "boolean"; - - if (!visited || active) { - // if the field hasn't been visited or is currently active then don't show the error - error = null; - } - - const rootClassNames = cx("Form-field", className, { - "Form--fieldError": !!error, - flex: horizontal, - }); - - return ( - <div id={formFieldId} className={rootClassNames}> - {align === "left" && <InputContainer>{children}</InputContainer>} - {(title || description) && ( - <FieldContainer horizontal={horizontal} align={align}> - <FieldRow> - {title && ( - <Label - id={`${name}-label`} - htmlFor={name} - horizontal={horizontal} - standAlone={isToggle && align === "right" && !description} - > - {title} - {error && <span className="text-error">: {error}</span>} - </Label> - )} - {info && ( - <Tooltip tooltip={info}> - <InfoIcon name="info" size={12} /> - </Tooltip> - )} - {infoLabel && ( - <Tooltip tooltip={infoLabelTooltip} maxWidth="100%"> - <InfoLabel>{infoLabel}</InfoLabel> - </Tooltip> - )} - </FieldRow> - {description && descriptionPosition === "top" && ( - <FormFieldDescription className="mb1" description={description} /> - )} - </FieldContainer> - )} - {align !== "left" && <InputContainer>{children}</InputContainer>} - {description && descriptionPosition === "bottom" && ( - <FormFieldDescription className="mt1" description={description} /> - )} - </div> - ); -} - -FormField.propTypes = propTypes; - -export default FormField; diff --git a/frontend/src/metabase/components/form/FormField.styled.jsx b/frontend/src/metabase/components/form/FormField/FormField.styled.tsx similarity index 84% rename from frontend/src/metabase/components/form/FormField.styled.jsx rename to frontend/src/metabase/components/form/FormField/FormField.styled.tsx index a8bc2f1bcbb866de64ed6de7ebd2fbd5383e72fc..aeb56539b74031b9a5bb4f6e0253b318d032f4bc 100644 --- a/frontend/src/metabase/components/form/FormField.styled.jsx +++ b/frontend/src/metabase/components/form/FormField/FormField.styled.tsx @@ -10,7 +10,10 @@ export const FieldRow = styled.div` margin-bottom: 0.5em; `; -export const Label = styled.label` +export const Label = styled.label<{ + horizontal?: boolean; + standAlone?: boolean; +}>` margin-bottom: 0; ${props => props.horizontal && @@ -42,7 +45,10 @@ export const InfoLabel = styled.span` cursor: default; `; -export const FieldContainer = styled.div` +export const FieldContainer = styled.div<{ + horizontal?: boolean; + align?: "left" | "right"; +}>` width: 100%; margin-right: ${props => (props.horizontal ? "1rem" : "")}; margin-left: ${props => (props.align === "left" ? "0.5rem" : "")}; @@ -50,9 +56,4 @@ export const FieldContainer = styled.div` export const InputContainer = styled.div` flex-shrink: 0; - ${props => - props.horizontal && - css` - margin-left: auto; - `} `; diff --git a/frontend/src/metabase/components/form/FormField/FormField.tsx b/frontend/src/metabase/components/form/FormField/FormField.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4ca46489ae5f11604430f77be374924246c15046 --- /dev/null +++ b/frontend/src/metabase/components/form/FormField/FormField.tsx @@ -0,0 +1,120 @@ +import React from "react"; + +import { + FieldName, + FieldValues, + FormField as FormFieldType, + BaseFieldDefinition, + FormFieldDefinition, +} from "metabase-types/forms"; + +import FormFieldView from "./FormFieldView"; + +type ReduxFormProps = Pick<FormFieldType, "name"> & + Partial<Pick<FormFieldType, "error" | "visited" | "active">>; + +interface FormFieldProps extends BaseFieldDefinition, ReduxFormProps { + field: FormFieldType; + formField: FormFieldDefinition; + values: FieldValues; + className?: string; + children: React.ReactNode; + onChangeField: (fieldName: FieldName, value: unknown) => void; +} + +const ALL_DOT_CHARS = /\./g; + +function getFieldId(formFieldName: FieldName) { + return `formField-${formFieldName.replace(ALL_DOT_CHARS, "-")}`; +} + +function getDescriptionPositionPropValue( + descriptionPosition?: "top" | "bottom", + formField?: FormFieldDefinition, +) { + return descriptionPosition ?? formField?.descriptionPosition ?? "top"; +} + +function getHiddenPropValue(hidden?: boolean, formField?: FormFieldDefinition) { + if (typeof hidden === "boolean") { + return hidden; + } + if (formField) { + return formField.hidden || formField.type === "hidden"; + } + return false; +} + +function getHorizontalPropValue( + horizontal?: boolean, + formField?: FormFieldDefinition, +) { + if (typeof horizontal === "boolean") { + return horizontal; + } + if (formField) { + return formField.horizontal || formField.type === "boolean"; + } + return false; +} + +function FormField({ + className, + formField, + children, + ...props +}: FormFieldProps) { + const title = props.title ?? formField?.title; + const type = props.type ?? formField.type; + const description = props.description ?? formField?.description; + const descriptionPosition = getDescriptionPositionPropValue( + props.descriptionPosition, + formField, + ); + + const info = props.info ?? formField?.info; + const infoLabel = props.infoLabel ?? formField?.infoLabel; + const infoLabelTooltip = + props.infoLabelTooltip ?? formField?.infoLabelTooltip; + + const align = props.align ?? formField?.align ?? "right"; + const hidden = getHiddenPropValue(props.hidden, formField); + const horizontal = getHorizontalPropValue(props.horizontal, formField); + + const isToggle = type === "boolean"; + const standAloneLabel = isToggle && align === "right" && !description; + + if (hidden) { + return null; + } + + const { name, error: errorProp, visited, active } = { + ...(props.field || {}), + ...props, + }; + + const shouldHideError = !visited || active; + const error = shouldHideError ? undefined : errorProp; + + return ( + <FormFieldView + fieldId={getFieldId(name)} + className={className} + name={name} + error={error} + title={title} + description={description} + descriptionPosition={descriptionPosition} + info={info} + infoLabel={infoLabel} + infoLabelTooltip={infoLabelTooltip} + align={align} + standAloneLabel={standAloneLabel} + horizontal={horizontal} + > + {children} + </FormFieldView> + ); +} + +export default FormField; diff --git a/frontend/src/metabase/components/form/FormFieldDescription.tsx b/frontend/src/metabase/components/form/FormField/FormFieldDescription.tsx similarity index 100% rename from frontend/src/metabase/components/form/FormFieldDescription.tsx rename to frontend/src/metabase/components/form/FormField/FormFieldDescription.tsx diff --git a/frontend/src/metabase/components/form/FormField/FormFieldView.tsx b/frontend/src/metabase/components/form/FormField/FormFieldView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c7ab1c6284e588415a0ff56e72e25a7e1658eea7 --- /dev/null +++ b/frontend/src/metabase/components/form/FormField/FormFieldView.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import cx from "classnames"; + +import Tooltip from "metabase/components/Tooltip"; + +import { BaseFieldDefinition } from "metabase-types/forms"; + +import { FormFieldDescription } from "./FormFieldDescription"; +import { + FieldRow, + Label, + InfoIcon, + InputContainer, + FieldContainer, + InfoLabel, +} from "./FormField.styled"; + +interface FormFieldViewProps extends BaseFieldDefinition { + fieldId: string; + error?: string; + className?: string; + standAloneLabel?: boolean; + children: React.ReactNode; +} + +function FormFieldView({ + fieldId, + className, + name, + error, + title, + description, + descriptionPosition, + info, + infoLabel, + infoLabelTooltip, + align, + horizontal, + standAloneLabel, + children, +}: FormFieldViewProps) { + const rootClassNames = cx("Form-field", className, { + "Form--fieldError": !!error, + flex: horizontal, + }); + + return ( + <div id={fieldId} className={rootClassNames}> + {align === "left" && <InputContainer>{children}</InputContainer>} + {(title || description) && ( + <FieldContainer horizontal={horizontal} align={align}> + <FieldRow> + {title && ( + <Label + id={`${name}-label`} + htmlFor={name} + horizontal={horizontal} + standAlone={standAloneLabel} + > + {title} + {error && <span className="text-error">: {error}</span>} + </Label> + )} + {info && ( + <Tooltip tooltip={info}> + <InfoIcon name="info" size={12} /> + </Tooltip> + )} + {infoLabel && ( + <Tooltip tooltip={infoLabelTooltip} maxWidth="100%"> + <InfoLabel>{infoLabel}</InfoLabel> + </Tooltip> + )} + </FieldRow> + {description && descriptionPosition === "top" && ( + <FormFieldDescription className="mb1" description={description} /> + )} + </FieldContainer> + )} + {align !== "left" && <InputContainer>{children}</InputContainer>} + {description && descriptionPosition === "bottom" && ( + <FormFieldDescription className="mt1" description={description} /> + )} + </div> + ); +} + +export default FormFieldView; diff --git a/frontend/src/metabase/components/form/FormField/index.ts b/frontend/src/metabase/components/form/FormField/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2d6f74d4381bd3560ca0441c57cba1acd4dc081 --- /dev/null +++ b/frontend/src/metabase/components/form/FormField/index.ts @@ -0,0 +1 @@ +export { default } from "./FormField";