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>
<ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}> <ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}>
<SpinnerOverlay visible={loading}/> <SpinnerOverlay visible={loading}/>
{deleteIdentifier &&
<ConfirmationModal <ConfirmationModal
visible visible={!!deleteIdentifier}
title={'Confirm key deletion'} title={'Confirm key deletion'}
buttonText={'Yes, delete key'} buttonText={'Yes, delete key'}
onConfirmed={() => { onConfirmed={() => {
doDeletion(deleteIdentifier); doDeletion(deleteIdentifier);
setDeleteIdentifier(''); setDeleteIdentifier('');
}} }}
onDismissed={() => setDeleteIdentifier('')} onModalDismissed={() => setDeleteIdentifier('')}
> >
Are you sure you wish to delete this API key? All requests using it will immediately be Are you sure you wish to delete this API key? All requests using it will immediately be
invalidated and will fail. invalidated and will fail.
</ConfirmationModal> </ConfirmationModal>
}
{ {
keys.length === 0 ? keys.length === 0 ?
<p css={tw`text-center text-sm`}> <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 { Field, Form, Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup'; import { object, string } from 'yup';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import Modal from '@/components/elements/Modal';
import createApiKey from '@/api/account/createApiKey'; import createApiKey from '@/api/account/createApiKey';
import { Actions, useStoreActions } from 'easy-peasy'; import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
@ -13,6 +12,7 @@ import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import Input, { Textarea } from '@/components/elements/Input'; import Input, { Textarea } from '@/components/elements/Input';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
interface Values { interface Values {
description: string; description: string;
@ -44,29 +44,11 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
return ( return (
<> <>
<Modal <ApiKeyModal
visible={apiKey.length > 0} visible={apiKey.length > 0}
onDismissed={() => setApiKey('')} onModalDismissed={() => setApiKey('')}
closeOnEscape={false} apiKey={apiKey}
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>
<Formik <Formik
onSubmit={submit} onSubmit={submit}
initialValues={{ description: '', allowedIps: '' }} initialValues={{ description: '', allowedIps: '' }}

View file

@ -1,7 +1,8 @@
import React from 'react'; import React, { useContext, useEffect } from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
type Props = { type Props = {
title: string; title: string;
@ -9,26 +10,31 @@ type Props = {
children: string; children: string;
onConfirmed: () => void; onConfirmed: () => void;
showSpinnerOverlay?: boolean; showSpinnerOverlay?: boolean;
} & RequiredModalProps; };
const ConfirmationModal = ({ title, appear, children, visible, buttonText, onConfirmed, showSpinnerOverlay, onDismissed }: Props) => ( const ConfirmationModal = ({ title, children, buttonText, onConfirmed, showSpinnerOverlay }: Props) => {
<Modal const { dismiss, toggleSpinner } = useContext(ModalContext);
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>
);
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 }>` const Container = styled.div<{ timeout: number }>`
.fade-enter, .fade-exit { .fade-enter, .fade-exit, .fade-appear {
will-change: opacity; will-change: opacity;
} }
.fade-enter { .fade-enter, .fade-appear {
${tw`opacity-0`}; ${tw`opacity-0`};
&.fade-enter-active { &.fade-enter-active, &.fade-appear-active {
${tw`opacity-100 transition-opacity ease-in`}; ${tw`opacity-100 transition-opacity ease-in`};
transition-duration: ${props => props.timeout}ms; transition-duration: ${props => props.timeout}ms;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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