airbnb/caravel

View on GitHub
superset-frontend/src/components/Modal/Modal.tsx

Summary

Maintainability
B
5 hrs
Test Coverage
/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
import {
  isValidElement,
  cloneElement,
  CSSProperties,
  ReactNode,
  useMemo,
  useRef,
  useState,
} from 'react';
import { isNil } from 'lodash';
import { ModalFuncProps } from 'antd/lib/modal';
import { styled, t } from '@superset-ui/core';
import { css } from '@emotion/react';
import { AntdModal, AntdModalProps } from 'src/components';
import Button from 'src/components/Button';
import { Resizable, ResizableProps } from 're-resizable';
import Draggable, {
  DraggableBounds,
  DraggableData,
  DraggableEvent,
  DraggableProps,
} from 'react-draggable';

export interface ModalProps {
  className?: string;
  children: ReactNode;
  disablePrimaryButton?: boolean;
  primaryTooltipMessage?: ReactNode;
  primaryButtonLoading?: boolean;
  onHide: () => void;
  onHandledPrimaryAction?: () => void;
  primaryButtonName?: string;
  primaryButtonType?: 'primary' | 'danger';
  show: boolean;
  name?: string;
  title: ReactNode;
  width?: string;
  maxWidth?: string;
  responsive?: boolean;
  hideFooter?: boolean;
  centered?: boolean;
  footer?: ReactNode;
  wrapProps?: object;
  height?: string;
  closable?: boolean;
  resizable?: boolean;
  resizableConfig?: ResizableProps;
  draggable?: boolean;
  draggableConfig?: DraggableProps;
  destroyOnClose?: boolean;
  maskClosable?: boolean;
  zIndex?: number;
  bodyStyle?: CSSProperties;
}

interface StyledModalProps {
  maxWidth?: string;
  responsive?: boolean;
  height?: string;
  hideFooter?: boolean;
  draggable?: boolean;
  resizable?: boolean;
}

const MODAL_HEADER_HEIGHT = 55;
const MODAL_MIN_CONTENT_HEIGHT = 54;
const MODAL_FOOTER_HEIGHT = 65;

const RESIZABLE_MIN_HEIGHT = MODAL_HEADER_HEIGHT + MODAL_MIN_CONTENT_HEIGHT;
const RESIZABLE_MIN_WIDTH = '380px';
const RESIZABLE_MAX_HEIGHT = '100vh';
const RESIZABLE_MAX_WIDTH = '100vw';

const BaseModal = (props: AntdModalProps) => (
  // Removes mask animation. Fixed in 4.6.0.
  // https://github.com/ant-design/ant-design/issues/27192
  <AntdModal {...props} maskTransitionName="" />
);

export const StyledModal = styled(BaseModal)<StyledModalProps>`
  ${({ theme, responsive, maxWidth }) =>
    responsive &&
    css`
      max-width: ${maxWidth ?? '900px'};
      padding-left: ${theme.gridUnit * 3}px;
      padding-right: ${theme.gridUnit * 3}px;
      padding-bottom: 0;
      top: 0;
    `}

  .ant-modal-content {
    display: flex;
    flex-direction: column;
    max-height: ${({ theme }) => `calc(100vh - ${theme.gridUnit * 8}px)`};
    margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
    margin-top: ${({ theme }) => theme.gridUnit * 4}px;
  }

  .ant-modal-header {
    flex: 0 0 auto;
    background-color: ${({ theme }) => theme.colors.grayscale.light4};
    border-radius: ${({ theme }) => theme.borderRadius}px
      ${({ theme }) => theme.borderRadius}px 0 0;
    padding-left: ${({ theme }) => theme.gridUnit * 4}px;
    padding-right: ${({ theme }) => theme.gridUnit * 4}px;

    .ant-modal-title h4 {
      display: flex;
      margin: 0;
      align-items: center;
    }
  }

  .ant-modal-close-x {
    display: flex;
    align-items: center;

    .close {
      flex: 1 1 auto;
      margin-bottom: ${({ theme }) => theme.gridUnit}px;
      color: ${({ theme }) => theme.colors.secondary.dark1};
      font-size: 32px;
      font-weight: ${({ theme }) => theme.typography.weights.light};
    }
  }

  .ant-modal-body {
    flex: 0 1 auto;
    padding: ${({ theme }) => theme.gridUnit * 4}px;
    overflow: auto;
    ${({ resizable, height }) => !resizable && height && `height: ${height};`}
  }
  .ant-modal-footer {
    flex: 0 0 1;
    border-top: ${({ theme }) => theme.gridUnit / 4}px solid
      ${({ theme }) => theme.colors.grayscale.light2};
    padding: ${({ theme }) => theme.gridUnit * 4}px;

    .btn {
      font-size: 12px;
    }

    .btn + .btn {
      margin-left: ${({ theme }) => theme.gridUnit * 2}px;
    }
  }

  // styling for Tabs component
  // Aaron note 20-11-19: this seems to be exclusively here for the Edit Database modal.
  // TODO: remove this as it is a special case.
  .ant-tabs-top {
    margin-top: -${({ theme }) => theme.gridUnit * 4}px;
  }

  &.no-content-padding .ant-modal-body {
    padding: 0;
  }

  ${({ draggable, theme }) =>
    draggable &&
    `
    .ant-modal-header {
      padding: 0;
      .draggable-trigger {
          cursor: move;
          padding: ${theme.gridUnit * 4}px;
          width: 100%;
        }
    }
  `};

  ${({ resizable, hideFooter }) =>
    resizable &&
    `
    .resizable {
      pointer-events: all;

      .resizable-wrapper {
        height: 100%;
      }

      .ant-modal-content {
        height: 100%;

        .ant-modal-body {
          /* 100% - header height - footer height */
          height: ${
            hideFooter
              ? `calc(100% - ${MODAL_HEADER_HEIGHT}px);`
              : `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px);`
          }
        }
      }
    }
  `}
`;
const defaultResizableConfig = (hideFooter: boolean | undefined) => ({
  maxHeight: RESIZABLE_MAX_HEIGHT,
  maxWidth: RESIZABLE_MAX_WIDTH,
  minHeight: hideFooter
    ? RESIZABLE_MIN_HEIGHT
    : RESIZABLE_MIN_HEIGHT + MODAL_FOOTER_HEIGHT,
  minWidth: RESIZABLE_MIN_WIDTH,
  enable: {
    bottom: true,
    bottomLeft: false,
    bottomRight: true,
    left: false,
    top: false,
    topLeft: false,
    topRight: false,
    right: true,
  },
});

const CustomModal = ({
  children,
  disablePrimaryButton = false,
  primaryTooltipMessage,
  primaryButtonLoading = false,
  onHide,
  onHandledPrimaryAction,
  primaryButtonName = t('OK'),
  primaryButtonType = 'primary',
  show,
  name,
  title,
  width,
  maxWidth,
  responsive = false,
  centered,
  footer,
  hideFooter,
  wrapProps,
  draggable = false,
  resizable = false,
  resizableConfig = defaultResizableConfig(hideFooter),
  draggableConfig,
  destroyOnClose,
  ...rest
}: ModalProps) => {
  const draggableRef = useRef<HTMLDivElement>(null);
  const [bounds, setBounds] = useState<DraggableBounds>();
  const [dragDisabled, setDragDisabled] = useState<boolean>(true);
  let FooterComponent;
  if (isValidElement(footer)) {
    // If a footer component is provided inject a closeModal function
    // so the footer can provide a "close" button if desired
    FooterComponent = cloneElement(footer, {
      closeModal: onHide,
    } as Partial<unknown>);
  }
  const modalFooter = isNil(FooterComponent)
    ? [
        <Button key="back" onClick={onHide} cta data-test="modal-cancel-button">
          {t('Cancel')}
        </Button>,
        <Button
          key="submit"
          buttonStyle={primaryButtonType}
          disabled={disablePrimaryButton}
          tooltip={primaryTooltipMessage}
          loading={primaryButtonLoading}
          onClick={onHandledPrimaryAction}
          cta
          data-test="modal-confirm-button"
        >
          {primaryButtonName}
        </Button>,
      ]
    : FooterComponent;

  const modalWidth = width || (responsive ? '100vw' : '600px');
  const shouldShowMask = !(resizable || draggable);

  const onDragStart = (_: DraggableEvent, uiData: DraggableData) => {
    const { clientWidth, clientHeight } = window?.document?.documentElement;
    const targetRect = draggableRef?.current?.getBoundingClientRect();

    if (targetRect) {
      setBounds({
        left: -targetRect?.left + uiData?.x,
        right: clientWidth - (targetRect?.right - uiData?.x),
        top: -targetRect?.top + uiData?.y,
        bottom: clientHeight - (targetRect?.bottom - uiData?.y),
      });
    }
  };

  const getResizableConfig = useMemo(() => {
    if (Object.keys(resizableConfig).length === 0) {
      return defaultResizableConfig(hideFooter);
    }
    return resizableConfig;
  }, [hideFooter, resizableConfig]);

  const ModalTitle = () =>
    draggable ? (
      <div
        className="draggable-trigger"
        onMouseOver={() => dragDisabled && setDragDisabled(false)}
        onMouseOut={() => !dragDisabled && setDragDisabled(true)}
      >
        {title}
      </div>
    ) : (
      <>{title}</>
    );

  return (
    <StyledModal
      centered={!!centered}
      onOk={onHandledPrimaryAction}
      onCancel={onHide}
      width={modalWidth}
      maxWidth={maxWidth}
      responsive={responsive}
      visible={show}
      title={<ModalTitle />}
      closeIcon={
        <span className="close" aria-hidden="true">
          ×
        </span>
      }
      footer={!hideFooter ? modalFooter : null}
      hideFooter={hideFooter}
      wrapProps={{ 'data-test': `${name || title}-modal`, ...wrapProps }}
      modalRender={modal =>
        resizable || draggable ? (
          <Draggable
            disabled={!draggable || dragDisabled}
            bounds={bounds}
            onStart={(event, uiData) => onDragStart(event, uiData)}
            {...draggableConfig}
          >
            {resizable ? (
              <Resizable className="resizable" {...getResizableConfig}>
                <div className="resizable-wrapper" ref={draggableRef}>
                  {modal}
                </div>
              </Resizable>
            ) : (
              <div ref={draggableRef}>{modal}</div>
            )}
          </Draggable>
        ) : (
          modal
        )
      }
      mask={shouldShowMask}
      draggable={draggable}
      resizable={resizable}
      destroyOnClose={destroyOnClose}
      {...rest}
    >
      {children}
    </StyledModal>
  );
};
CustomModal.displayName = 'Modal';

// Ant Design 4 does not allow overriding Modal's buttons when
// using one of the pre-defined functions. Ant Design 5 Modal introduced
// the footer property that will allow that. Meanwhile, we're replicating
// Button style using global CSS in src/GlobalStyles.tsx.
// TODO: Replace this logic when on Ant Design 5.
const buttonProps = {
  okButtonProps: { className: 'modal-functions-ok-button' },
  cancelButtonProps: { className: 'modal-functions-cancel-button' },
};

// TODO: in another PR, rename this to CompatabilityModal
// and demote it as the default export.
// We should start using AntD component interfaces going forward.
const Modal = Object.assign(CustomModal, {
  error: (config: ModalFuncProps) =>
    AntdModal.error({ ...config, ...buttonProps }),
  warning: (config: ModalFuncProps) =>
    AntdModal.warning({ ...config, ...buttonProps }),
  confirm: (config: ModalFuncProps) =>
    AntdModal.confirm({ ...config, ...buttonProps }),
  useModal: AntdModal.useModal,
});

export default Modal;