Modal cleanup, begin transitioning towards the new dialog

This commit is contained in:
DaneEveritt 2022-06-20 11:17:33 -04:00
parent 3834aca3fe
commit 7dd74ecc9d
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
10 changed files with 121 additions and 96 deletions

View File

@ -36,6 +36,7 @@ return [
], ],
], ],
'server' => [ 'server' => [
'reinstall' => 'Reinstalled server',
'backup' => [ 'backup' => [
'download' => 'Downloaded the :name backup', 'download' => 'Downloaded the :name backup',
'delete' => 'Deleted the :name backup', 'delete' => 'Deleted the :name backup',
@ -88,7 +89,6 @@ return [
], ],
'settings' => [ 'settings' => [
'rename' => 'Renamed the server from :old to :new', 'rename' => 'Renamed the server from :old to :new',
'reinstall' => 'Triggered a server reinstall',
], ],
'startup' => [ 'startup' => [
'edit' => 'Changed the :variable variable from ":old" to ":new"', 'edit' => 'Changed the :variable variable from ":old" to ":new"',

View File

@ -5,46 +5,42 @@ import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import deleteApiKey from '@/api/account/deleteApiKey'; import deleteApiKey from '@/api/account/deleteApiKey';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import { httpErrorToHuman } from '@/api/http';
import { format } from 'date-fns'; import { format } from 'date-fns';
import PageContentBlock from '@/components/elements/PageContentBlock'; import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox'; import GreyRowBox from '@/components/elements/GreyRowBox';
import { Dialog } from '@/components/elements/dialog';
import { useFlashKey } from '@/plugins/useFlash';
import Code from '@/components/elements/Code';
export default () => { export default () => {
const [ deleteIdentifier, setDeleteIdentifier ] = useState(''); const [ deleteIdentifier, setDeleteIdentifier ] = useState('');
const [ keys, setKeys ] = useState<ApiKey[]>([]); const [ keys, setKeys ] = useState<ApiKey[]>([]);
const [ loading, setLoading ] = useState(true); const [ loading, setLoading ] = useState(true);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const { clearAndAddHttpError } = useFlashKey('account');
useEffect(() => { useEffect(() => {
clearFlashes('account');
getApiKeys() getApiKeys()
.then(keys => setKeys(keys)) .then(keys => setKeys(keys))
.then(() => setLoading(false)) .then(() => setLoading(false))
.catch(error => { .catch(error => clearAndAddHttpError(error));
console.error(error);
addError({ key: 'account', message: httpErrorToHuman(error) });
});
}, []); }, []);
const doDeletion = (identifier: string) => { const doDeletion = (identifier: string) => {
setLoading(true); setLoading(true);
clearFlashes('account');
clearAndAddHttpError();
deleteApiKey(identifier) deleteApiKey(identifier)
.then(() => setKeys(s => ([ .then(() => setKeys(s => ([
...(s || []).filter(key => key.identifier !== identifier), ...(s || []).filter(key => key.identifier !== identifier),
]))) ])))
.catch(error => { .catch(error => clearAndAddHttpError(error))
console.error(error); .then(() => {
addError({ key: 'account', message: httpErrorToHuman(error) }); setLoading(false);
}) setDeleteIdentifier('');
.then(() => setLoading(false)); });
}; };
return ( return (
@ -56,19 +52,15 @@ export default () => {
</ContentBox> </ContentBox>
<ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}> <ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
<SpinnerOverlay visible={loading}/> <SpinnerOverlay visible={loading}/>
<ConfirmationModal <Dialog.Confirm
visible={!!deleteIdentifier} title={'Delete API Key'}
title={'Confirm key deletion'} confirm={'Delete Key'}
buttonText={'Yes, delete key'} open={!!deleteIdentifier}
onConfirmed={() => { onClose={() => setDeleteIdentifier('')}
doDeletion(deleteIdentifier); onConfirmed={() => doDeletion(deleteIdentifier)}
setDeleteIdentifier('');
}}
onModalDismissed={() => setDeleteIdentifier('')}
> >
Are you sure you wish to delete this API key? All requests using it will immediately be All requests using the <Code>{deleteIdentifier}</Code> key will be invalidated.
invalidated and will fail. </Dialog.Confirm>
</ConfirmationModal>
{ {
keys.length === 0 ? keys.length === 0 ?
<p css={tw`text-center text-sm`}> <p css={tw`text-center text-sm`}>

View File

@ -55,7 +55,7 @@ export default () => {
{format(key.createdAt, 'MMM do, yyyy HH:mm')} {format(key.createdAt, 'MMM do, yyyy HH:mm')}
</p> </p>
</div> </div>
<DeleteSSHKeyButton fingerprint={key.fingerprint} /> <DeleteSSHKeyButton name={key.name} fingerprint={key.fingerprint} />
</GreyRowBox> </GreyRowBox>
)) ))
} }

View File

@ -3,10 +3,11 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useFlashKey } from '@/plugins/useFlash'; import { useFlashKey } from '@/plugins/useFlash';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import { deleteSSHKey, useSSHKeys } from '@/api/account/ssh-keys'; import { deleteSSHKey, useSSHKeys } from '@/api/account/ssh-keys';
import { Dialog } from '@/components/elements/dialog';
import Code from '@/components/elements/Code';
export default ({ fingerprint }: { fingerprint: string }) => { export default ({ name, fingerprint }: { name: string; fingerprint: string }) => {
const { clearAndAddHttpError } = useFlashKey('account'); const { clearAndAddHttpError } = useFlashKey('account');
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
const { mutate } = useSSHKeys(); const { mutate } = useSSHKeys();
@ -19,22 +20,22 @@ export default ({ fingerprint }: { fingerprint: string }) => {
deleteSSHKey(fingerprint), deleteSSHKey(fingerprint),
]) ])
.catch((error) => { .catch((error) => {
mutate(undefined, true); mutate(undefined, true).catch(console.error);
clearAndAddHttpError(error); clearAndAddHttpError(error);
}); });
}; };
return ( return (
<> <>
<ConfirmationModal <Dialog.Confirm
visible={visible} open={visible}
title={'Confirm Key Deletion'} title={'Delete SSH Key'}
buttonText={'Yes, Delete SSH Key'} confirm={'Delete Key'}
onConfirmed={onClick} onConfirmed={onClick}
onModalDismissed={() => setVisible(false)} onClose={() => setVisible(false)}
> >
Are you sure you wish to delete this SSH key? Removing the <Code>{name}</Code> SSH key will invalidate its usage across the Panel.
</ConfirmationModal> </Dialog.Confirm>
<button css={tw`ml-4 p-2 text-sm`} onClick={() => setVisible(true)}> <button css={tw`ml-4 p-2 text-sm`} onClick={() => setVisible(true)}>
<FontAwesomeIcon <FontAwesomeIcon
icon={faTrashAlt} icon={faTrashAlt}

View File

@ -0,0 +1,18 @@
import React from 'react';
import classNames from 'classnames';
interface CodeProps {
dark?: boolean | undefined;
children: React.ReactChild | React.ReactFragment | React.ReactPortal;
}
export default ({ dark, children }: CodeProps) => (
<code
className={classNames('font-mono text-sm px-2 py-1 rounded', {
'bg-neutral-700': !dark,
'bg-neutral-900 text-gray-100': dark,
})}
>
{children}
</code>
);

View File

@ -87,7 +87,7 @@ export default ({ activity, children }: Props) => {
<span className={'text-gray-400'}>&nbsp;|&nbsp;</span> <span className={'text-gray-400'}>&nbsp;|&nbsp;</span>
<Tooltip <Tooltip
placement={'right'} placement={'right'}
content={format(activity.timestamp, 'MMM do, yyyy h:mma')} content={format(activity.timestamp, 'MMM do, yyyy H:mm:ss')}
> >
<span> <span>
{formatDistanceToNowStrict(activity.timestamp, { addSuffix: true })} {formatDistanceToNowStrict(activity.timestamp, { addSuffix: true })}

View File

@ -0,0 +1,22 @@
import React from 'react';
import { Dialog } from '@/components/elements/dialog/index';
import { DialogProps } from '@/components/elements/dialog/Dialog';
import { Button } from '@/components/elements/button/index';
type ConfirmationProps = Omit<DialogProps, 'description' | 'children'> & {
children: React.ReactNode;
confirm?: string | undefined;
onConfirmed: () => void;
}
export default ({ confirm = 'Okay', children, onConfirmed, ...props }: ConfirmationProps) => {
return (
<Dialog {...props} description={typeof children === 'string' ? children : undefined}>
{typeof children !== 'string' && children}
<Dialog.Buttons>
<Button.Text onClick={props.onClose}>Cancel</Button.Text>
<Button.Danger onClick={onConfirmed}>{confirm}</Button.Danger>
</Dialog.Buttons>
</Dialog>
);
};

View File

@ -5,13 +5,14 @@ import { XIcon } from '@heroicons/react/solid';
import DialogIcon from '@/components/elements/dialog/DialogIcon'; import DialogIcon from '@/components/elements/dialog/DialogIcon';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import classNames from 'classnames'; import classNames from 'classnames';
import ConfirmationDialog from '@/components/elements/dialog/ConfirmationDialog';
interface Props { export interface DialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
hideCloseIcon?: boolean; hideCloseIcon?: boolean;
title?: string; title?: string;
description?: string; description?: string | undefined;
children?: React.ReactNode; children?: React.ReactNode;
} }
@ -19,7 +20,7 @@ const DialogButtons = ({ children }: { children: React.ReactNode }) => (
<>{children}</> <>{children}</>
); );
const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }: Props) => { const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }: DialogProps) => {
const items = React.Children.toArray(children || []); const items = React.Children.toArray(children || []);
const [ buttons, icon, content ] = [ const [ buttons, icon, content ] = [
// @ts-expect-error // @ts-expect-error
@ -59,9 +60,9 @@ const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }:
> >
<div className={'flex p-6'}> <div className={'flex p-6'}>
{icon && <div className={'mr-4'}>{icon}</div>} {icon && <div className={'mr-4'}>{icon}</div>}
<div className={'flex-1 max-h-[70vh] overflow-y-scroll overflow-x-hidden'}> <div className={'flex-1 max-h-[70vh]'}>
{title && {title &&
<HDialog.Title className={'font-header text-xl font-medium mb-2 text-white pr-4'}> <HDialog.Title className={'font-header text-xl font-medium mb-2 text-gray-50 pr-4'}>
{title} {title}
</HDialog.Title> </HDialog.Title>
} }
@ -91,6 +92,10 @@ const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }:
); );
}; };
const _Dialog = Object.assign(Dialog, { Buttons: DialogButtons, Icon: DialogIcon }); const _Dialog = Object.assign(Dialog, {
Confirm: ConfirmationDialog,
Buttons: DialogButtons,
Icon: DialogIcon,
});
export default _Dialog; export default _Dialog;

View File

@ -13,7 +13,6 @@ import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import deleteBackup from '@/api/server/backups/deleteBackup'; import deleteBackup from '@/api/server/backups/deleteBackup';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import tw from 'twin.macro'; import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups'; import getServerBackups from '@/api/swr/getServerBackups';
@ -22,6 +21,7 @@ import { ServerContext } from '@/state/server';
import Input from '@/components/elements/Input'; import Input from '@/components/elements/Input';
import { restoreServerBackup } from '@/api/server/backups'; import { restoreServerBackup } from '@/api/server/backups';
import http, { httpErrorToHuman } from '@/api/http'; import http, { httpErrorToHuman } from '@/api/http';
import { Dialog } from '@/components/elements/dialog';
interface Props { interface Props {
backup: ServerBackup; backup: ServerBackup;
@ -103,35 +103,29 @@ export default ({ backup }: Props) => {
return ( return (
<> <>
<ConfirmationModal <Dialog.Confirm
visible={modal === 'unlock'} open={modal === 'unlock'}
title={'Unlock this backup?'} onClose={() => setModal('')}
title={`Unlock "${backup.name}"`}
onConfirmed={onLockToggle} onConfirmed={onLockToggle}
onModalDismissed={() => setModal('')}
buttonText={'Yes, unlock'}
> >
Are you sure you want to unlock this backup? It will no longer be protected from automated or This backup will no longer be protected from automated or accidental deletions.
accidental deletions. </Dialog.Confirm>
</ConfirmationModal> <Dialog.Confirm
<ConfirmationModal open={modal === 'restore'}
visible={modal === 'restore'} onClose={() => setModal('')}
title={'Restore this backup?'} confirm={'Restore'}
buttonText={'Restore backup'} title={`Restore "${backup.name}"`}
onConfirmed={() => doRestorationAction()} onConfirmed={() => doRestorationAction()}
onModalDismissed={() => setModal('')}
> >
<p css={tw`text-neutral-300`}> <p>
This server will be stopped in order to restore the backup. Once the backup has started you will Your server will be stopped. You will not be able to control the power state, access the file
not be able to control the server power state, access the file manager, or create additional backups manager, or create additional backups until completed.
until it has completed.
</p> </p>
<p css={tw`text-neutral-300 mt-4`}> <p css={tw`mt-4 -mb-2 bg-gray-700 p-3 rounded`}>
Are you sure you want to continue?
</p>
<p css={tw`mt-4 -mb-2 bg-neutral-900 p-3 rounded`}>
<label <label
htmlFor={'restore_truncate'} htmlFor={'restore_truncate'}
css={tw`text-base text-neutral-200 flex items-center cursor-pointer`} css={tw`text-base flex items-center cursor-pointer`}
> >
<Input <Input
type={'checkbox'} type={'checkbox'}
@ -141,27 +135,26 @@ export default ({ backup }: Props) => {
checked={truncate} checked={truncate}
onChange={() => setTruncate(s => !s)} onChange={() => setTruncate(s => !s)}
/> />
Remove all files and folders before restoring this backup. Delete all files before restoring backup.
</label> </label>
</p> </p>
</ConfirmationModal> </Dialog.Confirm>
<ConfirmationModal <Dialog.Confirm
visible={modal === 'delete'} title={`Delete "${backup.name}"`}
title={'Delete this backup?'} confirm={'Continue'}
buttonText={'Yes, delete backup'} open={modal === 'delete'}
onConfirmed={() => doDeletion()} onClose={() => setModal('')}
onModalDismissed={() => setModal('')} onConfirmed={doDeletion}
> >
Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot This is a permanent operation. The backup cannot be recovered once deleted.
be recovered once deleted. </Dialog.Confirm>
</ConfirmationModal>
<SpinnerOverlay visible={loading} fixed/> <SpinnerOverlay visible={loading} fixed/>
{backup.isSuccessful ? {backup.isSuccessful ?
<DropdownMenu <DropdownMenu
renderToggle={onClick => ( renderToggle={onClick => (
<button <button
onClick={onClick} onClick={onClick}
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`} css={tw`text-gray-200 transition-colors duration-150 hover:text-gray-100 p-2`}
> >
<FontAwesomeIcon icon={faEllipsisH}/> <FontAwesomeIcon icon={faEllipsisH}/>
</button> </button>
@ -203,7 +196,7 @@ export default ({ backup }: Props) => {
: :
<button <button
onClick={() => setModal('delete')} onClick={() => setModal('delete')}
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`} css={tw`text-gray-200 transition-colors duration-150 hover:text-gray-100 p-2`}
> >
<FontAwesomeIcon icon={faTrashAlt}/> <FontAwesomeIcon icon={faTrashAlt}/>
</button> </button>

View File

@ -1,23 +1,21 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import TitledGreyBox from '@/components/elements/TitledGreyBox'; import TitledGreyBox from '@/components/elements/TitledGreyBox';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import reinstallServer from '@/api/server/reinstallServer'; import reinstallServer from '@/api/server/reinstallServer';
import { Actions, useStoreActions } from 'easy-peasy'; import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import { Dialog } from '@/components/elements/dialog';
export default () => { export default () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const [ isSubmitting, setIsSubmitting ] = useState(false);
const [ modalVisible, setModalVisible ] = useState(false); const [ modalVisible, setModalVisible ] = useState(false);
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const reinstall = () => { const reinstall = () => {
clearFlashes('settings'); clearFlashes('settings');
setIsSubmitting(true);
reinstallServer(uuid) reinstallServer(uuid)
.then(() => { .then(() => {
addFlash({ addFlash({
@ -31,10 +29,7 @@ export default () => {
addFlash({ key: 'settings', type: 'error', message: httpErrorToHuman(error) }); addFlash({ key: 'settings', type: 'error', message: httpErrorToHuman(error) });
}) })
.then(() => { .then(() => setModalVisible(false));
setIsSubmitting(false);
setModalVisible(false);
});
}; };
useEffect(() => { useEffect(() => {
@ -43,17 +38,16 @@ export default () => {
return ( return (
<TitledGreyBox title={'Reinstall Server'} css={tw`relative`}> <TitledGreyBox title={'Reinstall Server'} css={tw`relative`}>
<ConfirmationModal <Dialog.Confirm
open={modalVisible}
title={'Confirm server reinstallation'} title={'Confirm server reinstallation'}
buttonText={'Yes, reinstall server'} confirm={'Yes, reinstall server'}
onClose={() => setModalVisible(false)}
onConfirmed={reinstall} onConfirmed={reinstall}
showSpinnerOverlay={isSubmitting}
visible={modalVisible}
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?
</ConfirmationModal> </Dialog.Confirm>
<p css={tw`text-sm`}> <p css={tw`text-sm`}>
Reinstalling your server will stop it, and then re-run the installation script that initially Reinstalling your server will stop it, and then re-run the installation script that initially
set it up.&nbsp; set it up.&nbsp;