From 9e7c74c2acce114f94d8daeb94fa1c813245c402 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 9 Aug 2024 20:05:07 +0300 Subject: [PATCH] New: Bulk import list exclusions removal --- .../ImportListExclusionRow.tsx | 25 +- .../ImportListExclusions.tsx | 285 +++++++++++------- .../Actions/Settings/importListExclusions.js | 40 ++- .../Exclusions/ImportListExclusionService.cs | 6 + src/NzbDrone.Core/Localization/Core/en.json | 4 +- .../ImportListExclusionBulkResource.cs | 9 + .../ImportListExclusionController.cs | 12 +- 7 files changed, 249 insertions(+), 132 deletions(-) create mode 100644 src/Sonarr.Api.V3/ImportLists/ImportListExclusionBulkResource.cs diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx index 37de7940a..155d53a78 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionRow.tsx @@ -1,21 +1,28 @@ import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; import IconButton from 'Components/Link/IconButton'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import TableRow from 'Components/Table/TableRow'; import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; import { icons, kinds } from 'Helpers/Props'; +import { deleteImportListExclusion } from 'Store/Actions/Settings/importListExclusions'; import ImportListExclusion from 'typings/ImportListExclusion'; +import { SelectStateInputProps } from 'typings/props'; import translate from 'Utilities/String/translate'; import EditImportListExclusionModal from './EditImportListExclusionModal'; import styles from './ImportListExclusionRow.css'; interface ImportListExclusionRowProps extends ImportListExclusion { - onConfirmDeleteImportListExclusion: (id: number) => void; + isSelected: boolean; + onSelectedChange: (options: SelectStateInputProps) => void; } function ImportListExclusionRow(props: ImportListExclusionRowProps) { - const { id, title, tvdbId, onConfirmDeleteImportListExclusion } = props; + const { id, tvdbId, title, isSelected, onSelectedChange } = props; + + const dispatch = useDispatch(); const [ isEditImportListExclusionModalOpen, @@ -29,12 +36,18 @@ function ImportListExclusionRow(props: ImportListExclusionRowProps) { setDeleteImportListExclusionModalClosed, ] = useModalOpenState(false); - const onConfirmDeleteImportListExclusionPress = useCallback(() => { - onConfirmDeleteImportListExclusion(id); - }, [id, onConfirmDeleteImportListExclusion]); + const handleDeletePress = useCallback(() => { + dispatch(deleteImportListExclusion({ id })); + }, [id, dispatch]); return ( + + {title} {tvdbId} @@ -58,7 +71,7 @@ function ImportListExclusionRow(props: ImportListExclusionRowProps) { title={translate('DeleteImportListExclusion')} message={translate('DeleteImportListExclusionMessageText')} confirmLabel={translate('Delete')} - onConfirm={onConfirmDeleteImportListExclusionPress} + onConfirm={handleDeletePress} onCancel={setDeleteImportListExclusionModalClosed} /> diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx index 8c7033686..628a73b07 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx @@ -1,24 +1,40 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useHistory } from 'react-router'; import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; import FieldSet from 'Components/FieldSet'; import IconButton from 'Components/Link/IconButton'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; import PageSectionContent from 'Components/Page/PageSectionContent'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import TablePager from 'Components/Table/TablePager'; import TableRow from 'Components/Table/TableRow'; +import usePaging from 'Components/Table/usePaging'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; -import { icons } from 'Helpers/Props'; -import * as importListExclusionActions from 'Store/Actions/Settings/importListExclusions'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { icons, kinds } from 'Helpers/Props'; +import { + bulkDeleteImportListExclusions, + clearImportListExclusions, + fetchImportListExclusions, + gotoImportListExclusionPage, + setImportListExclusionSort, + setImportListExclusionTableOption, +} from 'Store/Actions/Settings/importListExclusions'; +import { CheckInputChanged } from 'typings/inputs'; +import { SelectStateInputProps } from 'typings/props'; +import { TableOptionsChangePayload } from 'typings/Table'; import { registerPagePopulator, unregisterPagePopulator, } from 'Utilities/pagePopulator'; import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; import EditImportListExclusionModal from './EditImportListExclusionModal'; import ImportListExclusionRow from './ImportListExclusionRow'; @@ -54,95 +70,7 @@ function createImportListExlucionsSelector() { } function ImportListExclusions() { - const history = useHistory(); - const useCurrentPage = history.action === 'POP'; - - const dispatch = useDispatch(); - - const fetchImportListExclusions = useCallback(() => { - dispatch(importListExclusionActions.fetchImportListExclusions()); - }, [dispatch]); - - const deleteImportListExclusion = useCallback( - (payload: { id: number }) => { - dispatch(importListExclusionActions.deleteImportListExclusion(payload)); - }, - [dispatch] - ); - - const gotoImportListExclusionFirstPage = useCallback(() => { - dispatch(importListExclusionActions.gotoImportListExclusionFirstPage()); - }, [dispatch]); - - const gotoImportListExclusionPreviousPage = useCallback(() => { - dispatch(importListExclusionActions.gotoImportListExclusionPreviousPage()); - }, [dispatch]); - - const gotoImportListExclusionNextPage = useCallback(() => { - dispatch(importListExclusionActions.gotoImportListExclusionNextPage()); - }, [dispatch]); - - const gotoImportListExclusionLastPage = useCallback(() => { - dispatch(importListExclusionActions.gotoImportListExclusionLastPage()); - }, [dispatch]); - - const gotoImportListExclusionPage = useCallback( - (page: number) => { - dispatch( - importListExclusionActions.gotoImportListExclusionPage({ page }) - ); - }, - [dispatch] - ); - - const setImportListExclusionSort = useCallback( - (sortKey: { sortKey: string }) => { - dispatch( - importListExclusionActions.setImportListExclusionSort({ sortKey }) - ); - }, - [dispatch] - ); - - const setImportListTableOption = useCallback( - (payload: { pageSize: number }) => { - dispatch( - importListExclusionActions.setImportListExclusionTableOption(payload) - ); - - if (payload.pageSize) { - dispatch(importListExclusionActions.gotoImportListExclusionFirstPage()); - } - }, - [dispatch] - ); - - const repopulate = useCallback(() => { - gotoImportListExclusionFirstPage(); - }, [gotoImportListExclusionFirstPage]); - - useEffect(() => { - registerPagePopulator(repopulate); - - if (useCurrentPage) { - fetchImportListExclusions(); - } else { - gotoImportListExclusionFirstPage(); - } - - return () => unregisterPagePopulator(repopulate); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onConfirmDeleteImportListExclusion = useCallback( - (id: number) => { - deleteImportListExclusion({ id }); - repopulate(); - }, - [deleteImportListExclusion, repopulate] - ); - - const selected = useSelector(createImportListExlucionsSelector()); + const requestCurrentPage = useCurrentPage(); const { isFetching, @@ -152,9 +80,127 @@ function ImportListExclusions() { sortKey, error, sortDirection, + page, + totalPages, totalRecords, - ...otherProps - } = selected; + isDeleting, + deleteError, + } = useSelector(createImportListExlucionsSelector()); + + const dispatch = useDispatch(); + + const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] = + useState(false); + const previousIsDeleting = usePrevious(isDeleting); + + const [selectState, setSelectState] = useSelectState(); + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const handleSelectAllChange = useCallback( + ({ value }: CheckInputChanged) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const handleSelectedChange = useCallback( + ({ id, value, shiftKey = false }: SelectStateInputProps) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const handleDeleteSelectedPress = useCallback(() => { + setIsConfirmDeleteModalOpen(true); + }, [setIsConfirmDeleteModalOpen]); + + const handleDeleteSelectedConfirmed = useCallback(() => { + dispatch(bulkDeleteImportListExclusions({ ids: selectedIds })); + setIsConfirmDeleteModalOpen(false); + }, [selectedIds, setIsConfirmDeleteModalOpen, dispatch]); + + const handleConfirmDeleteModalClose = useCallback(() => { + setIsConfirmDeleteModalOpen(false); + }, [setIsConfirmDeleteModalOpen]); + + const { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + } = usePaging({ + page, + totalPages, + gotoPage: gotoImportListExclusionPage, + }); + + const handleSortPress = useCallback( + (sortKey: { sortKey: string }) => { + dispatch(setImportListExclusionSort({ sortKey })); + }, + [dispatch] + ); + + const handleTableOptionChange = useCallback( + (payload: TableOptionsChangePayload) => { + dispatch(setImportListExclusionTableOption(payload)); + + if (payload.pageSize) { + dispatch(gotoImportListExclusionPage({ page: 1 })); + } + }, + [dispatch] + ); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchImportListExclusions()); + } else { + dispatch(gotoImportListExclusionPage({ page: 1 })); + } + + return () => { + dispatch(clearImportListExclusions()); + }; + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchImportListExclusions()); + }; + + registerPagePopulator(repopulate); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [dispatch]); + + useEffect(() => { + if (previousIsDeleting && !isDeleting && !deleteError) { + setSelectState({ type: 'unselectAll', items }); + + dispatch(fetchImportListExclusions()); + } + }, [ + previousIsDeleting, + isDeleting, + deleteError, + items, + dispatch, + setSelectState, + ]); const [ isAddImportListExclusionModalOpen, @@ -173,13 +219,17 @@ function ImportListExclusions() { error={error} > {items.map((item) => { @@ -187,16 +237,23 @@ function ImportListExclusions() { ); })} - - + + + {translate('Delete')} + + + + ); diff --git a/frontend/src/Store/Actions/Settings/importListExclusions.js b/frontend/src/Store/Actions/Settings/importListExclusions.js index 3af8bf9ec..a89d65208 100644 --- a/frontend/src/Store/Actions/Settings/importListExclusions.js +++ b/frontend/src/Store/Actions/Settings/importListExclusions.js @@ -1,7 +1,9 @@ import { createAction } from 'redux-actions'; +import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; import createServerSideCollectionHandlers from 'Store/Actions/Creators/createServerSideCollectionHandlers'; +import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import createSetTableOptionReducer from 'Store/Actions/Creators/Reducers/createSetTableOptionReducer'; import { createThunk, handleThunks } from 'Store/thunks'; @@ -16,29 +18,26 @@ const section = 'settings.importListExclusions'; // Actions Types export const FETCH_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/fetchImportListExclusions'; -export const GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionFirstPage'; -export const GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionPreviousPage'; -export const GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionNextPage'; -export const GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionLastPage'; export const GOTO_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionPage'; export const SET_IMPORT_LIST_EXCLUSION_SORT = 'settings/importListExclusions/setImportListExclusionSort'; -export const SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION = 'settings/importListExclusions/setImportListExclusionTableOption'; export const SAVE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/saveImportListExclusion'; export const DELETE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/deleteImportListExclusion'; +export const BULK_DELETE_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/bulkDeleteImportListExclusions'; +export const CLEAR_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/clearImportListExclusions'; + +export const SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION = 'settings/importListExclusions/setImportListExclusionTableOption'; export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/setImportListExclusionValue'; // // Action Creators export const fetchImportListExclusions = createThunk(FETCH_IMPORT_LIST_EXCLUSIONS); -export const gotoImportListExclusionFirstPage = createThunk(GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE); -export const gotoImportListExclusionPreviousPage = createThunk(GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE); -export const gotoImportListExclusionNextPage = createThunk(GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE); -export const gotoImportListExclusionLastPage = createThunk(GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE); export const gotoImportListExclusionPage = createThunk(GOTO_IMPORT_LIST_EXCLUSION_PAGE); export const setImportListExclusionSort = createThunk(SET_IMPORT_LIST_EXCLUSION_SORT); export const saveImportListExclusion = createThunk(SAVE_IMPORT_LIST_EXCLUSION); export const deleteImportListExclusion = createThunk(DELETE_IMPORT_LIST_EXCLUSION); +export const bulkDeleteImportListExclusions = createThunk(BULK_DELETE_IMPORT_LIST_EXCLUSIONS); +export const clearImportListExclusions = createAction(CLEAR_IMPORT_LIST_EXCLUSIONS); export const setImportListExclusionTableOption = createAction(SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION); export const setImportListExclusionValue = createAction(SET_IMPORT_LIST_EXCLUSION_VALUE, (payload) => { @@ -64,6 +63,8 @@ export default { items: [], isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, pendingChanges: {} }, @@ -77,16 +78,13 @@ export default { fetchImportListExclusions, { [serverSideCollectionHandlers.FETCH]: FETCH_IMPORT_LIST_EXCLUSIONS, - [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE, - [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE, - [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE, - [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE, [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_IMPORT_LIST_EXCLUSION_PAGE, [serverSideCollectionHandlers.SORT]: SET_IMPORT_LIST_EXCLUSION_SORT } ), [SAVE_IMPORT_LIST_EXCLUSION]: createSaveProviderHandler(section, '/importlistexclusion'), - [DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion') + [DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion'), + [BULK_DELETE_IMPORT_LIST_EXCLUSIONS]: createBulkRemoveItemHandler(section, '/importlistexclusion/bulk') }), // @@ -94,7 +92,19 @@ export default { reducers: { [SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section), - [SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION]: createSetTableOptionReducer(section) + [SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_IMPORT_LIST_EXCLUSIONS]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isDeleting: false, + deleteError: null, + pendingChanges: {}, + totalPages: 0, + totalRecords: 0 + }) } }; diff --git a/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionService.cs b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionService.cs index 2a9f0a9ec..704472125 100644 --- a/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionService.cs +++ b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionService.cs @@ -12,6 +12,7 @@ namespace NzbDrone.Core.ImportLists.Exclusions List All(); PagingSpec Paged(PagingSpec pagingSpec); void Delete(int id); + void Delete(List ids); ImportListExclusion Get(int id); ImportListExclusion FindByTvdbId(int tvdbId); ImportListExclusion Update(ImportListExclusion importListExclusion); @@ -41,6 +42,11 @@ namespace NzbDrone.Core.ImportLists.Exclusions _repo.Delete(id); } + public void Delete(List ids) + { + _repo.DeleteMany(ids); + } + public ImportListExclusion Get(int id) { return _repo.Get(id); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 8d7d90087..3ffc97af5 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -363,10 +363,12 @@ "DeleteRemotePathMappingMessageText": "Are you sure you want to delete this remote path mapping?", "DeleteRootFolder": "Delete Root Folder", "DeleteRootFolderMessageText": "Are you sure you want to delete the root folder '{path}'?", + "DeleteSelected": "Delete Selected", "DeleteSelectedDownloadClients": "Delete Download Client(s)", "DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {count} selected download client(s)?", "DeleteSelectedEpisodeFiles": "Delete Selected Episode Files", "DeleteSelectedEpisodeFilesHelpText": "Are you sure you want to delete the selected episode files?", + "DeleteSelectedImportListExclusionsMessageText": "Are you sure you want to delete the selected import list exclusions?", "DeleteSelectedImportLists": "Delete Import List(s)", "DeleteSelectedImportListsMessageText": "Are you sure you want to delete {count} selected import list(s)?", "DeleteSelectedIndexers": "Delete Indexer(s)", @@ -1787,8 +1789,8 @@ "SeasonPremieresOnly": "Season Premieres Only", "Seasons": "Seasons", "SeasonsMonitoredAll": "All", - "SeasonsMonitoredPartial": "Partial", "SeasonsMonitoredNone": "None", + "SeasonsMonitoredPartial": "Partial", "SeasonsMonitoredStatus": "Seasons Monitored", "SecretToken": "Secret Token", "Security": "Security", diff --git a/src/Sonarr.Api.V3/ImportLists/ImportListExclusionBulkResource.cs b/src/Sonarr.Api.V3/ImportLists/ImportListExclusionBulkResource.cs new file mode 100644 index 000000000..c257d35ad --- /dev/null +++ b/src/Sonarr.Api.V3/ImportLists/ImportListExclusionBulkResource.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Sonarr.Api.V3.ImportLists +{ + public class ImportListExclusionBulkResource + { + public HashSet Ids { get; set; } + } +} diff --git a/src/Sonarr.Api.V3/ImportLists/ImportListExclusionController.cs b/src/Sonarr.Api.V3/ImportLists/ImportListExclusionController.cs index 58742ad79..8efb44e18 100644 --- a/src/Sonarr.Api.V3/ImportLists/ImportListExclusionController.cs +++ b/src/Sonarr.Api.V3/ImportLists/ImportListExclusionController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using FluentValidation; using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.ImportLists.Exclusions; @@ -65,9 +66,18 @@ namespace Sonarr.Api.V3.ImportLists } [RestDeleteById] - public void DeleteImportListExclusionResource(int id) + public void DeleteImportListExclusion(int id) { _importListExclusionService.Delete(id); } + + [HttpDelete("bulk")] + [Produces("application/json")] + public object DeleteImportListExclusions([FromBody] ImportListExclusionBulkResource resource) + { + _importListExclusionService.Delete(resource.Ids.ToList()); + + return new { }; + } } }