From a06a9e06bd118c4e606d40381a2b5afc5d4ac27a Mon Sep 17 00:00:00 2001
From: Anton Kulyk <kuliks.anton@gmail.com>
Date: Thu, 12 May 2022 19:26:28 +0100
Subject: [PATCH] Refactor FormField component (#22375)

* Extend types

* Refactor FormField

* Fix prop values

* Fix types

* Fix field type check
---
 frontend/src/metabase-types/forms/index.ts    |  24 +++-
 .../metabase/components/form/FormField.jsx    | 134 ------------------
 .../FormField.styled.tsx}                     |  15 +-
 .../components/form/FormField/FormField.tsx   | 120 ++++++++++++++++
 .../{ => FormField}/FormFieldDescription.tsx  |   0
 .../form/FormField/FormFieldView.tsx          |  88 ++++++++++++
 .../components/form/FormField/index.ts        |   1 +
 7 files changed, 238 insertions(+), 144 deletions(-)
 delete mode 100644 frontend/src/metabase/components/form/FormField.jsx
 rename frontend/src/metabase/components/form/{FormField.styled.jsx => FormField/FormField.styled.tsx} (84%)
 create mode 100644 frontend/src/metabase/components/form/FormField/FormField.tsx
 rename frontend/src/metabase/components/form/{ => FormField}/FormFieldDescription.tsx (100%)
 create mode 100644 frontend/src/metabase/components/form/FormField/FormFieldView.tsx
 create mode 100644 frontend/src/metabase/components/form/FormField/index.ts

diff --git a/frontend/src/metabase-types/forms/index.ts b/frontend/src/metabase-types/forms/index.ts
index a64b178a213..5affc99e8b3 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 7bc3a066295..00000000000
--- 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 a8bc2f1bcbb..aeb56539b74 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 00000000000..4ca46489ae5
--- /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 00000000000..c7ab1c6284e
--- /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 00000000000..e2d6f74d438
--- /dev/null
+++ b/frontend/src/metabase/components/form/FormField/index.ts
@@ -0,0 +1 @@
+export { default } from "./FormField";
-- 
GitLab