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:
parent
d41b86f0ea
commit
c28cba92e2
14 changed files with 192 additions and 70 deletions
|
@ -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`}>
|
||||
|
|
38
resources/scripts/components/dashboard/ApiKeyModal.tsx
Normal file
38
resources/scripts/components/dashboard/ApiKeyModal.tsx
Normal 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);
|
|
@ -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: '' }}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 => (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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'}
|
||||
|
|
15
resources/scripts/context/ModalContext.ts
Normal file
15
resources/scripts/context/ModalContext.ts
Normal 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;
|
81
resources/scripts/hoc/asModal.tsx
Normal file
81
resources/scripts/hoc/asModal.tsx
Normal 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;
|
Loading…
Reference in a new issue