diff --git a/frontend/src/Activity/Blacklist/Blacklist.js b/frontend/src/Activity/Blacklist/Blacklist.js index 2d0a1e9f4..015b19759 100644 --- a/frontend/src/Activity/Blacklist/Blacklist.js +++ b/frontend/src/Activity/Blacklist/Blacklist.js @@ -1,7 +1,14 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { align, icons } from 'Helpers/Props'; +import getRemovedItems from 'Utilities/Object/getRemovedItems'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds } from 'Helpers/Props'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; @@ -15,6 +22,72 @@ import BlacklistRowConnector from './BlacklistRowConnector'; class Blacklist extends Component { + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmRemoveModalOpen: false, + items: props.items + }; + } + + componentDidUpdate(prevProps) { + const { + items + } = this.props; + + if (hasDifferentItems(prevProps.items, items)) { + this.setState((state) => { + return { + ...removeOldSelectedState(state, getRemovedItems(prevProps.items, items)), + items + }; + }); + + return; + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onRemoveSelectedPress = () => { + this.setState({ isConfirmRemoveModalOpen: true }); + } + + onRemoveSelectedConfirmed = () => { + this.props.onRemoveSelected(this.getSelectedIds()); + this.setState({ isConfirmRemoveModalOpen: false }); + } + + onConfirmRemoveModalClose = () => { + this.setState({ isConfirmRemoveModalOpen: false }); + } + // // Render @@ -26,15 +99,33 @@ class Blacklist extends Component { items, columns, totalRecords, + isRemoving, isClearingBlacklistExecuting, onClearBlacklistPress, ...otherProps } = this.props; + const { + allSelected, + allUnselected, + selectedState, + isConfirmRemoveModalOpen + } = this.state; + + const selectedIds = this.getSelectedIds(); + return ( + + { isFetching && !isPopulated && - + } { !isFetching && !!error && -
Unable to load blacklist
+
Unable to load blacklist
} { isPopulated && !error && !items.length && -
- No history blacklist -
+
+ No history blacklist +
} { isPopulated && !error && !!items.length && -
- - - { - items.map((item) => { - return ( - - ); - }) - } - -
+
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
- -
+ +
} + +
); } @@ -116,7 +223,9 @@ Blacklist.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, totalRecords: PropTypes.number, + isRemoving: PropTypes.bool.isRequired, isClearingBlacklistExecuting: PropTypes.bool.isRequired, + onRemoveSelected: PropTypes.func.isRequired, onClearBlacklistPress: PropTypes.func.isRequired }; diff --git a/frontend/src/Activity/Blacklist/BlacklistConnector.js b/frontend/src/Activity/Blacklist/BlacklistConnector.js index b182e7bb2..1cb8e4a43 100644 --- a/frontend/src/Activity/Blacklist/BlacklistConnector.js +++ b/frontend/src/Activity/Blacklist/BlacklistConnector.js @@ -89,6 +89,10 @@ class BlacklistConnector extends Component { this.props.gotoBlacklistPage({ page }); } + onRemoveSelected = (ids) => { + this.props.removeBlacklistItems({ ids }); + } + onSortPress = (sortKey) => { this.props.setBlacklistSort({ sortKey }); } @@ -124,6 +128,7 @@ class BlacklistConnector extends Component { onNextPagePress={this.onNextPagePress} onLastPagePress={this.onLastPagePress} onPageSelect={this.onPageSelect} + onRemoveSelected={this.onRemoveSelected} onSortPress={this.onSortPress} onTableOptionChange={this.onTableOptionChange} onClearBlacklistPress={this.onClearBlacklistPress} @@ -143,6 +148,7 @@ BlacklistConnector.propTypes = { gotoBlacklistNextPage: PropTypes.func.isRequired, gotoBlacklistLastPage: PropTypes.func.isRequired, gotoBlacklistPage: PropTypes.func.isRequired, + removeBlacklistItems: PropTypes.func.isRequired, setBlacklistSort: PropTypes.func.isRequired, setBlacklistTableOption: PropTypes.func.isRequired, clearBlacklist: PropTypes.func.isRequired, diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js index 47859a80f..cb07ce803 100644 --- a/frontend/src/Activity/Blacklist/BlacklistRow.js +++ b/frontend/src/Activity/Blacklist/BlacklistRow.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { icons, kinds } from 'Helpers/Props'; import IconButton from 'Components/Link/IconButton'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; @@ -40,6 +41,7 @@ class BlacklistRow extends Component { render() { const { + id, series, sourceTitle, language, @@ -48,12 +50,20 @@ class BlacklistRow extends Component { protocol, indexer, message, + isSelected, columns, + onSelectedChange, onRemovePress } = this.props; return ( + + { columns.map((column) => { const { @@ -179,7 +189,9 @@ BlacklistRow.propTypes = { protocol: PropTypes.string.isRequired, indexer: PropTypes.string, message: PropTypes.string, + isSelected: PropTypes.bool.isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired, onRemovePress: PropTypes.func.isRequired }; diff --git a/frontend/src/Activity/Blacklist/BlacklistRowConnector.js b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js index efba18fab..29b9e9e78 100644 --- a/frontend/src/Activity/Blacklist/BlacklistRowConnector.js +++ b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { removeFromBlacklist } from 'Store/Actions/blacklistActions'; +import { removeBlacklistItem } from 'Store/Actions/blacklistActions'; import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; import BlacklistRow from './BlacklistRow'; @@ -18,7 +18,7 @@ function createMapStateToProps() { function createMapDispatchToProps(dispatch, props) { return { onRemovePress() { - dispatch(removeFromBlacklist({ id: props.id })); + dispatch(removeBlacklistItem({ id: props.id })); } }; } diff --git a/frontend/src/Store/Actions/blacklistActions.js b/frontend/src/Store/Actions/blacklistActions.js index ee7f44f84..c079bc990 100644 --- a/frontend/src/Store/Actions/blacklistActions.js +++ b/frontend/src/Store/Actions/blacklistActions.js @@ -1,4 +1,6 @@ import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; import { createThunk, handleThunks } from 'Store/thunks'; import { sortDirections } from 'Helpers/Props'; @@ -7,6 +9,7 @@ import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptio import createHandleActions from './Creators/createHandleActions'; import createRemoveItemHandler from './Creators/createRemoveItemHandler'; import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { set, updateItem } from './baseActions'; // // Variables @@ -24,6 +27,7 @@ export const defaultState = { sortDirection: sortDirections.DESCENDING, error: null, items: [], + isRemoving: false, columns: [ { @@ -87,7 +91,8 @@ export const GOTO_LAST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistLastPage'; export const GOTO_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPage'; export const SET_BLACKLIST_SORT = 'blacklist/setBlacklistSort'; export const SET_BLACKLIST_TABLE_OPTION = 'blacklist/setBlacklistTableOption'; -export const REMOVE_FROM_BLACKLIST = 'blacklist/removeFromBlacklist'; +export const REMOVE_BLACKLIST_ITEM = 'blacklist/removeBlacklistItem'; +export const REMOVE_BLACKLIST_ITEMS = 'blacklist/removeBlacklistItems'; export const CLEAR_BLACKLIST = 'blacklist/clearBlacklist'; // @@ -101,7 +106,8 @@ export const gotoBlacklistLastPage = createThunk(GOTO_LAST_BLACKLIST_PAGE); export const gotoBlacklistPage = createThunk(GOTO_BLACKLIST_PAGE); export const setBlacklistSort = createThunk(SET_BLACKLIST_SORT); export const setBlacklistTableOption = createAction(SET_BLACKLIST_TABLE_OPTION); -export const removeFromBlacklist = createThunk(REMOVE_FROM_BLACKLIST); +export const removeBlacklistItem = createThunk(REMOVE_BLACKLIST_ITEM); +export const removeBlacklistItems = createThunk(REMOVE_BLACKLIST_ITEMS); export const clearBlacklist = createAction(CLEAR_BLACKLIST); // @@ -122,7 +128,53 @@ export const actionHandlers = handleThunks({ [serverSideCollectionHandlers.SORT]: SET_BLACKLIST_SORT }), - [REMOVE_FROM_BLACKLIST]: createRemoveItemHandler(section, '/blacklist') + [REMOVE_BLACKLIST_ITEM]: createRemoveItemHandler(section, '/blacklist'), + + [REMOVE_BLACKLIST_ITEMS]: function(getState, payload, dispatch) { + const { + ids + } = payload; + + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section, + id, + isRemoving: true + }); + }), + + set({ section, isRemoving: true }) + ])); + + const promise = createAjaxRequest({ + url: '/blacklist/bulk', + method: 'DELETE', + dataType: 'json', + data: JSON.stringify({ ids }) + }).request; + + promise.done((data) => { + // Don't use batchActions with thunks + dispatch(fetchBlacklist()); + + dispatch(set({ section, isRemoving: false })); + }); + + promise.fail((xhr) => { + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section, + id, + isRemoving: false + }); + }), + + set({ section, isRemoving: false }) + ])); + }); + } }); // diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js index a73009deb..97d37a599 100644 --- a/frontend/src/Store/Actions/queueActions.js +++ b/frontend/src/Store/Actions/queueActions.js @@ -318,9 +318,9 @@ export const actionHandlers = handleThunks({ }).request; promise.done((data) => { - dispatch(batchActions([ - fetchQueue(), + dispatch(fetchQueue()); + dispatch(batchActions([ ...ids.map((id) => { return updateItem({ section: paged, @@ -404,10 +404,10 @@ export const actionHandlers = handleThunks({ }).request; promise.done((data) => { - dispatch(batchActions([ - set({ section: paged, isRemoving: false }), - fetchQueue() - ])); + // Don't use batchActions with thunks + dispatch(fetchQueue()); + + dispatch(set({ section: paged, isRemoving: false })); }); promise.fail((xhr) => { diff --git a/src/NzbDrone.Core/Blacklisting/BlacklistService.cs b/src/NzbDrone.Core/Blacklisting/BlacklistService.cs index c08020b22..2ea325b39 100644 --- a/src/NzbDrone.Core/Blacklisting/BlacklistService.cs +++ b/src/NzbDrone.Core/Blacklisting/BlacklistService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; @@ -16,6 +17,7 @@ namespace NzbDrone.Core.Blacklisting bool Blacklisted(int seriesId, ReleaseInfo release); PagingSpec Paged(PagingSpec pagingSpec); void Delete(int id); + void Delete(List ids); } public class BlacklistService : IBlacklistService, @@ -65,6 +67,11 @@ namespace NzbDrone.Core.Blacklisting _blacklistRepository.Delete(id); } + public void Delete(List ids) + { + _blacklistRepository.DeleteMany(ids); + } + private bool SameNzb(Blacklist item, ReleaseInfo release) { if (item.PublishedDate == release.PublishDate) diff --git a/src/Sonarr.Api.V3/Blacklist/BlacklistBulkResource.cs b/src/Sonarr.Api.V3/Blacklist/BlacklistBulkResource.cs new file mode 100644 index 000000000..7831a71d9 --- /dev/null +++ b/src/Sonarr.Api.V3/Blacklist/BlacklistBulkResource.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Api.V3.Blacklist +{ + public class BlacklistBulkResource + { + public List Ids { get; set; } + } +} diff --git a/src/Sonarr.Api.V3/Blacklist/BlacklistModule.cs b/src/Sonarr.Api.V3/Blacklist/BlacklistModule.cs index 76c5144d0..e2172b473 100644 --- a/src/Sonarr.Api.V3/Blacklist/BlacklistModule.cs +++ b/src/Sonarr.Api.V3/Blacklist/BlacklistModule.cs @@ -1,6 +1,8 @@ -using NzbDrone.Core.Blacklisting; +using NzbDrone.Api.V3.Blacklist; +using NzbDrone.Core.Blacklisting; using NzbDrone.Core.Datastore; using Sonarr.Http; +using Sonarr.Http.Extensions; namespace Sonarr.Api.V3.Blacklist { @@ -13,6 +15,8 @@ namespace Sonarr.Api.V3.Blacklist _blacklistService = blacklistService; GetResourcePaged = GetBlacklist; DeleteResource = DeleteBlacklist; + + Delete("/bulk", x => Remove()); } private PagingResource GetBlacklist(PagingResource pagingResource) @@ -26,5 +30,14 @@ namespace Sonarr.Api.V3.Blacklist { _blacklistService.Delete(id); } + + private object Remove() + { + var resource = Request.Body.FromJson(); + + _blacklistService.Delete(resource.Ids); + + return new object(); + } } } \ No newline at end of file