diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js index 8afbf9ea0..4c970e469 100644 --- a/frontend/src/Components/Table/Table.js +++ b/frontend/src/Components/Table/Table.js @@ -66,7 +66,9 @@ function Table(props) { columns.map((column) => { const { name, - isVisible + isVisible, + isSortable, + ...otherColumnProps } = column; if (!isVisible) { @@ -84,6 +86,7 @@ function Table(props) { name={name} isSortable={false} {...otherProps} + {...otherColumnProps} > 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.css b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css new file mode 100644 index 000000000..e213a1c11 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css @@ -0,0 +1,6 @@ +.actions { + composes: headerCell from '~Components/Table/TableHeaderCell.css'; + + width: 35px; + white-space: nowrap; +} diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css.d.ts b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css.d.ts index 626717e71..d8ea83dc1 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css.d.ts +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css.d.ts @@ -1,8 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'addButton': string; - 'addImportListExclusion': string; + 'actions': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx index 8c7033686..a93ecda3c 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.tsx @@ -1,28 +1,46 @@ -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 Column from 'Components/Table/Column'; 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'; +import styles from './ImportListExclusions.css'; -const COLUMNS = [ +const COLUMNS: Column[] = [ { name: 'title', label: () => translate('Title'), @@ -36,13 +54,15 @@ const COLUMNS = [ isSortable: true, }, { + className: styles.actions, name: 'actions', + label: '', isVisible: true, isSortable: false, }, ]; -function createImportListExlucionsSelector() { +function createImportListExclusionsSelector() { return createSelector( (state: AppState) => state.settings.importListExclusions, (importListExclusions) => { @@ -54,95 +74,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 +84,127 @@ function ImportListExclusions() { sortKey, error, sortDirection, + page, + totalPages, totalRecords, - ...otherProps - } = selected; + isDeleting, + deleteError, + } = useSelector(createImportListExclusionsSelector()); + + 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 +223,17 @@ function ImportListExclusions() { error={error} > {items.map((item) => { @@ -187,16 +241,23 @@ function ImportListExclusions() { ); })} - - + + + {translate('Delete')} + + + + ); diff --git a/frontend/src/Settings/ImportLists/ImportListSettings.js b/frontend/src/Settings/ImportLists/ImportListSettings.js index 1ec50526e..6a7365158 100644 --- a/frontend/src/Settings/ImportLists/ImportListSettings.js +++ b/frontend/src/Settings/ImportLists/ImportListSettings.js @@ -7,7 +7,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import { icons } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; -import ImportListsExclusions from './ImportListExclusions/ImportListExclusions'; +import ImportListExclusions from './ImportListExclusions/ImportListExclusions'; import ImportListsConnector from './ImportLists/ImportListsConnector'; import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal'; import ImportListOptions from './Options/ImportListOptions'; @@ -113,7 +113,8 @@ class ImportListSettings extends Component { onChildStateChange={this.onChildStateChange} /> - + + { @@ -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/frontend/src/Store/Actions/Settings/importLists.js b/frontend/src/Store/Actions/Settings/importLists.js index 3475fbd2b..13b5590bc 100644 --- a/frontend/src/Store/Actions/Settings/importLists.js +++ b/frontend/src/Store/Actions/Settings/importLists.js @@ -20,19 +20,19 @@ const section = 'settings.importLists'; // // Actions Types -export const FETCH_IMPORT_LISTS = 'settings/importlists/fetchImportLists'; -export const FETCH_IMPORT_LIST_SCHEMA = 'settings/importlists/fetchImportListSchema'; -export const SELECT_IMPORT_LIST_SCHEMA = 'settings/importlists/selectImportListSchema'; -export const SET_IMPORT_LIST_VALUE = 'settings/importlists/setImportListValue'; -export const SET_IMPORT_LIST_FIELD_VALUE = 'settings/importlists/setImportListFieldValue'; -export const SAVE_IMPORT_LIST = 'settings/importlists/saveImportList'; -export const CANCEL_SAVE_IMPORT_LIST = 'settings/importlists/cancelSaveImportList'; -export const DELETE_IMPORT_LIST = 'settings/importlists/deleteImportList'; -export const TEST_IMPORT_LIST = 'settings/importlists/testImportList'; -export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList'; -export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists'; -export const BULK_EDIT_IMPORT_LISTS = 'settings/importlists/bulkEditImportLists'; -export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists'; +export const FETCH_IMPORT_LISTS = 'settings/importLists/fetchImportLists'; +export const FETCH_IMPORT_LIST_SCHEMA = 'settings/importLists/fetchImportListSchema'; +export const SELECT_IMPORT_LIST_SCHEMA = 'settings/importLists/selectImportListSchema'; +export const SET_IMPORT_LIST_VALUE = 'settings/importLists/setImportListValue'; +export const SET_IMPORT_LIST_FIELD_VALUE = 'settings/importLists/setImportListFieldValue'; +export const SAVE_IMPORT_LIST = 'settings/importLists/saveImportList'; +export const CANCEL_SAVE_IMPORT_LIST = 'settings/importLists/cancelSaveImportList'; +export const DELETE_IMPORT_LIST = 'settings/importLists/deleteImportList'; +export const TEST_IMPORT_LIST = 'settings/importLists/testImportList'; +export const CANCEL_TEST_IMPORT_LIST = 'settings/importLists/cancelTestImportList'; +export const TEST_ALL_IMPORT_LISTS = 'settings/importLists/testAllImportLists'; +export const BULK_EDIT_IMPORT_LISTS = 'settings/importLists/bulkEditImportLists'; +export const BULK_DELETE_IMPORT_LISTS = 'settings/importLists/bulkDeleteImportLists'; // // Action Creators 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 { }; + } } }