From d7a9c17d05d125d5418e33bf8d232e6c1b48e922 Mon Sep 17 00:00:00 2001
From: Oisin Coveney <oisin@metabase.com>
Date: Tue, 23 May 2023 12:46:32 +0300
Subject: [PATCH] Split Modal into separate TS files (#30845)

---
 .../components/Modal/FullPageModal.tsx        | 118 +++++++++
 .../src/metabase/components/Modal/Modal.jsx   | 240 ------------------
 .../src/metabase/components/Modal/Modal.tsx   |  27 ++
 .../Modal/RoutelessFullPageModal.tsx          |   5 +
 .../metabase/components/Modal/WindowModal.tsx | 125 +++++++++
 .../src/metabase/components/Modal/index.jsx   |   1 -
 .../src/metabase/components/Modal/index.tsx   |   2 +
 .../src/metabase/components/Modal/utils.tsx   |  32 +++
 8 files changed, 309 insertions(+), 241 deletions(-)
 create mode 100644 frontend/src/metabase/components/Modal/FullPageModal.tsx
 delete mode 100644 frontend/src/metabase/components/Modal/Modal.jsx
 create mode 100644 frontend/src/metabase/components/Modal/Modal.tsx
 create mode 100644 frontend/src/metabase/components/Modal/RoutelessFullPageModal.tsx
 create mode 100644 frontend/src/metabase/components/Modal/WindowModal.tsx
 delete mode 100644 frontend/src/metabase/components/Modal/index.jsx
 create mode 100644 frontend/src/metabase/components/Modal/index.tsx
 create mode 100644 frontend/src/metabase/components/Modal/utils.tsx

diff --git a/frontend/src/metabase/components/Modal/FullPageModal.tsx b/frontend/src/metabase/components/Modal/FullPageModal.tsx
new file mode 100644
index 00000000000..f2761ec478f
--- /dev/null
+++ b/frontend/src/metabase/components/Modal/FullPageModal.tsx
@@ -0,0 +1,118 @@
+import React, { Component } from "react";
+import { Motion, spring } from "react-motion";
+import { getScrollX, getScrollY } from "metabase/lib/dom";
+import SandboxedPortal from "metabase/components/SandboxedPortal";
+import {
+  BaseModalProps,
+  getModalContent,
+} from "metabase/components/Modal/utils";
+import { MaybeOnClickOutsideWrapper } from "metabase/components/Modal/MaybeOnClickOutsideWrapper";
+
+export type FullPageModalProps = BaseModalProps & {
+  isOpen: boolean;
+  onClose?: () => void;
+  fullPageModal?: boolean;
+};
+
+type FullPageModalState = {
+  isOpen: boolean;
+};
+
+export class FullPageModal extends Component<
+  FullPageModalProps,
+  FullPageModalState
+> {
+  _modalElement: HTMLDivElement;
+  _scrollX: number;
+  _scrollY: number;
+
+  constructor(props: FullPageModalProps) {
+    super(props);
+    this.state = {
+      isOpen: true,
+    };
+
+    this._modalElement = document.createElement("div");
+    this._modalElement.className = "ModalContainer";
+    document.body.appendChild(this._modalElement);
+
+    // save the scroll position, scroll to the top left, and disable scrolling
+    this._scrollX = getScrollX();
+    this._scrollY = getScrollY();
+    window.scrollTo(0, 0);
+    document.body.style.overflow = "hidden";
+  }
+
+  setTopOfModalToBottomOfNav() {
+    const nav = document.body.querySelector(".Nav");
+    if (nav) {
+      this._modalElement.style.top = nav.getBoundingClientRect().bottom + "px";
+    }
+  }
+
+  componentDidMount() {
+    this.setTopOfModalToBottomOfNav();
+  }
+
+  componentDidUpdate() {
+    if (!this.state.isOpen) {
+      document.body.style.overflow = "";
+    }
+    this.setTopOfModalToBottomOfNav();
+  }
+
+  componentWillUnmount() {
+    if (this._modalElement.parentNode) {
+      this._modalElement.parentNode.removeChild(this._modalElement);
+    }
+    document.body.style.overflow = "";
+  }
+
+  handleDismissal = () => {
+    this.setState({ isOpen: false });
+
+    // wait for animations to complete before unmounting
+    setTimeout(() => this.props.onClose && this.props.onClose(), 300);
+  };
+
+  render() {
+    const open = this.state.isOpen;
+    return (
+      <Motion
+        defaultStyle={{ opacity: 0, top: 20 }}
+        style={
+          open
+            ? { opacity: spring(1), top: spring(0) }
+            : { opacity: spring(0), top: spring(20) }
+        }
+      >
+        {motionStyle => (
+          <SandboxedPortal container={this._modalElement}>
+            <div className="Modal--full">
+              {/* Using an OnClickOutsideWrapper is weird since this modal
+              occupies the entire screen. We do this to put this modal on top of
+              the OnClickOutsideWrapper popover stack.  Otherwise, clicks within
+              this modal might be seen as clicks outside another popover. */}
+              <MaybeOnClickOutsideWrapper
+                handleDismissal={this.handleDismissal}
+                closeOnClickOutside={this.props.closeOnClickOutside}
+              >
+                <div
+                  className="full-height relative scroll-y"
+                  style={motionStyle}
+                >
+                  {getModalContent({
+                    ...this.props,
+                    fullPageModal: true,
+                    formModal: !!this.props.form,
+                    onClose: this.handleDismissal,
+                  })}
+                </div>
+              </MaybeOnClickOutsideWrapper>
+            </div>
+          </SandboxedPortal>
+        )}
+      </Motion>
+    );
+  }
+}
diff --git a/frontend/src/metabase/components/Modal/Modal.jsx b/frontend/src/metabase/components/Modal/Modal.jsx
deleted file mode 100644
index f8d593ced40..00000000000
--- a/frontend/src/metabase/components/Modal/Modal.jsx
+++ /dev/null
@@ -1,240 +0,0 @@
-/* eslint-disable react/prop-types */
-import React, { Component } from "react";
-import PropTypes from "prop-types";
-import cx from "classnames";
-
-import { CSSTransition, TransitionGroup } from "react-transition-group";
-import { Motion, spring } from "react-motion";
-import _ from "underscore";
-import { getScrollX, getScrollY } from "metabase/lib/dom";
-
-import SandboxedPortal from "metabase/components/SandboxedPortal";
-import routeless from "metabase/hoc/Routeless";
-import ModalContent from "metabase/components/ModalContent";
-import { MaybeOnClickOutsideWrapper } from "metabase/components/Modal/MaybeOnClickOutsideWrapper";
-
-function getModalContent(props) {
-  if (
-    React.Children.count(props.children) > 1 ||
-    props.title != null ||
-    props.footer != null
-  ) {
-    return <ModalContent {..._.omit(props, "className", "style")} />;
-  } else {
-    return React.Children.only(props.children);
-  }
-}
-
-export class WindowModal extends Component {
-  static propTypes = {
-    isOpen: PropTypes.bool,
-    enableMouseEvents: PropTypes.bool,
-    enableTransition: PropTypes.bool,
-    closeOnClickOutside: PropTypes.bool,
-  };
-
-  static defaultProps = {
-    className: "Modal",
-    backdropClassName: "Modal-backdrop",
-    enableTransition: true,
-  };
-
-  constructor(props) {
-    super(props);
-
-    this._modalElement = document.createElement("div");
-    this._modalElement.className = "ModalContainer";
-    document.body.appendChild(this._modalElement);
-  }
-
-  componentWillUnmount() {
-    this._modalElement.parentNode.removeChild(this._modalElement);
-  }
-
-  handleDismissal = () => {
-    if (this.props.onClose) {
-      this.props.onClose();
-    }
-  };
-
-  _modalComponent() {
-    const className = cx(
-      this.props.className,
-      ...["small", "medium", "wide", "tall", "fit"]
-        .filter(type => this.props[type])
-        .map(type => `Modal--${type}`),
-    );
-    return (
-      <MaybeOnClickOutsideWrapper
-        closeOnClickOutside={this.props.closeOnClickOutside}
-        backdropElement={this._modalElement}
-        handleDismissal={this.handleDismissal}
-      >
-        <div
-          className={cx(className, "relative bg-white rounded")}
-          role="dialog"
-        >
-          {getModalContent({
-            ...this.props,
-            fullPageModal: false,
-            // if there is a form then its a form modal, or if there's a form
-            // modal prop use that
-            formModal: !!this.props.form || this.props.formModal,
-          })}
-        </div>
-      </MaybeOnClickOutsideWrapper>
-    );
-  }
-
-  render() {
-    const {
-      enableMouseEvents,
-      backdropClassName,
-      isOpen,
-      style,
-      enableTransition,
-    } = this.props;
-    const backdropClassnames =
-      "flex justify-center align-center fixed top left bottom right";
-
-    return (
-      <SandboxedPortal
-        container={this._modalElement}
-        enableMouseEvents={enableMouseEvents}
-      >
-        <TransitionGroup
-          appear={enableTransition}
-          enter={enableTransition}
-          exit={enableTransition}
-        >
-          {isOpen && (
-            <CSSTransition
-              key="modal"
-              classNames="Modal"
-              timeout={{
-                appear: 250,
-                enter: 250,
-                exit: 250,
-              }}
-            >
-              <div
-                className={cx(backdropClassName, backdropClassnames)}
-                style={style}
-              >
-                {this._modalComponent()}
-              </div>
-            </CSSTransition>
-          )}
-        </TransitionGroup>
-      </SandboxedPortal>
-    );
-  }
-}
-
-export class FullPageModal extends Component {
-  constructor(props) {
-    super(props);
-    this.state = {
-      isOpen: true,
-    };
-
-    this._modalElement = document.createElement("div");
-    this._modalElement.className = "ModalContainer";
-    document.body.appendChild(this._modalElement);
-
-    // save the scroll position, scroll to the top left, and disable scrolling
-    this._scrollX = getScrollX();
-    this._scrollY = getScrollY();
-    window.scrollTo(0, 0);
-    document.body.style.overflow = "hidden";
-  }
-
-  setTopOfModalToBottomOfNav() {
-    const nav = document.body.querySelector(".Nav");
-    if (nav) {
-      this._modalElement.style.top = nav.getBoundingClientRect().bottom + "px";
-    }
-  }
-
-  componentDidMount() {
-    this.setTopOfModalToBottomOfNav();
-  }
-
-  componentDidUpdate() {
-    if (!this.state.isOpen) {
-      document.body.style.overflow = "";
-    }
-    this.setTopOfModalToBottomOfNav();
-  }
-
-  componentWillUnmount() {
-    this._modalElement.parentNode.removeChild(this._modalElement);
-    document.body.style.overflow = "";
-  }
-
-  handleDismissal = () => {
-    this.setState({ isOpen: false });
-
-    // wait for animations to complete before unmounting
-    setTimeout(() => this.props.onClose && this.props.onClose(), 300);
-  };
-
-  render() {
-    const open = this.state.isOpen;
-    return (
-      <Motion
-        defaultStyle={{ opacity: 0, top: 20 }}
-        style={
-          open
-            ? { opacity: spring(1), top: spring(0) }
-            : { opacity: spring(0), top: spring(20) }
-        }
-      >
-        {motionStyle => (
-          <SandboxedPortal container={this._modalElement}>
-            <div className="Modal--full">
-              {/* Using an OnClickOutsideWrapper is weird since this modal
-              occupies the entire screen. We do this to put this modal on top of
-              the OnClickOutsideWrapper popover stack.  Otherwise, clicks within
-              this modal might be seen as clicks outside another popover. */}
-              <MaybeOnClickOutsideWrapper
-                closeOnClickOutside={this.props.closeOnClickOutside}
-                handleDismissal={this.handleDismissal}
-              >
-                <div
-                  className="full-height relative scroll-y"
-                  style={motionStyle}
-                >
-                  {getModalContent({
-                    ...this.props,
-                    fullPageModal: true,
-                    formModal: !!this.props.form,
-                    onClose: this.handleDismissal,
-                  })}
-                </div>
-              </MaybeOnClickOutsideWrapper>
-            </div>
-          </SandboxedPortal>
-        )}
-      </Motion>
-    );
-  }
-}
-
-// the "routeless" version should only be used for non-inline modals
-const RoutelessFullPageModal = routeless(FullPageModal);
-
-const Modal = ({ full = false, ...props }) =>
-  full ? (
-    props.isOpen ? (
-      <RoutelessFullPageModal {...props} />
-    ) : null
-  ) : (
-    <WindowModal {...props} />
-  );
-
-Modal.defaultProps = {
-  isOpen: true,
-};
-
-export default Modal;
diff --git a/frontend/src/metabase/components/Modal/Modal.tsx b/frontend/src/metabase/components/Modal/Modal.tsx
new file mode 100644
index 00000000000..ab0e3df6df9
--- /dev/null
+++ b/frontend/src/metabase/components/Modal/Modal.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+import { RoutelessFullPageModal } from "metabase/components/Modal/RoutelessFullPageModal";
+import {
+  WindowModal,
+  WindowModalProps,
+} from "metabase/components/Modal/WindowModal";
+import type { FullPageModalProps } from "metabase/components/Modal/FullPageModal";
+
+const Modal = ({
+  full = false,
+  ...props
+}: {
+  full?: boolean;
+  isOpen?: boolean;
+} & (WindowModalProps & FullPageModalProps)) => {
+  if (full) {
+    return props.isOpen ? <RoutelessFullPageModal {...props} /> : null;
+  } else {
+    return <WindowModal {...props} />;
+  }
+};
+
+Modal.defaultProps = {
+  isOpen: true,
+};
+
+export { Modal };
diff --git a/frontend/src/metabase/components/Modal/RoutelessFullPageModal.tsx b/frontend/src/metabase/components/Modal/RoutelessFullPageModal.tsx
new file mode 100644
index 00000000000..5bd36d63dea
--- /dev/null
+++ b/frontend/src/metabase/components/Modal/RoutelessFullPageModal.tsx
@@ -0,0 +1,5 @@
+// the "routeless" version should only be used for non-inline modals
+import routeless from "metabase/hoc/Routeless";
+import { FullPageModal } from "metabase/components/Modal/FullPageModal";
+
+export const RoutelessFullPageModal = routeless(FullPageModal);
diff --git a/frontend/src/metabase/components/Modal/WindowModal.tsx b/frontend/src/metabase/components/Modal/WindowModal.tsx
new file mode 100644
index 00000000000..528bcd6f5ea
--- /dev/null
+++ b/frontend/src/metabase/components/Modal/WindowModal.tsx
@@ -0,0 +1,125 @@
+import React, { Component, CSSProperties } from "react";
+import { CSSTransition, TransitionGroup } from "react-transition-group";
+import cx from "classnames";
+import {
+  getModalContent,
+  ModalSize,
+  modalSizes,
+  BaseModalProps,
+} from "metabase/components/Modal/utils";
+
+import SandboxedPortal from "metabase/components/SandboxedPortal";
+import { MaybeOnClickOutsideWrapper } from "metabase/components/Modal/MaybeOnClickOutsideWrapper";
+
+export type WindowModalProps = BaseModalProps & {
+  isOpen?: boolean;
+  onClose?: () => void;
+  fullPageModal?: boolean;
+  formModal?: boolean;
+  style?: CSSProperties;
+} & {
+  [size in ModalSize]?: boolean;
+};
+
+export class WindowModal extends Component<WindowModalProps> {
+  _modalElement: HTMLDivElement;
+
+  static defaultProps = {
+    className: "Modal",
+    backdropClassName: "Modal-backdrop",
+    enableTransition: true,
+  };
+
+  constructor(props: WindowModalProps) {
+    super(props);
+
+    this._modalElement = document.createElement("div");
+    this._modalElement.className = "ModalContainer";
+    document.body.appendChild(this._modalElement);
+  }
+
+  componentWillUnmount() {
+    if (this._modalElement.parentNode) {
+      this._modalElement.parentNode.removeChild(this._modalElement);
+    }
+  }
+
+  handleDismissal = () => {
+    if (this.props.onClose) {
+      this.props.onClose();
+    }
+  };
+
+  _modalComponent() {
+    const className = cx(
+      this.props.className,
+      ...modalSizes
+        .filter(type => this.props[type])
+        .map(type => `Modal--${type}`),
+    );
+    return (
+      <MaybeOnClickOutsideWrapper
+        backdropElement={this._modalElement}
+        handleDismissal={this.handleDismissal}
+        closeOnClickOutside={this.props.closeOnClickOutside}
+      >
+        <div
+          className={cx(className, "relative bg-white rounded")}
+          role="dialog"
+        >
+          {getModalContent({
+            ...this.props,
+            fullPageModal: false,
+            // if there is a form then its a form modal, or if there's a form
+            // modal prop use that
+            formModal: !!this.props.form || this.props.formModal,
+          })}
+        </div>
+      </MaybeOnClickOutsideWrapper>
+    );
+  }
+
+  render() {
+    const {
+      enableMouseEvents,
+      backdropClassName,
+      isOpen,
+      style,
+      enableTransition,
+    } = this.props;
+    const backdropClassnames =
+      "flex justify-center align-center fixed top left bottom right";
+
+    return (
+      <SandboxedPortal
+        container={this._modalElement}
+        enableMouseEvents={enableMouseEvents}
+      >
+        <TransitionGroup
+          appear={enableTransition}
+          enter={enableTransition}
+          exit={enableTransition}
+        >
+          {isOpen && (
+            <CSSTransition
+              key="modal"
+              classNames="Modal"
+              timeout={{
+                appear: 250,
+                enter: 250,
+                exit: 250,
+              }}
+            >
+              <div
+                className={cx(backdropClassName, backdropClassnames)}
+                style={style}
+              >
+                {this._modalComponent()}
+              </div>
+            </CSSTransition>
+          )}
+        </TransitionGroup>
+      </SandboxedPortal>
+    );
+  }
+}
diff --git a/frontend/src/metabase/components/Modal/index.jsx b/frontend/src/metabase/components/Modal/index.jsx
deleted file mode 100644
index 09b91f72b90..00000000000
--- a/frontend/src/metabase/components/Modal/index.jsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./Modal";
diff --git a/frontend/src/metabase/components/Modal/index.tsx b/frontend/src/metabase/components/Modal/index.tsx
new file mode 100644
index 00000000000..bfd909550f3
--- /dev/null
+++ b/frontend/src/metabase/components/Modal/index.tsx
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/no-default-export -- deprecated usage
+export { Modal as default } from "./Modal";
diff --git a/frontend/src/metabase/components/Modal/utils.tsx b/frontend/src/metabase/components/Modal/utils.tsx
new file mode 100644
index 00000000000..24891d64524
--- /dev/null
+++ b/frontend/src/metabase/components/Modal/utils.tsx
@@ -0,0 +1,32 @@
+import React from "react";
+import _ from "underscore";
+import ModalContent from "metabase/components/ModalContent";
+
+export const modalSizes = ["small", "medium", "wide", "tall", "fit"] as const;
+export type ModalSize = typeof modalSizes[number];
+
+export type BaseModalProps = {
+  children?: React.ReactNode;
+  className?: string;
+  backdropClassName?: string;
+  enableMouseEvents?: boolean;
+  enableTransition?: boolean;
+  closeOnClickOutside?: boolean;
+  noBackdrop?: boolean;
+  noCloseOnBackdrop?: boolean;
+  form?: unknown;
+  title?: string;
+  footer?: string;
+};
+
+export function getModalContent(props: any) {
+  if (
+    React.Children.count(props.children) > 1 ||
+    props.title != null ||
+    props.footer != null
+  ) {
+    return <ModalContent {..._.omit(props, "className", "style")} />;
+  } else {
+    return React.Children.only(props.children);
+  }
+}
-- 
GitLab