New: UI Updates (Backup Restore in App, Profile Cloning)

UI Pulls from Sonarr
This commit is contained in:
Qstick 2018-01-14 17:11:37 -05:00
parent 80a5701b99
commit 744742b5ff
80 changed files with 2376 additions and 795 deletions

View file

@ -0,0 +1,11 @@
.type {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 20px;
}
.actions {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 70px;
}

View file

@ -0,0 +1,152 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
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,
time
} = this.props;
const {
isRestoreModalOpen,
isConfirmDeleteModalOpen
} = this.state;
let iconClassName = icons.SCHEDULED;
let iconTooltip = 'Scheduled';
if (type === 'manual') {
iconClassName = icons.INTERACTIVE;
iconTooltip = 'Manual';
} else if (type === 'update') {
iconClassName = icons.UPDATE;
iconTooltip = 'Before update';
}
return (
<TableRow key={id}>
<TableRowCell className={styles.type}>
{
<Icon
name={iconClassName}
title={iconTooltip}
/>
}
</TableRowCell>
<TableRowCell>
<Link
to={path}
noRouter={true}
>
{name}
</Link>
</TableRowCell>
<RelativeDateCellConnector
date={time}
/>
<TableRowCell className={styles.actions}>
<IconButton
name={icons.RESTORE}
onPress={this.onRestorePress}
/>
<IconButton
name={icons.DELETE}
onPress={this.onDeletePress}
/>
</TableRowCell>
<RestoreBackupModalConnector
isOpen={isRestoreModalOpen}
id={id}
name={name}
onModalClose={this.onRestoreModalClose}
/>
<ConfirmModal
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title="Delete Backup"
message={`Are you sure you want to delete the backup '${name}'?`}
confirmLabel="Delete"
onConfirm={this.onConfirmDeletePress}
onCancel={this.onConfirmDeleteModalClose}
/>
</TableRow>
);
}
}
BackupRow.propTypes = {
id: PropTypes.number.isRequired,
type: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
time: PropTypes.string.isRequired,
onDeleteBackupPress: PropTypes.func.isRequired
};
export default BackupRow;

View file

@ -1,5 +0,0 @@
.type {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 20px;
}

View file

@ -1,20 +1,16 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import styles from './Backups.css';
import BackupRow from './BackupRow';
import RestoreBackupModalConnector from './RestoreBackupModalConnector';
const columns = [
{
@ -30,24 +26,53 @@ const columns = [
name: 'time',
label: '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
onBackupPress,
onDeleteBackupPress
} = this.props;
const hasBackups = !isFetching && items.length > 0;
const noBackups = !isFetching && !items.length;
const hasBackups = isPopulated && !!items.length;
const noBackups = isPopulated && !items.length;
return (
<PageContent title="Backups">
@ -59,15 +84,26 @@ class Backups extends Component {
isSpinning={backupExecuting}
onPress={onBackupPress}
/>
<PageToolbarButton
label="Restore Backup"
iconName={icons.RESTORE}
onPress={this.onRestorePress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector>
{
isFetching &&
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to load backups</div>
}
{
noBackups &&
<div>No backups are available</div>
@ -89,42 +125,16 @@ class Backups extends Component {
time
} = item;
let iconClassName = icons.SCHEDULED;
let iconTooltip = 'Scheduled';
if (type === 'manual') {
iconClassName = icons.INTERACTIVE;
iconTooltip = 'Manual';
} else if (item === 'update') {
iconClassName = icons.UPDATE;
iconTooltip = 'Before update';
}
return (
<TableRow key={id}>
<TableRowCell className={styles.type}>
{
<Icon
name={iconClassName}
title={iconTooltip}
/>
}
</TableRowCell>
<TableRowCell>
<Link
to={path}
noRouter={true}
>
{name}
</Link>
</TableRowCell>
<RelativeDateCellConnector
className={styles.time}
date={time}
/>
</TableRow>
<BackupRow
key={id}
id={id}
type={type}
name={name}
path={path}
time={time}
onDeleteBackupPress={onDeleteBackupPress}
/>
);
})
}
@ -132,6 +142,11 @@ class Backups extends Component {
</Table>
}
</PageContentBodyConnector>
<RestoreBackupModalConnector
isOpen={this.state.isRestoreModalOpen}
onModalClose={this.onRestoreModalClose}
/>
</PageContent>
);
}
@ -140,9 +155,12 @@ class Backups extends Component {
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
onBackupPress: PropTypes.func.isRequired,
onDeleteBackupPress: PropTypes.func.isRequired
};
export default Backups;

View file

@ -4,7 +4,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import { fetchBackups } from 'Store/Actions/systemActions';
import { fetchBackups, deleteBackup } from 'Store/Actions/systemActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import Backups from './Backups';
@ -16,6 +16,8 @@ function createMapStateToProps() {
(backups, commands) => {
const {
isFetching,
isPopulated,
error,
items
} = backups;
@ -23,6 +25,8 @@ function createMapStateToProps() {
return {
isFetching,
isPopulated,
error,
items,
backupExecuting
};
@ -30,10 +34,23 @@ function createMapStateToProps() {
);
}
const mapDispatchToProps = {
fetchBackups,
executeCommand
};
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchBackups() {
dispatch(fetchBackups());
},
onDeleteBackupPress(id) {
dispatch(deleteBackup({ id }));
},
onBackupPress() {
dispatch(executeCommand({
name: commandNames.BACKUP
}));
}
};
}
class BackupsConnector extends Component {
@ -41,31 +58,21 @@ class BackupsConnector extends Component {
// Lifecycle
componentDidMount() {
this.props.fetchBackups();
this.props.dispatchFetchBackups();
}
componentDidUpdate(prevProps) {
if (prevProps.backupExecuting && !this.props.backupExecuting) {
this.props.fetchBackups();
this.props.dispatchFetchBackups();
}
}
//
// Listeners
onBackupPress = () => {
this.props.executeCommand({
name: commandNames.BACKUP
});
}
//
// Render
render() {
return (
<Backups
onBackupPress={this.onBackupPress}
{...this.props}
/>
);
@ -74,8 +81,7 @@ class BackupsConnector extends Component {
BackupsConnector.propTypes = {
backupExecuting: PropTypes.bool.isRequired,
fetchBackups: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
dispatchFetchBackups: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(BackupsConnector);
export default connect(createMapStateToProps, createMapDispatchToProps)(BackupsConnector);

View file

@ -0,0 +1,31 @@
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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<RestoreBackupModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
RestoreBackupModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RestoreBackupModal;

View file

@ -0,0 +1,15 @@
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);

View file

@ -0,0 +1,24 @@
.additionalInfo {
flex-grow: 1;
color: #777;
}
.steps {
margin-top: 20px;
}
.step {
display: flex;
font-size: $largeFontSize;
line-height: 20px;
}
.stepState {
margin-right: 8px;
}
@media only screen and (max-width: $breakpointSmall) {
composes: modalFooter from 'Components/Modal/ModalFooter.css';
flex-wrap: wrap;
}

View file

@ -0,0 +1,232 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import styles from './RestoreBackupModalContent.css';
function getErrorMessage(error) {
if (!error || !error.responseJSON || !error.responseJSON.message) {
return 'Error restoring backup';
}
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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Restore Backup
</ModalHeader>
<ModalBody>
{
!!id && `Would you like to restore the backup '${name}'?`
}
{
!id &&
<TextInput
type="file"
name="path"
value={path}
onChange={this.onPathChange}
/>
}
<div className={styles.steps}>
<div className={styles.step}>
<div className={styles.stepState}>
<Icon
size={20}
{...getStepIconProps(isRestoring, isRestored, restoreError)}
/>
</div>
<div>Restore</div>
</div>
<div className={styles.step}>
<div className={styles.stepState}>
<Icon
size={20}
{...getStepIconProps(isRestarting, isRestarted)}
/>
</div>
<div>Restart</div>
</div>
<div className={styles.step}>
<div className={styles.stepState}>
<Icon
size={20}
{...getStepIconProps(isReloading, false)}
/>
</div>
<div>Reload</div>
</div>
</div>
</ModalBody>
<ModalFooter>
<div className={styles.additionalInfo}>
Note: Lidarr will automatically restart and reload the UI during the restore process.
</div>
<Button onPress={onModalClose}>
Cancel
</Button>
<SpinnerButton
kind={kinds.WARNING}
isDisabled={isRestoreDisabled}
isSpinning={isRestoring}
onPress={this.onRestorePress}
>
Restore
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
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;

View file

@ -0,0 +1,37 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { restoreBackup, restart } 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);