1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2024-12-14 11:23:42 +02:00

New: Bulk remove from Blacklist

Closes #3500
This commit is contained in:
Mark McDowall 2020-10-11 15:28:32 -07:00
parent fae38a107f
commit 67f5628340
9 changed files with 251 additions and 43 deletions

View File

@ -1,7 +1,14 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; 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 LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import Table from 'Components/Table/Table'; import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
@ -15,6 +22,72 @@ import BlacklistRowConnector from './BlacklistRowConnector';
class Blacklist extends Component { 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 // Render
@ -26,15 +99,33 @@ class Blacklist extends Component {
items, items,
columns, columns,
totalRecords, totalRecords,
isRemoving,
isClearingBlacklistExecuting, isClearingBlacklistExecuting,
onClearBlacklistPress, onClearBlacklistPress,
...otherProps ...otherProps
} = this.props; } = this.props;
const {
allSelected,
allUnselected,
selectedState,
isConfirmRemoveModalOpen
} = this.state;
const selectedIds = this.getSelectedIds();
return ( return (
<PageContent title="Blacklist"> <PageContent title="Blacklist">
<PageToolbar> <PageToolbar>
<PageToolbarSection> <PageToolbarSection>
<PageToolbarButton
label="Remove Selected"
iconName={icons.REMOVE}
isDisabled={!selectedIds.length}
isSpinning={isRemoving}
onPress={this.onRemoveSelectedPress}
/>
<PageToolbarButton <PageToolbarButton
label="Clear" label="Clear"
iconName={icons.CLEAR} iconName={icons.CLEAR}
@ -59,51 +150,67 @@ class Blacklist extends Component {
<PageContentBody> <PageContentBody>
{ {
isFetching && !isPopulated && isFetching && !isPopulated &&
<LoadingIndicator /> <LoadingIndicator />
} }
{ {
!isFetching && !!error && !isFetching && !!error &&
<div>Unable to load blacklist</div> <div>Unable to load blacklist</div>
} }
{ {
isPopulated && !error && !items.length && isPopulated && !error && !items.length &&
<div> <div>
No history blacklist No history blacklist
</div> </div>
} }
{ {
isPopulated && !error && !!items.length && isPopulated && !error && !!items.length &&
<div> <div>
<Table <Table
columns={columns} selectAll={true}
{...otherProps} allSelected={allSelected}
> allUnselected={allUnselected}
<TableBody> columns={columns}
{ {...otherProps}
items.map((item) => { onSelectAllChange={this.onSelectAllChange}
return ( >
<BlacklistRowConnector <TableBody>
key={item.id} {
columns={columns} items.map((item) => {
{...item} return (
/> <BlacklistRowConnector
); key={item.id}
}) isSelected={selectedState[item.id] || false}
} columns={columns}
</TableBody> {...item}
</Table> onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager <TablePager
totalRecords={totalRecords} totalRecords={totalRecords}
isFetching={isFetching} isFetching={isFetching}
{...otherProps} {...otherProps}
/> />
</div> </div>
} }
</PageContentBody> </PageContentBody>
<ConfirmModal
isOpen={isConfirmRemoveModalOpen}
kind={kinds.DANGER}
title="Remove Selected"
message={'Are you sure you want to remove the selected items from the blacklist?'}
confirmLabel="Remove Selected"
onConfirm={this.onRemoveSelectedConfirmed}
onCancel={this.onConfirmRemoveModalClose}
/>
</PageContent> </PageContent>
); );
} }
@ -116,7 +223,9 @@ Blacklist.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
totalRecords: PropTypes.number, totalRecords: PropTypes.number,
isRemoving: PropTypes.bool.isRequired,
isClearingBlacklistExecuting: PropTypes.bool.isRequired, isClearingBlacklistExecuting: PropTypes.bool.isRequired,
onRemoveSelected: PropTypes.func.isRequired,
onClearBlacklistPress: PropTypes.func.isRequired onClearBlacklistPress: PropTypes.func.isRequired
}; };

View File

@ -89,6 +89,10 @@ class BlacklistConnector extends Component {
this.props.gotoBlacklistPage({ page }); this.props.gotoBlacklistPage({ page });
} }
onRemoveSelected = (ids) => {
this.props.removeBlacklistItems({ ids });
}
onSortPress = (sortKey) => { onSortPress = (sortKey) => {
this.props.setBlacklistSort({ sortKey }); this.props.setBlacklistSort({ sortKey });
} }
@ -124,6 +128,7 @@ class BlacklistConnector extends Component {
onNextPagePress={this.onNextPagePress} onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress} onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect} onPageSelect={this.onPageSelect}
onRemoveSelected={this.onRemoveSelected}
onSortPress={this.onSortPress} onSortPress={this.onSortPress}
onTableOptionChange={this.onTableOptionChange} onTableOptionChange={this.onTableOptionChange}
onClearBlacklistPress={this.onClearBlacklistPress} onClearBlacklistPress={this.onClearBlacklistPress}
@ -143,6 +148,7 @@ BlacklistConnector.propTypes = {
gotoBlacklistNextPage: PropTypes.func.isRequired, gotoBlacklistNextPage: PropTypes.func.isRequired,
gotoBlacklistLastPage: PropTypes.func.isRequired, gotoBlacklistLastPage: PropTypes.func.isRequired,
gotoBlacklistPage: PropTypes.func.isRequired, gotoBlacklistPage: PropTypes.func.isRequired,
removeBlacklistItems: PropTypes.func.isRequired,
setBlacklistSort: PropTypes.func.isRequired, setBlacklistSort: PropTypes.func.isRequired,
setBlacklistTableOption: PropTypes.func.isRequired, setBlacklistTableOption: PropTypes.func.isRequired,
clearBlacklist: PropTypes.func.isRequired, clearBlacklist: PropTypes.func.isRequired,

View File

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
@ -40,6 +41,7 @@ class BlacklistRow extends Component {
render() { render() {
const { const {
id,
series, series,
sourceTitle, sourceTitle,
language, language,
@ -48,12 +50,20 @@ class BlacklistRow extends Component {
protocol, protocol,
indexer, indexer,
message, message,
isSelected,
columns, columns,
onSelectedChange,
onRemovePress onRemovePress
} = this.props; } = this.props;
return ( return (
<TableRow> <TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{ {
columns.map((column) => { columns.map((column) => {
const { const {
@ -179,7 +189,9 @@ BlacklistRow.propTypes = {
protocol: PropTypes.string.isRequired, protocol: PropTypes.string.isRequired,
indexer: PropTypes.string, indexer: PropTypes.string,
message: PropTypes.string, message: PropTypes.string,
isSelected: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelectedChange: PropTypes.func.isRequired,
onRemovePress: PropTypes.func.isRequired onRemovePress: PropTypes.func.isRequired
}; };

View File

@ -1,6 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { removeFromBlacklist } from 'Store/Actions/blacklistActions'; import { removeBlacklistItem } from 'Store/Actions/blacklistActions';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import BlacklistRow from './BlacklistRow'; import BlacklistRow from './BlacklistRow';
@ -18,7 +18,7 @@ function createMapStateToProps() {
function createMapDispatchToProps(dispatch, props) { function createMapDispatchToProps(dispatch, props) {
return { return {
onRemovePress() { onRemovePress() {
dispatch(removeFromBlacklist({ id: props.id })); dispatch(removeBlacklistItem({ id: props.id }));
} }
}; };
} }

View File

@ -1,4 +1,6 @@
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
import { createThunk, handleThunks } from 'Store/thunks'; import { createThunk, handleThunks } from 'Store/thunks';
import { sortDirections } from 'Helpers/Props'; import { sortDirections } from 'Helpers/Props';
@ -7,6 +9,7 @@ import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptio
import createHandleActions from './Creators/createHandleActions'; import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler'; import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import { set, updateItem } from './baseActions';
// //
// Variables // Variables
@ -24,6 +27,7 @@ export const defaultState = {
sortDirection: sortDirections.DESCENDING, sortDirection: sortDirections.DESCENDING,
error: null, error: null,
items: [], items: [],
isRemoving: false,
columns: [ columns: [
{ {
@ -87,7 +91,8 @@ export const GOTO_LAST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistLastPage';
export const GOTO_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPage'; export const GOTO_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPage';
export const SET_BLACKLIST_SORT = 'blacklist/setBlacklistSort'; export const SET_BLACKLIST_SORT = 'blacklist/setBlacklistSort';
export const SET_BLACKLIST_TABLE_OPTION = 'blacklist/setBlacklistTableOption'; 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'; 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 gotoBlacklistPage = createThunk(GOTO_BLACKLIST_PAGE);
export const setBlacklistSort = createThunk(SET_BLACKLIST_SORT); export const setBlacklistSort = createThunk(SET_BLACKLIST_SORT);
export const setBlacklistTableOption = createAction(SET_BLACKLIST_TABLE_OPTION); 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); export const clearBlacklist = createAction(CLEAR_BLACKLIST);
// //
@ -122,7 +128,53 @@ export const actionHandlers = handleThunks({
[serverSideCollectionHandlers.SORT]: SET_BLACKLIST_SORT [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 })
]));
});
}
}); });
// //

View File

@ -318,9 +318,9 @@ export const actionHandlers = handleThunks({
}).request; }).request;
promise.done((data) => { promise.done((data) => {
dispatch(batchActions([ dispatch(fetchQueue());
fetchQueue(),
dispatch(batchActions([
...ids.map((id) => { ...ids.map((id) => {
return updateItem({ return updateItem({
section: paged, section: paged,
@ -404,10 +404,10 @@ export const actionHandlers = handleThunks({
}).request; }).request;
promise.done((data) => { promise.done((data) => {
dispatch(batchActions([ // Don't use batchActions with thunks
set({ section: paged, isRemoving: false }), dispatch(fetchQueue());
fetchQueue()
])); dispatch(set({ section: paged, isRemoving: false }));
}); });
promise.fail((xhr) => { promise.fail((xhr) => {

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore;
@ -16,6 +17,7 @@ public interface IBlacklistService
bool Blacklisted(int seriesId, ReleaseInfo release); bool Blacklisted(int seriesId, ReleaseInfo release);
PagingSpec<Blacklist> Paged(PagingSpec<Blacklist> pagingSpec); PagingSpec<Blacklist> Paged(PagingSpec<Blacklist> pagingSpec);
void Delete(int id); void Delete(int id);
void Delete(List<int> ids);
} }
public class BlacklistService : IBlacklistService, public class BlacklistService : IBlacklistService,
@ -65,6 +67,11 @@ public void Delete(int id)
_blacklistRepository.Delete(id); _blacklistRepository.Delete(id);
} }
public void Delete(List<int> ids)
{
_blacklistRepository.DeleteMany(ids);
}
private bool SameNzb(Blacklist item, ReleaseInfo release) private bool SameNzb(Blacklist item, ReleaseInfo release)
{ {
if (item.PublishedDate == release.PublishDate) if (item.PublishedDate == release.PublishDate)

View File

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace NzbDrone.Api.V3.Blacklist
{
public class BlacklistBulkResource
{
public List<int> Ids { get; set; }
}
}

View File

@ -1,6 +1,8 @@
using NzbDrone.Core.Blacklisting; using NzbDrone.Api.V3.Blacklist;
using NzbDrone.Core.Blacklisting;
using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore;
using Sonarr.Http; using Sonarr.Http;
using Sonarr.Http.Extensions;
namespace Sonarr.Api.V3.Blacklist namespace Sonarr.Api.V3.Blacklist
{ {
@ -13,6 +15,8 @@ public BlacklistModule(IBlacklistService blacklistService)
_blacklistService = blacklistService; _blacklistService = blacklistService;
GetResourcePaged = GetBlacklist; GetResourcePaged = GetBlacklist;
DeleteResource = DeleteBlacklist; DeleteResource = DeleteBlacklist;
Delete("/bulk", x => Remove());
} }
private PagingResource<BlacklistResource> GetBlacklist(PagingResource<BlacklistResource> pagingResource) private PagingResource<BlacklistResource> GetBlacklist(PagingResource<BlacklistResource> pagingResource)
@ -26,5 +30,14 @@ private void DeleteBlacklist(int id)
{ {
_blacklistService.Delete(id); _blacklistService.Delete(id);
} }
private object Remove()
{
var resource = Request.Body.FromJson<BlacklistBulkResource>();
_blacklistService.Delete(resource.Ids);
return new object();
}
} }
} }