superset-frontend/src/components/Modal/Modal.tsx
/**
* 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;