New: Book editor on author details page

pull/1335/head^2
ta264 3 years ago
parent bf852cadbe
commit 4887ed0d2f

@ -5,6 +5,7 @@ import DeleteAuthorModal from 'Author/Delete/DeleteAuthorModal';
import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector';
import AuthorHistoryTable from 'Author/History/AuthorHistoryTable';
import MonitoringOptionsModal from 'Author/MonitoringOptions/MonitoringOptionsModal';
import BookEditorFooter from 'Book/Editor/BookEditorFooter';
import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
@ -22,6 +23,7 @@ import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal';
@ -53,13 +55,56 @@ class AuthorDetails extends Component {
isDeleteAuthorModalOpen: false,
isInteractiveImportModalOpen: false,
isMonitorOptionsModalOpen: false,
isBookEditorActive: false,
allExpanded: false,
allCollapsed: false,
expandedState: {},
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
selectedTabIndex: 0
};
}
//
// Control
setSelectedState = (items) => {
const {
selectedState
} = this.state;
const newSelectedState = {};
items.forEach((item) => {
const isItemSelected = selectedState[item.id];
if (isItemSelected) {
newSelectedState[item.id] = isItemSelected;
} else {
newSelectedState[item.id] = false;
}
});
const selectedCount = getSelectedIds(newSelectedState).length;
const newStateCount = Object.keys(newSelectedState).length;
let isAllSelected = false;
let isAllUnselected = false;
if (selectedCount === 0) {
isAllUnselected = true;
} else if (selectedCount === newStateCount) {
isAllSelected = true;
}
this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected });
}
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
}
//
// Listeners
@ -114,6 +159,10 @@ class AuthorDetails extends Component {
this.setState({ isMonitorOptionsModalOpen: false });
}
onBookEditorTogglePress = () => {
this.setState({ isBookEditorActive: !this.state.isBookEditorActive });
}
onExpandAllPress = () => {
const {
allExpanded,
@ -137,6 +186,27 @@ class AuthorDetails extends Component {
});
}
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
}
onSelectAllPress = () => {
this.onSelectAllChange({ value: !this.state.allSelected });
}
onSelectedChange = (items, id, value, shiftKey = false) => {
this.setState((state) => {
return toggleSelected(state, items, id, value, shiftKey);
});
}
onSaveSelected = (changes) => {
this.props.onSaveSelected({
bookIds: this.getSelectedIds(),
...changes
});
}
onTabSelect = (index, lastIndex) => {
this.setState({ selectedTabIndex: index });
}
@ -165,6 +235,10 @@ class AuthorDetails extends Component {
nextAuthor,
onRefreshPress,
onSearchPress,
isSaving,
saveError,
isDeleting,
deleteError,
statistics
} = this.props;
@ -175,6 +249,9 @@ class AuthorDetails extends Component {
isDeleteAuthorModalOpen,
isInteractiveImportModalOpen,
isMonitorOptionsModalOpen,
isBookEditorActive,
allSelected,
selectedState,
allExpanded,
allCollapsed,
expandedState,
@ -189,6 +266,8 @@ class AuthorDetails extends Component {
expandIcon = icons.EXPAND;
}
const selectedBookIds = this.getSelectedIds();
return (
<PageContent title={authorName}>
<PageToolbar>
@ -252,6 +331,33 @@ class AuthorDetails extends Component {
iconName={icons.DELETE}
onPress={this.onDeleteAuthorPress}
/>
<PageToolbarSeparator />
{
isBookEditorActive ?
<PageToolbarButton
label={translate('BookList')}
iconName={icons.AUTHOR_CONTINUING}
onPress={this.onBookEditorTogglePress}
/> :
<PageToolbarButton
label={translate('BookEditor')}
iconName={icons.EDIT}
onPress={this.onBookEditorTogglePress}
/>
}
{
isBookEditorActive ?
<PageToolbarButton
label={allSelected ? translate('UnselectAll') : translate('SelectAll')}
iconName={icons.CHECK_SQUARE}
onPress={this.onSelectAllPress}
/> :
null
}
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
@ -377,7 +483,11 @@ class AuthorDetails extends Component {
<AuthorDetailsSeasonConnector
authorId={id}
isExpanded={true}
selectedState={selectedState}
onExpandPress={this.onExpandPress}
setSelectedState={this.setSelectedState}
onSelectedChange={this.onSelectedChange}
isBookEditorActive={isBookEditorActive}
/>
</TabPanel>
@ -422,7 +532,6 @@ class AuthorDetails extends Component {
</TabPanel>
</Tabs>
}
</div>
<div className={styles.metadataMessage}>
@ -474,6 +583,19 @@ class AuthorDetails extends Component {
onModalClose={this.onMonitorOptionsClose}
/>
</PageContentBody>
{
isBookEditorActive &&
<BookEditorFooter
bookIds={selectedBookIds}
selectedCount={selectedBookIds.length}
isSaving={isSaving}
saveError={saveError}
isDeleting={isDeleting}
deleteError={deleteError}
onSaveSelected={this.onSaveSelected}
/>
}
</PageContent>
);
}
@ -493,7 +615,6 @@ AuthorDetails.propTypes = {
images: PropTypes.arrayOf(PropTypes.object).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
isSaving: PropTypes.bool.isRequired,
isRefreshing: PropTypes.bool.isRequired,
isSearching: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
@ -510,13 +631,17 @@ AuthorDetails.propTypes = {
isSmallScreen: PropTypes.bool.isRequired,
onMonitorTogglePress: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
onSearchPress: PropTypes.func.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
onSaveSelected: PropTypes.func.isRequired
};
AuthorDetails.defaultProps = {
statistics: {},
tags: [],
isSaving: false
tags: []
};
export default AuthorDetails;

@ -6,6 +6,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { toggleAuthorMonitored } from 'Store/Actions/authorActions';
import { saveBookEditor } from 'Store/Actions/bookEditorActions';
import { clearBookFiles, fetchBookFiles } from 'Store/Actions/bookFileActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
@ -21,7 +22,8 @@ import AuthorDetails from './AuthorDetails';
const selectBooks = createSelector(
(state) => state.books,
(books) => {
(state) => state.bookEditor,
(books, editor) => {
const {
items,
isFetching,
@ -37,7 +39,8 @@ const selectBooks = createSelector(
isBooksPopulated: isPopulated,
booksError: error,
hasBooks,
hasMonitoredBooks
hasMonitoredBooks,
...editor
};
}
);
@ -187,6 +190,7 @@ function createMapStateToProps() {
const mapDispatchToProps = {
fetchSeries,
clearSeries,
saveBookEditor,
fetchBookFiles,
clearBookFiles,
toggleAuthorMonitored,
@ -282,6 +286,10 @@ class AuthorDetailsConnector extends Component {
});
}
onSaveSelected = (payload) => {
this.props.saveBookEditor(payload);
}
//
// Render
@ -292,6 +300,7 @@ class AuthorDetailsConnector extends Component {
onMonitorTogglePress={this.onMonitorTogglePress}
onRefreshPress={this.onRefreshPress}
onSearchPress={this.onSearchPress}
onSaveSelected={this.onSaveSelected}
/>
);
}
@ -307,6 +316,7 @@ AuthorDetailsConnector.propTypes = {
isRenamingAuthor: PropTypes.bool.isRequired,
fetchSeries: PropTypes.func.isRequired,
clearSeries: PropTypes.func.isRequired,
saveBookEditor: PropTypes.func.isRequired,
fetchBookFiles: PropTypes.func.isRequired,
clearBookFiles: PropTypes.func.isRequired,
toggleAuthorMonitored: PropTypes.func.isRequired,

@ -4,6 +4,7 @@ import React, { Component } from 'react';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { sortDirections } from 'Helpers/Props';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import getToggledRange from 'Utilities/Table/getToggledRange';
import BookRowConnector from './BookRowConnector';
import styles from './AuthorDetailsSeason.css';
@ -21,6 +22,26 @@ class AuthorDetailsSeason extends Component {
};
}
componentDidMount() {
this.props.setSelectedState(this.props.items);
}
componentDidUpdate(prevProps) {
const {
items,
sortKey,
sortDirection,
setSelectedState
} = this.props;
if (sortKey !== prevProps.sortKey ||
sortDirection !== prevProps.sortDirection ||
hasDifferentItemsOrOrder(prevProps.items, items)
) {
setSelectedState(items);
}
}
//
// Listeners
@ -42,26 +63,42 @@ class AuthorDetailsSeason extends Component {
this.props.onMonitorBookPress(_.uniq(bookIds), monitored);
}
onSelectedChange = ({ id, value, shiftKey = false }) => {
const {
onSelectedChange,
items
} = this.props;
return onSelectedChange(items, id, value, shiftKey);
}
//
// Render
render() {
const {
items,
isBookEditorActive,
columns,
sortKey,
sortDirection,
onSortPress,
onTableOptionChange
onTableOptionChange,
selectedState
} = this.props;
let titleColumns = columns;
if (!isBookEditorActive) {
titleColumns = columns.filter((x) => x.name !== 'select');
}
return (
<div
className={styles.bookType}
>
<div className={styles.books}>
<Table
columns={columns}
columns={titleColumns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
@ -76,6 +113,9 @@ class AuthorDetailsSeason extends Component {
columns={columns}
{...item}
onMonitorBookPress={this.onMonitorBookPress}
isBookEditorActive={isBookEditorActive}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
/>
);
})
@ -92,9 +132,13 @@ AuthorDetailsSeason.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isBookEditorActive: PropTypes.bool.isRequired,
selectedState: PropTypes.object.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired,
onExpandPress: PropTypes.func.isRequired,
setSelectedState: PropTypes.func.isRequired,
onSelectedChange: PropTypes.func.isRequired,
onSortPress: PropTypes.func.isRequired,
onMonitorBookPress: PropTypes.func.isRequired,
uiSettings: PropTypes.object.isRequired

@ -6,6 +6,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
import StarRating from 'Components/StarRating';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import BookStatus from './BookStatus';
import styles from './BookRow.css';
@ -65,6 +66,9 @@ class BookRow extends Component {
authorMonitored,
titleSlug,
bookFiles,
isBookEditorActive,
isSelected,
onSelectedChange,
columns
} = this.props;
@ -84,6 +88,18 @@ class BookRow extends Component {
return null;
}
if (isBookEditorActive && name === 'select') {
return (
<TableSelectCell
key={name}
id={id}
isSelected={isSelected}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
);
}
if (name === 'monitored') {
return (
<TableRowCell
@ -220,6 +236,9 @@ BookRow.propTypes = {
isSaving: PropTypes.bool,
authorMonitored: PropTypes.bool.isRequired,
bookFiles: PropTypes.arrayOf(PropTypes.object).isRequired,
isBookEditorActive: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onMonitorBookPress: PropTypes.func.isRequired
};

@ -0,0 +1,70 @@
.inputContainer {
margin-right: 20px;
min-width: 150px;
}
.buttonContainer {
display: flex;
justify-content: flex-end;
flex-grow: 1;
}
.buttonContainerContent {
flex-grow: 0;
}
.buttons {
display: flex;
justify-content: flex-end;
flex-grow: 1;
}
.organizeSelectedButton,
.tagsButton {
composes: button from '~Components/Link/SpinnerButton.css';
margin-right: 10px;
height: 35px;
}
.deleteSelectedButton {
composes: button from '~Components/Link/SpinnerButton.css';
margin-left: 50px;
height: 35px;
}
@media only screen and (max-width: $breakpointExtraLarge) {
.deleteSelectedButton {
margin-left: 0;
}
}
@media only screen and (max-width: $breakpointLarge) {
.buttonContainer {
justify-content: flex-start;
margin-top: 10px;
}
}
@media only screen and (max-width: $breakpointSmall) {
.inputContainer {
margin-right: 0;
}
.buttonContainer {
justify-content: flex-start;
}
.buttonContainerContent {
flex-grow: 1;
}
.buttons {
justify-content: space-between;
}
.selectedAuthorLabel {
text-align: left;
}
}

@ -0,0 +1,156 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import BookEditorFooterLabel from './BookEditorFooterLabel';
import DeleteBookModal from './Delete/DeleteBookModal';
import styles from './BookEditorFooter.css';
const NO_CHANGE = 'noChange';
class BookEditorFooter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
monitored: NO_CHANGE,
rootFolderPath: NO_CHANGE,
savingTags: false,
isDeleteBookModalOpen: false,
isTagsModalOpen: false,
isConfirmMoveModalOpen: false,
destinationRootFolder: null
};
}
componentDidUpdate(prevProps) {
const {
isSaving,
saveError
} = this.props;
if (prevProps.isSaving && !isSaving && !saveError) {
this.setState({
monitored: NO_CHANGE,
rootFolderPath: NO_CHANGE,
savingTags: false
});
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
if (value === NO_CHANGE) {
return;
}
switch (name) {
case 'monitored':
this.props.onSaveSelected({ [name]: value === 'monitored' });
break;
default:
this.props.onSaveSelected({ [name]: value });
}
}
onDeleteSelectedPress = () => {
this.setState({ isDeleteBookModalOpen: true });
}
onDeleteBookModalClose = () => {
this.setState({ isDeleteBookModalOpen: false });
}
//
// Render
render() {
const {
bookIds,
selectedCount,
isSaving,
isDeleting
} = this.props;
const {
monitored,
isDeleteBookModalOpen
} = this.state;
const monitoredOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'monitored', value: 'Monitored' },
{ key: 'unmonitored', value: 'Unmonitored' }
];
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<BookEditorFooterLabel
label={translate('MonitorBook')}
isSaving={isSaving && monitored !== NO_CHANGE}
/>
<SelectInput
name="monitored"
value={monitored}
values={monitoredOptions}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.buttonContainer}>
<div className={styles.buttonContainerContent}>
<BookEditorFooterLabel
label={translate('SelectedCountBooksSelectedInterp', [selectedCount])}
isSaving={false}
/>
<div className={styles.buttons}>
<SpinnerButton
className={styles.deleteSelectedButton}
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!selectedCount || isDeleting}
onPress={this.onDeleteSelectedPress}
>
Delete
</SpinnerButton>
</div>
</div>
</div>
<DeleteBookModal
isOpen={isDeleteBookModalOpen}
bookIds={bookIds}
onModalClose={this.onDeleteBookModalClose}
/>
</PageContentFooter>
);
}
}
BookEditorFooter.propTypes = {
bookIds: PropTypes.arrayOf(PropTypes.number).isRequired,
selectedCount: PropTypes.number.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
onSaveSelected: PropTypes.func.isRequired
};
export default BookEditorFooter;

@ -0,0 +1,8 @@
.label {
margin-bottom: 3px;
font-weight: bold;
}
.savingIcon {
margin-left: 8px;
}

@ -0,0 +1,40 @@
import PropTypes from 'prop-types';
import React from 'react';
import SpinnerIcon from 'Components/SpinnerIcon';
import { icons } from 'Helpers/Props';
import styles from './BookEditorFooterLabel.css';
function BookEditorFooterLabel(props) {
const {
className,
label,
isSaving
} = props;
return (
<div className={className}>
{label}
{
isSaving &&
<SpinnerIcon
className={styles.savingIcon}
name={icons.SPINNER}
isSpinning={true}
/>
}
</div>
);
}
BookEditorFooterLabel.propTypes = {
className: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
isSaving: PropTypes.bool.isRequired
};
BookEditorFooterLabel.defaultProps = {
className: styles.label
};
export default BookEditorFooterLabel;

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DeleteBookModalContentConnector from './DeleteBookModalContentConnector';
function DeleteBookModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<DeleteBookModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
DeleteBookModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default DeleteBookModal;

@ -0,0 +1,9 @@
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.deleteFilesMessage {
margin-top: 20px;
color: $dangerColor;
}

@ -0,0 +1,172 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
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 { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './DeleteBookModalContent.css';
class DeleteBookModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
deleteFiles: false,
addImportListExclusion: true
};
}
//
// Listeners
onDeleteFilesChange = ({ value }) => {
this.setState({ deleteFiles: value });
}
onAddImportListExclusionChange = ({ value }) => {
this.setState({ addImportListExclusion: value });
}
onDeleteBookConfirmed = () => {
const {
deleteFiles,
addImportListExclusion
} = this.state;
this.setState({ deleteFiles: false });
this.props.onDeleteSelectedPress(deleteFiles, addImportListExclusion);
}
//
// Render
render() {
const {
book,
files,
onModalClose
} = this.props;
const {
deleteFiles,
addImportListExclusion
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Delete Selected Book
</ModalHeader>
<ModalBody>
<div>
<FormGroup>
<FormLabel>{`Delete File${book.length > 1 ? 's' : ''}`}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={'Delete book files'}
kind={kinds.DANGER}
isDisabled={files.length === 0}
onChange={this.onDeleteFilesChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('AddListExclusion')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="addImportListExclusion"
value={addImportListExclusion}
helpText={translate('AddImportListExclusionHelpText')}
kind={kinds.DANGER}
onChange={this.onAddImportListExclusionChange}
/>
</FormGroup>
{
!addImportListExclusion &&
<div className={styles.deleteFilesMessage}>
<div>
{translate('IfYouDontAddAnImportListExclusionAndTheAuthorHasAMetadataProfileOtherThanNoneThenThisBookMayBeReaddedDuringTheNextAuthorRefresh')}
</div>
</div>
}
</div>
<div className={styles.message}>
{`Are you sure you want to delete ${book.length} selected book${book.length > 1 ? 's' : ''}${deleteFiles ? ' and their files' : ''}?`}
</div>
<ul>
{
book.map((s) => {
return (
<li key={s.title}>
<span>{s.title}</span>
</li>
);
})
}
</ul>
{
deleteFiles &&
<div>
<div className={styles.deleteFilesMessage}>
{translate('TheFollowingFilesWillBeDeleted')}
</div>
<ul>
{
files.map((s) => {
return (
<li key={s.path}>
<span>{s.path}</span>
</li>
);
})
}
</ul>
</div>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onDeleteBookConfirmed}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
);
}
}
DeleteBookModalContent.propTypes = {
book: PropTypes.arrayOf(PropTypes.object).isRequired,
files: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteSelectedPress: PropTypes.func.isRequired
};
export default DeleteBookModalContent;

@ -0,0 +1,54 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { bulkDeleteBook } from 'Store/Actions/bookEditorActions';
import DeleteBookModalContent from './DeleteBookModalContent';
function createMapStateToProps() {
return createSelector(
(state, { bookIds }) => bookIds,
(state) => state.books.items,
(state) => state.bookFiles.items,
(bookIds, allBooks, allBookFiles) => {
const selectedBook = _.intersectionWith(allBooks, bookIds, (s, id) => {
return s.id === id;
});
const sortedBook = _.orderBy(selectedBook, 'title');
const selectedFiles = _.intersectionWith(allBookFiles, bookIds, (s, id) => {
return s.bookId === id;
});
const files = _.orderBy(selectedFiles, ['bookId', 'path']);
const book = _.map(sortedBook, (s) => {
return {
title: s.title,
path: s.path
};
});
return {
book,
files
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onDeleteSelectedPress(deleteFiles, addImportListExclusion) {
dispatch(bulkDeleteBook({
bookIds: props.bookIds,
deleteFiles,
addImportListExclusion
}));
props.onModalClose();
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteBookModalContent);

@ -36,6 +36,7 @@ import {
faCaretDown as fasCaretDown,
faCheck as fasCheck,
faCheckCircle as fasCheckCircle,
faCheckSquare as fasCheckSquare,
faChevronCircleDown as fasChevronCircleDown,
faChevronCircleRight as fasChevronCircleRight,
faChevronCircleUp as fasChevronCircleUp,
@ -127,6 +128,7 @@ export const CARET_DOWN = fasCaretDown;
export const CHECK = fasCheck;
export const CHECK_INDETERMINATE = fasMinus;
export const CHECK_CIRCLE = fasCheckCircle;
export const CHECK_SQUARE = fasCheckSquare;
export const CIRCLE = fasCircle;
export const CIRCLE_OUTLINE = farCircle;
export const CLEAR = fasTrashAlt;

@ -132,6 +132,14 @@ export const defaultState = {
},
columns: [
{
name: 'select',
columnLabel: 'Select',
isSortable: false,
isVisible: true,
isModifiable: false,
isHidden: true
},
{
name: 'monitored',
columnLabel: 'Monitored',

@ -0,0 +1,114 @@
import { batchActions } from 'redux-batched-actions';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { set, updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
//
// Variables
export const section = 'bookEditor';
//
// State
export const defaultState = {
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null
};
//
// Actions Types
export const SAVE_BOOK_EDITOR = 'bookEditor/saveBookEditor';
export const BULK_DELETE_BOOK = 'bookEditor/bulkDeleteBook';
//
// Action Creators
export const saveBookEditor = createThunk(SAVE_BOOK_EDITOR);
export const bulkDeleteBook = createThunk(BULK_DELETE_BOOK);
//
// Action Handlers
export const actionHandlers = handleThunks({
[SAVE_BOOK_EDITOR]: function(getState, payload, dispatch) {
dispatch(set({
section,
isSaving: true
}));
const promise = createAjaxRequest({
url: '/book/editor',
method: 'PUT',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done((data) => {
dispatch(batchActions([
...data.map((book) => {
return updateItem({
id: book.id,
section: 'books',
...book
});
}),
set({
section,
isSaving: false,
saveError: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isSaving: false,
saveError: xhr
}));
});
},
[BULK_DELETE_BOOK]: function(getState, payload, dispatch) {
dispatch(set({
section,
isDeleting: true
}));
const promise = createAjaxRequest({
url: '/book/editor',
method: 'DELETE',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done(() => {
// SignalR will take care of removing the book from the collection
dispatch(set({
section,
isDeleting: false,
deleteError: null
}));
});
promise.fail((xhr) => {
dispatch(set({
section,
isDeleting: false,
deleteError: xhr
}));
});
}
});
//
// Reducers
export const reducers = createHandleActions({}, defaultState, section);

@ -6,6 +6,7 @@ import * as authorHistory from './authorHistoryActions';
import * as authorIndex from './authorIndexActions';
import * as blocklist from './blocklistActions';
import * as books from './bookActions';
import * as bookEditor from './bookEditorActions';
import * as bookFiles from './bookFileActions';
import * as bookHistory from './bookHistoryActions';
import * as bookIndex from './bookIndexActions';
@ -43,6 +44,7 @@ export default [
bookHistory,
bookIndex,
books,
bookEditor,
bookStudio,
calendar,
captcha,

@ -66,6 +66,7 @@
"Book": "Book",
"BookAvailableButMissing": "Book Available, but Missing",
"BookDownloaded": "Book Downloaded",
"BookEditor": "Book Editor",
"BookFileCountBookCountTotalTotalBookCountInterp": "{0} / {1} (Total: {2})",
"BookFileCounttotalBookCountBooksDownloadedInterp": "{0}/{1} books downloaded",
"BookFilesCountMessage": "No book files",
@ -73,6 +74,7 @@
"BookIsDownloading": "Book is downloading",
"BookIsDownloadingInterp": "Book is downloading - {0}% {1}",
"BookIsNotMonitored": "Book is not monitored",
"BookList": "Book List",
"BookMissingFromDisk": "Book missing from disk",
"BookMonitoring": "Book Monitoring",
"BookNaming": "Book Naming",
@ -558,7 +560,9 @@
"SearchSelected": "Search Selected",
"Season": "Season",
"Security": "Security",
"SelectAll": "Select All",
"SelectedCountAuthorsSelectedInterp": "{0} Author(s) Selected",
"SelectedCountBooksSelectedInterp": "{0} Book(s) Selected",
"SendAnonymousUsageData": "Send Anonymous Usage Data",
"SendMetadataToCalibre": "Send Metadata to Calibre",
"Series": "Series",
@ -644,6 +648,7 @@
"TestAllLists": "Test All Lists",
"TheAuthorFolderAndAllOfItsContentWillBeDeleted": "The author folder {0} and all of its content will be deleted.",
"TheBooksFilesWillBeDeleted": "The book's files will be deleted.",
"TheFollowingFilesWillBeDeleted": "The following files will be deleted:",
"ThisWillApplyToAllIndexersPleaseFollowTheRulesSetForthByThem": "This will apply to all indexers, please follow the rules set forth by them",
"Time": "Time",
"TimeFormat": "Time Format",
@ -706,6 +711,7 @@
"UnmappedFiles": "UnmappedFiles",
"Unmonitored": "Unmonitored",
"UnmonitoredHelpText": "Include unmonitored books in the iCal feed",
"UnselectAll": "Unselect All",
"UpdateAll": "Update all",
"UpdateAutomaticallyHelpText": "Automatically download and install updates. You will still be able to install from System: Updates",
"UpdateCovers": "Update Covers",

@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Books;
using NzbDrone.Core.Messaging.Commands;
using Readarr.Http;
namespace Readarr.Api.V1.Books
{
[V1ApiController("book/editor")]
public class BookEditorController : Controller
{
private readonly IBookService _bookService;
private readonly IManageCommandQueue _commandQueueManager;
public BookEditorController(IBookService bookService, IManageCommandQueue commandQueueManager)
{
_bookService = bookService;
_commandQueueManager = commandQueueManager;
}
[HttpPut]
public IActionResult SaveAll([FromBody] BookEditorResource resource)
{
var booksToUpdate = _bookService.GetBooks(resource.BookIds);
foreach (var book in booksToUpdate)
{
if (resource.Monitored.HasValue)
{
book.Monitored = resource.Monitored.Value;
}
}
_bookService.UpdateMany(booksToUpdate);
return Accepted(booksToUpdate.ToResource());
}
[HttpDelete]
public object DeleteBook([FromBody] BookEditorResource resource)
{
foreach (var bookId in resource.BookIds)
{
_bookService.DeleteBook(bookId, resource.DeleteFiles ?? false, resource.AddImportListExclusion ?? false);
}
return new object();
}
}
}

@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace Readarr.Api.V1.Books
{
public class BookEditorResource
{
public List<int> BookIds { get; set; }
public bool? Monitored { get; set; }
public bool? DeleteFiles { get; set; }
public bool? AddImportListExclusion { get; set; }
}
}
Loading…
Cancel
Save