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>
|
||||||
<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`}>
|
||||||
|
|
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 { 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: '' }}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 => (
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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?
|
||||||
|
|
|
@ -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'}
|
||||||
|
|
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