Make modals programatically controllable via a HOC

This allows entire components to be unmounted when the modal is hidden without affecting the fade in/out of the modal itself.

This also makes it easier to programatically dismiss a modal without having to copy the visibility all over the place, and makes working with props much simpler in those modal components
This commit is contained in:
Dane Everitt 2020-08-17 21:35:11 -07:00
parent d41b86f0ea
commit c28cba92e2
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
14 changed files with 192 additions and 70 deletions

View file

@ -61,21 +61,19 @@ export default () => {
</ContentBox>
<ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}>
<SpinnerOverlay visible={loading}/>
{deleteIdentifier &&
<ConfirmationModal
visible
visible={!!deleteIdentifier}
title={'Confirm key deletion'}
buttonText={'Yes, delete key'}
onConfirmed={() => {
doDeletion(deleteIdentifier);
setDeleteIdentifier('');
}}
onDismissed={() => setDeleteIdentifier('')}
onModalDismissed={() => setDeleteIdentifier('')}
>
Are you sure you wish to delete this API key? All requests using it will immediately be
invalidated and will fail.
</ConfirmationModal>
}
{
keys.length === 0 ?
<p css={tw`text-center text-sm`}>

View file

@ -0,0 +1,38 @@
import React, { useContext } from 'react';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
interface Props {
apiKey: string;
}
const ApiKeyModal = ({ apiKey }: Props) => {
const { dismiss } = useContext(ModalContext);
return (
<>
<h3 css={tw`mb-6`}>Your API Key</h3>
<p css={tw`text-sm mb-6`}>
The API key you have requested is shown below. Please store this in a safe location, it will not be
shown again.
</p>
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
<code css={tw`font-mono`}>{apiKey}</code>
</pre>
<div css={tw`flex justify-end mt-6`}>
<Button type={'button'} onClick={() => dismiss()}>
Close
</Button>
</div>
</>
);
};
ApiKeyModal.displayName = 'ApiKeyModal';
export default asModal({
closeOnEscape: false,
closeOnBackground: false,
})(ApiKeyModal);

View file

@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { Field, Form, Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import Modal from '@/components/elements/Modal';
import createApiKey from '@/api/account/createApiKey';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
@ -13,6 +12,7 @@ import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Input, { Textarea } from '@/components/elements/Input';
import styled from 'styled-components/macro';
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
interface Values {
description: string;
@ -44,29 +44,11 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
return (
<>
<Modal
<ApiKeyModal
visible={apiKey.length > 0}
onDismissed={() => setApiKey('')}
closeOnEscape={false}
closeOnBackground={false}
>
<h3 css={tw`mb-6`}>Your API Key</h3>
<p css={tw`text-sm mb-6`}>
The API key you have requested is shown below. Please store this in a safe location, it will not be
shown again.
</p>
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
<code css={tw`font-mono`}>{apiKey}</code>
</pre>
<div css={tw`flex justify-end mt-6`}>
<Button
type={'button'}
onClick={() => setApiKey('')}
>
Close
</Button>
</div>
</Modal>
onModalDismissed={() => setApiKey('')}
apiKey={apiKey}
/>
<Formik
onSubmit={submit}
initialValues={{ description: '', allowedIps: '' }}

View file

@ -1,7 +1,8 @@
import React from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import React, { useContext, useEffect } from 'react';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
type Props = {
title: string;
@ -9,26 +10,31 @@ type Props = {
children: string;
onConfirmed: () => void;
showSpinnerOverlay?: boolean;
} & RequiredModalProps;
};
const ConfirmationModal = ({ title, appear, children, visible, buttonText, onConfirmed, showSpinnerOverlay, onDismissed }: Props) => (
<Modal
appear={appear || true}
visible={visible}
showSpinnerOverlay={showSpinnerOverlay}
onDismissed={() => onDismissed()}
>
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
<p css={tw`text-sm`}>{children}</p>
<div css={tw`flex items-center justify-end mt-8`}>
<Button isSecondary onClick={() => onDismissed()}>
Cancel
</Button>
<Button color={'red'} css={tw`ml-4`} onClick={() => onConfirmed()}>
{buttonText}
</Button>
</div>
</Modal>
);
const ConfirmationModal = ({ title, children, buttonText, onConfirmed, showSpinnerOverlay }: Props) => {
const { dismiss, toggleSpinner } = useContext(ModalContext);
export default ConfirmationModal;
useEffect(() => {
toggleSpinner(showSpinnerOverlay);
}, [ showSpinnerOverlay ]);
return (
<>
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
<p css={tw`text-sm`}>{children}</p>
<div css={tw`flex items-center justify-end mt-8`}>
<Button isSecondary onClick={() => dismiss()}>
Cancel
</Button>
<Button color={'red'} css={tw`ml-4`} onClick={() => onConfirmed()}>
{buttonText}
</Button>
</div>
</>
);
};
ConfirmationModal.displayName = 'ConfirmationModal';
export default asModal()(ConfirmationModal);

View file

@ -8,14 +8,14 @@ interface Props extends Omit<CSSTransitionProps, 'timeout' | 'classNames'> {
}
const Container = styled.div<{ timeout: number }>`
.fade-enter, .fade-exit {
.fade-enter, .fade-exit, .fade-appear {
will-change: opacity;
}
.fade-enter {
.fade-enter, .fade-appear {
${tw`opacity-0`};
&.fade-enter-active {
&.fade-enter-active, &.fade-appear-active {
${tw`opacity-100 transition-opacity ease-in`};
transition-duration: ${props => props.timeout}ms;
}

View file

@ -13,7 +13,7 @@ export interface RequiredModalProps {
top?: boolean;
}
interface Props extends RequiredModalProps {
export interface ModalProps extends RequiredModalProps {
dismissable?: boolean;
closeOnEscape?: boolean;
closeOnBackground?: boolean;
@ -40,7 +40,7 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>`
}
`;
const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => {
const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => {
const [ render, setRender ] = useState(visible);
const isDismissable = useMemo(() => {
@ -62,7 +62,13 @@ const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverl
}, [ render ]);
return (
<Fade timeout={150} appear={appear} in={render} unmountOnExit onExited={onDismissed}>
<Fade
in={render}
timeout={150}
appear={appear || true}
unmountOnExit
onExited={() => onDismissed()}
>
<ModalMask
onClick={e => {
if (isDismissable && closeOnBackground) {

View file

@ -65,18 +65,16 @@ export default ({ backup }: Props) => {
checksum={backup.sha256Hash}
/>
}
{deleteVisible &&
<ConfirmationModal
visible={deleteVisible}
title={'Delete this backup?'}
buttonText={'Yes, delete backup'}
onConfirmed={() => doDeletion()}
visible={deleteVisible}
onDismissed={() => setDeleteVisible(false)}
onModalDismissed={() => setDeleteVisible(false)}
>
Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot
be recovered once deleted.
</ConfirmationModal>
}
<SpinnerOverlay visible={loading} fixed/>
<DropdownMenu
renderToggle={onClick => (

View file

@ -72,7 +72,7 @@ const MassActionsBar = () => {
title={'Delete these files?'}
buttonText={'Yes, Delete Files'}
onConfirmed={onClickConfirmDeletion}
onDismissed={() => setShowConfirm(false)}
onModalDismissed={() => setShowConfirm(false)}
>
Deleting files is a permanent operation, you cannot undo this action.
</ConfirmationModal>

View file

@ -39,12 +39,12 @@ export default ({ scheduleId, onDeleted }: Props) => {
return (
<>
<ConfirmationModal
showSpinnerOverlay={isLoading}
visible={visible}
title={'Delete schedule?'}
buttonText={'Yes, delete schedule'}
onConfirmed={onDelete}
visible={visible}
onDismissed={() => setVisible(false)}
showSpinnerOverlay={isLoading}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this schedule? All tasks will be removed and any running processes
will be terminated.

View file

@ -69,7 +69,7 @@ export default ({ schedule, task }: Props) => {
buttonText={'Delete Task'}
onConfirmed={onConfirmDeletion}
visible={visible}
onDismissed={() => setVisible(false)}
onModalDismissed={() => setVisible(false)}
>
Are you sure you want to delete this task? This action cannot be undone.
</ConfirmationModal>

View file

@ -46,10 +46,10 @@ export default () => {
<ConfirmationModal
title={'Confirm server reinstallation'}
buttonText={'Yes, reinstall server'}
onConfirmed={() => reinstall()}
onConfirmed={reinstall}
showSpinnerOverlay={isSubmitting}
visible={modalVisible}
onDismissed={() => setModalVisible(false)}
onModalDismissed={() => setModalVisible(false)}
>
Your server will be stopped and some files may be deleted or modified during this process, are you sure
you wish to continue?

View file

@ -35,19 +35,17 @@ export default ({ subuser }: { subuser: Subuser }) => {
return (
<>
{showConfirmation &&
<ConfirmationModal
title={'Delete this subuser?'}
buttonText={'Yes, remove subuser'}
visible
showSpinnerOverlay={loading}
onConfirmed={() => doDeletion()}
onDismissed={() => setShowConfirmation(false)}
onModalDismissed={() => setShowConfirmation(false)}
>
Are you sure you wish to remove this subuser? They will have all access to this server revoked
immediately.
</ConfirmationModal>
}
<button
type={'button'}
aria-label={'Delete subuser'}

View file

@ -0,0 +1,15 @@
import React from 'react';
export interface ModalContextValues {
dismiss: () => void;
toggleSpinner: (visible?: boolean) => void;
}
const ModalContext = React.createContext<ModalContextValues>({
dismiss: () => null,
toggleSpinner: () => null,
});
ModalContext.displayName = 'ModalContext';
export default ModalContext;

View file

@ -0,0 +1,81 @@
import React from 'react';
import Modal, { ModalProps } from '@/components/elements/Modal';
import ModalContext from '@/context/ModalContext';
export interface AsModalProps {
visible: boolean;
onModalDismissed?: () => void;
}
type ExtendedModalProps = Omit<ModalProps, 'appear' | 'visible' | 'onDismissed'>;
interface State {
render: boolean;
visible: boolean;
showSpinnerOverlay: boolean;
}
function asModal (modalProps?: ExtendedModalProps) {
// eslint-disable-next-line @typescript-eslint/ban-types
return function <T extends object> (Component: React.ComponentType<T>) {
return class extends React.PureComponent <T & AsModalProps, State> {
static displayName = `asModal(${Component.displayName})`;
constructor (props: T & AsModalProps) {
super(props);
this.state = {
render: props.visible,
visible: props.visible,
showSpinnerOverlay: modalProps?.showSpinnerOverlay || false,
};
}
componentDidUpdate (prevProps: Readonly<T & AsModalProps>) {
if (prevProps.visible && !this.props.visible) {
// noinspection JSPotentiallyInvalidUsageOfThis
this.setState({ visible: false });
} else if (!prevProps.visible && this.props.visible) {
// noinspection JSPotentiallyInvalidUsageOfThis
this.setState({ render: true, visible: true });
}
}
dismiss = () => this.setState({ visible: false });
toggleSpinner = (value?: boolean) => this.setState({ showSpinnerOverlay: value || false });
render () {
return (
<ModalContext.Provider
value={{
dismiss: this.dismiss.bind(this),
toggleSpinner: this.toggleSpinner.bind(this),
}}
>
{
this.state.render ?
<Modal
appear
visible={this.state.visible}
showSpinnerOverlay={this.state.showSpinnerOverlay}
onDismissed={() => this.setState({ render: false }, () => {
if (typeof this.props.onModalDismissed === 'function') {
this.props.onModalDismissed();
}
})}
{...modalProps}
>
<Component {...this.props}/>
</Modal>
:
null
}
</ModalContext.Provider>
);
}
};
};
}
export default asModal;