diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx index fbe4a15bb..3ed7bcde0 100644 --- a/frontend/src/App/AppRoutes.tsx +++ b/frontend/src/App/AppRoutes.tsx @@ -24,7 +24,7 @@ import QualityConnector from 'Settings/Quality/QualityConnector'; import Settings from 'Settings/Settings'; import TagSettings from 'Settings/Tags/TagSettings'; import UISettingsConnector from 'Settings/UI/UISettingsConnector'; -import BackupsConnector from 'System/Backup/BackupsConnector'; +import Backups from 'System/Backup/Backups'; import LogsTableConnector from 'System/Events/LogsTableConnector'; import Logs from 'System/Logs/Logs'; import Status from 'System/Status/Status'; @@ -147,7 +147,7 @@ function AppRoutes() { - + diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index e3183833b..0f1aa5dee 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -62,6 +62,7 @@ export interface AppSectionState { isConnected: boolean; isDisconnected: boolean; isReconnecting: boolean; + isRestarting: boolean; isSidebarVisible: boolean; version: string; prevVersion?: string; diff --git a/frontend/src/App/State/BackupAppState.ts b/frontend/src/App/State/BackupAppState.ts new file mode 100644 index 000000000..cdb20f390 --- /dev/null +++ b/frontend/src/App/State/BackupAppState.ts @@ -0,0 +1,9 @@ +import Backup from 'typings/Backup'; +import AppSectionState, { Error } from './AppSectionState'; + +interface BackupAppState extends AppSectionState { + isRestoring: boolean; + restoreError?: Error; +} + +export default BackupAppState; diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts index 1161f0e1e..c4f63da67 100644 --- a/frontend/src/App/State/SystemAppState.ts +++ b/frontend/src/App/State/SystemAppState.ts @@ -4,6 +4,7 @@ import SystemStatus from 'typings/SystemStatus'; import Task from 'typings/Task'; import Update from 'typings/Update'; import AppSectionState, { AppSectionItemState } from './AppSectionState'; +import BackupAppState from './BackupAppState'; export type DiskSpaceAppState = AppSectionState; export type HealthAppState = AppSectionState; @@ -12,6 +13,7 @@ export type TaskAppState = AppSectionState; export type UpdateAppState = AppSectionState; interface SystemAppState { + backups: BackupAppState; diskSpace: DiskSpaceAppState; health: HealthAppState; status: SystemStatusAppState; diff --git a/frontend/src/Components/Form/NumberInput.tsx b/frontend/src/Components/Form/NumberInput.tsx index 283a56a3b..896e13022 100644 --- a/frontend/src/Components/Form/NumberInput.tsx +++ b/frontend/src/Components/Form/NumberInput.tsx @@ -24,8 +24,7 @@ function parseValue( return newValue; } -interface NumberInputProps - extends Omit, 'value' | 'onChange'> { +interface NumberInputProps extends Omit { value?: number | null; min?: number; max?: number; diff --git a/frontend/src/Components/Form/PasswordInput.tsx b/frontend/src/Components/Form/PasswordInput.tsx index 776c2b913..98da46e7e 100644 --- a/frontend/src/Components/Form/PasswordInput.tsx +++ b/frontend/src/Components/Form/PasswordInput.tsx @@ -7,7 +7,7 @@ function onCopy(e: SyntheticEvent) { e.nativeEvent.stopImmediatePropagation(); } -function PasswordInput(props: TextInputProps) { +function PasswordInput(props: TextInputProps) { return ; } diff --git a/frontend/src/Components/Form/TextInput.tsx b/frontend/src/Components/Form/TextInput.tsx index 647b9f2ac..5728294b1 100644 --- a/frontend/src/Components/Form/TextInput.tsx +++ b/frontend/src/Components/Form/TextInput.tsx @@ -7,13 +7,11 @@ import React, { useEffect, useRef, } from 'react'; -import { InputType } from 'Helpers/Props/inputTypes'; import { FileInputChanged, InputChanged } from 'typings/inputs'; import styles from './TextInput.css'; -export interface TextInputProps { +interface CommonTextInputProps { className?: string; - type?: InputType; readOnly?: boolean; autoFocus?: boolean; placeholder?: string; @@ -25,14 +23,23 @@ export interface TextInputProps { step?: number; min?: number; max?: number; - onChange: (change: InputChanged | FileInputChanged) => void; onFocus?: (event: FocusEvent) => void; onBlur?: (event: SyntheticEvent) => void; onCopy?: (event: SyntheticEvent) => void; onSelectionChange?: (start: number | null, end: number | null) => void; } -function TextInput({ +export interface TextInputProps extends CommonTextInputProps { + type?: 'date' | 'number' | 'password' | 'text'; + onChange: (change: InputChanged) => void; +} + +export interface FileInputProps extends CommonTextInputProps { + type: 'file'; + onChange: (change: FileInputChanged) => void; +} + +function TextInput({ className = styles.input, type = 'text', readOnly = false, @@ -51,7 +58,7 @@ function TextInput({ onCopy, onChange, onSelectionChange, -}: TextInputProps) { +}: TextInputProps | FileInputProps): JSX.Element { const inputRef = useRef(null); const selectionTimeout = useRef>(); const selectionStart = useRef(); diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js deleted file mode 100644 index c82dfeef3..000000000 --- a/frontend/src/System/Backup/BackupRow.js +++ /dev/null @@ -1,164 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import ConfirmModal from 'Components/Modal/ConfirmModal'; -import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRow from 'Components/Table/TableRow'; -import { icons, kinds } from 'Helpers/Props'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import RestoreBackupModalConnector from './RestoreBackupModalConnector'; -import styles from './BackupRow.css'; - -class BackupRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isRestoreModalOpen: false, - isConfirmDeleteModalOpen: false - }; - } - - // - // Listeners - - onRestorePress = () => { - this.setState({ isRestoreModalOpen: true }); - }; - - onRestoreModalClose = () => { - this.setState({ isRestoreModalOpen: false }); - }; - - onDeletePress = () => { - this.setState({ isConfirmDeleteModalOpen: true }); - }; - - onConfirmDeleteModalClose = () => { - this.setState({ isConfirmDeleteModalOpen: false }); - }; - - onConfirmDeletePress = () => { - const { - id, - onDeleteBackupPress - } = this.props; - - this.setState({ isConfirmDeleteModalOpen: false }, () => { - onDeleteBackupPress(id); - }); - }; - - // - // Render - - render() { - const { - id, - type, - name, - path, - size, - time - } = this.props; - - const { - isRestoreModalOpen, - isConfirmDeleteModalOpen - } = this.state; - - let iconClassName = icons.SCHEDULED; - let iconTooltip = translate('Scheduled'); - - if (type === 'manual') { - iconClassName = icons.INTERACTIVE; - iconTooltip = translate('Manual'); - } else if (type === 'update') { - iconClassName = icons.UPDATE; - iconTooltip = translate('BeforeUpdate'); - } - - return ( - - - { - - } - - - - - {name} - - - - - {formatBytes(size)} - - - - - - - - - - - - - - - ); - } -} - -BackupRow.propTypes = { - id: PropTypes.number.isRequired, - type: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - time: PropTypes.string.isRequired, - onDeleteBackupPress: PropTypes.func.isRequired -}; - -export default BackupRow; diff --git a/frontend/src/System/Backup/BackupRow.tsx b/frontend/src/System/Backup/BackupRow.tsx new file mode 100644 index 000000000..c368559f3 --- /dev/null +++ b/frontend/src/System/Backup/BackupRow.tsx @@ -0,0 +1,127 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRow from 'Components/Table/TableRow'; +import { icons, kinds } from 'Helpers/Props'; +import { deleteBackup } from 'Store/Actions/systemActions'; +import { BackupType } from 'typings/Backup'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import RestoreBackupModal from './RestoreBackupModal'; +import styles from './BackupRow.css'; + +interface BackupRowProps { + id: number; + type: BackupType; + name: string; + path: string; + size: number; + time: string; +} + +function BackupRow({ id, type, name, path, size, time }: BackupRowProps) { + const dispatch = useDispatch(); + const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false); + const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] = + useState(false); + + const { iconClassName, iconTooltip } = useMemo(() => { + if (type === 'manual') { + return { + iconClassName: icons.INTERACTIVE, + iconTooltip: translate('Manual'), + }; + } + + if (type === 'update') { + return { + iconClassName: icons.UPDATE, + iconTooltip: translate('BeforeUpdate'), + }; + } + + return { + iconClassName: icons.SCHEDULED, + iconTooltip: translate('Scheduled'), + }; + }, [type]); + + const handleRestorePress = useCallback(() => { + setIsRestoreModalOpen(true); + }, []); + + const handleRestoreModalClose = useCallback(() => { + setIsRestoreModalOpen(false); + }, []); + + const handleDeletePress = useCallback(() => { + setIsConfirmDeleteModalOpen(true); + }, []); + + const handleConfirmDeleteModalClose = useCallback(() => { + setIsConfirmDeleteModalOpen(false); + }, []); + + const handleConfirmDeletePress = useCallback(() => { + dispatch(deleteBackup({ id })); + setIsConfirmDeleteModalOpen(false); + }, [id, dispatch]); + + return ( + + + + + + + + {name} + + + + {formatBytes(size)} + + + + + + + + + + + + + + ); +} + +export default BackupRow; diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js deleted file mode 100644 index ede2f97f6..000000000 --- a/frontend/src/System/Backup/Backups.js +++ /dev/null @@ -1,179 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import { icons, kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import BackupRow from './BackupRow'; -import RestoreBackupModalConnector from './RestoreBackupModalConnector'; - -const columns = [ - { - name: 'type', - isVisible: true - }, - { - name: 'name', - label: () => translate('Name'), - isVisible: true - }, - { - name: 'size', - label: () => translate('Size'), - isVisible: true - }, - { - name: 'time', - label: () => translate('Time'), - isVisible: true - }, - { - name: 'actions', - isVisible: true - } -]; - -class Backups extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isRestoreModalOpen: false - }; - } - - // - // Listeners - - onRestorePress = () => { - this.setState({ isRestoreModalOpen: true }); - }; - - onRestoreModalClose = () => { - this.setState({ isRestoreModalOpen: false }); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - items, - backupExecuting, - onBackupPress, - onDeleteBackupPress - } = this.props; - - const hasBackups = isPopulated && !!items.length; - const noBackups = isPopulated && !items.length; - - return ( - - - - - - - - - - - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && - - {translate('BackupsLoadError')} - - } - - { - noBackups && - - {translate('NoBackupsAreAvailable')} - - } - - { - hasBackups && - - - { - items.map((item) => { - const { - id, - type, - name, - path, - size, - time - } = item; - - return ( - - ); - }) - } - - - } - - - - - ); - } - -} - -Backups.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.array.isRequired, - backupExecuting: PropTypes.bool.isRequired, - onBackupPress: PropTypes.func.isRequired, - onDeleteBackupPress: PropTypes.func.isRequired -}; - -export default Backups; diff --git a/frontend/src/System/Backup/Backups.tsx b/frontend/src/System/Backup/Backups.tsx new file mode 100644 index 000000000..c2313a8a1 --- /dev/null +++ b/frontend/src/System/Backup/Backups.tsx @@ -0,0 +1,156 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import Column from 'Components/Table/Column'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons, kinds } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchBackups } from 'Store/Actions/systemActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import translate from 'Utilities/String/translate'; +import BackupRow from './BackupRow'; +import RestoreBackupModal from './RestoreBackupModal'; + +const columns: Column[] = [ + { + name: 'type', + label: '', + isVisible: true, + }, + { + name: 'name', + label: () => translate('Name'), + isVisible: true, + }, + { + name: 'size', + label: () => translate('Size'), + isVisible: true, + }, + { + name: 'time', + label: () => translate('Time'), + isVisible: true, + }, + { + name: 'actions', + label: '', + isVisible: true, + }, +]; + +function Backups() { + const dispatch = useDispatch(); + + const { isFetching, isPopulated, error, items } = useSelector( + (state: AppState) => state.system.backups + ); + + const isBackupExecuting = useSelector( + createCommandExecutingSelector(commandNames.BACKUP) + ); + + const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false); + + const wasBackupExecuting = usePrevious(isBackupExecuting); + const hasBackups = isPopulated && !!items.length; + const noBackups = isPopulated && !items.length; + + const handleBackupPress = useCallback(() => { + dispatch( + executeCommand({ + name: commandNames.BACKUP, + }) + ); + }, [dispatch]); + + const handleRestorePress = useCallback(() => { + setIsRestoreModalOpen(true); + }, []); + + const handleRestoreModalClose = useCallback(() => { + setIsRestoreModalOpen(false); + }, []); + + useEffect(() => { + dispatch(fetchBackups()); + }, [dispatch]); + + useEffect(() => { + if (wasBackupExecuting && !isBackupExecuting) { + dispatch(fetchBackups()); + } + }, [isBackupExecuting, wasBackupExecuting, dispatch]); + + return ( + + + + + + + + + + + {isFetching && !isPopulated ? : null} + + {!isFetching && !!error ? ( + {translate('BackupsLoadError')} + ) : null} + + {noBackups ? ( + {translate('NoBackupsAreAvailable')} + ) : null} + + {hasBackups ? ( + + + {items.map((item) => { + const { id, type, name, path, size, time } = item; + + return ( + + ); + })} + + + ) : null} + + + + + ); +} + +export default Backups; diff --git a/frontend/src/System/Backup/BackupsConnector.js b/frontend/src/System/Backup/BackupsConnector.js deleted file mode 100644 index 1353b6196..000000000 --- a/frontend/src/System/Backup/BackupsConnector.js +++ /dev/null @@ -1,84 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { deleteBackup, fetchBackups } from 'Store/Actions/systemActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import Backups from './Backups'; - -function createMapStateToProps() { - return createSelector( - (state) => state.system.backups, - createCommandExecutingSelector(commandNames.BACKUP), - (backups, backupExecuting) => { - const { - isFetching, - isPopulated, - error, - items - } = backups; - - return { - isFetching, - isPopulated, - error, - items, - backupExecuting - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - dispatchFetchBackups() { - dispatch(fetchBackups()); - }, - - onDeleteBackupPress(id) { - dispatch(deleteBackup({ id })); - }, - - onBackupPress() { - dispatch(executeCommand({ - name: commandNames.BACKUP - })); - } - }; -} - -class BackupsConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchBackups(); - } - - componentDidUpdate(prevProps) { - if (prevProps.backupExecuting && !this.props.backupExecuting) { - this.props.dispatchFetchBackups(); - } - } - - // - // Render - - render() { - return ( - - ); - } -} - -BackupsConnector.propTypes = { - backupExecuting: PropTypes.bool.isRequired, - dispatchFetchBackups: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(BackupsConnector); diff --git a/frontend/src/System/Backup/RestoreBackupModal.js b/frontend/src/System/Backup/RestoreBackupModal.js deleted file mode 100644 index 48dad4d2a..000000000 --- a/frontend/src/System/Backup/RestoreBackupModal.js +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import RestoreBackupModalContentConnector from './RestoreBackupModalContentConnector'; - -function RestoreBackupModal(props) { - const { - isOpen, - onModalClose, - ...otherProps - } = props; - - return ( - - - - ); -} - -RestoreBackupModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default RestoreBackupModal; diff --git a/frontend/src/System/Backup/RestoreBackupModal.tsx b/frontend/src/System/Backup/RestoreBackupModal.tsx new file mode 100644 index 000000000..b2cee204c --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModal.tsx @@ -0,0 +1,36 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { clearRestoreBackup } from 'Store/Actions/systemActions'; +import RestoreBackupModalContent, { + RestoreBackupModalContentProps, +} from './RestoreBackupModalContent'; + +interface RestoreBackupModalProps extends RestoreBackupModalContentProps { + isOpen: boolean; + onModalClose: () => void; +} + +function RestoreBackupModal({ + isOpen, + onModalClose, + ...otherProps +}: RestoreBackupModalProps) { + const dispatch = useDispatch(); + + const handleModalClose = useCallback(() => { + dispatch(clearRestoreBackup()); + onModalClose(); + }, [dispatch, onModalClose]); + + return ( + + + + ); +} + +export default RestoreBackupModal; diff --git a/frontend/src/System/Backup/RestoreBackupModalConnector.js b/frontend/src/System/Backup/RestoreBackupModalConnector.js deleted file mode 100644 index 98cbcd11b..000000000 --- a/frontend/src/System/Backup/RestoreBackupModalConnector.js +++ /dev/null @@ -1,15 +0,0 @@ -import { connect } from 'react-redux'; -import { clearRestoreBackup } from 'Store/Actions/systemActions'; -import RestoreBackupModal from './RestoreBackupModal'; - -function createMapDispatchToProps(dispatch, props) { - return { - onModalClose() { - dispatch(clearRestoreBackup()); - - props.onModalClose(); - } - }; -} - -export default connect(null, createMapDispatchToProps)(RestoreBackupModal); diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.css b/frontend/src/System/Backup/RestoreBackupModalContent.css index 2775e8e08..f25df9546 100644 --- a/frontend/src/System/Backup/RestoreBackupModalContent.css +++ b/frontend/src/System/Backup/RestoreBackupModalContent.css @@ -9,6 +9,7 @@ .step { display: flex; + margin-bottom: 10px; font-size: $largeFontSize; line-height: 20px; } diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.js b/frontend/src/System/Backup/RestoreBackupModalContent.js deleted file mode 100644 index 9b5daa9f4..000000000 --- a/frontend/src/System/Backup/RestoreBackupModalContent.js +++ /dev/null @@ -1,241 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextInput from 'Components/Form/TextInput'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { icons, kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './RestoreBackupModalContent.css'; - -function getErrorMessage(error) { - if (!error || !error.responseJSON || !error.responseJSON.message) { - return translate('ErrorRestoringBackup'); - } - - return error.responseJSON.message; -} - -function getStepIconProps(isExecuting, hasExecuted, error) { - if (isExecuting) { - return { - name: icons.SPINNER, - isSpinning: true - }; - } - - if (hasExecuted) { - return { - name: icons.CHECK, - kind: kinds.SUCCESS - }; - } - - if (error) { - return { - name: icons.FATAL, - kinds: kinds.DANGER, - title: getErrorMessage(error) - }; - } - - return { - name: icons.PENDING - }; -} - -class RestoreBackupModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - file: null, - path: '', - isRestored: false, - isRestarted: false, - isReloading: false - }; - } - - componentDidUpdate(prevProps) { - const { - isRestoring, - restoreError, - isRestarting, - dispatchRestart - } = this.props; - - if (prevProps.isRestoring && !isRestoring && !restoreError) { - this.setState({ isRestored: true }, () => { - dispatchRestart(); - }); - } - - if (prevProps.isRestarting && !isRestarting) { - this.setState({ - isRestarted: true, - isReloading: true - }, () => { - location.reload(); - }); - } - } - - // - // Listeners - - onPathChange = ({ value, files }) => { - this.setState({ - file: files[0], - path: value - }); - }; - - onRestorePress = () => { - const { - id, - onRestorePress - } = this.props; - - onRestorePress({ - id, - file: this.state.file - }); - }; - - // - // Render - - render() { - const { - id, - name, - isRestoring, - restoreError, - isRestarting, - onModalClose - } = this.props; - - const { - path, - isRestored, - isRestarted, - isReloading - } = this.state; - - const isRestoreDisabled = ( - (!id && !path) || - isRestoring || - isRestarting || - isReloading - ); - - return ( - - - Restore Backup - - - - { - !!id && translate('WouldYouLikeToRestoreBackup', { - name - }) - } - - { - !id && - - } - - - - - - - - - {translate('Restore')} - - - - - - - - - - {translate('Restart')} - - - - - - - - - - {translate('Reload')} - - - - - - - - {translate('RestartReloadNote')} - - - - {translate('Cancel')} - - - - {translate('Restore')} - - - - ); - } -} - -RestoreBackupModalContent.propTypes = { - id: PropTypes.number, - name: PropTypes.string, - path: PropTypes.string, - isRestoring: PropTypes.bool.isRequired, - restoreError: PropTypes.object, - isRestarting: PropTypes.bool.isRequired, - dispatchRestart: PropTypes.func.isRequired, - onRestorePress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default RestoreBackupModalContent; diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.tsx b/frontend/src/System/Backup/RestoreBackupModalContent.tsx new file mode 100644 index 000000000..29e9fa29d --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContent.tsx @@ -0,0 +1,198 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Error } from 'App/State/AppSectionState'; +import AppState from 'App/State/AppState'; +import TextInput from 'Components/Form/TextInput'; +import Icon, { IconName, IconProps } from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons, kinds } from 'Helpers/Props'; +import { restart, restoreBackup } from 'Store/Actions/systemActions'; +import { FileInputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import styles from './RestoreBackupModalContent.css'; + +function getErrorMessage(error: Error) { + if ( + !error || + !error.responseJSON || + !('message' in error.responseJSON) || + !error.responseJSON.message + ) { + return translate('ErrorRestoringBackup'); + } + + return error.responseJSON.message; +} + +function getStepIconProps( + isExecuting: boolean, + hasExecuted: boolean, + error?: Error +): { + name: IconName; + kind?: IconProps['kind']; + title?: string; + isSpinning?: boolean; +} { + if (isExecuting) { + return { + name: icons.SPINNER, + isSpinning: true, + }; + } + + if (hasExecuted) { + return { + name: icons.CHECK, + kind: 'success', + }; + } + + if (error) { + return { + name: icons.FATAL, + kind: 'danger', + title: getErrorMessage(error), + }; + } + + return { + name: icons.PENDING, + }; +} + +export interface RestoreBackupModalContentProps { + id?: number; + name?: string; + onModalClose: () => void; +} + +function RestoreBackupModalContent({ + id, + name, + onModalClose, +}: RestoreBackupModalContentProps) { + const { isRestoring, restoreError } = useSelector( + (state: AppState) => state.system.backups + ); + + const { isRestarting } = useSelector((state: AppState) => state.app); + + const dispatch = useDispatch(); + const [path, setPath] = useState(''); + const [file, setFile] = useState(null); + const [isRestored, setIsRestored] = useState(false); + const [isRestarted, setIsRestarted] = useState(false); + const [isReloading, setIsReloading] = useState(false); + const wasRestoring = usePrevious(isRestoring); + const wasRestarting = usePrevious(isRestarting); + + const isRestoreDisabled = + (!id && !path) || isRestoring || isRestarting || isReloading; + + const handlePathChange = useCallback(({ value, files }: FileInputChanged) => { + if (!files?.length) { + return; + } + + setPath(value); + setFile(files[0]); + }, []); + + const handleRestorePress = useCallback(() => { + dispatch(restoreBackup({ id, file })); + }, [id, file, dispatch]); + + useEffect(() => { + if (wasRestoring && !isRestoring && !restoreError) { + setIsRestored(true); + dispatch(restart()); + } + }, [isRestoring, wasRestoring, restoreError, dispatch]); + + useEffect(() => { + if (wasRestarting && !isRestarting) { + setIsRestarted(true); + setIsReloading(true); + window.location.reload(); + } + }, [isRestarting, wasRestarting, dispatch]); + + return ( + + Restore Backup + + + {id && name ? ( + translate('WouldYouLikeToRestoreBackup', { + name, + }) + ) : ( + + )} + + + + + + + + {translate('Restore')} + + + + + + + + {translate('Restart')} + + + + + + + + {translate('Reload')} + + + + + + + {translate('RestartReloadNote')} + + + {translate('Cancel')} + + + {translate('Restore')} + + + + ); +} + +export default RestoreBackupModalContent; diff --git a/frontend/src/System/Backup/RestoreBackupModalContentConnector.js b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js deleted file mode 100644 index d408d0f50..000000000 --- a/frontend/src/System/Backup/RestoreBackupModalContentConnector.js +++ /dev/null @@ -1,37 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { restart, restoreBackup } from 'Store/Actions/systemActions'; -import RestoreBackupModalContent from './RestoreBackupModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.system.backups, - (state) => state.app.isRestarting, - (backups, isRestarting) => { - const { - isRestoring, - restoreError - } = backups; - - return { - isRestoring, - restoreError, - isRestarting - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onRestorePress(payload) { - dispatch(restoreBackup(payload)); - }, - - dispatchRestart() { - dispatch(restart()); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(RestoreBackupModalContent); diff --git a/frontend/src/typings/Backup.ts b/frontend/src/typings/Backup.ts new file mode 100644 index 000000000..9200fce90 --- /dev/null +++ b/frontend/src/typings/Backup.ts @@ -0,0 +1,13 @@ +import ModelBase from 'App/ModelBase'; + +export type BackupType = 'manual' | 'scheduled' | 'update'; + +interface Backup extends ModelBase { + name: string; + path: string; + type: BackupType; + size: number; + time: string; +} + +export default Backup;