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