diff --git a/frontend/src/Activity/Blacklist/Blacklist.js b/frontend/src/Activity/Blacklist/Blacklist.js index 520bb9b17..c1bb0ffd8 100644 --- a/frontend/src/Activity/Blacklist/Blacklist.js +++ b/frontend/src/Activity/Blacklist/Blacklist.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; @@ -10,11 +11,83 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TablePager from 'Components/Table/TablePager'; -import { align, icons } from 'Helpers/Props'; +import { align, icons, kinds } 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 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 @@ -28,6 +101,7 @@ class Blacklist extends Component { items, columns, totalRecords, + isRemoving, isClearingBlacklistExecuting, onClearBlacklistPress, ...otherProps @@ -36,10 +110,27 @@ class Blacklist extends Component { const isAllPopulated = isPopulated && isArtistPopulated; const isAnyFetching = isFetching || isArtistFetching; + const { + allSelected, + allUnselected, + selectedState, + isConfirmRemoveModalOpen + } = this.state; + + const selectedIds = this.getSelectedIds(); + return ( + + { @@ -92,8 +187,10 @@ class Blacklist extends Component { return ( ); }) @@ -109,6 +206,16 @@ class Blacklist extends Component { } + + ); } @@ -123,7 +230,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 993be14be..d9fd7871d 100644 --- a/frontend/src/Activity/Blacklist/BlacklistConnector.js +++ b/frontend/src/Activity/Blacklist/BlacklistConnector.js @@ -92,6 +92,10 @@ class BlacklistConnector extends Component { this.props.gotoBlacklistPage({ page }); } + onRemoveSelected = (ids) => { + this.props.removeBlacklistItems({ ids }); + } + onSortPress = (sortKey) => { this.props.setBlacklistSort({ sortKey }); } @@ -119,6 +123,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} @@ -138,6 +143,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 4608bad89..243c827a0 100644 --- a/frontend/src/Activity/Blacklist/BlacklistRow.js +++ b/frontend/src/Activity/Blacklist/BlacklistRow.js @@ -5,6 +5,7 @@ import ArtistNameLink from 'Artist/ArtistNameLink'; import IconButton from 'Components/Link/IconButton'; 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 { icons, kinds } from 'Helpers/Props'; import BlacklistDetailsModal from './BlacklistDetailsModal'; @@ -39,6 +40,7 @@ class BlacklistRow extends Component { render() { const { + id, artist, sourceTitle, quality, @@ -46,7 +48,9 @@ class BlacklistRow extends Component { protocol, indexer, message, + isSelected, columns, + onSelectedChange, onRemovePress } = this.props; @@ -56,6 +60,12 @@ class BlacklistRow extends Component { return ( + + { columns.map((column) => { const { @@ -167,7 +177,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 9841ec8e6..61eac391e 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 createArtistSelector from 'Store/Selectors/createArtistSelector'; 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 2a0de2df7..8fb8a9399 100644 --- a/frontend/src/Store/Actions/blacklistActions.js +++ b/frontend/src/Store/Actions/blacklistActions.js @@ -1,7 +1,10 @@ import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; import { sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { set, updateItem } from './baseActions'; import createHandleActions from './Creators/createHandleActions'; import createRemoveItemHandler from './Creators/createRemoveItemHandler'; import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; @@ -24,6 +27,7 @@ export const defaultState = { sortDirection: sortDirections.DESCENDING, error: null, items: [], + isRemoving: false, columns: [ { @@ -82,7 +86,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'; // @@ -96,7 +101,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); // @@ -117,7 +123,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 5b6983f32..3c427520e 100644 --- a/frontend/src/Store/Actions/queueActions.js +++ b/frontend/src/Store/Actions/queueActions.js @@ -306,9 +306,9 @@ export const actionHandlers = handleThunks({ }).request; promise.done((data) => { - dispatch(batchActions([ - fetchQueue(), + dispatch(fetchQueue()); + dispatch(batchActions([ ...ids.map((id) => { return updateItem({ section: paged, @@ -394,10 +394,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/Lidarr.Api.V1/Blacklist/BlacklistBulkResource.cs b/src/Lidarr.Api.V1/Blacklist/BlacklistBulkResource.cs new file mode 100644 index 000000000..b026f909e --- /dev/null +++ b/src/Lidarr.Api.V1/Blacklist/BlacklistBulkResource.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Lidarr.Api.V1.Blacklist +{ + public class BlacklistBulkResource + { + public List Ids { get; set; } + } +} diff --git a/src/Lidarr.Api.V1/Blacklist/BlacklistModule.cs b/src/Lidarr.Api.V1/Blacklist/BlacklistModule.cs index 8df232b72..1b1019d1b 100644 --- a/src/Lidarr.Api.V1/Blacklist/BlacklistModule.cs +++ b/src/Lidarr.Api.V1/Blacklist/BlacklistModule.cs @@ -1,4 +1,5 @@ using Lidarr.Http; +using Lidarr.Http.Extensions; using NzbDrone.Core.Blacklisting; using NzbDrone.Core.Datastore; @@ -13,6 +14,8 @@ namespace Lidarr.Api.V1.Blacklist _blacklistService = blacklistService; GetResourcePaged = GetBlacklist; DeleteResource = DeleteBlacklist; + + Delete("/bulk", x => Remove()); } private PagingResource GetBlacklist(PagingResource pagingResource) @@ -26,5 +29,14 @@ namespace Lidarr.Api.V1.Blacklist { _blacklistService.Delete(id); } + + private object Remove() + { + var resource = Request.Body.FromJson(); + + _blacklistService.Delete(resource.Ids); + + return new object(); + } } } diff --git a/src/NzbDrone.Core/Blacklisting/BlacklistService.cs b/src/NzbDrone.Core/Blacklisting/BlacklistService.cs index 2ce1f9d7d..1e0b02093 100644 --- a/src/NzbDrone.Core/Blacklisting/BlacklistService.cs +++ b/src/NzbDrone.Core/Blacklisting/BlacklistService.cs @@ -17,6 +17,7 @@ namespace NzbDrone.Core.Blacklisting bool Blacklisted(int artistId, ReleaseInfo release); PagingSpec Paged(PagingSpec pagingSpec); void Delete(int id); + void Delete(List ids); } public class BlacklistService : IBlacklistService, @@ -70,6 +71,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)