From 30ceb776159e4cb07ba197d2cdb774b03f8fd053 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 19 Aug 2024 02:55:13 +0300 Subject: [PATCH] New: Bulk manage custom formats Closes #5050 --- frontend/src/App/State/SettingsAppState.ts | 8 + .../CustomFormatSettingsPage.tsx | 9 +- .../Edit/ManageCustomFormatsEditModal.tsx | 28 ++ .../ManageCustomFormatsEditModalContent.css | 16 ++ ...nageCustomFormatsEditModalContent.css.d.ts | 8 + .../ManageCustomFormatsEditModalContent.tsx | 125 +++++++++ .../Manage/ManageCustomFormatsModal.tsx | 20 ++ .../ManageCustomFormatsModalContent.css | 16 ++ .../ManageCustomFormatsModalContent.css.d.ts | 9 + .../ManageCustomFormatsModalContent.tsx | 241 ++++++++++++++++++ .../Manage/ManageCustomFormatsModalRow.css | 6 + .../ManageCustomFormatsModalRow.css.d.ts | 8 + .../Manage/ManageCustomFormatsModalRow.tsx | 54 ++++ .../ManageCustomFormatsToolbarButton.tsx | 28 ++ .../ManageDownloadClientsModalContent.css | 2 +- .../ManageDownloadClientsModalContent.tsx | 4 +- .../Manage/ManageImportListsModalContent.css | 2 +- .../Manage/ManageImportListsModalContent.tsx | 4 +- .../Manage/ManageIndexersModalContent.css | 2 +- .../Manage/ManageIndexersModalContent.tsx | 4 +- .../Store/Actions/Settings/customFormats.js | 46 +++- .../Store/Actions/Settings/downloadClients.js | 4 +- .../src/Store/Actions/Settings/indexers.js | 4 +- frontend/src/typings/CustomFormat.ts | 6 +- .../CustomFormats/CustomFormatBulkResource.cs | 10 + .../CustomFormats/CustomFormatController.cs | 39 ++- .../CustomFormats/CustomFormatService.cs | 23 ++ src/NzbDrone.Core/Localization/Core/en.json | 7 + 28 files changed, 701 insertions(+), 32 deletions(-) create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModal.tsx create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css.d.ts create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.tsx create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModal.tsx create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css.d.ts create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.tsx create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css.d.ts create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsToolbarButton.tsx create mode 100644 src/Lidarr.Api.V1/CustomFormats/CustomFormatBulkResource.cs diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 547f353cd..e3f3bbcc5 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -4,6 +4,7 @@ import AppSectionState, { AppSectionSaveState, AppSectionSchemaState, } from 'App/State/AppSectionState'; +import CustomFormat from 'typings/CustomFormat'; import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; import Indexer from 'typings/Indexer'; @@ -41,6 +42,11 @@ export interface MetadataProfilesAppState extends AppSectionState, AppSectionSchemaState {} +export interface CustomFormatAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + export interface RootFolderAppState extends AppSectionState, AppSectionDeleteState, @@ -50,6 +56,8 @@ export type IndexerFlagSettingsAppState = AppSectionState; export type UiSettingsAppState = AppSectionItemState; interface SettingsAppState { + advancedSettings: boolean; + customFormats: CustomFormatAppState; downloadClients: DownloadClientAppState; importLists: ImportListAppState; indexerFlags: IndexerFlagSettingsAppState; diff --git a/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx b/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx index fee176554..66c208f9a 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx +++ b/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import PageContent from 'Components/Page/PageContent'; @@ -8,6 +8,7 @@ import ParseToolbarButton from 'Parse/ParseToolbarButton'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector'; +import ManageCustomFormatsToolbarButton from './CustomFormats/Manage/ManageCustomFormatsToolbarButton'; function CustomFormatSettingsPage() { return ( @@ -17,11 +18,13 @@ function CustomFormatSettingsPage() { // @ts-ignore showSave={false} additionalButtons={ - + <> - + + + } /> diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModal.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModal.tsx new file mode 100644 index 000000000..3ff5cfa37 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModal.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageCustomFormatsEditModalContent from './ManageCustomFormatsEditModalContent'; + +interface ManageCustomFormatsEditModalProps { + isOpen: boolean; + customFormatIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageCustomFormatsEditModal( + props: ManageCustomFormatsEditModalProps +) { + const { isOpen, customFormatIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageCustomFormatsEditModal; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css.d.ts b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.tsx new file mode 100644 index 000000000..25a2f85c2 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.tsx @@ -0,0 +1,125 @@ +import React, { useCallback, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageCustomFormatsEditModalContent.css'; + +interface SavePayload { + includeCustomFormatWhenRenaming?: boolean; +} + +interface ManageCustomFormatsEditModalContentProps { + customFormatIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const enableOptions = [ + { + key: NO_CHANGE, + get value() { + return translate('NoChange'); + }, + isDisabled: true, + }, + { + key: 'enabled', + get value() { + return translate('Enabled'); + }, + }, + { + key: 'disabled', + get value() { + return translate('Disabled'); + }, + }, +]; + +function ManageCustomFormatsEditModalContent( + props: ManageCustomFormatsEditModalContentProps +) { + const { customFormatIds, onSavePress, onModalClose } = props; + + const [includeCustomFormatWhenRenaming, setIncludeCustomFormatWhenRenaming] = + useState(NO_CHANGE); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (includeCustomFormatWhenRenaming !== NO_CHANGE) { + hasChanges = true; + payload.includeCustomFormatWhenRenaming = + includeCustomFormatWhenRenaming === 'enabled'; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [includeCustomFormatWhenRenaming, onSavePress, onModalClose]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'includeCustomFormatWhenRenaming': + setIncludeCustomFormatWhenRenaming(value); + break; + default: + console.warn( + `EditCustomFormatsModalContent Unknown Input: '${name}'` + ); + } + }, + [] + ); + + const selectedCount = customFormatIds.length; + + return ( + + {translate('EditSelectedCustomFormats')} + + + + {translate('IncludeCustomFormatWhenRenaming')} + + + + + + +
+ {translate('CountCustomFormatsSelected', { + count: selectedCount, + })} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageCustomFormatsEditModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModal.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModal.tsx new file mode 100644 index 000000000..dd3456437 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageCustomFormatsModalContent from './ManageCustomFormatsModalContent'; + +interface ManageCustomFormatsModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageCustomFormatsModal(props: ManageCustomFormatsModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageCustomFormatsModal; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css new file mode 100644 index 000000000..6ea04a0c8 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css @@ -0,0 +1,16 @@ +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css.d.ts b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'leftButtons': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.tsx new file mode 100644 index 000000000..eab8a4d67 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.tsx @@ -0,0 +1,241 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { CustomFormatAppState } from 'App/State/SettingsAppState'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { kinds } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { + bulkDeleteCustomFormats, + bulkEditCustomFormats, + setManageCustomFormatsSort, +} from 'Store/Actions/settingsActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SelectStateInputProps } from 'typings/props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageCustomFormatsEditModal from './Edit/ManageCustomFormatsEditModal'; +import ManageCustomFormatsModalRow from './ManageCustomFormatsModalRow'; +import styles from './ManageCustomFormatsModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageCustomFormatsModalRow +>['onSelectedChange']; + +const COLUMNS = [ + { + name: 'name', + label: () => translate('Name'), + isSortable: true, + isVisible: true, + }, + { + name: 'includeCustomFormatWhenRenaming', + label: () => translate('IncludeCustomFormatWhenRenaming'), + isSortable: true, + isVisible: true, + }, +]; + +interface ManageCustomFormatsModalContentProps { + onModalClose(): void; + sortKey?: string; + sortDirection?: SortDirection; +} + +function ManageCustomFormatsModalContent( + props: ManageCustomFormatsModalContentProps +) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + sortKey, + sortDirection, + }: CustomFormatAppState = useSelector( + createClientSideCollectionSelector('settings.customFormats') + ); + const dispatch = useDispatch(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds: number[] = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = selectedIds.length; + + const onSortPress = useCallback( + (value: string) => { + dispatch(setManageCustomFormatsSort({ sortKey: value })); + }, + [dispatch] + ); + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback(() => { + dispatch(bulkDeleteCustomFormats({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditCustomFormats({ + ids: selectedIds, + ...payload, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: SelectStateInputProps) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const errorMessage = getErrorMessage(error, 'Unable to load custom formats.'); + const anySelected = selectedCount > 0; + + return ( + + {translate('ManageCustomFormats')} + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !error && !items.length ? ( + {translate('NoCustomFormatsFound')} + ) : null} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + {translate('Delete')} + + + + {translate('Edit')} + +
+ + +
+ + + + +
+ ); +} + +export default ManageCustomFormatsModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css new file mode 100644 index 000000000..a7c85e340 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css @@ -0,0 +1,6 @@ +.name, +.includeCustomFormatWhenRenaming { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css.d.ts b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css.d.ts new file mode 100644 index 000000000..906d2dc54 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'includeCustomFormatWhenRenaming': string; + 'name': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx new file mode 100644 index 000000000..32b135970 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import { SelectStateInputProps } from 'typings/props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageCustomFormatsModalRow.css'; + +interface ManageCustomFormatsModalRowProps { + id: number; + name: string; + includeCustomFormatWhenRenaming: boolean; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function ManageCustomFormatsModalRow(props: ManageCustomFormatsModalRowProps) { + const { + id, + isSelected, + name, + includeCustomFormatWhenRenaming, + onSelectedChange, + } = props; + + const onSelectedChangeWrapper = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + return ( + + + + {name} + + + {includeCustomFormatWhenRenaming ? translate('Yes') : translate('No')} + + + ); +} + +export default ManageCustomFormatsModalRow; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsToolbarButton.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsToolbarButton.tsx new file mode 100644 index 000000000..f27f9e503 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsToolbarButton.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; +import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import ManageCustomFormatsModal from './ManageCustomFormatsModal'; + +function ManageCustomFormatsToolbarButton() { + const [isManageModalOpen, openManageModal, closeManageModal] = + useModalOpenState(false); + + return ( + <> + + + + + ); +} + +export default ManageCustomFormatsToolbarButton; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css index c106388ab..6ea04a0c8 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css @@ -13,4 +13,4 @@ composes: button from '~Components/Link/Button.css'; margin-right: 10px; -} \ No newline at end of file +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx index 2722f02fa..734f5efab 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -220,9 +220,9 @@ function ManageDownloadClientsModalContent( {error ?
{errorMessage}
: null} - {isPopulated && !error && !items.length && ( + {isPopulated && !error && !items.length ? ( {translate('NoDownloadClientsFound')} - )} + ) : null} {isPopulated && !!items.length && !isFetching && !isFetching ? ( {errorMessage} : null} - {isPopulated && !error && !items.length && ( + {isPopulated && !error && !items.length ? ( {translate('NoImportListsFound')} - )} + ) : null} {isPopulated && !!items.length && !isFetching && !isFetching ? (
{errorMessage} : null} - {isPopulated && !error && !items.length && ( + {isPopulated && !error && !items.length ? ( {translate('NoIndexersFound')} - )} + ) : null} {isPopulated && !!items.length && !isFetching && !isFetching ? (
{ return { @@ -47,20 +58,30 @@ export default { // State defaultState: { - isSchemaFetching: false, - isSchemaPopulated: false, isFetching: false, isPopulated: false, - schema: { - includeCustomFormatWhenRenaming: false - }, error: null, - isDeleting: false, - deleteError: null, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, items: [], - pendingChanges: {} + pendingChanges: {}, + + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: { + includeCustomFormatWhenRenaming: false + }, + + sortKey: 'name', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: ({ name }) => { + return name.toLocaleLowerCase(); + } + } }, // @@ -82,7 +103,10 @@ export default { })); createSaveProviderHandler(section, '/customformat')(getState, payload, dispatch); - } + }, + + [BULK_EDIT_CUSTOM_FORMATS]: createBulkEditItemHandler(section, '/customformat/bulk'), + [BULK_DELETE_CUSTOM_FORMATS]: createBulkRemoveItemHandler(section, '/customformat/bulk') }, // @@ -102,7 +126,9 @@ export default { newState.pendingChanges = pendingChanges; return updateSectionState(state, section, newState); - } + }, + + [SET_MANAGE_CUSTOM_FORMATS_SORT]: createSetClientSideCollectionSortReducer(section) } }; diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js index aee945ef5..1113e7daf 100644 --- a/frontend/src/Store/Actions/Settings/downloadClients.js +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -96,8 +96,8 @@ export default { sortKey: 'name', sortDirection: sortDirections.ASCENDING, sortPredicates: { - name: function(item) { - return item.name.toLowerCase(); + name: ({ name }) => { + return name.toLocaleLowerCase(); } } }, diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js index 1e9aded2f..511a2e475 100644 --- a/frontend/src/Store/Actions/Settings/indexers.js +++ b/frontend/src/Store/Actions/Settings/indexers.js @@ -100,8 +100,8 @@ export default { sortKey: 'name', sortDirection: sortDirections.ASCENDING, sortPredicates: { - name: function(item) { - return item.name.toLowerCase(); + name: ({ name }) => { + return name.toLocaleLowerCase(); } } }, diff --git a/frontend/src/typings/CustomFormat.ts b/frontend/src/typings/CustomFormat.ts index 7cef9f6ef..b3cc2a845 100644 --- a/frontend/src/typings/CustomFormat.ts +++ b/frontend/src/typings/CustomFormat.ts @@ -1,12 +1,14 @@ +import ModelBase from 'App/ModelBase'; + export interface QualityProfileFormatItem { format: number; name: string; score: number; } -interface CustomFormat { - id: number; +interface CustomFormat extends ModelBase { name: string; + includeCustomFormatWhenRenaming: boolean; } export default CustomFormat; diff --git a/src/Lidarr.Api.V1/CustomFormats/CustomFormatBulkResource.cs b/src/Lidarr.Api.V1/CustomFormats/CustomFormatBulkResource.cs new file mode 100644 index 000000000..4542743c6 --- /dev/null +++ b/src/Lidarr.Api.V1/CustomFormats/CustomFormatBulkResource.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace Lidarr.Api.V1.CustomFormats +{ + public class CustomFormatBulkResource + { + public HashSet Ids { get; set; } = new (); + public bool? IncludeCustomFormatWhenRenaming { get; set; } + } +} diff --git a/src/Lidarr.Api.V1/CustomFormats/CustomFormatController.cs b/src/Lidarr.Api.V1/CustomFormats/CustomFormatController.cs index 6885265db..f43fa1e49 100644 --- a/src/Lidarr.Api.V1/CustomFormats/CustomFormatController.cs +++ b/src/Lidarr.Api.V1/CustomFormats/CustomFormatController.cs @@ -47,6 +47,13 @@ namespace Lidarr.Api.V1.CustomFormats return _formatService.GetById(id).ToResource(true); } + [HttpGet] + [Produces("application/json")] + public List GetAll() + { + return _formatService.All().ToResource(true); + } + [RestPostById] [Consumes("application/json")] public ActionResult Create(CustomFormatResource customFormatResource) @@ -71,11 +78,26 @@ namespace Lidarr.Api.V1.CustomFormats return Accepted(model.Id); } - [HttpGet] + [HttpPut("bulk")] + [Consumes("application/json")] [Produces("application/json")] - public List GetAll() + public virtual ActionResult Update([FromBody] CustomFormatBulkResource resource) { - return _formatService.All().ToResource(true); + if (!resource.Ids.Any()) + { + throw new BadRequestException("ids must be provided"); + } + + var customFormats = resource.Ids.Select(id => _formatService.GetById(id)).ToList(); + + customFormats.ForEach(existing => + { + existing.IncludeCustomFormatWhenRenaming = resource.IncludeCustomFormatWhenRenaming ?? existing.IncludeCustomFormatWhenRenaming; + }); + + _formatService.Update(customFormats); + + return Accepted(customFormats.ConvertAll(cf => cf.ToResource(true))); } [RestDeleteById] @@ -84,12 +106,21 @@ namespace Lidarr.Api.V1.CustomFormats _formatService.Delete(id); } + [HttpDelete("bulk")] + [Consumes("application/json")] + public virtual object DeleteFormats([FromBody] CustomFormatBulkResource resource) + { + _formatService.Delete(resource.Ids.ToList()); + + return new { }; + } + [HttpGet("schema")] public object GetTemplates() { var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList(); - var presets = GetPresets(); + var presets = GetPresets().ToList(); foreach (var item in schema) { diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatService.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatService.cs index 5475ac5b8..f45a46810 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatService.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormatService.cs @@ -9,10 +9,12 @@ namespace NzbDrone.Core.CustomFormats public interface ICustomFormatService { void Update(CustomFormat customFormat); + void Update(List customFormat); CustomFormat Insert(CustomFormat customFormat); List All(); CustomFormat GetById(int id); void Delete(int id); + void Delete(List ids); } public class CustomFormatService : ICustomFormatService @@ -51,6 +53,12 @@ namespace NzbDrone.Core.CustomFormats _cache.Clear(); } + public void Update(List customFormat) + { + _formatRepository.UpdateMany(customFormat); + _cache.Clear(); + } + public CustomFormat Insert(CustomFormat customFormat) { // Add to DB then insert into profiles @@ -72,5 +80,20 @@ namespace NzbDrone.Core.CustomFormats _formatRepository.Delete(id); _cache.Clear(); } + + public void Delete(List ids) + { + foreach (var id in ids) + { + var format = _formatRepository.Get(id); + + // Remove from profiles before removing from DB + _eventAggregator.PublishEvent(new CustomFormatDeletedEvent(format)); + + _formatRepository.Delete(id); + } + + _cache.Clear(); + } } } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 466664174..4ef39994c 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -245,6 +245,7 @@ "CouldntFindAnyResultsForTerm": "Couldn't find any results for '{0}'", "CountAlbums": "{albumCount} albums", "CountArtistsSelected": "{count} artist(s) selected", + "CountCustomFormatsSelected": "{count} custom formats(s) selected", "CountDownloadClientsSelected": "{selectedCount} download client(s) selected", "CountImportListsSelected": "{selectedCount} import list(s) selected", "CountIndexersSelected": "{selectedCount} indexer(s) selected", @@ -333,6 +334,8 @@ "DeleteRootFolderMessageText": "Are you sure you want to delete the root folder '{name}'?", "DeleteSelected": "Delete Selected", "DeleteSelectedArtists": "Delete Selected Artists", + "DeleteSelectedCustomFormats": "Delete Custom Format(s)", + "DeleteSelectedCustomFormatsMessageText": "Are you sure you want to delete {count} selected custom format(s)?", "DeleteSelectedDownloadClients": "Delete Selected Download Client(s)", "DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {count} selected download client(s)?", "DeleteSelectedImportLists": "Delete Import List(s)", @@ -418,6 +421,7 @@ "EditRemotePathMapping": "Edit Remote Path Mapping", "EditRootFolder": "Edit Root Folder", "EditSelectedArtists": "Edit Selected Artists", + "EditSelectedCustomFormats": "Edit Selected Custom Formats", "EditSelectedDownloadClients": "Edit Selected Download Clients", "EditSelectedImportLists": "Edit Selected Import Lists", "EditSelectedIndexers": "Edit Selected Indexers", @@ -575,6 +579,7 @@ "ImportedTo": "Imported To", "Importing": "Importing", "Inactive": "Inactive", + "IncludeCustomFormatWhenRenaming": "Include Custom Format when Renaming", "IncludeCustomFormatWhenRenamingHelpText": "'Include in {Custom Formats} renaming format'", "IncludeHealthWarnings": "Include Health Warnings", "IncludeUnknownArtistItemsHelpText": "Show items without a artist in the queue, this could include removed artists, movies or anything else in {appName}'s category", @@ -669,6 +674,7 @@ "MIA": "MIA", "MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details", "ManageClients": "Manage Clients", + "ManageCustomFormats": "Manage Custom Formats", "ManageDownloadClients": "Manage Download Clients", "ManageImportLists": "Manage Import Lists", "ManageIndexers": "Manage Indexers", @@ -769,6 +775,7 @@ "NoAlbums": "No albums", "NoBackupsAreAvailable": "No backups are available", "NoChange": "No Change", + "NoCustomFormatsFound": "No custom formats found", "NoCutoffUnmetItems": "No cutoff unmet items", "NoDownloadClientsFound": "No download clients found", "NoEventsFound": "No events found",