From 190c4c5893d303b027cd38b49eca47330c84e793 Mon Sep 17 00:00:00 2001 From: Stevie Robinson Date: Thu, 9 May 2024 03:42:41 +0200 Subject: [PATCH] New: Blocklist Custom Filters (cherry picked from commit f81bb3ec1945d343dd0695a2826dac8833cb6346) Closes #9997 --- frontend/src/Activity/Blocklist/Blocklist.js | 27 +++++++++- .../Activity/Blocklist/BlocklistConnector.js | 19 +++++-- .../Blocklist/BlocklistFilterModal.tsx | 54 +++++++++++++++++++ frontend/src/App/State/AppState.ts | 2 + frontend/src/App/State/BlocklistAppState.ts | 8 +++ .../src/Store/Actions/blocklistActions.js | 33 +++++++++++- frontend/src/typings/Blocklist.ts | 16 ++++++ .../Blocklisting/BlocklistRepository.cs | 32 ++++++++--- src/NzbDrone.Core/Localization/Core/en.json | 1 + .../Blocklist/BlocklistController.cs | 15 +++++- 10 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx create mode 100644 frontend/src/App/State/BlocklistAppState.ts create mode 100644 frontend/src/typings/Blocklist.ts diff --git a/frontend/src/Activity/Blocklist/Blocklist.js b/frontend/src/Activity/Blocklist/Blocklist.js index 797aa5175..19026beb5 100644 --- a/frontend/src/Activity/Blocklist/Blocklist.js +++ b/frontend/src/Activity/Blocklist/Blocklist.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; @@ -20,6 +21,7 @@ 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 BlocklistFilterModal from './BlocklistFilterModal'; import BlocklistRowConnector from './BlocklistRowConnector'; class Blocklist extends Component { @@ -114,9 +116,13 @@ class Blocklist extends Component { error, items, columns, + selectedFilterKey, + filters, + customFilters, totalRecords, isRemoving, isClearingBlocklistExecuting, + onFilterSelect, ...otherProps } = this.props; @@ -161,6 +167,15 @@ class Blocklist extends Component { iconName={icons.TABLE} /> + + @@ -180,7 +195,11 @@ class Blocklist extends Component { { isPopulated && !error && !items.length && - {translate('NoHistoryBlocklist')} + { + selectedFilterKey === 'all' ? + translate('NoHistoryBlocklist') : + translate('BlocklistFilterHasNoItems') + } } @@ -251,11 +270,15 @@ Blocklist.propTypes = { error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, totalRecords: PropTypes.number, isRemoving: PropTypes.bool.isRequired, isClearingBlocklistExecuting: PropTypes.bool.isRequired, onRemoveSelected: PropTypes.func.isRequired, - onClearBlocklistPress: PropTypes.func.isRequired + onClearBlocklistPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired }; export default Blocklist; diff --git a/frontend/src/Activity/Blocklist/BlocklistConnector.js b/frontend/src/Activity/Blocklist/BlocklistConnector.js index b49b413e6..5eb055a06 100644 --- a/frontend/src/Activity/Blocklist/BlocklistConnector.js +++ b/frontend/src/Activity/Blocklist/BlocklistConnector.js @@ -6,6 +6,7 @@ import * as commandNames from 'Commands/commandNames'; import withCurrentPage from 'Components/withCurrentPage'; import * as blocklistActions from 'Store/Actions/blocklistActions'; import { executeCommand } from 'Store/Actions/commandActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import Blocklist from './Blocklist'; @@ -13,10 +14,12 @@ import Blocklist from './Blocklist'; function createMapStateToProps() { return createSelector( (state) => state.blocklist, + createCustomFiltersSelector('blocklist'), createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST), - (blocklist, isClearingBlocklistExecuting) => { + (blocklist, customFilters, isClearingBlocklistExecuting) => { return { isClearingBlocklistExecuting, + customFilters, ...blocklist }; } @@ -97,6 +100,14 @@ class BlocklistConnector extends Component { this.props.setBlocklistSort({ sortKey }); }; + onFilterSelect = (selectedFilterKey) => { + this.props.setBlocklistFilter({ selectedFilterKey }); + }; + + onClearBlocklistPress = () => { + this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST }); + }; + onTableOptionChange = (payload) => { this.props.setBlocklistTableOption(payload); @@ -105,10 +116,6 @@ class BlocklistConnector extends Component { } }; - onClearBlocklistPress = () => { - this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST }); - }; - // // Render @@ -122,6 +129,7 @@ class BlocklistConnector extends Component { onPageSelect={this.onPageSelect} onRemoveSelected={this.onRemoveSelected} onSortPress={this.onSortPress} + onFilterSelect={this.onFilterSelect} onTableOptionChange={this.onTableOptionChange} onClearBlocklistPress={this.onClearBlocklistPress} {...this.props} @@ -142,6 +150,7 @@ BlocklistConnector.propTypes = { gotoBlocklistPage: PropTypes.func.isRequired, removeBlocklistItems: PropTypes.func.isRequired, setBlocklistSort: PropTypes.func.isRequired, + setBlocklistFilter: PropTypes.func.isRequired, setBlocklistTableOption: PropTypes.func.isRequired, clearBlocklist: PropTypes.func.isRequired, executeCommand: PropTypes.func.isRequired diff --git a/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx b/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx new file mode 100644 index 000000000..ea80458f1 --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setBlocklistFilter } from 'Store/Actions/blocklistActions'; + +function createBlocklistSelector() { + return createSelector( + (state: AppState) => state.blocklist.items, + (blocklistItems) => { + return blocklistItems; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.blocklist.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface BlocklistFilterModalProps { + isOpen: boolean; +} + +export default function BlocklistFilterModal(props: BlocklistFilterModalProps) { + const sectionItems = useSelector(createBlocklistSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'blocklist'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setBlocklistFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 27a78d7aa..5d372b729 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,4 +1,5 @@ import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; +import BlocklistAppState from './BlocklistAppState'; import CalendarAppState from './CalendarAppState'; import CommandAppState from './CommandAppState'; import HistoryAppState from './HistoryAppState'; @@ -54,6 +55,7 @@ export interface AppSectionState { interface AppState { app: AppSectionState; + blocklist: BlocklistAppState; calendar: CalendarAppState; commands: CommandAppState; history: HistoryAppState; diff --git a/frontend/src/App/State/BlocklistAppState.ts b/frontend/src/App/State/BlocklistAppState.ts new file mode 100644 index 000000000..e838ad625 --- /dev/null +++ b/frontend/src/App/State/BlocklistAppState.ts @@ -0,0 +1,8 @@ +import Blocklist from 'typings/Blocklist'; +import AppSectionState, { AppSectionFilterState } from './AppSectionState'; + +interface BlocklistAppState + extends AppSectionState, + AppSectionFilterState {} + +export default BlocklistAppState; diff --git a/frontend/src/Store/Actions/blocklistActions.js b/frontend/src/Store/Actions/blocklistActions.js index 39ed4ed29..ab093ad78 100644 --- a/frontend/src/Store/Actions/blocklistActions.js +++ b/frontend/src/Store/Actions/blocklistActions.js @@ -1,6 +1,6 @@ import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; -import { sortDirections } from 'Helpers/Props'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; @@ -79,6 +79,31 @@ export const defaultState = { isVisible: true, isModifiable: false } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: () => translate('All'), + filters: [] + } + ], + + filterBuilderProps: [ + { + name: 'movieIds', + label: () => translate('Movie'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.MOVIE + }, + { + name: 'protocols', + label: () => translate('Protocol'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.PROTOCOL + } ] }; @@ -86,6 +111,7 @@ export const persistState = [ 'blocklist.pageSize', 'blocklist.sortKey', 'blocklist.sortDirection', + 'blocklist.selectedFilterKey', 'blocklist.columns' ]; @@ -99,6 +125,7 @@ export const GOTO_NEXT_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistNextPage'; export const GOTO_LAST_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistLastPage'; export const GOTO_BLOCKLIST_PAGE = 'blocklist/gotoBlocklistPage'; export const SET_BLOCKLIST_SORT = 'blocklist/setBlocklistSort'; +export const SET_BLOCKLIST_FILTER = 'blocklist/setBlocklistFilter'; export const SET_BLOCKLIST_TABLE_OPTION = 'blocklist/setBlocklistTableOption'; export const REMOVE_BLOCKLIST_ITEM = 'blocklist/removeBlocklistItem'; export const REMOVE_BLOCKLIST_ITEMS = 'blocklist/removeBlocklistItems'; @@ -114,6 +141,7 @@ export const gotoBlocklistNextPage = createThunk(GOTO_NEXT_BLOCKLIST_PAGE); export const gotoBlocklistLastPage = createThunk(GOTO_LAST_BLOCKLIST_PAGE); export const gotoBlocklistPage = createThunk(GOTO_BLOCKLIST_PAGE); export const setBlocklistSort = createThunk(SET_BLOCKLIST_SORT); +export const setBlocklistFilter = createThunk(SET_BLOCKLIST_FILTER); export const setBlocklistTableOption = createAction(SET_BLOCKLIST_TABLE_OPTION); export const removeBlocklistItem = createThunk(REMOVE_BLOCKLIST_ITEM); export const removeBlocklistItems = createThunk(REMOVE_BLOCKLIST_ITEMS); @@ -134,7 +162,8 @@ export const actionHandlers = handleThunks({ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLOCKLIST_PAGE, [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLOCKLIST_PAGE, [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLOCKLIST_PAGE, - [serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT + [serverSideCollectionHandlers.SORT]: SET_BLOCKLIST_SORT, + [serverSideCollectionHandlers.FILTER]: SET_BLOCKLIST_FILTER }), [REMOVE_BLOCKLIST_ITEM]: createRemoveItemHandler(section, '/blocklist'), diff --git a/frontend/src/typings/Blocklist.ts b/frontend/src/typings/Blocklist.ts new file mode 100644 index 000000000..846c6fab7 --- /dev/null +++ b/frontend/src/typings/Blocklist.ts @@ -0,0 +1,16 @@ +import ModelBase from 'App/ModelBase'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import CustomFormat from 'typings/CustomFormat'; + +interface Blocklist extends ModelBase { + languages: Language[]; + quality: QualityModel; + customFormats: CustomFormat[]; + title: string; + date?: string; + protocol: string; + movieId?: number; +} + +export default Blocklist; diff --git a/src/NzbDrone.Core/Blocklisting/BlocklistRepository.cs b/src/NzbDrone.Core/Blocklisting/BlocklistRepository.cs index 7403db265..39ca0f58b 100644 --- a/src/NzbDrone.Core/Blocklisting/BlocklistRepository.cs +++ b/src/NzbDrone.Core/Blocklisting/BlocklistRepository.cs @@ -48,14 +48,30 @@ public void DeleteForMovies(List movieIds) Delete(x => movieIds.Contains(x.MovieId)); } - protected override SqlBuilder PagedBuilder() => new SqlBuilder(_database.DatabaseType) - .Join((b, m) => b.MovieId == m.Id) - .LeftJoin((m, mm) => m.MovieMetadataId == mm.Id); + public override PagingSpec GetPaged(PagingSpec pagingSpec) + { + pagingSpec.Records = GetPagedRecords(PagedBuilder(), pagingSpec, PagedQuery); - protected override IEnumerable PagedQuery(SqlBuilder sql) => _database.QueryJoined(sql, (bl, movie) => - { - bl.Movie = movie; - return bl; - }); + var countTemplate = $"SELECT COUNT(*) FROM (SELECT /**select**/ FROM \"{TableMapping.Mapper.TableNameMapping(typeof(Blocklist))}\" /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/) AS \"Inner\""; + pagingSpec.TotalRecords = GetPagedRecordCount(PagedBuilder().Select(typeof(Blocklist)), pagingSpec, countTemplate); + + return pagingSpec; + } + + protected override SqlBuilder PagedBuilder() + { + var builder = Builder() + .Join((b, m) => b.MovieId == m.Id) + .LeftJoin((m, mm) => m.MovieMetadataId == mm.Id); + + return builder; + } + + protected override IEnumerable PagedQuery(SqlBuilder builder) => + _database.QueryJoined(builder, (blocklist, movie) => + { + blocklist.Movie = movie; + return blocklist; + }); } } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index df1cd6d6d..6efc2caf9 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -138,6 +138,7 @@ "BlocklistAndSearch": "Blocklist and Search", "BlocklistAndSearchHint": "Start a search for a replacement after blocklisting", "BlocklistAndSearchMultipleHint": "Start searches for replacements after blocklisting", + "BlocklistFilterHasNoItems": "Selected blocklist filter contains no items", "BlocklistLoadError": "Unable to load blocklist", "BlocklistMultipleOnlyHint": "Blocklist without searching for replacements", "BlocklistOnly": "Blocklist Only", diff --git a/src/Radarr.Api.V3/Blocklist/BlocklistController.cs b/src/Radarr.Api.V3/Blocklist/BlocklistController.cs index 3f65d40d9..58f6f2834 100644 --- a/src/Radarr.Api.V3/Blocklist/BlocklistController.cs +++ b/src/Radarr.Api.V3/Blocklist/BlocklistController.cs @@ -4,6 +4,7 @@ using NzbDrone.Core.Blocklisting; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Indexers; using Radarr.Http; using Radarr.Http.Extensions; using Radarr.Http.REST.Attributes; @@ -25,12 +26,22 @@ public BlocklistController(IBlocklistService blocklistService, [HttpGet] [Produces("application/json")] - public PagingResource GetBlocklist([FromQuery] PagingRequestResource paging) + public PagingResource GetBlocklist([FromQuery] PagingRequestResource paging, [FromQuery] int[] movieIds = null, [FromQuery] DownloadProtocol[] protocols = null) { var pagingResource = new PagingResource(paging); var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending); - return pagingSpec.ApplyToPage(_blocklistService.Paged, model => BlocklistResourceMapper.MapToResource(model, _formatCalculator)); + if (movieIds?.Any() == true) + { + pagingSpec.FilterExpressions.Add(b => movieIds.Contains(b.MovieId)); + } + + if (protocols?.Any() == true) + { + pagingSpec.FilterExpressions.Add(b => protocols.Contains(b.Protocol)); + } + + return pagingSpec.ApplyToPage(b => _blocklistService.Paged(pagingSpec), b => BlocklistResourceMapper.MapToResource(b, _formatCalculator)); } [HttpGet("movie")]