From 7354e02bfff7d653a99e6a940393104c9f368dc0 Mon Sep 17 00:00:00 2001 From: Qstick Date: Wed, 14 Mar 2018 21:28:46 -0400 Subject: [PATCH] New: Custom Filtering for UI (#234) --- frontend/src/Activity/History/History.js | 78 +---- .../src/Activity/History/HistoryConnector.js | 4 +- .../SelectArtist/ImportArtistSearchResult.css | 2 +- .../InteractiveAlbumSearchModalContent.css | 5 + .../InteractiveAlbumSearchModalContent.js | 91 ++++-- ...ractiveAlbumSearchModalContentConnector.js | 51 ++-- .../InteractiveSearchFilterModalConnector.js | 31 ++ frontend/src/AlbumStudio/AlbumStudio.js | 70 +---- .../src/AlbumStudio/AlbumStudioConnector.js | 4 +- .../Artist/Delete/DeleteArtistModalContent.js | 11 +- frontend/src/Artist/Details/ArtistDetails.js | 15 +- .../Artist/Details/ArtistDetailsConnector.js | 3 + frontend/src/Artist/Editor/ArtistEditor.js | 70 +---- .../Artist/Editor/ArtistEditorConnector.js | 4 +- frontend/src/Artist/Index/ArtistIndex.js | 37 +-- .../src/Artist/Index/ArtistIndexConnector.js | 4 +- .../src/Artist/Index/ArtistIndexFooter.js | 11 + frontend/src/Artist/Index/ArtistIndexPage.js | 0 .../Artist/Index/Banners/ArtistIndexBanner.js | 43 +-- .../Index/Banners/ArtistIndexBannerInfo.css | 3 +- .../Index/Banners/ArtistIndexBannerInfo.js | 11 +- .../Index/Banners/ArtistIndexBanners.js | 50 ++-- .../Banners/ArtistIndexBannersConnector.js | 2 +- .../Index/Menus/ArtistIndexFilterMenu.js | 74 ++--- .../Index/Overview/ArtistIndexOverview.js | 17 +- .../Index/Overview/ArtistIndexOverviewInfo.js | 11 +- .../Index/Overview/ArtistIndexOverviews.js | 38 +-- .../Overview/ArtistIndexOverviewsConnector.js | 2 +- .../ArtistIndexOverviewOptionsModalContent.js | 2 +- .../Artist/Index/Posters/ArtistIndexPoster.js | 44 +-- .../Index/Posters/ArtistIndexPosterInfo.css | 3 +- .../Index/Posters/ArtistIndexPosterInfo.js | 11 +- .../Index/Posters/ArtistIndexPosters.js | 50 ++-- .../Posters/ArtistIndexPostersConnector.js | 2 +- .../ProgressBar/ArtistIndexProgressBar.js | 4 +- .../Artist/Index/Table/ArtistIndexHeader.css | 2 +- .../src/Artist/Index/Table/ArtistIndexRow.js | 23 +- .../Artist/Index/Table/ArtistIndexTable.js | 57 ++-- .../Index/Table/ArtistIndexTableConnector.js | 2 +- .../src/Artist/MoveArtist/MoveArtistModal.js | 2 +- frontend/src/Calendar/CalendarPage.js | 48 +--- .../src/Calendar/CalendarPageConnector.js | 9 +- .../Builder/FilterBuilderModalContent.css | 16 ++ .../Builder/FilterBuilderModalContent.js | 192 +++++++++++++ .../FilterBuilderModalContentConnector.js | 28 ++ .../Filter/Builder/FilterBuilderRow.css | 32 +++ .../Filter/Builder/FilterBuilderRow.js | 248 ++++++++++++++++ .../Filter/Builder/FilterBuilderRowValue.js | 100 +++++++ .../Builder/FilterBuilderRowValueConnector.js | 50 ++++ .../Builder/FilterBuilderRowValueTag.css | 19 ++ .../Builder/FilterBuilderRowValueTag.js | 31 ++ .../IndexerFilterBuilderRowValueConnector.js | 79 +++++ .../Builder/ProtocolFilterBuilderRowValue.js | 18 ++ .../QualityFilterBuilderRowValueConnector.js | 75 +++++ .../Filter/CustomFilters/CustomFilter.css | 17 ++ .../Filter/CustomFilters/CustomFilter.js | 67 +++++ .../CustomFiltersModalContent.css | 3 + .../CustomFiltersModalContent.js | 68 +++++ frontend/src/Components/Filter/FilterModal.js | 90 ++++++ .../Components/Form/EnhancedSelectInput.css | 1 - frontend/src/Components/Form/PathInput.css | 2 +- frontend/src/Components/Form/TagInput.css | 122 ++++---- frontend/src/Components/Form/TagInput.js | 270 ++++++++++++++---- .../src/Components/Form/TagInputConnector.js | 2 +- .../src/Components/Form/TagInputInput.css | 6 + frontend/src/Components/Form/TagInputInput.js | 76 +++++ frontend/src/Components/Form/TagInputTag.js | 52 ++++ frontend/src/Components/Form/TextTagInput.js | 68 ----- .../Components/Form/TextTagInputConnector.js | 17 +- frontend/src/Components/Link/IconButton.css | 4 + frontend/src/Components/Link/IconButton.js | 12 +- frontend/src/Components/Link/Link.css | 2 +- frontend/src/Components/Menu/FilterMenu.js | 119 ++++++-- .../src/Components/Menu/FilterMenuContent.js | 85 ++++++ .../src/Components/Menu/FilterMenuItem.js | 24 +- .../src/Components/Menu/MenuItemSeparator.css | 5 + .../src/Components/Menu/MenuItemSeparator.js | 10 + .../src/Components/Menu/PageMenuButton.css | 11 + .../src/Components/Menu/PageMenuButton.js | 36 +++ .../src/Components/MonitorToggleButton.css | 4 +- .../src/Components/MonitorToggleButton.js | 35 +-- .../Page/Header/ArtistSearchInput.css | 5 +- .../Page/Header/ArtistSearchInput.js | 9 +- .../Page/Header/PageHeaderActionsMenu.css | 6 - .../Page/Header/PageHeaderActionsMenu.js | 3 +- .../Components/Page/Sidebar/PageSidebar.js | 27 +- .../Page/Toolbar/PageToolbarButton.css | 4 + frontend/src/Components/Table/TableRow.css | 2 +- frontend/src/Components/Table/VirtualTable.js | 17 +- .../src/Helpers/Props/filterBuilderTypes.js | 33 +++ .../Helpers/Props/filterBuilderValueTypes.js | 4 + frontend/src/Helpers/Props/index.js | 4 + .../MediaManagement/MediaManagement.js | 16 ++ .../Reducers/createCustomFilterReducers.js | 65 +++++ ...ateSetClientSideCollectionFilterReducer.js | 5 +- .../createBatchToggleAlbumMonitoredHandler.js | 66 ++--- .../createFetchServerSideCollectionHandler.js | 17 +- .../createToggleAlbumMonitoredHandler.js | 42 --- .../src/Store/Actions/albumHistoryActions.js | 5 +- .../src/Store/Actions/albumStudioActions.js | 14 +- frontend/src/Store/Actions/artistActions.js | 71 ++++- .../src/Store/Actions/artistEditorActions.js | 14 +- .../src/Store/Actions/artistIndexActions.js | 47 ++- frontend/src/Store/Actions/calendarActions.js | 67 +++-- frontend/src/Store/Actions/historyActions.js | 73 ++++- frontend/src/Store/Actions/releaseActions.js | 130 ++++++++- frontend/src/Store/Actions/systemActions.js | 50 +++- frontend/src/Store/Actions/wantedActions.js | 71 ++++- .../createClientSideCollectionSelector.js | 53 +++- frontend/src/Styles/Variables/colors.js | 18 +- frontend/src/System/Events/LogsTable.js | 241 +++++++--------- .../src/System/Events/LogsTableConnector.js | 4 +- frontend/src/System/Events/LogsTableRow.js | 4 +- frontend/src/System/Updates/Updates.js | 4 +- frontend/src/Utilities/Album/updateAlbums.js | 5 +- .../Array/getIndexOfFirstCharacter.js | 13 + .../Utilities/Filter/findSelectedFilters.js | 15 + .../src/Utilities/Filter/getFilterValue.js | 5 + .../src/Utilities/String/generateUUIDv4.js | 6 + .../src/Utilities/customFilterHandlers.js | 6 + .../src/Wanted/CutoffUnmet/CutoffUnmet.js | 49 +--- .../CutoffUnmet/CutoffUnmetConnector.js | 15 +- frontend/src/Wanted/Missing/Missing.js | 49 +--- .../src/Wanted/Missing/MissingConnector.js | 15 +- package.json | 1 - src/Lidarr.Api.V1/Artist/ArtistModule.cs | 7 +- src/Lidarr.Api.V1/Artist/ArtistResource.cs | 9 +- .../Artist/ArtistStatisticsResource.cs | 41 +++ .../Config/MediaManagementConfigResource.cs | 2 + src/Lidarr.Api.V1/History/HistoryModule.cs | 16 +- src/Lidarr.Api.V1/Lidarr.Api.V1.csproj | 1 + src/Lidarr.Api.V1/Logs/LogModule.cs | 35 +-- src/Lidarr.Api.V1/Logs/LogResource.cs | 5 +- .../TrackFiles/TrackFileResource.cs | 1 - src/Lidarr.Api.V1/Wanted/CutoffModule.cs | 8 +- src/Lidarr.Api.V1/Wanted/MissingModule.cs | 8 +- src/Lidarr.Http/Lidarr.Http.csproj | 1 + src/Lidarr.Http/PagingResource.cs | 3 +- src/Lidarr.Http/PagingResourceFilter.cs | 8 + src/Lidarr.Http/REST/RestModule.cs | 41 ++- .../Configuration/ConfigService.cs | 7 + .../Configuration/IConfigService.cs | 1 + .../Datastore/BasicRepository.cs | 21 +- src/NzbDrone.Core/Datastore/PagingSpec.cs | 9 +- .../Extras/Files/ExtraFileService.cs | 4 +- .../IndexerSearch/AlbumSearchService.cs | 41 +-- .../MediaFiles/DiskScanService.cs | 20 +- .../MediaFiles/MediaFileDeletionService.cs | 34 ++- .../Messaging/EventHandleOrderAttribute.cs | 22 ++ .../Messaging/Events/EventAggregator.cs | 27 +- src/NzbDrone.Core/Music/AlbumRepository.cs | 8 +- src/NzbDrone.Core/Music/TrackRepository.cs | 4 +- src/NzbDrone.Core/NzbDrone.Core.csproj | 1 + yarn.lock | 4 - 154 files changed, 3492 insertions(+), 1364 deletions(-) create mode 100644 frontend/src/Album/Search/InteractiveAlbumSearchModalContent.css create mode 100644 frontend/src/Album/Search/InteractiveSearchFilterModalConnector.js delete mode 100644 frontend/src/Artist/Index/ArtistIndexPage.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRow.css create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRow.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css create mode 100644 frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js create mode 100644 frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js create mode 100644 frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js create mode 100644 frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js create mode 100644 frontend/src/Components/Filter/CustomFilters/CustomFilter.css create mode 100644 frontend/src/Components/Filter/CustomFilters/CustomFilter.js create mode 100644 frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css create mode 100644 frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js create mode 100644 frontend/src/Components/Filter/FilterModal.js create mode 100644 frontend/src/Components/Form/TagInputInput.css create mode 100644 frontend/src/Components/Form/TagInputInput.js create mode 100644 frontend/src/Components/Form/TagInputTag.js delete mode 100644 frontend/src/Components/Form/TextTagInput.js create mode 100644 frontend/src/Components/Menu/FilterMenuContent.js create mode 100644 frontend/src/Components/Menu/MenuItemSeparator.css create mode 100644 frontend/src/Components/Menu/MenuItemSeparator.js create mode 100644 frontend/src/Components/Menu/PageMenuButton.css create mode 100644 frontend/src/Components/Menu/PageMenuButton.js create mode 100644 frontend/src/Helpers/Props/filterBuilderTypes.js create mode 100644 frontend/src/Helpers/Props/filterBuilderValueTypes.js create mode 100644 frontend/src/Store/Actions/Creators/Reducers/createCustomFilterReducers.js delete mode 100644 frontend/src/Store/Actions/Creators/createToggleAlbumMonitoredHandler.js create mode 100644 frontend/src/Utilities/Array/getIndexOfFirstCharacter.js create mode 100644 frontend/src/Utilities/Filter/findSelectedFilters.js create mode 100644 frontend/src/Utilities/Filter/getFilterValue.js create mode 100644 frontend/src/Utilities/String/generateUUIDv4.js create mode 100644 frontend/src/Utilities/customFilterHandlers.js create mode 100644 src/Lidarr.Api.V1/Artist/ArtistStatisticsResource.cs create mode 100644 src/Lidarr.Http/PagingResourceFilter.cs create mode 100644 src/NzbDrone.Core/Messaging/EventHandleOrderAttribute.cs diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js index ca3295ae3..8c9548d5d 100644 --- a/frontend/src/Activity/History/History.js +++ b/frontend/src/Activity/History/History.js @@ -12,8 +12,6 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import FilterMenu from 'Components/Menu/FilterMenu'; -import MenuContent from 'Components/Menu/MenuContent'; -import FilterMenuItem from 'Components/Menu/FilterMenuItem'; import HistoryRowConnector from './HistoryRowConnector'; class History extends Component { @@ -49,8 +47,8 @@ class History extends Component { error, items, columns, - filterKey, - filterValue, + selectedFilterKey, + filters, totalRecords, isAlbumsFetching, isAlbumsPopulated, @@ -77,67 +75,13 @@ class History extends Component { - - - - All - - - - Grabbed - - - - Imported - - - - Failed - - - - Deleted - - - - Renamed - - - + @@ -204,8 +148,8 @@ History.propTypes = { error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, - filterKey: PropTypes.string, - filterValue: PropTypes.string, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, totalRecords: PropTypes.number, isAlbumsFetching: PropTypes.bool.isRequired, isAlbumsPopulated: PropTypes.bool.isRequired, diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js index 35da9cc5d..fd606a5ad 100644 --- a/frontend/src/Activity/History/HistoryConnector.js +++ b/frontend/src/Activity/History/HistoryConnector.js @@ -105,8 +105,8 @@ class HistoryConnector extends Component { this.props.setHistorySort({ sortKey }); } - onFilterSelect = (filterKey, filterValue) => { - this.props.setHistoryFilter({ filterKey, filterValue }); + onFilterSelect = (selectedFilterKey) => { + this.props.setHistoryFilter({ selectedFilterKey }); } onTableOptionChange = (payload) => { diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css index 421a55237..f7bc065b5 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css @@ -3,6 +3,6 @@ width: 100%; &:hover { - background-color: $menuItemHoverColor; + background-color: $menuItemHoverBackgroundColor; } } diff --git a/frontend/src/Album/Search/InteractiveAlbumSearchModalContent.css b/frontend/src/Album/Search/InteractiveAlbumSearchModalContent.css new file mode 100644 index 000000000..8bd4c0f0d --- /dev/null +++ b/frontend/src/Album/Search/InteractiveAlbumSearchModalContent.css @@ -0,0 +1,5 @@ +.filterMenuContainer { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} diff --git a/frontend/src/Album/Search/InteractiveAlbumSearchModalContent.js b/frontend/src/Album/Search/InteractiveAlbumSearchModalContent.js index f12f3d9a0..f849f0912 100644 --- a/frontend/src/Album/Search/InteractiveAlbumSearchModalContent.js +++ b/frontend/src/Album/Search/InteractiveAlbumSearchModalContent.js @@ -1,8 +1,10 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { icons, sortDirections } from 'Helpers/Props'; +import { align, icons, sortDirections } from 'Helpers/Props'; import Button from 'Components/Link/Button'; import Icon from 'Components/Icon'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageMenuButton from 'Components/Menu/PageMenuButton'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import ModalContent from 'Components/Modal/ModalContent'; import ModalHeader from 'Components/Modal/ModalHeader'; @@ -10,7 +12,9 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalFooter from 'Components/Modal/ModalFooter'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; +import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector'; import InteractiveAlbumSearchRow from './InteractiveAlbumSearchRow'; +import styles from './InteractiveAlbumSearchModalContent.css'; const columns = [ { @@ -81,12 +85,17 @@ class InteractiveAlbumSearchModalContent extends Component { isFetching, isPopulated, error, + totalReleasesCount, items, + selectedFilterKey, + filters, + customFilters, sortKey, sortDirection, longDateFormat, timeFormat, onSortPress, + onFilterSelect, onGrabPress, onModalClose } = this.props; @@ -117,28 +126,59 @@ class InteractiveAlbumSearchModalContent extends Component { { isPopulated && hasItems && !error && - - - { - items.map((item) => { - return ( - - ); - }) - } - -
+
+
+ +
+ + { + !!totalReleasesCount && !items.length && +
+ All results are hidden by {filters.length > 1 ? 'filters' : 'a filter'}. +
+ } + + { + !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } + + { + totalReleasesCount !== items.length && !!items.length && +
+ Some results are hidden by {filters.length > 1 ? 'filters' : 'a filter'}. +
+ } +
} @@ -156,12 +196,17 @@ InteractiveAlbumSearchModalContent.propTypes = { isFetching: PropTypes.bool.isRequired, isPopulated: PropTypes.bool.isRequired, error: PropTypes.object, + totalReleasesCount: PropTypes.number.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, longDateFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, sortDirection: PropTypes.string, onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, onGrabPress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Album/Search/InteractiveAlbumSearchModalContentConnector.js b/frontend/src/Album/Search/InteractiveAlbumSearchModalContentConnector.js index e7b615678..2807dee42 100644 --- a/frontend/src/Album/Search/InteractiveAlbumSearchModalContentConnector.js +++ b/frontend/src/Album/Search/InteractiveAlbumSearchModalContentConnector.js @@ -1,19 +1,20 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import connectSection from 'Store/connectSection'; import { createSelector } from 'reselect'; -import { fetchReleases, clearReleases, cancelFetchReleases, setReleasesSort, grabRelease } from 'Store/Actions/releaseActions'; +import connectSection from 'Store/connectSection'; +import * as releaseActions from 'Store/Actions/releaseActions'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import InteractiveAlbumSearchModalContent from './InteractiveAlbumSearchModalContent'; function createMapStateToProps() { return createSelector( + (state) => state.releases.items.length, createClientSideCollectionSelector(), createUISettingsSelector(), - (releases, uiSettings) => { + (totalReleasesCount, releases, uiSettings) => { return { + totalReleasesCount, longDateFormat: uiSettings.longDateFormat, timeFormat: uiSettings.timeFormat, ...releases @@ -25,23 +26,27 @@ function createMapStateToProps() { function createMapDispatchToProps(dispatch, props) { return { dispatchFetchReleases({ albumId }) { - dispatch(fetchReleases({ albumId })); + dispatch(releaseActions.fetchReleases({ albumId })); }, dispatchCancelFetchReleases() { - dispatch(cancelFetchReleases()); + dispatch(releaseActions.cancelFetchReleases()); }, dispatchClearReleases() { - dispatch(clearReleases()); + dispatch(releaseActions.clearReleases()); + }, + + onSortPress(sortKey, sortDirection) { + dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection })); }, - dispatchSetReleasesSort({ sortKey, sortDirection }) { - dispatch(setReleasesSort({ sortKey, sortDirection })); + onFilterSelect(selectedFilterKey) { + dispatch(releaseActions.setReleasesFilter({ selectedFilterKey })); }, - dispatchGrabRelease({ guid, indexerId }) { - dispatch(grabRelease({ guid, indexerId })); + onGrabPress({ guid, indexerId }) { + dispatch(releaseActions.grabRelease({ guid, indexerId })); } }; } @@ -66,26 +71,18 @@ class InteractiveAlbumSearchModalContentConnector extends Component { this.props.dispatchClearReleases(); } - // - // Listeners - - onSortPress = (sortKey, sortDirection) => { - this.props.dispatchSetReleasesSort({ sortKey, sortDirection }); - } - - onGrabPress = (guid, indexerId) => { - this.props.dispatchGrabRelease({ guid, indexerId }); - } - // // Render render() { + const { + dispatchFetchReleases, + ...otherProps + } = this.props; + return ( ); } @@ -95,9 +92,7 @@ InteractiveAlbumSearchModalContentConnector.propTypes = { albumId: PropTypes.number, dispatchFetchReleases: PropTypes.func.isRequired, dispatchClearReleases: PropTypes.func.isRequired, - dispatchCancelFetchReleases: PropTypes.func.isRequired, - dispatchSetReleasesSort: PropTypes.func.isRequired, - dispatchGrabRelease: PropTypes.func.isRequired + dispatchCancelFetchReleases: PropTypes.func.isRequired }; export default connectSection( diff --git a/frontend/src/Album/Search/InteractiveSearchFilterModalConnector.js b/frontend/src/Album/Search/InteractiveSearchFilterModalConnector.js new file mode 100644 index 000000000..92067c526 --- /dev/null +++ b/frontend/src/Album/Search/InteractiveSearchFilterModalConnector.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import * as releaseActions from 'Store/Actions/releaseActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.releases.items, + (state) => state.releases.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onRemoveCustomFilterPress(index) { + dispatch(releaseActions.removeReleasesCustomFilter({ index })); + }, + + onSaveCustomFilterPress(payload) { + dispatch(releaseActions.saveReleasesCustomFilter(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal); diff --git a/frontend/src/AlbumStudio/AlbumStudio.js b/frontend/src/AlbumStudio/AlbumStudio.js index 8b62a0d81..ce8e5a908 100644 --- a/frontend/src/AlbumStudio/AlbumStudio.js +++ b/frontend/src/AlbumStudio/AlbumStudio.js @@ -10,8 +10,6 @@ import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import FilterMenu from 'Components/Menu/FilterMenu'; -import MenuContent from 'Components/Menu/MenuContent'; -import FilterMenuItem from 'Components/Menu/FilterMenuItem'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import NoArtist from 'Artist/NoArtist'; @@ -104,8 +102,9 @@ class AlbumStudio extends Component { isPopulated, error, items, - filterKey, - filterValue, + selectedFilterKey, + filters, + customFilters, sortKey, sortDirection, isSaving, @@ -125,57 +124,13 @@ class AlbumStudio extends Component { - - - - All - - - - Monitored Only - - - - Continuing Only - - - - Ended Only - - - - Missing Albums - - - + @@ -245,8 +200,9 @@ AlbumStudio.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), - filterKey: PropTypes.string, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, onSortPress: PropTypes.func.isRequired, diff --git a/frontend/src/AlbumStudio/AlbumStudioConnector.js b/frontend/src/AlbumStudio/AlbumStudioConnector.js index 15a8a74da..3eee418fa 100644 --- a/frontend/src/AlbumStudio/AlbumStudioConnector.js +++ b/frontend/src/AlbumStudio/AlbumStudioConnector.js @@ -57,8 +57,8 @@ class AlbumStudioConnector extends Component { this.props.setAlbumStudioSort({ sortKey }); } - onFilterSelect = (filterKey, filterValue, filterType) => { - this.props.setAlbumStudioFilter({ filterKey, filterValue, filterType }); + onFilterSelect = (selectedFilterKey) => { + this.props.setAlbumStudioFilter({ selectedFilterKey }); } onUpdateSelectedPress = (payload) => { diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Delete/DeleteArtistModalContent.js index 6a6435301..5ae69155e 100644 --- a/frontend/src/Artist/Delete/DeleteArtistModalContent.js +++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.js @@ -47,11 +47,15 @@ class DeleteArtistModalContent extends Component { const { artistName, path, - trackFileCount, - sizeOnDisk, + statistics, onModalClose } = this.props; + const { + trackFileCount, + sizeOnDisk + } = statistics; + const deleteFiles = this.state.deleteFiles; let deleteFilesLabel = `Delete ${trackFileCount} Track Files`; let deleteFilesHelpText = 'Delete the track files and artist folder'; @@ -126,8 +130,7 @@ class DeleteArtistModalContent extends Component { DeleteArtistModalContent.propTypes = { artistName: PropTypes.string.isRequired, path: PropTypes.string.isRequired, - trackFileCount: PropTypes.number.isRequired, - sizeOnDisk: PropTypes.number, + statistics: PropTypes.object.isRequired, onDeletePress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Details/ArtistDetails.js b/frontend/src/Artist/Details/ArtistDetails.js index 9ee23dd68..04396ae6a 100644 --- a/frontend/src/Artist/Details/ArtistDetails.js +++ b/frontend/src/Artist/Details/ArtistDetails.js @@ -161,8 +161,7 @@ class ArtistDetails extends Component { artistName, ratings, path, - sizeOnDisk, - trackFileCount, + statistics, qualityProfileId, monitored, albumTypes, @@ -178,12 +177,18 @@ class ArtistDetails extends Component { isPopulated, albumsError, trackFilesError, + hasMonitoredAlbums, previousArtist, nextArtist, onRefreshPress, onSearchPress } = this.props; + const { + trackFileCount, + sizeOnDisk + } = statistics; + const { isOrganizeModalOpen, isManageTracksOpen, @@ -230,7 +235,9 @@ class ArtistDetails extends Component { @@ -583,8 +590,7 @@ ArtistDetails.propTypes = { artistName: PropTypes.string.isRequired, ratings: PropTypes.object.isRequired, path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - trackFileCount: PropTypes.number, + statistics: PropTypes.object.isRequired, qualityProfileId: PropTypes.number.isRequired, monitored: PropTypes.bool.isRequired, albumTypes: PropTypes.arrayOf(PropTypes.string), @@ -600,6 +606,7 @@ ArtistDetails.propTypes = { isPopulated: PropTypes.bool.isRequired, albumsError: PropTypes.object, trackFilesError: PropTypes.object, + hasMonitoredAlbums: PropTypes.bool.isRequired, previousArtist: PropTypes.object.isRequired, nextArtist: PropTypes.object.isRequired, onRefreshPress: PropTypes.func.isRequired, diff --git a/frontend/src/Artist/Details/ArtistDetailsConnector.js b/frontend/src/Artist/Details/ArtistDetailsConnector.js index 9c0b847d3..34ae6dbf8 100644 --- a/frontend/src/Artist/Details/ArtistDetailsConnector.js +++ b/frontend/src/Artist/Details/ArtistDetailsConnector.js @@ -64,6 +64,8 @@ function createMapStateToProps() { return acc; }, []); + const hasMonitoredAlbums = albums.items.some((e) => e.monitored); + return { ...artist, albumTypes: sortedAlbumTypes, @@ -78,6 +80,7 @@ function createMapStateToProps() { isPopulated, albumsError, trackFilesError, + hasMonitoredAlbums, previousArtist, nextArtist }; diff --git a/frontend/src/Artist/Editor/ArtistEditor.js b/frontend/src/Artist/Editor/ArtistEditor.js index 1cd9c78f3..2ecf9abf3 100644 --- a/frontend/src/Artist/Editor/ArtistEditor.js +++ b/frontend/src/Artist/Editor/ArtistEditor.js @@ -10,8 +10,6 @@ import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import FilterMenu from 'Components/Menu/FilterMenu'; -import MenuContent from 'Components/Menu/MenuContent'; -import FilterMenuItem from 'Components/Menu/FilterMenuItem'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import NoArtist from 'Artist/NoArtist'; @@ -151,8 +149,9 @@ class ArtistEditor extends Component { isPopulated, error, items, - filterKey, - filterValue, + selectedFilterKey, + filters, + customFilters, sortKey, sortDirection, isSaving, @@ -180,57 +179,13 @@ class ArtistEditor extends Component { - - - - All - - - - Monitored Only - - - - Continuing Only - - - - Ended Only - - - - Missing Albums - - - + @@ -314,8 +269,9 @@ ArtistEditor.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), - filterKey: PropTypes.string, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, isDeleting: PropTypes.bool.isRequired, diff --git a/frontend/src/Artist/Editor/ArtistEditorConnector.js b/frontend/src/Artist/Editor/ArtistEditorConnector.js index 72967c24f..319a98aac 100644 --- a/frontend/src/Artist/Editor/ArtistEditorConnector.js +++ b/frontend/src/Artist/Editor/ArtistEditorConnector.js @@ -51,8 +51,8 @@ class ArtistEditorConnector extends Component { this.props.dispatchSetArtistEditorSort({ sortKey }); } - onFilterSelect = (filterKey, filterValue, filterType) => { - this.props.dispatchSetArtistEditorFilter({ filterKey, filterValue, filterType }); + onFilterSelect = (selectedFilterKey) => { + this.props.dispatchSetArtistEditorFilter({ selectedFilterKey }); } onSaveSelected = (payload) => { diff --git a/frontend/src/Artist/Index/ArtistIndex.js b/frontend/src/Artist/Index/ArtistIndex.js index 1acd5b3aa..213c8860f 100644 --- a/frontend/src/Artist/Index/ArtistIndex.js +++ b/frontend/src/Artist/Index/ArtistIndex.js @@ -49,11 +49,10 @@ class ArtistIndex extends Component { constructor(props, context) { super(props, context); - this._viewComponent = null; - this.state = { contentBody: null, jumpBarItems: [], + jumpToCharacter: null, isPosterOptionsModalOpen: false, isBannerOptionsModalOpen: false, isOverviewOptionsModalOpen: false, @@ -69,7 +68,8 @@ class ArtistIndex extends Component { const { items, sortKey, - sortDirection + sortDirection, + scrollTop } = this.props; if ( @@ -79,6 +79,10 @@ class ArtistIndex extends Component { ) { this.setJumpBarItems(); } + + if (this.state.jumpToCharacter != null && scrollTop !== prevProps.scrollTop) { + this.setState({ jumpToCharacter: null }); + } } // @@ -88,10 +92,6 @@ class ArtistIndex extends Component { this.setState({ contentBody: ref }); } - setViewComponentRef = (ref) => { - this._viewComponent = ref; - } - setJumpBarItems() { const { items, @@ -152,9 +152,8 @@ class ArtistIndex extends Component { this.setState({ isOverviewOptionsModalOpen: false }); } - onJumpBarItemPress = (item) => { - const viewComponent = this._viewComponent.getWrappedInstance(); - viewComponent.scrollToFirstCharacter(item); + onJumpBarItemPress = (jumpToCharacter) => { + this.setState({ jumpToCharacter }); } onRender = () => { @@ -187,8 +186,9 @@ class ArtistIndex extends Component { isPopulated, error, items, - filterKey, - filterValue, + selectedFilterKey, + filters, + customFilters, sortKey, sortDirection, view, @@ -206,6 +206,7 @@ class ArtistIndex extends Component { const { contentBody, jumpBarItems, + jumpToCharacter, isPosterOptionsModalOpen, isBannerOptionsModalOpen, isOverviewOptionsModalOpen, @@ -294,8 +295,9 @@ class ArtistIndex extends Component { /> @@ -324,9 +326,9 @@ class ArtistIndex extends Component { isLoaded &&
@@ -378,8 +380,9 @@ ArtistIndex.propTypes = { isPopulated: PropTypes.bool.isRequired, error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, - filterKey: PropTypes.string, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), view: PropTypes.string.isRequired, diff --git a/frontend/src/Artist/Index/ArtistIndexConnector.js b/frontend/src/Artist/Index/ArtistIndexConnector.js index fecc0b4da..0f0186e3c 100644 --- a/frontend/src/Artist/Index/ArtistIndexConnector.js +++ b/frontend/src/Artist/Index/ArtistIndexConnector.js @@ -100,8 +100,8 @@ class ArtistIndexConnector extends Component { this.props.setArtistSort({ sortKey }); } - onFilterSelect = (filterKey, filterValue, filterType) => { - this.props.setArtistFilter({ filterKey, filterValue, filterType }); + onFilterSelect = (selectedFilterKey) => { + this.props.setArtistFilter({ selectedFilterKey }); } onViewSelect = (view) => { diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.js b/frontend/src/Artist/Index/ArtistIndexFooter.js index c7610a079..14462fc85 100644 --- a/frontend/src/Artist/Index/ArtistIndexFooter.js +++ b/frontend/src/Artist/Index/ArtistIndexFooter.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; import styles from './ArtistIndexFooter.css'; @@ -11,6 +12,7 @@ function ArtistIndexFooter({ artist }) { let ended = 0; let continuing = 0; let monitored = 0; + let totalFileSize = 0; artist.forEach((s) => { tracks += s.trackCount || 0; @@ -25,6 +27,8 @@ function ArtistIndexFooter({ artist }) { if (s.monitored) { monitored++; } + + totalFileSize += s.statistics.sizeOnDisk || 0; }); return ( @@ -92,6 +96,13 @@ function ArtistIndexFooter({ artist }) { data={trackFiles} /> + + + +
); diff --git a/frontend/src/Artist/Index/ArtistIndexPage.js b/frontend/src/Artist/Index/ArtistIndexPage.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js index 6fb4b72cf..95291e5dc 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js @@ -61,8 +61,7 @@ class ArtistIndexBanner extends Component { status, foreignArtistId, nextAiring, - trackCount, - trackFileCount, + statistics, images, bannerWidth, bannerHeight, @@ -79,6 +78,12 @@ class ArtistIndexBanner extends Component { ...otherProps } = this.props; + const { + trackCount, + trackFileCount, + totalTrackCount + } = statistics; + const { isEditArtistModalOpen, isDeleteArtistModalOpen @@ -141,6 +146,7 @@ class ArtistIndexBanner extends Component { status={status} trackCount={trackCount} trackFileCount={trackFileCount} + totalTrackCount={totalTrackCount} posterWidth={bannerWidth} detailedProgressBar={detailedProgressBar} /> @@ -165,20 +171,22 @@ class ArtistIndexBanner extends Component { {qualityProfile.name} } - -
- { - getRelativeDate( - nextAiring, - shortDateFormat, - showRelativeDates, + { + nextAiring && +
{ - timeFormat, - timeForToday: true + getRelativeDate( + nextAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) } - ) - } -
+
+ } @@ -215,8 +224,7 @@ ArtistIndexBanner.propTypes = { status: PropTypes.string.isRequired, foreignArtistId: PropTypes.string.isRequired, nextAiring: PropTypes.string, - trackCount: PropTypes.number, - trackFileCount: PropTypes.number, + statistics: PropTypes.object.isRequired, images: PropTypes.arrayOf(PropTypes.object).isRequired, bannerWidth: PropTypes.number.isRequired, bannerHeight: PropTypes.number.isRequired, @@ -234,7 +242,8 @@ ArtistIndexBanner.propTypes = { ArtistIndexBanner.defaultProps = { trackCount: 0, - trackFileCount: 0 + trackFileCount: 0, + albumCount: 0 }; export default ArtistIndexBanner; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css index cab3dec61..aab27d827 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css @@ -1,6 +1,5 @@ .info { - background-color: $defaultColor; - color: $white; + background-color: #fafbfc; text-align: center; font-size: $smallFontSize; } diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js index f641de0e1..b44fefb9d 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js @@ -10,15 +10,19 @@ function ArtistIndexBannerInfo(props) { showQualityProfile, previousAiring, added, - albumCount, + statistics, path, - sizeOnDisk, sortKey, showRelativeDates, shortDateFormat, timeFormat } = props; + const { + albumCount, + sizeOnDisk + } = statistics; + if (sortKey === 'qualityProfileId' && !showQualityProfile) { return (
@@ -103,9 +107,8 @@ ArtistIndexBannerInfo.propTypes = { showQualityProfile: PropTypes.bool.isRequired, previousAiring: PropTypes.string, added: PropTypes.string, - albumCount: PropTypes.number.isRequired, + statistics: PropTypes.object.isRequired, path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, sortKey: PropTypes.string.isRequired, showRelativeDates: PropTypes.bool.isRequired, shortDateFormat: PropTypes.string.isRequired, diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js index 331b22019..b05332b28 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js @@ -1,9 +1,9 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ReactDOM from 'react-dom'; import Measure from 'react-measure'; import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import dimensions from 'Styles/Variables/dimensions'; import { sortDirections } from 'Helpers/Props'; @@ -116,11 +116,11 @@ class ArtistIndexBanners extends Component { componentDidUpdate(prevProps) { const { items, - filterKey, - filterValue, + filters, sortKey, sortDirection, - bannerOptions + bannerOptions, + jumpToCharacter } = this.props; const itemsChanged = hasDifferentItems(prevProps.items, items); @@ -134,44 +134,34 @@ class ArtistIndexBanners extends Component { } if ( - prevProps.filterKey !== filterKey || - prevProps.filterValue !== filterValue || + prevProps.filters !== filters || prevProps.sortKey !== sortKey || prevProps.sortDirection !== sortDirection || itemsChanged ) { this._grid.recomputeGridSize(); } - } - // - // Control + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); - scrollToFirstCharacter(character) { - const items = this.props.items; - const { - columnCount, - rowHeight - } = this.state; + if (index != null) { + const { + columnCount, + rowHeight + } = this.state; - const index = _.findIndex(items, (item) => { - const firstCharacter = item.sortName.charAt(0); + const row = Math.floor(index / columnCount); + const scrollTop = rowHeight * row; - if (character === '#') { - return !isNaN(firstCharacter); + this.props.onScroll({ scrollTop }); } - - return firstCharacter === character; - }); - - if (index != null) { - const row = Math.floor(index / columnCount); - const scrollTop = rowHeight * row; - - this.props.onScroll({ scrollTop }); } } + // + // Control + setGridRef = (ref) => { this._grid = ref; } @@ -319,12 +309,12 @@ class ArtistIndexBanners extends Component { ArtistIndexBanners.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, - filterKey: PropTypes.string, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), + filters: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), bannerOptions: PropTypes.object.isRequired, scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, contentBody: PropTypes.object.isRequired, showRelativeDates: PropTypes.bool.isRequired, shortDateFormat: PropTypes.string.isRequired, diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js index 21384039e..25bdf61cc 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js @@ -28,6 +28,6 @@ export default connectSection( createMapStateToProps, undefined, undefined, - { withRef: true }, + undefined, { section: 'artist', uiSection: 'artistIndex' } )(ArtistIndexBanners); diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js index 6b5c9b9e1..cf9b25ea1 100644 --- a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js +++ b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js @@ -2,80 +2,38 @@ import PropTypes from 'prop-types'; import React from 'react'; import { align } from 'Helpers/Props'; import FilterMenu from 'Components/Menu/FilterMenu'; -import MenuContent from 'Components/Menu/MenuContent'; -import FilterMenuItem from 'Components/Menu/FilterMenuItem'; function ArtistIndexFilterMenu(props) { const { - filterKey, - filterValue, + selectedFilterKey, + filters, + customFilters, isDisabled, onFilterSelect } = props; return ( - - - All - - - - Monitored Only - - - - Continuing Only - - - - Ended Only - - - - Missing Albums - - - + isDisabled={isDisabled} + selectedFilterKey={selectedFilterKey} + filters={filters} + customFilters={customFilters} + onFilterSelect={onFilterSelect} + /> ); } ArtistIndexFilterMenu.propTypes = { - filterKey: PropTypes.string, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, isDisabled: PropTypes.bool.isRequired, onFilterSelect: PropTypes.func.isRequired }; +ArtistIndexFilterMenu.defaultProps = { + showCustomFilters: false +}; + export default ArtistIndexFilterMenu; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js index b36fbc757..01cfe1598 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js @@ -78,8 +78,7 @@ class ArtistIndexOverview extends Component { status, foreignArtistId, nextAiring, - trackCount, - trackFileCount, + statistics, images, posterWidth, posterHeight, @@ -95,6 +94,12 @@ class ArtistIndexOverview extends Component { ...otherProps } = this.props; + const { + trackCount, + trackFileCount, + totalTrackCount + } = statistics; + const { isEditArtistModalOpen, isDeleteArtistModalOpen @@ -144,6 +149,7 @@ class ArtistIndexOverview extends Component { status={status} trackCount={trackCount} trackFileCount={trackFileCount} + totalTrackCount={totalTrackCount} posterWidth={posterWidth} detailedProgressBar={overviewOptions.detailedProgressBar} /> @@ -194,6 +200,7 @@ class ArtistIndexOverview extends Component { showRelativeDates={showRelativeDates} shortDateFormat={shortDateFormat} timeFormat={timeFormat} + statistics={statistics} {...overviewOptions} {...otherProps} /> @@ -227,8 +234,7 @@ ArtistIndexOverview.propTypes = { status: PropTypes.string.isRequired, foreignArtistId: PropTypes.string.isRequired, nextAiring: PropTypes.string, - trackCount: PropTypes.number, - trackFileCount: PropTypes.number, + statistics: PropTypes.object.isRequired, images: PropTypes.arrayOf(PropTypes.object).isRequired, posterWidth: PropTypes.number.isRequired, posterHeight: PropTypes.number.isRequired, @@ -245,7 +251,8 @@ ArtistIndexOverview.propTypes = { ArtistIndexOverview.defaultProps = { trackCount: 0, - trackFileCount: 0 + trackFileCount: 0, + albumCount: 0 }; export default ArtistIndexOverview; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js index 7b904bcad..004a48a51 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js @@ -31,15 +31,19 @@ function ArtistIndexOverviewInfo(props) { nextAiring, qualityProfile, added, - albumCount, + statistics, path, - sizeOnDisk, sortKey, showRelativeDates, shortDateFormat, timeFormat } = props; + const { + albumCount, + sizeOnDisk + } = statistics; + let albums = '1 album'; if (albumCount === 0) { @@ -203,9 +207,8 @@ ArtistIndexOverviewInfo.propTypes = { qualityProfile: PropTypes.object.isRequired, previousAiring: PropTypes.string, added: PropTypes.string, - albumCount: PropTypes.number.isRequired, + statistics: PropTypes.object.isRequired, path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, sortKey: PropTypes.string.isRequired, showRelativeDates: PropTypes.bool.isRequired, shortDateFormat: PropTypes.string.isRequired, diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js index 43028b32e..93c2c9764 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js @@ -4,6 +4,7 @@ import React, { Component } from 'react'; import ReactDOM from 'react-dom'; import Measure from 'react-measure'; import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import dimensions from 'Styles/Variables/dimensions'; import { sortDirections } from 'Helpers/Props'; @@ -76,11 +77,11 @@ class ArtistIndexOverviews extends Component { componentDidUpdate(prevProps) { const { items, - filterKey, - filterValue, + filters, sortKey, sortDirection, - overviewOptions + overviewOptions, + jumpToCharacter } = this.props; const itemsChanged = hasDifferentItems(prevProps.items, items); @@ -95,8 +96,7 @@ class ArtistIndexOverviews extends Component { } if ( - prevProps.filterKey !== filterKey || - prevProps.filterValue !== filterValue || + prevProps.filters !== filters || prevProps.sortKey !== sortKey || prevProps.sortDirection !== sortDirection || itemsChanged || @@ -104,6 +104,20 @@ class ArtistIndexOverviews extends Component { ) { this._grid.recomputeGridSize(); } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const { + rowHeight + } = this.state; + + const scrollTop = rowHeight * index; + + this.props.onScroll({ scrollTop }); + } + } } // @@ -115,15 +129,7 @@ class ArtistIndexOverviews extends Component { rowHeight } = this.state; - const index = _.findIndex(items, (item) => { - const firstCharacter = item.sortTitle.charAt(0); - - if (character === '#') { - return !isNaN(firstCharacter); - } - - return firstCharacter === character; - }); + const index = getIndexOfFirstCharacter(items, character); if (index != null) { const scrollTop = rowHeight * index; @@ -263,12 +269,12 @@ class ArtistIndexOverviews extends Component { ArtistIndexOverviews.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, - filterKey: PropTypes.string, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), + filters: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), overviewOptions: PropTypes.object.isRequired, scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, contentBody: PropTypes.object.isRequired, showRelativeDates: PropTypes.bool.isRequired, shortDateFormat: PropTypes.string.isRequired, diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js index a2075416f..3465729cc 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js @@ -28,6 +28,6 @@ export default connectSection( createMapStateToProps, undefined, undefined, - { withRef: true }, + undefined, { section: 'artist', uiSection: 'artistIndex' } )(ArtistIndexOverviews); diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js index 6ae1d8993..2134c7ef3 100644 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js @@ -204,7 +204,7 @@ class ArtistIndexOverviewOptionsModalContent extends Component { - Show Season Count + Show Album Count @@ -165,26 +171,28 @@ class ArtistIndexPoster extends Component { {qualityProfile.name}
} - -
- { - getRelativeDate( - nextAiring, - shortDateFormat, - showRelativeDates, + { + nextAiring && +
{ - timeFormat, - timeForToday: true + getRelativeDate( + nextAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) } - ) - } -
- +
+ } @@ -215,8 +223,7 @@ ArtistIndexPoster.propTypes = { status: PropTypes.string.isRequired, foreignArtistId: PropTypes.string.isRequired, nextAiring: PropTypes.string, - trackCount: PropTypes.number, - trackFileCount: PropTypes.number, + statistics: PropTypes.object.isRequired, images: PropTypes.arrayOf(PropTypes.object).isRequired, posterWidth: PropTypes.number.isRequired, posterHeight: PropTypes.number.isRequired, @@ -234,7 +241,8 @@ ArtistIndexPoster.propTypes = { ArtistIndexPoster.defaultProps = { trackCount: 0, - trackFileCount: 0 + trackFileCount: 0, + albumCount: 0 }; export default ArtistIndexPoster; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css index cab3dec61..aab27d827 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css @@ -1,6 +1,5 @@ .info { - background-color: $defaultColor; - color: $white; + background-color: #fafbfc; text-align: center; font-size: $smallFontSize; } diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js index 95039ffa7..432faf68e 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js @@ -10,15 +10,19 @@ function ArtistIndexPosterInfo(props) { showQualityProfile, previousAiring, added, - albumCount, + statistics, path, - sizeOnDisk, sortKey, showRelativeDates, shortDateFormat, timeFormat } = props; + const { + albumCount, + sizeOnDisk + } = statistics; + if (sortKey === 'qualityProfileId' && !showQualityProfile) { return (
@@ -103,9 +107,8 @@ ArtistIndexPosterInfo.propTypes = { showQualityProfile: PropTypes.bool.isRequired, previousAiring: PropTypes.string, added: PropTypes.string, - albumCount: PropTypes.number, + statistics: PropTypes.object.isRequired, path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, sortKey: PropTypes.string.isRequired, showRelativeDates: PropTypes.bool.isRequired, shortDateFormat: PropTypes.string.isRequired, diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js index 56c3d1ce7..000b14887 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js @@ -1,9 +1,9 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ReactDOM from 'react-dom'; import Measure from 'react-measure'; import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import dimensions from 'Styles/Variables/dimensions'; import { sortDirections } from 'Helpers/Props'; @@ -116,11 +116,11 @@ class ArtistIndexPosters extends Component { componentDidUpdate(prevProps) { const { items, - filterKey, - filterValue, + filters, sortKey, sortDirection, - posterOptions + posterOptions, + jumpToCharacter } = this.props; const itemsChanged = hasDifferentItems(prevProps.items, items); @@ -134,44 +134,34 @@ class ArtistIndexPosters extends Component { } if ( - prevProps.filterKey !== filterKey || - prevProps.filterValue !== filterValue || + prevProps.filters !== filters || prevProps.sortKey !== sortKey || prevProps.sortDirection !== sortDirection || itemsChanged ) { this._grid.recomputeGridSize(); } - } - // - // Control + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); - scrollToFirstCharacter(character) { - const items = this.props.items; - const { - columnCount, - rowHeight - } = this.state; + if (index != null) { + const { + columnCount, + rowHeight + } = this.state; - const index = _.findIndex(items, (item) => { - const firstCharacter = item.sortName.charAt(0); + const row = Math.floor(index / columnCount); + const scrollTop = rowHeight * row; - if (character === '#') { - return !isNaN(firstCharacter); + this.props.onScroll({ scrollTop }); } - - return firstCharacter === character; - }); - - if (index != null) { - const row = Math.floor(index / columnCount); - const scrollTop = rowHeight * row; - - this.props.onScroll({ scrollTop }); } } + // + // Control + setGridRef = (ref) => { this._grid = ref; } @@ -319,12 +309,12 @@ class ArtistIndexPosters extends Component { ArtistIndexPosters.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, - filterKey: PropTypes.string, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), + filters: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), posterOptions: PropTypes.object.isRequired, scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, contentBody: PropTypes.object.isRequired, showRelativeDates: PropTypes.bool.isRequired, shortDateFormat: PropTypes.string.isRequired, diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js index 6ec135987..786b187a8 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js @@ -28,6 +28,6 @@ export default connectSection( createMapStateToProps, undefined, undefined, - { withRef: true }, + undefined, { section: 'artist', uiSection: 'artistIndex' } )(ArtistIndexPosters); diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js index 27f68f7fb..6be32a46d 100644 --- a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js +++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js @@ -11,6 +11,7 @@ function ArtistIndexProgressBar(props) { status, trackCount, trackFileCount, + totalTrackCount, posterWidth, detailedProgressBar } = props; @@ -27,7 +28,7 @@ function ArtistIndexProgressBar(props) { size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL} showText={detailedProgressBar} text={text} - title={detailedProgressBar ? null : text} + title={`${trackFileCount} / ${trackCount} (Total: ${totalTrackCount})`} width={posterWidth} /> ); @@ -38,6 +39,7 @@ ArtistIndexProgressBar.propTypes = { status: PropTypes.string.isRequired, trackCount: PropTypes.number.isRequired, trackFileCount: PropTypes.number.isRequired, + totalTrackCount: PropTypes.number.isRequired, posterWidth: PropTypes.number.isRequired, detailedProgressBar: PropTypes.bool.isRequired }; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css index a42ee7a03..31261d74b 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css +++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css @@ -55,7 +55,7 @@ .sizeOnDisk { composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; - flex: 0 0 110px; + flex: 0 0 115px; } .tags { diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.js b/frontend/src/Artist/Index/Table/ArtistIndexRow.js index f03c0489b..a042752f5 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexRow.js +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.js @@ -74,18 +74,22 @@ class ArtistIndexRow extends Component { nextAlbum, lastAlbum, added, - albumCount, - trackCount, - trackFileCount, - totalTrackCount, + statistics, path, - sizeOnDisk, tags, columns, isRefreshingArtist, onRefreshArtistPress } = this.props; + const { + albumCount, + trackCount, + trackFileCount, + totalTrackCount, + sizeOnDisk + } = statistics; + const { isEditArtistModalOpen, isDeleteArtistModalOpen @@ -367,13 +371,9 @@ ArtistIndexRow.propTypes = { nextAlbum: PropTypes.object, lastAlbum: PropTypes.object, added: PropTypes.string, - albumCount: PropTypes.number, - trackCount: PropTypes.number, - trackFileCount: PropTypes.number, - totalTrackCount: PropTypes.number, + statistics: PropTypes.object.isRequired, latestAlbum: PropTypes.object, path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, tags: PropTypes.arrayOf(PropTypes.number).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, isRefreshingArtist: PropTypes.bool.isRequired, @@ -382,7 +382,8 @@ ArtistIndexRow.propTypes = { ArtistIndexRow.defaultProps = { trackCount: 0, - trackFileCount: 0 + trackFileCount: 0, + albumCount: 0 }; export default ArtistIndexRow; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.js b/frontend/src/Artist/Index/Table/ArtistIndexTable.js index e4ee39d39..eee92a418 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.js +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.js @@ -1,6 +1,6 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; import { sortDirections } from 'Helpers/Props'; import VirtualTable from 'Components/Table/VirtualTable'; import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; @@ -9,40 +9,37 @@ import ArtistIndexRow from './ArtistIndexRow'; import styles from './ArtistIndexTable.css'; class ArtistIndexTable extends Component { - constructor(props, context) { - super(props, context); - this._table = null; - } // - // Control + // Lifecycle - /** - * Sets the reference to the virtual table - * @param ref - */ - setTableRef = (ref) => { - this._table = ref; - }; + constructor(props, context) { + super(props, context); - scrollToFirstCharacter(character) { - const items = this.props.items; + this.state = { + scrollIndex: null + }; + } - const row = _.findIndex(items, (item) => { - const firstCharacter = item.sortName.charAt(0); + componentDidUpdate(prevProps) { + const jumpToCharacter = this.props.jumpToCharacter; - if (character === '#') { - return !isNaN(firstCharacter); - } + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const items = this.props.items; - return firstCharacter === character; - }); + const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter); - if (row != null) { - this._table.scrollToRow(row); + if (scrollIndex != null) { + this.setState({ scrollIndex }); + } + } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) { + this.setState({ scrollIndex: null }); } } + // + // Control + rowRenderer = ({ key, rowIndex, style }) => { const { items, @@ -72,8 +69,7 @@ class ArtistIndexTable extends Component { const { items, columns, - filterKey, - filterValue, + filters, sortKey, sortDirection, isSmallScreen, @@ -86,10 +82,10 @@ class ArtistIndexTable extends Component { return ( } columns={columns} - filterKey={filterKey} - filterValue={filterValue} + filters={filters} sortKey={sortKey} sortDirection={sortDirection} onRender={onRender} @@ -118,11 +113,11 @@ class ArtistIndexTable extends Component { ArtistIndexTable.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, - filterKey: PropTypes.string, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), + filters: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, contentBody: PropTypes.object.isRequired, isSmallScreen: PropTypes.bool.isRequired, onSortPress: PropTypes.func.isRequired, diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js index c49c0cf07..3e4813fcf 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js @@ -29,6 +29,6 @@ export default connectSection( createMapStateToProps, createMapDispatchToProps, undefined, - { withRef: true }, + undefined, { section: 'artist', uiSection: 'artistIndex' } )(ArtistIndexTable); diff --git a/frontend/src/Artist/MoveArtist/MoveArtistModal.js b/frontend/src/Artist/MoveArtist/MoveArtistModal.js index 8d5fa2d91..395e4ff95 100644 --- a/frontend/src/Artist/MoveArtist/MoveArtistModal.js +++ b/frontend/src/Artist/MoveArtist/MoveArtistModal.js @@ -25,7 +25,7 @@ function MoveArtistModal(props) { !destinationPath && !destinationRootFolder ) { - console.error('orginalPath and destinationPath OR destinationRootFolder must be provied'); + console.error('orginalPath and destinationPath OR destinationRootFolder must be provided'); } return ( diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js index b76cde468..6d235f806 100644 --- a/frontend/src/Calendar/CalendarPage.js +++ b/frontend/src/Calendar/CalendarPage.js @@ -2,14 +2,13 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import Measure from 'react-measure'; import { align, icons } from 'Helpers/Props'; +import getFilterValue from 'Utilities/Filter/getFilterValue'; import PageContent from 'Components/Page/PageContent'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import FilterMenu from 'Components/Menu/FilterMenu'; -import MenuContent from 'Components/Menu/MenuContent'; -import FilterMenuItem from 'Components/Menu/FilterMenuItem'; import NoArtist from 'Artist/NoArtist'; import CalendarLinkModal from './iCal/CalendarLinkModal'; import Legend from './Legend/Legend'; @@ -42,10 +41,6 @@ class CalendarPage extends Component { this.props.onDaysCountChange(days); } - onFilterMenuItemPress = (filterKey, unmonitored) => { - this.props.onUnmonitoredChange(unmonitored); - } - onGetCalendarLinkPress = () => { this.setState({ isCalendarLinkModalOpen: true }); } @@ -59,12 +54,15 @@ class CalendarPage extends Component { render() { const { - unmonitored, + selectedFilterKey, + filters, hasArtist, - colorImpairedMode + colorImpairedMode, + onFilterSelect } = this.props; const isMeasured = this.state.width > 0; + let PageComponent = 'div'; if (isMeasured) { @@ -85,30 +83,11 @@ class CalendarPage extends Component { - - - All - - - - Monitored Only - - - + selectedFilterKey={selectedFilterKey} + filters={filters} + customFilters={[]} + onFilterSelect={onFilterSelect} + /> @@ -139,11 +118,12 @@ class CalendarPage extends Component { } CalendarPage.propTypes = { - unmonitored: PropTypes.bool.isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, hasArtist: PropTypes.bool.isRequired, colorImpairedMode: PropTypes.bool.isRequired, onDaysCountChange: PropTypes.func.isRequired, - onUnmonitoredChange: PropTypes.func.isRequired + onFilterSelect: PropTypes.func.isRequired }; export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js index df2d00ed4..fbd6a17f9 100644 --- a/frontend/src/Calendar/CalendarPageConnector.js +++ b/frontend/src/Calendar/CalendarPageConnector.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { setCalendarDaysCount, setCalendarIncludeUnmonitored } from 'Store/Actions/calendarActions'; +import { setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; import createArtistCountSelector from 'Store/Selectors/createArtistCountSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import CalendarPage from './CalendarPage'; @@ -12,7 +12,8 @@ function createMapStateToProps() { createUISettingsSelector(), (calendar, artistCount, uiSettings) => { return { - unmonitored: calendar.unmonitored, + filters: calendar.filters, + selectedFilterKey: calendar.selectedFilterKey, showUpcoming: calendar.showUpcoming, colorImpairedMode: uiSettings.enableColorImpairedMode, hasArtist: !!artistCount @@ -27,8 +28,8 @@ function createMapDispatchToProps(dispatch, props) { dispatch(setCalendarDaysCount({ dayCount })); }, - onUnmonitoredChange(unmonitored) { - dispatch(setCalendarIncludeUnmonitored({ unmonitored })); + onFilterSelect(selectedFilterKey) { + dispatch(setCalendarFilter({ selectedFilterKey })); } }; } diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css new file mode 100644 index 000000000..6cc8fab67 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css @@ -0,0 +1,16 @@ +.labelContainer { + margin-bottom: 20px; +} + +.label { + margin-bottom: 5px; + font-weight: bold; +} + +.labelInputContainer { + width: 300px; +} + +.rows { + margin-bottom: 100px; +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js new file mode 100644 index 000000000..d2a72b67c --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js @@ -0,0 +1,192 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import FilterBuilderRow from './FilterBuilderRow'; +import styles from './FilterBuilderModalContent.css'; + +class FilterBuilderModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const filters = [...props.filters]; + + // Push an empty filter if there aren't any filters. FilterBuilderRow + // will handle initializing the filter. + + if (!filters.length) { + filters.push({}); + } + + this.state = { + label: props.label, + filters, + labelErrors: [] + }; + } + + // + // Listeners + + onLabelChange = ({ value }) => { + this.setState({ label: value }); + } + + onFilterChange = (index, filter) => { + const filters = [...this.state.filters]; + filters.splice(index, 1, filter); + + this.setState({ + filters + }); + } + + onAddFilterPress = () => { + const filters = [...this.state.filters]; + filters.push({}); + + this.setState({ + filters + }); + } + + onRemoveFilterPress = (index) => { + const filters = [...this.state.filters]; + filters.splice(index, 1); + + this.setState({ + filters + }); + } + + onSaveFilterPress = () => { + const { + customFilterKey: key, + onSaveCustomFilterPress, + onModalClose + } = this.props; + + const { + label, + filters + } = this.state; + + if (!label) { + this.setState({ + labelErrors: [ + { + message: 'Label is required' + } + ] + }); + + return; + } + + onSaveCustomFilterPress({ key, label, filters }); + onModalClose(); + } + + // + // Render + + render() { + const { + sectionItems, + filterBuilderProps, + onModalClose + } = this.props; + + const { + label, + filters, + labelErrors + } = this.state; + + return ( + + + Custom Filter + + + +
+
+ Label +
+ +
+ +
+
+ +
Filters
+ +
+ { + filters.map((filter, index) => { + return ( + + ); + }) + } +
+
+ + + + + + +
+ ); + } +} + +FilterBuilderModalContent.propTypes = { + customFilterKey: PropTypes.string, + label: PropTypes.string.isRequired, + sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired, + onRemoveCustomFilterPress: PropTypes.func.isRequired, + onSaveCustomFilterPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FilterBuilderModalContent; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js new file mode 100644 index 000000000..e7f237793 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import FilterBuilderModalContent from './FilterBuilderModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { customFilters }) => customFilters, + (state, { customFilterKey }) => customFilterKey, + (customFilters, customFilterKey) => { + if (customFilterKey) { + const customFilter = customFilters.find((c) => c.key === customFilterKey); + + return { + customFilterKey: customFilter.key, + label: customFilter.label, + filters: customFilter.filters + }; + } + + return { + label: '', + filters: [] + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderModalContent); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.css b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css new file mode 100644 index 000000000..c5471b253 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css @@ -0,0 +1,32 @@ +.filterRow { + display: flex; + margin-bottom: 5px; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.inputContainer { + flex: 0 1 200px; + margin-right: 10px; +} + +.valueInputContainer { + flex: 0 1 300px; + margin-right: 10px; +} + +.actionsContainer { + display: flex; +} + +@media only screen and (max-width: $breakpointSmall) { + .filterRow { + display: block; + } + + .inputContainer { + margin-bottom: 10px; + } +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js new file mode 100644 index 000000000..8541ac9e8 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -0,0 +1,248 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; +import SelectInput from 'Components/Form/SelectInput'; +import IconButton from 'Components/Link/IconButton'; +import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; +import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; +import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; +import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; +import styles from './FilterBuilderRow.css'; + +function getselectedFilterBuilderProp(filterBuilderProps, name) { + return filterBuilderProps.find((a) => { + return a.name === name; + }); +} + +function getFilterTypeOptions(filterBuilderProps, filterKey) { + const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, filterKey); + + if (!selectedFilterBuilderProp) { + return []; + } + + return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type]; +} + +function getDefaultFilterType(selectedFilterBuilderProp) { + return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type][0].key; +} + +function getRowValueConnector(selectedFilterBuilderProp) { + if (!selectedFilterBuilderProp) { + return FilterBuilderRowValueConnector; + } + + const valueType = selectedFilterBuilderProp.valueType; + + switch (valueType) { + case filterBuilderValueTypes.INDEXER: + return IndexerFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.PROTOCOL: + return ProtocolFilterBuilderRowValue; + + case filterBuilderValueTypes.QUALITY: + return QualityFilterBuilderRowValueConnector; + + default: + return FilterBuilderRowValueConnector; + } +} + +class FilterBuilderRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + selectedFilterBuilderProp: null + }; + } + + componentDidMount() { + const { + index, + filterKey, + filterBuilderProps, + onFilterChange + } = this.props; + + if (filterKey) { + const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey); + this.setState({ selectedFilterBuilderProp }); + + return; + } + + const selectedFilterBuilderProp = filterBuilderProps[0]; + + const filter = { + key: selectedFilterBuilderProp.name, + value: [], + type: getDefaultFilterType(selectedFilterBuilderProp) + }; + + this.setState({ selectedFilterBuilderProp }, () => { + onFilterChange(index, filter); + }); + } + + // + // Listeners + + onFilterKeyChange = ({ value: key }) => { + const { + index, + filterBuilderProps, + onFilterChange + } = this.props; + + const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, key); + const type = getDefaultFilterType(selectedFilterBuilderProp); + + const filter = { + key, + value: [], + type + }; + + this.setState({ selectedFilterBuilderProp }, () => { + onFilterChange(index, filter); + }); + } + + onFilterChange = ({ name, value }) => { + const { + index, + filterKey, + filterValue, + filterType, + onFilterChange + } = this.props; + + const filter = { + key: filterKey, + value: filterValue, + type: filterType + }; + + filter[name] = value; + + onFilterChange(index, filter); + } + + onAddPress = () => { + const { + index, + onAddPress + } = this.props; + + onAddPress(index); + } + + onRemovePress = () => { + const { + index, + onRemovePress + } = this.props; + + onRemovePress(index); + } + + // + // Render + + render() { + const { + filterKey, + filterType, + filterValue, + filterCount, + filterBuilderProps + } = this.props; + + const { + selectedFilterBuilderProp + } = this.state; + + const keyOptions = filterBuilderProps.map((availablePropFilter) => { + return { + key: availablePropFilter.name, + value: availablePropFilter.label + }; + }); + + const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); + + return ( +
+
+ { + filterKey && + + } +
+ +
+ { + filterType && + + } +
+ +
+ { + filterValue != null && !!selectedFilterBuilderProp && + + } +
+ +
+ + + +
+
+ ); + } +} + +FilterBuilderRow.propTypes = { + index: PropTypes.number.isRequired, + filterKey: PropTypes.string, + filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]), + filterType: PropTypes.string, + filterCount: PropTypes.number.isRequired, + filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired, + onFilterChange: PropTypes.func.isRequired, + onAddPress: PropTypes.func.isRequired, + onRemovePress: PropTypes.func.isRequired +}; + +export default FilterBuilderRow; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js new file mode 100644 index 000000000..0104cd4a6 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds, filterBuilderTypes } from 'Helpers/Props'; +import TagInput, { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValueTag from './FilterBuilderRowValueTag'; + +const NAME = 'value'; + +class FilterBuilderRowValue extends Component { + + // + // Listeners + + onTagAdd = (tag) => { + const { + filterValue, + selectedFilterBuilderProp, + onChange + } = this.props; + + let id = tag.id; + + if (id == null) { + id = selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER ? + parseInt(tag.name) : + tag.name; + } + + onChange({ + name: NAME, + value: [...filterValue, id] + }); + } + + onTagDelete = ({ index }) => { + const { + filterValue, + onChange + } = this.props; + + const value = filterValue.filter((v, i) => i !== index); + + onChange({ + name: NAME, + value + }); + } + + // + // Render + + render() { + const { + filterValue, + tagList + } = this.props; + + const hasItems = !!tagList.length; + + const tags = filterValue.map((id) => { + if (hasItems) { + const tag = tagList.find((t) => t.id === id); + + return { + id, + name: tag && tag.name + }; + } + return { + id, + name: id + }; + }); + + return ( + + ); + } +} + +FilterBuilderRowValue.propTypes = { + filterValue: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])).isRequired, + selectedFilterBuilderProp: PropTypes.object.isRequired, + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + onChange: PropTypes.func.isRequired +}; + +export default FilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js new file mode 100644 index 000000000..fd0832334 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js @@ -0,0 +1,50 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { filterBuilderTypes } from 'Helpers/Props'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createTagListSelector() { + return createSelector( + (state, { sectionItems }) => _.get(state, sectionItems), + (state, { selectedFilterBuilderProp }) => selectedFilterBuilderProp, + (sectionItems, selectedFilterBuilderProp) => { + if ( + selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER || + selectedFilterBuilderProp.type === filterBuilderTypes.STRING + ) { + return []; + } + + let items = []; + + if (selectedFilterBuilderProp.optionsSelector) { + items = sectionItems.map(selectedFilterBuilderProp.optionsSelector); + } else { + items = sectionItems.map((item) => { + const name = item[selectedFilterBuilderProp.name]; + + return { + id: name, + name + }; + }); + } + + return _.uniqBy(items, 'id'); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createTagListSelector(), + (tagList) => { + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css new file mode 100644 index 000000000..1c4c5acf1 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css @@ -0,0 +1,19 @@ +.tag { + &.isLastTag { + .or { + display: none; + } + } +} + +.label { + composes: label from 'Components/Label.css'; + + border-style: none; + font-size: 13px; +} + +.or { + margin: 0 3px; + color: $themeDarkColor; +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js new file mode 100644 index 000000000..573e05759 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import TagInputTag from 'Components/Form/TagInputTag'; +import styles from './FilterBuilderRowValueTag.css'; + +function FilterBuilderRowValueTag(props) { + return ( + + + + { + !props.isLastTag && + + or + + } + + ); +} + +FilterBuilderRowValueTag.propTypes = { + isLastTag: PropTypes.bool.isRequired +}; + +export default FilterBuilderRowValueTag; diff --git a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..968b26d2c --- /dev/null +++ b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexers } from 'Store/Actions/settingsActions'; +import { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (qualityProfiles) => { + const { + isFetching, + isPopulated, + error, + items + } = qualityProfiles; + + const tagList = items.map((item) => { + return { + id: item.id, + name: item.name + }; + }); + + return { + isFetching, + isPopulated, + error, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchIndexers: fetchIndexers +}; + +class IndexerFilterBuilderRowValueConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.dispatchFetchIndexers(); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +IndexerFilterBuilderRowValueConnector.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + dispatchFetchIndexers: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerFilterBuilderRowValueConnector); diff --git a/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js new file mode 100644 index 000000000..ae63ae0eb --- /dev/null +++ b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: 'torrent', name: 'Torrent' }, + { id: 'usenet', name: 'Usenet' } +]; + +function ProtocolFilterBuilderRowValue(props) { + return ( + + ); +} + +export default ProtocolFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..ee1dc732e --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js @@ -0,0 +1,75 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const { + isFetchingSchema: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + schema + } = qualityProfiles; + + const tagList = getQualities(schema.items); + + return { + isFetching, + isPopulated, + error, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityProfileSchema: fetchQualityProfileSchema +}; + +class QualityFilterBuilderRowValueConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.dispatchFetchQualityProfileSchema(); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +QualityFilterBuilderRowValueConnector.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + dispatchFetchQualityProfileSchema: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QualityFilterBuilderRowValueConnector); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.css b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css new file mode 100644 index 000000000..7acb69dc7 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css @@ -0,0 +1,17 @@ +.customFilter { + display: flex; + margin-bottom: 5px; + padding: 5px; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.label { + flex: 0 1 300px; +} + +.actions { + flex: 0 0 60px; +} diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js new file mode 100644 index 000000000..62551978b --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import styles from './CustomFilter.css'; + +class CustomFilter extends Component { + + // + // Listeners + + onEditPress = () => { + const { + customFilterKey, + onEditPress + } = this.props; + + onEditPress(customFilterKey); + } + + onRemovePress = () => { + const { + customFilterKey, + onRemovePress + } = this.props; + + onRemovePress({ key: customFilterKey }); + } + + // + // Render + + render() { + const { + label + } = this.props; + + return ( +
+
+ {label} +
+ +
+ + + +
+
+ ); + } +} + +CustomFilter.propTypes = { + customFilterKey: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + onEditPress: PropTypes.func.isRequired, + onRemovePress: PropTypes.func.isRequired +}; + +export default CustomFilter; diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css new file mode 100644 index 000000000..c391764dc --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css @@ -0,0 +1,3 @@ +.addButtonContainer { + margin-top: 15px; +} diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js new file mode 100644 index 000000000..ac27bdd23 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import CustomFilter from './CustomFilter'; +import styles from './CustomFiltersModalContent.css'; + +function CustomFiltersModalContent(props) { + const { + customFilters, + onAddCustomFilter, + onRemoveCustomFilterPress, + onEditCustomFilter, + onModalClose + } = props; + + return ( + + + Custom Filters + + + + { + customFilters.map((customFilter, index) => { + return ( + + ); + }) + } + +
+ +
+
+ + + + +
+ ); +} + +CustomFiltersModalContent.propTypes = { + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + onAddCustomFilter: PropTypes.func.isRequired, + onRemoveCustomFilterPress: PropTypes.func.isRequired, + onEditCustomFilter: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CustomFiltersModalContent; diff --git a/frontend/src/Components/Filter/FilterModal.js b/frontend/src/Components/Filter/FilterModal.js new file mode 100644 index 000000000..944caf070 --- /dev/null +++ b/frontend/src/Components/Filter/FilterModal.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import FilterBuilderModalContentConnector from './Builder/FilterBuilderModalContentConnector'; +import CustomFiltersModalContent from './CustomFilters/CustomFiltersModalContent'; + +class FilterModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + filterBuilder: !props.customFilters.length, + customFilterKey: null + }; + } + + // + // Listeners + + onAddCustomFilter = () => { + this.setState({ + filterBuilder: true + }); + } + + onEditCustomFilter = (customFilterKey) => { + this.setState({ + filterBuilder: true, + customFilterKey + }); + } + + onModalClose = () => { + this.setState({ + filterBuilder: false, + customFilterKey: null + }, () => { + this.props.onModalClose(); + }); + } + + // + // Render + + render() { + const { + isOpen, + ...otherProps + } = this.props; + + const { + filterBuilder, + customFilterKey + } = this.state; + + return ( + + { + filterBuilder ? + : + + } + + ); + } +} + +FilterModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FilterModal; diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css index e269a7adf..568e35f40 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.css +++ b/frontend/src/Components/Form/EnhancedSelectInput.css @@ -31,7 +31,6 @@ .isDisabled { opacity: 0.7; cursor: not-allowed; - pointer-events: all !important; } .dropdownArrowContainer { diff --git a/frontend/src/Components/Form/PathInput.css b/frontend/src/Components/Form/PathInput.css index d3851b204..ce9fd8ebe 100644 --- a/frontend/src/Components/Form/PathInput.css +++ b/frontend/src/Components/Form/PathInput.css @@ -58,7 +58,7 @@ } .pathHighlighted { - background-color: $menuItemHoverColor; + background-color: $menuItemHoverBackgroundColor; } .fileBrowserButton { diff --git a/frontend/src/Components/Form/TagInput.css b/frontend/src/Components/Form/TagInput.css index 87853ccfb..1112b6e86 100644 --- a/frontend/src/Components/Form/TagInput.css +++ b/frontend/src/Components/Form/TagInput.css @@ -1,97 +1,77 @@ -.container { +.inputContainer { composes: input from 'Components/Form/Input.css'; - display: flex; - flex-wrap: wrap; + position: relative; + padding: 0; min-height: 35px; height: auto; -} - -.containerFocused { - outline: 0; - border-color: $inputFocusBorderColor; - box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor; -} -.selectedTagContainer { - flex: 0 0 auto; -} - -.selectedTag { - composes: label from 'Components/Label.css'; - - border-style: none; - font-size: 13px; + &.isFocused { + outline: 0; + border-color: $inputFocusBorderColor; + box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor; + } } -/* Selected Tag Kinds */ - -.info { - composes: info from 'Components/Label.css'; +.hasError { + composes: hasError from 'Components/Form/Input.css'; } -.success { - composes: success from 'Components/Label.css'; +.hasWarning { + composes: hasWarning from 'Components/Form/Input.css'; } -.warning { - composes: warning from 'Components/Label.css'; +.tags { + flex: 0 0 auto; + max-width: 100%; } -.danger { - composes: danger from 'Components/Label.css'; +.input { + flex: 1 1 0%; + margin-left: 3px; + min-width: 20%; + max-width: 100%; + width: 0%; + border: none; } -.searchInputContainer { - position: relative; - flex: 1 0 100px; - margin-top: 1px; - padding-left: 5px; +.suggestionsContainer { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; } -.searchInput { - max-width: 100%; - font-size: 13px; - - input { - margin: 0; - padding: 0; - max-width: 100%; - outline: none; - border: 0; +.containerOpen { + .suggestionsContainer { + position: absolute; + right: -1px; + left: -1px; + z-index: 1; + overflow-y: auto; + margin-top: 1px; + max-height: 110px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; } } -.suggestions { - position: absolute; - z-index: 1; - overflow-y: auto; - max-height: 200px; - width: 100%; - border: 1px solid $inputBorderColor; - border-radius: 4px; - background-color: $white; - box-shadow: inset 0 1px 1px $inputBoxShadowColor; - - ul { - margin: 5px 0; - padding-left: 0; - list-style-type: none; - } - - li { - padding: 0 16px; - } +.suggestionsList { + margin: 5px 0; + padding-left: 0; + list-style-type: none; +} - li mark { - font-weight: bold; - } +.suggestion { + padding: 0 16px; + cursor: default; - li:hover { - background-color: $menuItemHoverColor; + &:hover { + background-color: $menuItemHoverBackgroundColor; } } -.suggestionActive { - background-color: $menuItemHoverColor; +.suggestionHighlighted { + background-color: $menuItemHoverBackgroundColor; } diff --git a/frontend/src/Components/Form/TagInput.js b/frontend/src/Components/Form/TagInput.js index 3ad360308..61f893446 100644 --- a/frontend/src/Components/Form/TagInput.js +++ b/frontend/src/Components/Form/TagInput.js @@ -1,11 +1,27 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import ReactTags from 'react-tag-autocomplete'; +import Autosuggest from 'react-autosuggest'; import classNames from 'classnames'; import { kinds } from 'Helpers/Props'; +import TagInputInput from './TagInputInput'; +import TagInputTag from './TagInputTag'; import styles from './TagInput.css'; +function getTag(value, selectedIndex, suggestions, allowNew) { + if (selectedIndex == null && value) { + const existingTag = _.find(suggestions, { name: value }); + + if (existingTag) { + return existingTag; + } else if (allowNew) { + return { name: value }; + } + } else if (selectedIndex != null) { + return suggestions[selectedIndex]; + } +} + class TagInput extends Component { // @@ -14,97 +30,240 @@ class TagInput extends Component { constructor(props, context) { super(props, context); - this._tagsRef = null; - this._inputRef = null; + this.state = { + value: '', + suggestions: [], + isFocused: false + }; + + this._autosuggestRef = null; } // // Control - _setTagsRef = (ref) => { - this._tagsRef = ref; + _setAutosuggestRef = (ref) => { + this._autosuggestRef = ref; + } - if (ref) { - this._inputRef = this._tagsRef.input.input; + getSuggestionValue({ name }) { + return name; + } - this._inputRef.addEventListener('blur', this.onInputBlur); - } else if (this._inputRef) { - this._inputRef.removeEventListener('blur', this.onInputBlur); - } + shouldRenderSuggestions = (value) => { + return value.length >= this.props.minQueryLength; + } + + renderSuggestion({ name }, { query }) { + return name; } // // Listeners + onInputContainerPress = () => { + this._autosuggestRef.input.focus(); + } + + onTagAdd(tag) { + this.props.onTagAdd(tag); + + this.setState({ + value: '', + suggestions: [] + }); + } + + onInputChange = (event, { newValue, method }) => { + const value = _.isObject(newValue) ? newValue.name : newValue; + + if (method === 'type') { + this.setState({ value }); + } + } + + onInputKeyDown = (event) => { + const { + tags, + allowNew, + delimiters, + onTagDelete + } = this.props; + + const { + value, + suggestions + } = this.state; + + const keyCode = event.keyCode; + + if (keyCode === 8 && !value.length) { + const index = tags.length - 1; + + if (index >= 0) { + onTagDelete({ index, id: tags[index].id }); + } + + setTimeout(() => { + this.onSuggestionsFetchRequested({ value: '' }); + }); + + event.preventDefault(); + } + + if (delimiters.includes(keyCode)) { + const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex; + const tag = getTag(value, selectedIndex, suggestions, allowNew); + + if (tag) { + this.onTagAdd(tag); + } + + event.preventDefault(); + } + } + + onInputFocus = () => { + this.setState({ isFocused: true }); + } + onInputBlur = () => { - if (!this._tagsRef) { + this.setState({ isFocused: false }); + + if (!this._autosuggestRef) { return; } const { - tagList, allowNew } = this.props; - const query = this._tagsRef.state.query.trim(); + const { + value, + suggestions + } = this.state; - if (query) { - const existingTag = _.find(tagList, { name: query }); + const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex; + const tag = getTag(value, selectedIndex, suggestions, allowNew); - if (existingTag) { - this._tagsRef.addTag(existingTag); - } else if (allowNew) { - this._tagsRef.addTag({ name: query }); - } + if (tag) { + this.onTagAdd(tag); } } + onSuggestionsFetchRequested = ({ value }) => { + const lowerCaseValue = value.toLowerCase(); + + const { + tags, + tagList + } = this.props; + + const suggestions = tagList.filter((tag) => { + return ( + tag.name.toLowerCase().includes(lowerCaseValue) && + !tags.some((t) => t.id === tag.id)); + }); + + this.setState({ suggestions }); + } + + onSuggestionsClearRequested = () => { + // Required because props aren't always rendered, but no-op + // because we don't want to reset the paths after a path is selected. + } + + onSuggestionSelected = (event, { suggestion }) => { + this.onTagAdd(suggestion); + } + // // Render - render() { + renderInputComponent = (inputProps) => { const { tags, - tagList, - allowNew, kind, - placeholder, - onTagAdd, + tagComponent, onTagDelete } = this.props; - const tagInputClassNames = { - root: styles.container, - rootFocused: styles.containerFocused, - selected: styles.selectedTagContainer, - selectedTag: classNames(styles.selectedTag, styles[kind]), - search: styles.searchInputContainer, - searchInput: styles.searchInput, - suggestions: styles.suggestions, - suggestionActive: styles.suggestionActive, - suggestionDisabled: styles.suggestionDisabled + return ( + + ); + } + + render() { + const { + placeholder, + hasError, + hasWarning + } = this.props; + + const { + value, + suggestions, + isFocused + } = this.state; + + const inputProps = { + className: styles.input, + name, + value, + placeholder, + autoComplete: 'off', + spellCheck: false, + onChange: this.onInputChange, + onKeyDown: this.onInputKeyDown, + onFocus: this.onInputFocus, + onBlur: this.onInputBlur + }; + + const theme = { + container: classNames( + styles.inputContainer, + isFocused && styles.isFocused, + hasError && styles.hasError, + hasWarning && styles.hasWarning, + ), + containerOpen: styles.containerOpen, + suggestionsContainer: styles.suggestionsContainer, + suggestionsList: styles.suggestionsList, + suggestion: styles.suggestion, + suggestionHighlighted: styles.suggestionHighlighted }; return ( - ); } } -const tagShape = { - id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired +export const tagShape = { + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + name: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired }; TagInput.propTypes = { @@ -113,6 +272,11 @@ TagInput.propTypes = { allowNew: PropTypes.bool.isRequired, kind: PropTypes.oneOf(kinds.all).isRequired, placeholder: PropTypes.string.isRequired, + delimiters: PropTypes.arrayOf(PropTypes.number).isRequired, + minQueryLength: PropTypes.number.isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + tagComponent: PropTypes.func.isRequired, onTagAdd: PropTypes.func.isRequired, onTagDelete: PropTypes.func.isRequired }; @@ -120,7 +284,11 @@ TagInput.propTypes = { TagInput.defaultProps = { allowNew: true, kind: kinds.INFO, - placeholder: '' + placeholder: '', + // Tab, enter, space and comma + delimiters: [9, 13, 32, 188], + minQueryLength: 1, + tagComponent: TagInputTag }; export default TagInput; diff --git a/frontend/src/Components/Form/TagInputConnector.js b/frontend/src/Components/Form/TagInputConnector.js index 163b36895..5265e9e4f 100644 --- a/frontend/src/Components/Form/TagInputConnector.js +++ b/frontend/src/Components/Form/TagInputConnector.js @@ -103,7 +103,7 @@ class TagInputConnector extends Component { this.props.onChange({ name, value: newValue }); } - onTagDelete = (index) => { + onTagDelete = ({ index }) => { const { name, value diff --git a/frontend/src/Components/Form/TagInputInput.css b/frontend/src/Components/Form/TagInputInput.css new file mode 100644 index 000000000..182320b1a --- /dev/null +++ b/frontend/src/Components/Form/TagInputInput.css @@ -0,0 +1,6 @@ +.inputContainer { + display: flex; + flex-wrap: wrap; + padding: 6px 16px; + cursor: default; +} diff --git a/frontend/src/Components/Form/TagInputInput.js b/frontend/src/Components/Form/TagInputInput.js new file mode 100644 index 000000000..8bd075774 --- /dev/null +++ b/frontend/src/Components/Form/TagInputInput.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import { tagShape } from './TagInput'; +import styles from './TagInputInput.css'; + +class TagInputInput extends Component { + + onMouseDown = (event) => { + event.preventDefault(); + + const { + isFocused, + onInputContainerPress + } = this.props; + + if (isFocused) { + return; + } + + onInputContainerPress(); + } + + render() { + const { + className, + tags, + inputProps, + kind, + tagComponent: TagComponent, + onTagDelete + } = this.props; + + return ( +
+ { + tags.map((tag, index) => { + return ( + + ); + }) + } + + +
+ ); + } +} + +TagInputInput.propTypes = { + className: PropTypes.string.isRequired, + tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + inputProps: PropTypes.object.isRequired, + kind: PropTypes.oneOf(kinds.all).isRequired, + isFocused: PropTypes.bool.isRequired, + tagComponent: PropTypes.func.isRequired, + onTagDelete: PropTypes.func.isRequired, + onInputContainerPress: PropTypes.func.isRequired +}; + +TagInputInput.defaultProps = { + className: styles.inputContainer +}; + +export default TagInputInput; diff --git a/frontend/src/Components/Form/TagInputTag.js b/frontend/src/Components/Form/TagInputTag.js new file mode 100644 index 000000000..8cb5486bc --- /dev/null +++ b/frontend/src/Components/Form/TagInputTag.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import { tagShape } from './TagInput'; + +class TagInputTag extends Component { + + // + // Listeners + + onDelete = () => { + const { + index, + tag, + onDelete + } = this.props; + + onDelete({ + index, + id: tag.id + }); + } + + // + // Render + + render() { + const { + tag, + kind + } = this.props; + + return ( + + + + ); + } +} + +TagInputTag.propTypes = { + index: PropTypes.number.isRequired, + tag: PropTypes.shape(tagShape), + kind: PropTypes.oneOf(kinds.all).isRequired, + onDelete: PropTypes.func.isRequired +}; + +export default TagInputTag; diff --git a/frontend/src/Components/Form/TextTagInput.js b/frontend/src/Components/Form/TextTagInput.js deleted file mode 100644 index ae9d35baa..000000000 --- a/frontend/src/Components/Form/TextTagInput.js +++ /dev/null @@ -1,68 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ReactTags from 'react-tag-autocomplete'; -import classNames from 'classnames'; -import { kinds } from 'Helpers/Props'; -import styles from './TagInput.css'; - -class TextTagInput extends Component { - - // - // Render - - render() { - const { - tags, - allowNew, - kind, - placeholder, - onTagAdd, - onTagDelete - } = this.props; - - const tagInputClassNames = { - root: styles.container, - rootFocused: styles.containerFocused, - selected: styles.selectedTagContainer, - selectedTag: classNames(styles.selectedTag, styles[kind]), - search: styles.searchInputContainer, - searchInput: styles.searchInput, - suggestions: styles.suggestions, - suggestionActive: styles.suggestionActive, - suggestionDisabled: styles.suggestionDisabled - }; - - return ( - - ); - } -} - -const tagShape = { - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired -}; - -TextTagInput.propTypes = { - tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - allowNew: PropTypes.bool.isRequired, - kind: PropTypes.string.isRequired, - placeholder: PropTypes.string, - onTagAdd: PropTypes.func.isRequired, - onTagDelete: PropTypes.func.isRequired -}; - -TextTagInput.defaultProps = { - allowNew: true, - kind: kinds.INFO -}; - -export default TextTagInput; diff --git a/frontend/src/Components/Form/TextTagInputConnector.js b/frontend/src/Components/Form/TextTagInputConnector.js index 2c9e52cbc..03d593b75 100644 --- a/frontend/src/Components/Form/TextTagInputConnector.js +++ b/frontend/src/Components/Form/TextTagInputConnector.js @@ -4,7 +4,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import split from 'Utilities/String/split'; -import TextTagInput from './TextTagInput'; +import TagInput from './TagInput'; function createMapStateToProps() { return createSelector( @@ -34,25 +34,27 @@ class TextTagInputConnector extends Component { onTagAdd = (tag) => { const { name, - value + value, + onChange } = this.props; const newValue = split(value); newValue.push(tag.name); - this.props.onChange({ name, value: newValue.join(',') }); + onChange({ name, value: newValue.join(',') }); } - onTagDelete = (index) => { + onTagDelete = ({ index }) => { const { name, - value + value, + onChange } = this.props; const newValue = split(value); newValue.splice(index, 1); - this.props.onChange({ + onChange({ name, value: newValue.join(',') }); @@ -63,7 +65,8 @@ class TextTagInputConnector extends Component { render() { return ( - - - {children} - - ); +class FilterMenu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isFilterModalOpen: false + }; + } + + // + // Listeners + + onCustomFiltersPress = () => { + this.setState({ isFilterModalOpen: true }); + } + + onFiltersModalClose = () => { + this.setState({ isFilterModalOpen: false }); + } + + // + // Render + + render(props) { + const { + className, + isDisabled, + selectedFilterKey, + filters, + customFilters, + buttonComponent: ButtonComponent, + filterModalConnectorComponent: FilterModalConnectorComponent, + onFilterSelect, + ...otherProps + } = this.props; + + const showCustomFilters = !!FilterModalConnectorComponent; + + return ( +
+ + + + + + + + { + showCustomFilters && + + } +
+ ); + } } FilterMenu.propTypes = { className: PropTypes.string, - children: PropTypes.node.isRequired, - isDisabled: PropTypes.bool.isRequired + isDisabled: PropTypes.bool.isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + buttonComponent: PropTypes.func.isRequired, + filterModalConnectorComponent: PropTypes.func, + onFilterSelect: PropTypes.func.isRequired }; FilterMenu.defaultProps = { className: styles.filterMenu, - isDisabled: false + isDisabled: false, + buttonComponent: ToolbarMenuButton }; export default FilterMenu; diff --git a/frontend/src/Components/Menu/FilterMenuContent.js b/frontend/src/Components/Menu/FilterMenuContent.js new file mode 100644 index 000000000..2433e9db1 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenuContent.js @@ -0,0 +1,85 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuContent from './MenuContent'; +import FilterMenuItem from './FilterMenuItem'; +import MenuItem from './MenuItem'; +import MenuItemSeparator from './MenuItemSeparator'; + +class FilterMenuContent extends Component { + + // + // Render + + render() { + const { + selectedFilterKey, + filters, + customFilters, + showCustomFilters, + onFilterSelect, + onCustomFiltersPress, + ...otherProps + } = this.props; + + return ( + + { + filters.map((filter) => { + return ( + + {filter.label} + + ); + }) + } + + { + customFilters.map((filter) => { + return ( + + {filter.label} + + ); + }) + } + + { + showCustomFilters && + + } + + { + showCustomFilters && + + Custom Filters + + } + + ); + } +} + +FilterMenuContent.propTypes = { + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + showCustomFilters: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onCustomFiltersPress: PropTypes.func.isRequired +}; + +FilterMenuContent.defaultProps = { + showCustomFilters: false +}; + +export default FilterMenuContent; diff --git a/frontend/src/Components/Menu/FilterMenuItem.js b/frontend/src/Components/Menu/FilterMenuItem.js index 54c293c49..f8afb8364 100644 --- a/frontend/src/Components/Menu/FilterMenuItem.js +++ b/frontend/src/Components/Menu/FilterMenuItem.js @@ -9,12 +9,11 @@ class FilterMenuItem extends Component { onPress = () => { const { - name, - value, + filterKey, onPress } = this.props; - onPress(name, value); + onPress(filterKey); } // @@ -22,18 +21,14 @@ class FilterMenuItem extends Component { render() { const { - name, - value, filterKey, - filterValue, + selectedFilterKey, ...otherProps } = this.props; - const isSelected = name === filterKey && value === filterValue; - return ( @@ -42,16 +37,9 @@ class FilterMenuItem extends Component { } FilterMenuItem.propTypes = { - name: PropTypes.string, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]), - filterKey: PropTypes.string, - filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]), + filterKey: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.string.isRequired, onPress: PropTypes.func.isRequired }; -FilterMenuItem.defaultProps = { - name: null, - value: null -}; - export default FilterMenuItem; diff --git a/frontend/src/Components/Menu/MenuItemSeparator.css b/frontend/src/Components/Menu/MenuItemSeparator.css new file mode 100644 index 000000000..a867e3153 --- /dev/null +++ b/frontend/src/Components/Menu/MenuItemSeparator.css @@ -0,0 +1,5 @@ +.separator { + overflow: hidden; + height: 1px; + background-color: $themeDarkColor; +} diff --git a/frontend/src/Components/Menu/MenuItemSeparator.js b/frontend/src/Components/Menu/MenuItemSeparator.js new file mode 100644 index 000000000..e586670c9 --- /dev/null +++ b/frontend/src/Components/Menu/MenuItemSeparator.js @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './MenuItemSeparator.css'; + +function MenuItemSeparator() { + return ( +
+ ); +} + +export default MenuItemSeparator; diff --git a/frontend/src/Components/Menu/PageMenuButton.css b/frontend/src/Components/Menu/PageMenuButton.css new file mode 100644 index 000000000..e6954f600 --- /dev/null +++ b/frontend/src/Components/Menu/PageMenuButton.css @@ -0,0 +1,11 @@ +.menuButton { + composes: menuButton from './MenuButton.css'; + + &:hover { + color: #666; + } +} + +.label { + margin-left: 5px; +} diff --git a/frontend/src/Components/Menu/PageMenuButton.js b/frontend/src/Components/Menu/PageMenuButton.js new file mode 100644 index 000000000..abbfc98f8 --- /dev/null +++ b/frontend/src/Components/Menu/PageMenuButton.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import MenuButton from 'Components/Menu/MenuButton'; +import styles from './PageMenuButton.css'; + +function PageMenuButton(props) { + const { + iconName, + text, + ...otherProps + } = props; + + return ( + + + +
+ {text} +
+
+ ); +} + +PageMenuButton.propTypes = { + iconName: PropTypes.object.isRequired, + text: PropTypes.string +}; + +export default PageMenuButton; diff --git a/frontend/src/Components/MonitorToggleButton.css b/frontend/src/Components/MonitorToggleButton.css index e2c68bed1..794af1e98 100644 --- a/frontend/src/Components/MonitorToggleButton.css +++ b/frontend/src/Components/MonitorToggleButton.css @@ -5,9 +5,7 @@ font-size: inherit; } -.disabledButton { - composes: button from 'Components/Link/IconButton.css'; - +.isDisabled { color: $disabledColor; cursor: not-allowed; } diff --git a/frontend/src/Components/MonitorToggleButton.js b/frontend/src/Components/MonitorToggleButton.js index 8802cb1a2..c92db9bc0 100644 --- a/frontend/src/Components/MonitorToggleButton.js +++ b/frontend/src/Components/MonitorToggleButton.js @@ -1,10 +1,22 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import classNames from 'classnames'; import { icons } from 'Helpers/Props'; -import Icon from 'Components/Icon'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import styles from './MonitorToggleButton.css'; +function getTooltip(monitored, isDisabled) { + if (isDisabled) { + return 'Cannot toogle monitored state when artist is unmonitored'; + } + + if (monitored) { + return 'Monitored, click to unmonitor'; + } + + return 'Unmonitored, click to monitor'; +} + class MonitorToggleButton extends Component { // @@ -29,27 +41,18 @@ class MonitorToggleButton extends Component { ...otherProps } = this.props; - const monitoredMessage = 'Monitored, click to unmonitor'; - const unmonitoredMessage = 'Unmonitored, click to monitor'; const iconName = monitored ? icons.MONITORED : icons.UNMONITORED; - if (isDisabled) { - return ( - - ); - } - return ( { + onSuggestionSelected = (event, { suggestion }) => { if (suggestion.type === ADD_NEW_TYPE) { this.props.onGoToAddNewArtist(this.state.value); } else { @@ -181,7 +181,7 @@ class ArtistSearchInput extends Component { }); } - if (suggestions.length <= 3) { + if (value.length >= 3) { suggestionGroups.push({ title: 'Add New Artist', suggestions: [ @@ -218,10 +218,7 @@ class ArtistSearchInput extends Component { return (
- + -
+ 210 || touchStartX < 180)) { return; - } else if (!isSidebarVisible && touchStartX > 30) { + } else if (!isSidebarVisible && touchStartX > 40) { return; } @@ -347,22 +347,29 @@ class PageSidebar extends Component { onTouchMove = (event) => { const touches = event.touches; const currentTouchX = touches[0].pageX; - const currentTouchY = touches[0].pageY; + // const currentTouchY = touches[0].pageY; + // const isSidebarVisible = this.props.isSidebarVisible; if (!this._touchStartX) { return; } - if (Math.abs(this._touchStartY - currentTouchY) > 20) { - this.setState({ - transition: 'none', - transform: 0 - }); + // This is a bit funky when trying to close and you scroll + // vertical too much by mistake, commenting out for now. + // TODO: Evaluate if this should be nuked - return; - } + // if (Math.abs(this._touchStartY - currentTouchY) > 40) { + // const transform = isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1; + + // this.setState({ + // transition: 'none', + // transform + // }); + + // return; + // } - if (Math.abs(this._touchStartX - currentTouchX) < 20) { + if (Math.abs(this._touchStartX - currentTouchX) < 40) { return; } diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css index e788303a2..11944c1e9 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css @@ -7,6 +7,10 @@ &:hover { color: $toobarButtonHoverColor; } + + &.isDisabled { + color: $disabledColor; + } } .isDisabled { diff --git a/frontend/src/Components/Table/TableRow.css b/frontend/src/Components/Table/TableRow.css index 9664733b4..dcc6ad8cf 100644 --- a/frontend/src/Components/Table/TableRow.css +++ b/frontend/src/Components/Table/TableRow.css @@ -2,6 +2,6 @@ transition: background-color 500ms; &:hover { - background-color: #fafbfc; + background-color: $tableRowHoverBackgroundColor; } } diff --git a/frontend/src/Components/Table/VirtualTable.js b/frontend/src/Components/Table/VirtualTable.js index fb0417325..6d9af2382 100644 --- a/frontend/src/Components/Table/VirtualTable.js +++ b/frontend/src/Components/Table/VirtualTable.js @@ -50,6 +50,16 @@ class VirtualTable extends Component { this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); } + componentDidUpdate(prevProps, preState) { + const scrollIndex = this.props.scrollIndex; + + if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) { + const scrollTop = (scrollIndex + 1) * ROW_HEIGHT + 20; + + this.props.onScroll({ scrollTop }); + } + } + // // Control @@ -57,12 +67,6 @@ class VirtualTable extends Component { return this.props.items[index]; } - scrollToRow = (rowIndex) => { - const scrollTop = (rowIndex + 1) * ROW_HEIGHT + 20; - - this.props.onScroll({ scrollTop }); - } - // // Listeners @@ -144,6 +148,7 @@ VirtualTable.propTypes = { className: PropTypes.string.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, scrollTop: PropTypes.number.isRequired, + scrollIndex: PropTypes.number, contentBody: PropTypes.object.isRequired, isSmallScreen: PropTypes.bool.isRequired, header: PropTypes.node.isRequired, diff --git a/frontend/src/Helpers/Props/filterBuilderTypes.js b/frontend/src/Helpers/Props/filterBuilderTypes.js new file mode 100644 index 000000000..45f7c205b --- /dev/null +++ b/frontend/src/Helpers/Props/filterBuilderTypes.js @@ -0,0 +1,33 @@ +import * as filterTypes from './filterTypes'; + +export const EXACT = 'exact'; +export const NUMBER = 'number'; +export const STRING = 'string'; + +export const all = [ + EXACT, + NUMBER, + STRING +]; + +export const possibleFilterTypes = { + [EXACT]: [ + { key: filterTypes.EQUAL, value: 'Is' }, + { key: filterTypes.NOT_EQUAL, value: 'Is Not' } + ], + + [NUMBER]: [ + { key: filterTypes.EQUAL, value: 'Equal' }, + { key: filterTypes.GREATER_THAN, value: 'Greater Than' }, + { key: filterTypes.GREATER_THAN_OR_EQUAL, value: 'Greater Than or Equal' }, + { key: filterTypes.LESS_THAN, value: 'Less Than' }, + { key: filterTypes.LESS_THAN_OR_EQUAL, value: 'Less Than or Equal' }, + { key: filterTypes.NOT_EQUAL, value: 'Not Equal' } + ], + + [STRING]: [ + { key: filterTypes.CONTAINS, value: 'Contains' }, + { key: filterTypes.EQUAL, value: 'Equal' }, + { key: filterTypes.NOT_EQUAL, value: 'Not Equal' } + ] +}; diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js new file mode 100644 index 000000000..253bea721 --- /dev/null +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -0,0 +1,4 @@ +export const DEFAULT = 'default'; +export const INDEXER = 'indexer'; +export const PROTOCOL = 'protocol'; +export const QUALITY = 'quality'; diff --git a/frontend/src/Helpers/Props/index.js b/frontend/src/Helpers/Props/index.js index 0a989a26f..0127dff3c 100644 --- a/frontend/src/Helpers/Props/index.js +++ b/frontend/src/Helpers/Props/index.js @@ -1,5 +1,7 @@ import * as align from './align'; import * as inputTypes from './inputTypes'; +import * as filterBuilderTypes from './filterBuilderTypes'; +import * as filterBuilderValueTypes from './filterBuilderValueTypes'; import * as filterTypes from './filterTypes'; import * as icons from './icons'; import * as kinds from './kinds'; @@ -12,6 +14,8 @@ import * as tooltipPositions from './tooltipPositions'; export { align, inputTypes, + filterBuilderTypes, + filterBuilderValueTypes, filterTypes, icons, kinds, diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js index ed4090c3f..8fe516a58 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -83,6 +83,22 @@ class MediaManagement extends Component { {...settings.createEmptyArtistFolders} /> + + + Delete empty folders + + + } diff --git a/frontend/src/Store/Actions/Creators/Reducers/createCustomFilterReducers.js b/frontend/src/Store/Actions/Creators/Reducers/createCustomFilterReducers.js new file mode 100644 index 000000000..8bec20cfc --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createCustomFilterReducers.js @@ -0,0 +1,65 @@ +import customFilterHandlers from 'Utilities/customFilterHandlers'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import generateUUIDv4 from 'Utilities/String/generateUUIDv4'; + +function createRemoveCustomFilterReducer(section) { + return (state, { payload }) => { + const newState = getSectionState(state, section); + const index = newState.customFilters.findIndex((c) => c.key === payload.key); + + newState.customFilters = [...newState.customFilters]; + newState.customFilters.splice(index, 1); + + // Reset the selected filter to the first filter if the selected filter + // is being deleted. + // TODO: Server side collections need to have their collections refetched + + if (newState.selectedFilterKey === payload.key) { + newState.selectedFilterKey = newState.filters[0].key; + } + + return updateSectionState(state, section, newState); + }; +} + +function createSaveCustomFilterReducer(section) { + return (state, { payload }) => { + const newState = getSectionState(state, section); + + const { + label, + filters + } = payload; + + let key = payload.key; + + newState.customFilters = [...newState.customFilters]; + + if (key) { + const index = newState.customFilters.findIndex((c) => c.key === key); + + newState.customFilters.splice(index, 1, { key, label, filters }); + } else { + key = generateUUIDv4(); + + newState.customFilters.push({ + key, + label, + filters + }); + } + + // TODO: Server side collections need to have their collections refetched + newState.selectedFilterKey = key; + + return updateSectionState(state, section, newState); + }; +} + +export default function createCustomFilterReducers(section, handlers) { + return { + [handlers[customFilterHandlers.REMOVE]]: createRemoveCustomFilterReducer(section), + [handlers[customFilterHandlers.SAVE]]: createSaveCustomFilterReducer(section) + }; +} diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js index d756a736e..d58bb1cd4 100644 --- a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js @@ -1,14 +1,11 @@ import getSectionState from 'Utilities/State/getSectionState'; import updateSectionState from 'Utilities/State/updateSectionState'; -import { filterTypes } from 'Helpers/Props'; function createSetClientSideCollectionFilterReducer(section) { return (state, { payload }) => { const newState = getSectionState(state, section); - newState.filterKey = payload.filterKey; - newState.filterValue = payload.filterValue; - newState.filterType = payload.filterType || filterTypes.EQUAL; + newState.selectedFilterKey = payload.selectedFilterKey; return updateSectionState(state, section, newState); }; diff --git a/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js index d94f2cc5d..bc6014d97 100644 --- a/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js +++ b/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js @@ -2,40 +2,40 @@ import $ from 'jquery'; import updateAlbums from 'Utilities/Album/updateAlbums'; import getSectionState from 'Utilities/State/getSectionState'; -function createBatchToggleAlbumMonitoredHandler(section) { - return function(payload) { - return function(dispatch, getState) { - const { - albumIds, +function createBatchToggleAlbumMonitoredHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + const { + albumIds, + monitored + } = payload; + + const state = getSectionState(getState(), section, true); + + dispatch(updateAlbums(section, state.items, albumIds, { + isSaving: true + })); + + const promise = $.ajax({ + url: '/album/monitor', + method: 'PUT', + data: JSON.stringify({ albumIds, monitored }), + dataType: 'json' + }); + + promise.done(() => { + dispatch(updateAlbums(section, state.items, albumIds, { + isSaving: false, monitored - } = payload; - - const state = getSectionState(getState(), section, true); - - updateAlbums(dispatch, section, state.items, albumIds, { - isSaving: true - }); - - const promise = $.ajax({ - url: '/album/monitor', - method: 'PUT', - data: JSON.stringify({ albumIds, monitored }), - dataType: 'json' - }); - - promise.done(() => { - updateAlbums(dispatch, section, state.items, albumIds, { - isSaving: false, - monitored - }); - }); - - promise.fail(() => { - updateAlbums(dispatch, section, state.items, albumIds, { - isSaving: false - }); - }); - }; + })); + + dispatch(fetchHandler()); + }); + + promise.fail(() => { + dispatch(updateAlbums(section, state.items, albumIds, { + isSaving: false + })); + }); }; } diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js index d4c07e8cb..e30e615b1 100644 --- a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js +++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js @@ -1,6 +1,7 @@ import _ from 'lodash'; import $ from 'jquery'; import { batchActions } from 'redux-batched-actions'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; import getSectionState from 'Utilities/State/getSectionState'; import { set, updateServerSideCollection } from '../baseActions'; @@ -15,11 +16,21 @@ function createFetchServerSideCollectionHandler(section, url) { _.pick(sectionState, [ 'pageSize', 'sortDirection', - 'sortKey', - 'filterKey', - 'filterValue' + 'sortKey' ])); + const { + selectedFilterKey, + filters, + customFilters + } = sectionState; + + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); + + selectedFilters.forEach((filter) => { + data[filter.key] = filter.value; + }); + const promise = $.ajax({ url, data diff --git a/frontend/src/Store/Actions/Creators/createToggleAlbumMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createToggleAlbumMonitoredHandler.js deleted file mode 100644 index 309aec0aa..000000000 --- a/frontend/src/Store/Actions/Creators/createToggleAlbumMonitoredHandler.js +++ /dev/null @@ -1,42 +0,0 @@ -import $ from 'jquery'; -import updateAlbums from 'Utilities/Album/updateAlbums'; -import getSectionState from 'Utilities/State/getSectionState'; - -function createToggleAlbumMonitoredHandler(section) { - return function(payload) { - return function(dispatch, getState) { - const { - albumId, - monitored - } = payload; - - const state = getSectionState(getState(), section, true); - - updateAlbums(dispatch, section, state.items, [albumId], { - isSaving: true - }); - - const promise = $.ajax({ - url: `/album/${albumId}`, - method: 'PUT', - data: JSON.stringify({ monitored }), - dataType: 'json' - }); - - promise.done(() => { - updateAlbums(dispatch, section, state.items, [albumId], { - isSaving: false, - monitored - }); - }); - - promise.fail(() => { - updateAlbums(dispatch, section, state.items, [albumId], { - isSaving: false - }); - }); - }; - }; -} - -export default createToggleAlbumMonitoredHandler; diff --git a/frontend/src/Store/Actions/albumHistoryActions.js b/frontend/src/Store/Actions/albumHistoryActions.js index fb60bc8ce..060ad6390 100644 --- a/frontend/src/Store/Actions/albumHistoryActions.js +++ b/frontend/src/Store/Actions/albumHistoryActions.js @@ -46,10 +46,9 @@ export const actionHandlers = handleThunks({ const queryParams = { pageSize: 1000, page: 1, - filterKey: 'albumId', - filterValue: payload.albumId, sortKey: 'date', - sortDirection: sortDirections.DESCENDING + sortDirection: sortDirections.DESCENDING, + albumId: payload.albumId }; const promise = $.ajax({ diff --git a/frontend/src/Store/Actions/albumStudioActions.js b/frontend/src/Store/Actions/albumStudioActions.js index bb8c5a602..45dd2dbdb 100644 --- a/frontend/src/Store/Actions/albumStudioActions.js +++ b/frontend/src/Store/Actions/albumStudioActions.js @@ -2,7 +2,7 @@ import _ from 'lodash'; import $ from 'jquery'; import { createAction } from 'redux-actions'; import getMonitoringOptions from 'Utilities/Artist/getMonitoringOptions'; -import { filterTypes, sortDirections } from 'Helpers/Props'; +import { sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; @@ -25,17 +25,17 @@ export const defaultState = { sortDirection: sortDirections.ASCENDING, secondarySortKey: 'sortName', secondarySortDirection: sortDirections.ASCENDING, - filterKey: null, - filterValue: null, - filterType: filterTypes.EQUAL + selectedFilterKey: 'all', + // filters come from artistActions + customFilters: [] + // filterPredicates come from artistActions }; export const persistState = [ 'albumStudio.sortKey', 'albumStudio.sortDirection', - 'albumStudio.filterKey', - 'albumStudio.filterValue', - 'albumStudio.filterType' + 'albumStudio.selectedFilterKey', + 'albumStudio.customFilters' ]; // diff --git a/frontend/src/Store/Actions/artistActions.js b/frontend/src/Store/Actions/artistActions.js index 152c66ef7..d8087242a 100644 --- a/frontend/src/Store/Actions/artistActions.js +++ b/frontend/src/Store/Actions/artistActions.js @@ -2,7 +2,7 @@ import _ from 'lodash'; import $ from 'jquery'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; -import { sortDirections } from 'Helpers/Props'; +import { filterTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; import createFetchHandler from './Creators/createFetchHandler'; @@ -28,6 +28,75 @@ export const defaultState = { items: [], sortKey: 'sortName', sortDirection: sortDirections.ASCENDING, + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'monitored', + label: 'Monitored Only', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored Only', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'continuing', + label: 'Continuing Only', + filters: [ + { + key: 'status', + value: 'continuing', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'ended', + label: 'Ended Only', + filters: [ + { + key: 'status', + value: 'ended', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'missing', + label: 'Missing Albums', + filters: [ + { + key: 'missing', + value: true, + type: filterTypes.EQUAL + } + ] + } + ], + + filterPredicates: { + missing: function(item) { + return item.statistics.trackCount - item.statistics.trackFileCount > 0; + } + }, + pendingChanges: {} }; diff --git a/frontend/src/Store/Actions/artistEditorActions.js b/frontend/src/Store/Actions/artistEditorActions.js index b8f2dcc20..694947a97 100644 --- a/frontend/src/Store/Actions/artistEditorActions.js +++ b/frontend/src/Store/Actions/artistEditorActions.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; -import { filterTypes, sortDirections } from 'Helpers/Props'; +import { sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; @@ -25,17 +25,17 @@ export const defaultState = { sortDirection: sortDirections.ASCENDING, secondarySortKey: 'sortName', secondarySortDirection: sortDirections.ASCENDING, - filterKey: null, - filterValue: null, - filterType: filterTypes.EQUAL + selectedFilterKey: 'all', + // filters come from artistActions + customFilters: [] + // filterPredicates come from artistActions }; export const persistState = [ 'artistEditor.sortKey', 'artistEditor.sortDirection', - 'artistEditor.filterKey', - 'artistEditor.filterValue', - 'artistEditor.filterType' + 'artistEditor.selectedFilterKey', + 'artistEditor.customFilters' ]; // diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js index 429a0a587..1f047a55e 100644 --- a/frontend/src/Store/Actions/artistIndexActions.js +++ b/frontend/src/Store/Actions/artistIndexActions.js @@ -1,6 +1,6 @@ import moment from 'moment'; import { createAction } from 'redux-actions'; -import { filterTypes, sortDirections } from 'Helpers/Props'; +import { sortDirections } from 'Helpers/Props'; import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; @@ -19,9 +19,6 @@ export const defaultState = { sortDirection: sortDirections.ASCENDING, secondarySortKey: 'sortName', secondarySortDirection: sortDirections.ASCENDING, - filterKey: null, - filterValue: null, - filterType: filterTypes.EQUAL, view: 'posters', posterOptions: { @@ -155,45 +152,41 @@ export const defaultState = { ], sortPredicates: { - nextAiring: function(item, direction) { - const nextAiring = item.nextAiring; - - if (nextAiring) { - return moment(nextAiring).unix(); - } - - if (direction === sortDirections.DESCENDING) { - return 0; - } - - return Number.MAX_VALUE; - }, - trackProgress: function(item) { const { trackCount = 0, trackFileCount - } = item; + } = item.statistics; const progress = trackCount ? trackFileCount / trackCount * 100 : 100; return progress + trackCount / 1000000; + }, + + albumCount: function(item) { + return item.statistics.albumCount; + }, + + trackCount: function(item) { + return item.statistics.totalTrackCount; + }, + + sizeOnDisk: function(item) { + return item.statistics.sizeOnDisk; } }, - filterPredicates: { - missing: function(item) { - return item.trackCount - item.trackFileCount > 0; - } - } + selectedFilterKey: 'all', + // filters come from artistActions + customFilters: [] + // filterPredicates come from artistActions }; export const persistState = [ 'artistIndex.sortKey', 'artistIndex.sortDirection', - 'artistIndex.filterKey', - 'artistIndex.filterValue', - 'artistIndex.filterType', + 'artistIndex.selectedFilterKey', + 'artistIndex.customFilters', 'artistIndex.view', 'artistIndex.columns', 'artistIndex.posterOptions', diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js index acc3b0f17..2b2a97317 100644 --- a/frontend/src/Store/Actions/calendarActions.js +++ b/frontend/src/Store/Actions/calendarActions.js @@ -3,6 +3,7 @@ import $ from 'jquery'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; import moment from 'moment'; +import { filterTypes } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import * as calendarViews from 'Calendar/calendarViews'; import createHandleActions from './Creators/createHandleActions'; @@ -31,16 +32,42 @@ export const defaultState = { dates: [], dayCount: 7, view: window.innerWidth > 768 ? 'week' : 'day', - unmonitored: false, showUpcoming: true, error: null, - items: [] + items: [], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [ + { + key: 'unmonitored', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored', + filters: [ + { + key: 'unmonitored', + value: true, + type: filterTypes.EQUAL + } + ] + } + ] }; export const persistState = [ 'calendar.view', - 'calendar.unmonitored', - 'calendar.showUpcoming' + 'calendar.showUpcoming', + 'calendar.selectedFilterKey' ]; // @@ -48,8 +75,8 @@ export const persistState = [ export const FETCH_CALENDAR = 'calendar/fetchCalendar'; export const SET_CALENDAR_DAYS_COUNT = 'calendar/setCalendarDaysCount'; -export const SET_CALENDAR_INCLUDE_UNMONITORED = 'calendar/setCalendarIncludeUnmonitored'; export const SET_CALENDAR_VIEW = 'calendar/setCalendarView'; +export const SET_CALENDAR_FILTER = 'calendar/setCalendarFilter'; export const GOTO_CALENDAR_TODAY = 'calendar/gotoCalendarToday'; export const GOTO_CALENDAR_PREVIOUS_RANGE = 'calendar/gotoCalendarPreviousRange'; export const GOTO_CALENDAR_NEXT_RANGE = 'calendar/gotoCalendarNextRange'; @@ -155,8 +182,8 @@ function isRangePopulated(start, end, state) { export const fetchCalendar = createThunk(FETCH_CALENDAR); export const setCalendarDaysCount = createThunk(SET_CALENDAR_DAYS_COUNT); -export const setCalendarIncludeUnmonitored = createThunk(SET_CALENDAR_INCLUDE_UNMONITORED); export const setCalendarView = createThunk(SET_CALENDAR_VIEW); +export const setCalendarFilter = createThunk(SET_CALENDAR_FILTER); export const gotoCalendarToday = createThunk(GOTO_CALENDAR_TODAY); export const gotoCalendarPreviousRange = createThunk(GOTO_CALENDAR_PREVIOUS_RANGE); export const gotoCalendarNextRange = createThunk(GOTO_CALENDAR_NEXT_RANGE); @@ -166,9 +193,11 @@ export const clearCalendar = createAction(CLEAR_CALENDAR); // Action Handlers export const actionHandlers = handleThunks({ + [FETCH_CALENDAR]: function(getState, payload, dispatch) { const state = getState(); - const unmonitored = state.calendar.unmonitored; + const selectedFilter = state.calendar.selectedFilterKey; + const unmonitored = state.calendar.filters.find((f) => f.key === selectedFilter).filters[0].value; const { time, @@ -245,18 +274,6 @@ export const actionHandlers = handleThunks({ dispatch(fetchCalendar({ time, view })); }, - [SET_CALENDAR_INCLUDE_UNMONITORED]: function(getState, payload, dispatch) { - dispatch(set({ - section, - unmonitored: payload.unmonitored - })); - - const state = getState(); - const { time, view } = state.calendar; - - dispatch(fetchCalendar({ time, view })); - }, - [SET_CALENDAR_VIEW]: function(getState, payload, dispatch) { const state = getState(); const view = payload.view; @@ -300,6 +317,18 @@ export const actionHandlers = handleThunks({ const amount = view === calendarViews.FORECAST ? dayCount : 1; const time = moment(state.calendar.time).add(amount, viewRanges[view]); + dispatch(fetchCalendar({ time, view })); + }, + + [SET_CALENDAR_FILTER]: function(getState, payload, dispatch) { + dispatch(set({ + section, + selectedFilterKey: payload.selectedFilterKey + })); + + const state = getState(); + const { time, view } = state.calendar; + dispatch(fetchCalendar({ time, view })); } }); diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js index 329607f78..98c5203be 100644 --- a/frontend/src/Store/Actions/historyActions.js +++ b/frontend/src/Store/Actions/historyActions.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { createAction } from 'redux-actions'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; -import { sortDirections } from 'Helpers/Props'; +import { filterTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createClearReducer from './Creators/Reducers/createClearReducer'; import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; @@ -24,8 +24,6 @@ export const defaultState = { pageSize: 20, sortKey: 'date', sortDirection: sortDirections.DESCENDING, - filterKey: null, - filterValue: null, items: [], columns: [ @@ -89,15 +87,80 @@ export const defaultState = { isVisible: true, isModifiable: false } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'grabbed', + label: 'Grabbed', + filters: [ + { + key: 'eventType', + value: '1', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'imported', + label: 'Imported', + filters: [ + { + key: 'eventType', + value: '3', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'failed', + label: 'Failed', + filters: [ + { + key: 'eventType', + value: '4', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'deleted', + label: 'Deleted', + filters: [ + { + key: 'eventType', + value: '5', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'renamed', + label: 'Renamed', + filters: [ + { + key: 'eventType', + value: '6', + type: filterTypes.EQUAL + } + ] + } ] + }; export const persistState = [ 'history.pageSize', 'history.sortKey', 'history.sortDirection', - 'history.filterKey', - 'history.filterValue' + 'history.selectedFilterKey' ]; // diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js index ca5f50b0c..62a1bbc4b 100644 --- a/frontend/src/Store/Actions/releaseActions.js +++ b/frontend/src/Store/Actions/releaseActions.js @@ -1,8 +1,11 @@ import $ from 'jquery'; import { createAction } from 'redux-actions'; -import { sortDirections } from 'Helpers/Props'; +import customFilterHandlers from 'Utilities/customFilterHandlers'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createCustomFilterReducers from './Creators/Reducers/createCustomFilterReducers'; import createFetchHandler from './Creators/createFetchHandler'; import createHandleActions from './Creators/createHandleActions'; @@ -40,9 +43,116 @@ export const defaultState = { return releaseWeight; } - } + }, + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'discography-pack', + label: 'Discography', + filters: [ + { + key: 'discography', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'not-discography-pack', + label: 'Not Discography', + filters: [ + { + key: 'discography', + value: false, + type: filterTypes.EQUAL + } + ] + } + ], + + filterPredicates: { + quality: function(item, value, type) { + const qualityId = item.quality.quality.id; + + if (type === filterTypes.EQUAL) { + return qualityId === value; + } + + if (type === filterTypes.NOT_EQUAL) { + return qualityId !== value; + } + + // Default to false + return false; + } + }, + + filterBuilderProps: [ + { + name: 'title', + label: 'Title', + type: filterBuilderTypes.STRING + }, + { + name: 'age', + label: 'Age', + type: filterBuilderTypes.NUMBER + }, + { + name: 'protocol', + label: 'Protocol', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.PROTOCOL + }, + { + name: 'indexerId', + label: 'Indexer', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.INDEXER + }, + { + name: 'size', + label: 'Size', + type: filterBuilderTypes.NUMBER + }, + { + name: 'seeders', + label: 'Seeders', + type: filterBuilderTypes.NUMBER + }, + { + name: 'leechers', + label: 'Peers', + type: filterBuilderTypes.NUMBER + }, + { + name: 'quality', + label: 'Quality', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY + }, + { + name: 'rejections', + label: 'Rejections', + type: filterBuilderTypes.NUMBER + } + ], + + customFilters: [] }; +export const persistState = [ + 'releases.selectedFilterKey', + 'releases.customFilters' +]; + // // Actions Types @@ -52,6 +162,10 @@ export const SET_RELEASES_SORT = 'releases/setReleasesSort'; export const CLEAR_RELEASES = 'releases/clearReleases'; export const GRAB_RELEASE = 'releases/grabRelease'; export const UPDATE_RELEASE = 'releases/updateRelease'; +export const SET_RELEASES_FILTER = 'releases/setReleasesFilter'; +export const ADD_RELEASES_CUSTOM_FILTER = 'releases/addReleasesCustomFilter'; +export const REMOVE_RELEASES_CUSTOM_FILTER = 'releases/removeReleasesCustomFilter'; +export const SAVE_RELEASES_CUSTOM_FILTER = 'releases/saveReleasesCustomFilter'; // // Action Creators @@ -62,6 +176,10 @@ export const setReleasesSort = createAction(SET_RELEASES_SORT); export const clearReleases = createAction(CLEAR_RELEASES); export const grabRelease = createThunk(GRAB_RELEASE); export const updateRelease = createAction(UPDATE_RELEASE); +export const setReleasesFilter = createAction(SET_RELEASES_FILTER); +export const addReleasesCustomFilter = createAction(ADD_RELEASES_CUSTOM_FILTER); +export const removeReleasesCustomFilter = createAction(REMOVE_RELEASES_CUSTOM_FILTER); +export const saveReleasesCustomFilter = createAction(SAVE_RELEASES_CUSTOM_FILTER); // // Helpers @@ -147,6 +265,12 @@ export const reducers = createHandleActions({ return newState; }, - [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section) + [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(section), + + ...createCustomFilterReducers(section, { + [customFilterHandlers.REMOVE]: REMOVE_RELEASES_CUSTOM_FILTER, + [customFilterHandlers.SAVE]: SAVE_RELEASES_CUSTOM_FILTER + }) }, defaultState, section); diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js index 112c9aeef..de156cde2 100644 --- a/frontend/src/Store/Actions/systemActions.js +++ b/frontend/src/Store/Actions/systemActions.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { createAction } from 'redux-actions'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; -import { sortDirections } from 'Helpers/Props'; +import { filterTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import { setAppValue } from 'Store/Actions/appActions'; import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; @@ -73,8 +73,6 @@ export const defaultState = { pageSize: 50, sortKey: 'time', sortDirection: sortDirections.DESCENDING, - filterKey: null, - filterValue: null, error: null, items: [], @@ -108,6 +106,49 @@ export const defaultState = { isVisible: true, isModifiable: false } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'info', + label: 'Info', + filters: [ + { + key: 'level', + value: 'info', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'warn', + label: 'Warn', + filters: [ + { + key: 'level', + value: 'warn', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'error', + label: 'Error', + filters: [ + { + key: 'level', + value: 'error', + type: filterTypes.EQUAL + } + ] + } ] }, @@ -130,8 +171,7 @@ export const persistState = [ 'system.logs.pageSize', 'system.logs.sortKey', 'system.logs.sortDirection', - 'system.logs.filterKey', - 'system.logs.filterValue' + 'system.logs.selectedFilterKey' ]; // diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js index 2de0f972e..5756da38d 100644 --- a/frontend/src/Store/Actions/wantedActions.js +++ b/frontend/src/Store/Actions/wantedActions.js @@ -1,6 +1,6 @@ import { createAction } from 'redux-actions'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; -import { sortDirections } from 'Helpers/Props'; +import { filterTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createClearReducer from './Creators/Reducers/createClearReducer'; import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; @@ -23,8 +23,6 @@ export const defaultState = { pageSize: 20, sortKey: 'releaseDate', sortDirection: sortDirections.DESCENDING, - filterKey: 'monitored', - filterValue: 'true', error: null, items: [], @@ -68,6 +66,33 @@ export const defaultState = { isVisible: true, isModifiable: false } + ], + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'monitored', + label: 'Monitored', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + } ] }, @@ -77,9 +102,6 @@ export const defaultState = { pageSize: 20, sortKey: 'releaseDate', sortDirection: sortDirections.DESCENDING, - filterKey: 'monitored', - filterValue: true, - error: null, items: [], columns: [ @@ -127,6 +149,33 @@ export const defaultState = { isVisible: true, isModifiable: false } + ], + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'monitored', + label: 'Monitored', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + } ] } }; @@ -135,14 +184,12 @@ export const persistState = [ 'wanted.missing.pageSize', 'wanted.missing.sortKey', 'wanted.missing.sortDirection', - 'wanted.missing.filterKey', - 'wanted.missing.filterValue', + 'wanted.missing.selectedFilterKey', 'wanted.missing.columns', 'wanted.cutoffUnmet.pageSize', 'wanted.cutoffUnmet.sortKey', 'wanted.cutoffUnmet.sortDirection', - 'wanted.cutoffUnmet.filterKey', - 'wanted.cutoffUnmet.filterValue', + 'wanted.cutoffUnmet.selectedFilterKey', 'wanted.cutoffUnmet.columns' ]; @@ -225,7 +272,7 @@ export const actionHandlers = handleThunks({ } ), - [BATCH_TOGGLE_MISSING_ALBUMS]: createBatchToggleAlbumMonitoredHandler('wanted.missing'), + [BATCH_TOGGLE_MISSING_ALBUMS]: createBatchToggleAlbumMonitoredHandler('wanted.missing', fetchMissing), ...createServerSideCollectionHandlers( 'wanted.cutoffUnmet', @@ -243,7 +290,7 @@ export const actionHandlers = handleThunks({ } ), - [BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS]: createBatchToggleAlbumMonitoredHandler('wanted.cutoffUnmet') + [BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS]: createBatchToggleAlbumMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet) }); diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js index 56b75c80c..de85d1801 100644 --- a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js +++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js @@ -1,10 +1,11 @@ import _ from 'lodash'; import { createSelector } from 'reselect'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; import { filterTypes, sortDirections } from 'Helpers/Props'; const filterTypePredicates = { [filterTypes.CONTAINS]: function(value, filterValue) { - return value.toLowerCase().indexOf(filterValue.toLowerCase()) > -1; + return value.toLowerCase().contains(filterValue.toLowerCase()); }, [filterTypes.EQUAL]: function(value, filterValue) { @@ -46,26 +47,54 @@ function getSortClause(sortKey, sortDirection, sortPredicates) { function filter(items, state) { const { - filterKey, - filterValue, - filterType, + selectedFilterKey, + filters, + customFilters, filterPredicates } = state; - if (!filterKey || !filterValue) { + if (!selectedFilterKey) { return items; } - return _.filter(items, (item) => { - if (filterPredicates && filterPredicates.hasOwnProperty(filterKey)) { - return filterPredicates[filterKey](item); - } + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); - if (item.hasOwnProperty(filterKey)) { - return filterTypePredicates[filterType](item[filterKey], filterValue); + return _.filter(items, (item) => { + let i = 0; + let accepted = true; + + while (accepted && i < selectedFilters.length) { + const { + key, + value, + type = filterTypes.EQUAL + } = selectedFilters[i]; + + if (filterPredicates && filterPredicates.hasOwnProperty(key)) { + const predicate = filterPredicates[key]; + + if (Array.isArray(value)) { + accepted = value.some((v) => predicate(item, v, type)); + } else { + accepted = predicate(item, value, type); + } + } else if (item.hasOwnProperty(key)) { + const predicate = filterTypePredicates[type]; + + if (Array.isArray(value)) { + accepted = value.some((v) => predicate(item[key], v)); + } else { + accepted = predicate(item[key], value); + } + } else { + // Default to false if the filter can't be tested + accepted = false; + } + + i++; } - return false; + return accepted; }); } diff --git a/frontend/src/Styles/Variables/colors.js b/frontend/src/Styles/Variables/colors.js index ed168cdde..e85e39721 100644 --- a/frontend/src/Styles/Variables/colors.js +++ b/frontend/src/Styles/Variables/colors.js @@ -22,8 +22,8 @@ module.exports = { themeBlue: '#00A65B', themeRed: '#c4273c', - themeDarkColor: '#216044', - themeLightColor: '#216044', + themeDarkColor: '#353535', + themeLightColor: '#1d563d', torrentColor: '#00853d', usenetColor: '#17b1d9', @@ -36,14 +36,14 @@ module.exports = { // Sidebar sidebarColor: '#e1e2e3', - sidebarBackgroundColor: '#216044', - sidebarActiveBackgroundColor: '#353535', + sidebarBackgroundColor: '#353535', + sidebarActiveBackgroundColor: '#252525', // Toolbar toolbarColor: '#e1e2e3', - toolbarBackgroundColor: '#216044', + toolbarBackgroundColor: '#1d563d', toolbarMenuItemBackgroundColor: '#4D8069', - toolbarMenuItemHoverBackgroundColor: '#216044', + toolbarMenuItemHoverBackgroundColor: '#353535', toolbarLabelColor: '#8895aa', // Accents @@ -66,7 +66,7 @@ module.exports = { defaultHoverBorderColor: '#d6d6d6;', primaryBackgroundColor: '#0b8750', - primaryBorderColor: '#216044', + primaryBorderColor: '#1d563d', primaryHoverBackgroundColor: '#097948', primaryHoverBorderColor: '#1D563D;', @@ -85,6 +85,7 @@ module.exports = { dangerHoverBackgroundColor: '#ee3d3d', dangerHoverBorderColor: '#ec2626;', + iconButtonDisabledColor: '#7a7a7a', iconButtonHoverColor: '#666', iconButtonHoverLightColor: '#ccc', @@ -99,6 +100,7 @@ module.exports = { // Menu menuItemColor: '#e1e2e3', menuItemHoverColor: '#fbfcfc', + menuItemHoverBackgroundColor: '#f5f7fa', // // Toolbar @@ -157,7 +159,7 @@ module.exports = { popoverArrowBorderColor: 'rgba(0, 0, 0, 0.25)', popoverTitleBackgroundInverseColor: '#3a3f51', - popoverTitleBorderInverseColor: '#216044', + popoverTitleBorderInverseColor: '#353535', popoverShadowInverseColor: 'rgba(0, 0, 0, 0.2)', popoverArrowBorderInverseColor: 'rgba(58, 63, 81, 0.75)', diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js index 7ed1f6cf1..1858d483a 100644 --- a/frontend/src/System/Events/LogsTable.js +++ b/frontend/src/System/Events/LogsTable.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +import React from 'react'; import { align, icons } from 'Helpers/Props'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import Table from 'Components/Table/Table'; @@ -11,151 +11,102 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import FilterMenu from 'Components/Menu/FilterMenu'; -import FilterMenuItem from 'Components/Menu/FilterMenuItem'; -import MenuContent from 'Components/Menu/MenuContent'; import LogsTableRow from './LogsTableRow'; -class LogsTable extends Component { - - // - // Listeners - - onFilterMenuItemPress = (filterKey, filterValue) => { - this.props.onFilterSelect(filterKey, filterValue); - } - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - items, - columns, - filterKey, - filterValue, - totalRecords, - clearLogExecuting, - onRefreshPress, - onClearLogsPress, - ...otherProps - } = this.props; - - return ( - - - - - - - - - - - - - All - - - - Info - - - - Warn - - - - Error - - - - - - - - { - isFetching && !isPopulated && - - } - - { - isPopulated && !error && !items.length && -
+function LogsTable(props) { + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + totalRecords, + clearLogExecuting, + onRefreshPress, + onClearLogsPress, + onFilterSelect, + ...otherProps + } = props; + + return ( + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + isPopulated && !error && !items.length && +
No logs found -
- } - - { - isPopulated && !error && !!items.length && -
- - - { - items.map((item) => { - return ( - - ); - }) - } - -
- - -
- } -
-
- ); - } - +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); } LogsTable.propTypes = { @@ -164,8 +115,8 @@ LogsTable.propTypes = { error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, - filterKey: PropTypes.string, - filterValue: PropTypes.string, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, totalRecords: PropTypes.number, clearLogExecuting: PropTypes.bool.isRequired, onFilterSelect: PropTypes.func.isRequired, diff --git a/frontend/src/System/Events/LogsTableConnector.js b/frontend/src/System/Events/LogsTableConnector.js index 428ea90fd..649e33f67 100644 --- a/frontend/src/System/Events/LogsTableConnector.js +++ b/frontend/src/System/Events/LogsTableConnector.js @@ -71,8 +71,8 @@ class LogsTableConnector extends Component { this.props.setLogsSort({ sortKey }); } - onFilterSelect = (filterKey, filterValue) => { - this.props.setLogsFilter({ filterKey, filterValue }); + onFilterSelect = (selectedFilterKey) => { + this.props.setLogsFilter({ selectedFilterKey }); } onTableOptionChange = (payload) => { diff --git a/frontend/src/System/Events/LogsTableRow.js b/frontend/src/System/Events/LogsTableRow.js index 6321c5748..2d3e55038 100644 --- a/frontend/src/System/Events/LogsTableRow.js +++ b/frontend/src/System/Events/LogsTableRow.js @@ -82,8 +82,8 @@ class LogsTableRow extends Component { className={styles.level} > diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js index 08a12e6d1..36a31a3db 100644 --- a/frontend/src/System/Updates/Updates.js +++ b/frontend/src/System/Updates/Updates.js @@ -1,7 +1,7 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { kinds } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; import formatDate from 'Utilities/Date/formatDate'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import SpinnerButton from 'Components/Link/SpinnerButton'; @@ -62,7 +62,7 @@ class Updates extends Component {
diff --git a/frontend/src/Utilities/Album/updateAlbums.js b/frontend/src/Utilities/Album/updateAlbums.js index 8f1c4169a..259ef510e 100644 --- a/frontend/src/Utilities/Album/updateAlbums.js +++ b/frontend/src/Utilities/Album/updateAlbums.js @@ -1,8 +1,7 @@ -/* eslint max-params: 0 */ import _ from 'lodash'; import { update } from 'Store/Actions/baseActions'; -function updateAlbums(dispatch, section, albums, albumIds, options) { +function updateAlbums(section, albums, albumIds, options) { const data = _.reduce(albums, (result, item) => { if (albumIds.indexOf(item.id) > -1) { result.push({ @@ -16,7 +15,7 @@ function updateAlbums(dispatch, section, albums, albumIds, options) { return result; }, []); - dispatch(update({ section, data })); + return update({ section, data }); } export default updateAlbums; diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js new file mode 100644 index 000000000..165bb5cc1 --- /dev/null +++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js @@ -0,0 +1,13 @@ +import _ from 'lodash'; + +export default function getIndexOfFirstCharacter(items, character) { + return _.findIndex(items, (item) => { + const firstCharacter = item.sortTitle.charAt(0); + + if (character === '#') { + return !isNaN(firstCharacter); + } + + return firstCharacter === character; + }); +} diff --git a/frontend/src/Utilities/Filter/findSelectedFilters.js b/frontend/src/Utilities/Filter/findSelectedFilters.js new file mode 100644 index 000000000..4306f9439 --- /dev/null +++ b/frontend/src/Utilities/Filter/findSelectedFilters.js @@ -0,0 +1,15 @@ +export default function findSelectedFilters(selectedFilterKey, filters = [], customFilters = []) { + if (!selectedFilterKey) { + return []; + } + + const selectedFilter = [...filters, ...customFilters].find((f) => f.key === selectedFilterKey); + + if (!selectedFilter) { + // TODO: throw in dev + console.error('Matching filter not found'); + return []; + } + + return selectedFilter.filters; +} diff --git a/frontend/src/Utilities/Filter/getFilterValue.js b/frontend/src/Utilities/Filter/getFilterValue.js new file mode 100644 index 000000000..a8fc82183 --- /dev/null +++ b/frontend/src/Utilities/Filter/getFilterValue.js @@ -0,0 +1,5 @@ +export default function getFilterValue(filters, filterKey) { + const filter = filters.find((f) => f.key === filterKey); + + return filter && filter.value; +} diff --git a/frontend/src/Utilities/String/generateUUIDv4.js b/frontend/src/Utilities/String/generateUUIDv4.js new file mode 100644 index 000000000..51b15ec60 --- /dev/null +++ b/frontend/src/Utilities/String/generateUUIDv4.js @@ -0,0 +1,6 @@ +export default function generateUUIDv4() { + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) => + // eslint-disable-next-line no-bitwise + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); +} diff --git a/frontend/src/Utilities/customFilterHandlers.js b/frontend/src/Utilities/customFilterHandlers.js new file mode 100644 index 000000000..19be120ac --- /dev/null +++ b/frontend/src/Utilities/customFilterHandlers.js @@ -0,0 +1,6 @@ +const customFilterHandlers = { + REMOVE: 'remove', + SAVE: 'save' +}; + +export default customFilterHandlers; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js index 6f86efcf9..237ac3293 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import getFilterValue from 'Utilities/Filter/getFilterValue'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; @@ -17,8 +18,6 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import FilterMenu from 'Components/Menu/FilterMenu'; -import MenuContent from 'Components/Menu/MenuContent'; -import FilterMenuItem from 'Components/Menu/FilterMenuItem'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import CutoffUnmetRowConnector from './CutoffUnmetRowConnector'; @@ -58,10 +57,6 @@ class CutoffUnmet extends Component { // // Listeners - onFilterMenuItemPress = (filterKey, filterValue) => { - this.props.onFilterSelect(filterKey, filterValue); - } - onSelectAllChange = ({ value }) => { this.setState(selectAll(this.state.selectedState, value)); } @@ -106,13 +101,14 @@ class CutoffUnmet extends Component { isPopulated, error, items, + selectedFilterKey, + filters, columns, totalRecords, isSearchingForAlbums, isSearchingForCutoffUnmetAlbums, isSaving, - filterKey, - filterValue, + onFilterSelect, ...otherProps } = this.props; @@ -124,6 +120,7 @@ class CutoffUnmet extends Component { } = this.state; const itemsSelected = !!this.getSelectedIds().length; + const monitoredFilterValue = getFilterValue(filters, 'monitored'); return ( @@ -138,7 +135,7 @@ class CutoffUnmet extends Component { /> - - - - Monitored - - - - Unmonitored - - - + @@ -270,13 +251,13 @@ CutoffUnmet.propTypes = { isPopulated: PropTypes.bool.isRequired, error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, totalRecords: PropTypes.number, isSearchingForAlbums: PropTypes.bool.isRequired, isSearchingForCutoffUnmetAlbums: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired, - filterKey: PropTypes.string, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), onFilterSelect: PropTypes.func.isRequired, onSearchSelectedPress: PropTypes.func.isRequired, onToggleSelectedPress: PropTypes.func.isRequired, diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js index 423bdad53..bc93388a3 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js @@ -4,6 +4,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import getFilterValue from 'Utilities/Filter/getFilterValue'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; @@ -105,8 +106,8 @@ class CutoffUnmetConnector extends Component { this.props.setCutoffUnmetSort({ sortKey }); } - onFilterSelect = (filterKey, filterValue) => { - this.props.setCutoffUnmetFilter({ filterKey, filterValue }); + onFilterSelect = (selectedFilterKey) => { + this.props.setCutoffUnmetFilter({ selectedFilterKey }); } onTableOptionChange = (payload) => { @@ -126,13 +127,14 @@ class CutoffUnmetConnector extends Component { onToggleSelectedPress = (selected) => { const { - filterKey, - filterValue + filters } = this.props; + const monitored = getFilterValue(filters, 'monitored'); + this.props.batchToggleCutoffUnmetAlbums({ albumIds: selected, - monitored: filterKey !== 'monitored' || !filterValue + monitored: monitored == null || !monitored }); } @@ -167,8 +169,7 @@ class CutoffUnmetConnector extends Component { CutoffUnmetConnector.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, - filterKey: PropTypes.string.isRequired, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), + filters: PropTypes.arrayOf(PropTypes.object).isRequired, fetchCutoffUnmet: PropTypes.func.isRequired, gotoCutoffUnmetFirstPage: PropTypes.func.isRequired, gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired, diff --git a/frontend/src/Wanted/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js index b13a34c2b..c613f101f 100644 --- a/frontend/src/Wanted/Missing/Missing.js +++ b/frontend/src/Wanted/Missing/Missing.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import getFilterValue from 'Utilities/Filter/getFilterValue'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; @@ -17,8 +18,6 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import FilterMenu from 'Components/Menu/FilterMenu'; -import MenuContent from 'Components/Menu/MenuContent'; -import FilterMenuItem from 'Components/Menu/FilterMenuItem'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import MissingRowConnector from './MissingRowConnector'; @@ -59,10 +58,6 @@ class Missing extends Component { // // Listeners - onFilterMenuItemPress = (filterKey, filterValue) => { - this.props.onFilterSelect(filterKey, filterValue); - } - onSelectAllChange = ({ value }) => { this.setState(selectAll(this.state.selectedState, value)); } @@ -115,13 +110,14 @@ class Missing extends Component { isPopulated, error, items, + selectedFilterKey, + filters, columns, totalRecords, isSearchingForAlbums, isSearchingForMissingAlbums, isSaving, - filterKey, - filterValue, + onFilterSelect, ...otherProps } = this.props; @@ -134,6 +130,7 @@ class Missing extends Component { } = this.state; const itemsSelected = !!this.getSelectedIds().length; + const monitoredFilterValue = getFilterValue(filters, 'monitored'); return ( @@ -148,7 +145,7 @@ class Missing extends Component { /> - - - - Monitored - - - - Unmonitored - - - + @@ -292,13 +273,13 @@ Missing.propTypes = { isPopulated: PropTypes.bool.isRequired, error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, totalRecords: PropTypes.number, isSearchingForAlbums: PropTypes.bool.isRequired, isSearchingForMissingAlbums: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired, - filterKey: PropTypes.string, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), onFilterSelect: PropTypes.func.isRequired, onSearchSelectedPress: PropTypes.func.isRequired, onToggleSelectedPress: PropTypes.func.isRequired, diff --git a/frontend/src/Wanted/Missing/MissingConnector.js b/frontend/src/Wanted/Missing/MissingConnector.js index 80db02eb0..c7d7e8328 100644 --- a/frontend/src/Wanted/Missing/MissingConnector.js +++ b/frontend/src/Wanted/Missing/MissingConnector.js @@ -4,6 +4,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import getFilterValue from 'Utilities/Filter/getFilterValue'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; @@ -95,8 +96,8 @@ class MissingConnector extends Component { this.props.setMissingSort({ sortKey }); } - onFilterSelect = (filterKey, filterValue) => { - this.props.setMissingFilter({ filterKey, filterValue }); + onFilterSelect = (selectedFilterKey) => { + this.props.setMissingFilter({ selectedFilterKey }); } onTableOptionChange = (payload) => { @@ -116,13 +117,14 @@ class MissingConnector extends Component { onToggleSelectedPress = (selected) => { const { - filterKey, - filterValue + filters } = this.props; + const monitored = getFilterValue(filters, 'monitored'); + this.props.batchToggleMissingAlbums({ albumIds: selected, - monitored: filterKey !== 'monitored' || !filterValue + monitored: monitored == null || !monitored }); } @@ -157,8 +159,7 @@ class MissingConnector extends Component { MissingConnector.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, - filterKey: PropTypes.string.isRequired, - filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]), + filters: PropTypes.arrayOf(PropTypes.object).isRequired, fetchMissing: PropTypes.func.isRequired, gotoMissingFirstPage: PropTypes.func.isRequired, gotoMissingPreviousPage: PropTypes.func.isRequired, diff --git a/package.json b/package.json index 92a5bf73a..edd476736 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,6 @@ "react-router-redux": "5.0.0-alpha.9", "react-slider": "0.9.0", "react-tabs": "2.2.1", - "react-tag-autocomplete": "5.5.0", "react-tether": "0.6.1", "react-text-truncate": "0.12.1", "react-virtualized": "9.18.0", diff --git a/src/Lidarr.Api.V1/Artist/ArtistModule.cs b/src/Lidarr.Api.V1/Artist/ArtistModule.cs index 8395a19c8..a7380e441 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistModule.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistModule.cs @@ -203,12 +203,7 @@ namespace Lidarr.Api.V1.Artist private void LinkArtistStatistics(ArtistResource resource, ArtistStatistics artistStatistics) { - resource.TotalTrackCount = artistStatistics.TotalTrackCount; - resource.TrackCount = artistStatistics.TrackCount; - resource.TrackFileCount = artistStatistics.TrackFileCount; - resource.SizeOnDisk = artistStatistics.SizeOnDisk; - resource.AlbumCount = artistStatistics.AlbumCount; - + resource.Statistics = artistStatistics.ToResource(); } //private void PopulateAlternateTitles(List resources) diff --git a/src/Lidarr.Api.V1/Artist/ArtistResource.cs b/src/Lidarr.Api.V1/Artist/ArtistResource.cs index df3b60b57..7826744f0 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistResource.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistResource.cs @@ -30,12 +30,7 @@ namespace Lidarr.Api.V1.Artist public string ArtistType { get; set; } public string Disambiguation { get; set; } public List Links { get; set; } - - public int? AlbumCount { get; set; } - public int? TotalTrackCount { get; set; } - public int? TrackCount { get; set; } - public int? TrackFileCount { get; set; } - public long? SizeOnDisk { get; set; } + public Album NextAlbum { get; set; } public Album LastAlbum { get; set; } @@ -64,7 +59,7 @@ namespace Lidarr.Api.V1.Artist public AddArtistOptions AddOptions { get; set; } public Ratings Ratings { get; set; } - //TODO: Add series statistics as a property of the series (instead of individual properties) + public ArtistStatisticsResource Statistics { get; set; } } public static class ArtistResourceMapper diff --git a/src/Lidarr.Api.V1/Artist/ArtistStatisticsResource.cs b/src/Lidarr.Api.V1/Artist/ArtistStatisticsResource.cs new file mode 100644 index 000000000..6759ff839 --- /dev/null +++ b/src/Lidarr.Api.V1/Artist/ArtistStatisticsResource.cs @@ -0,0 +1,41 @@ +using System; +using NzbDrone.Core.ArtistStats; + +namespace Lidarr.Api.V1.Artist +{ + public class ArtistStatisticsResource + { + public int AlbumCount { get; set; } + public int TrackFileCount { get; set; } + public int TrackCount { get; set; } + public int TotalTrackCount { get; set; } + public long SizeOnDisk { get; set; } + + public decimal PercentOfTracks + { + get + { + if (TrackCount == 0) return 0; + + return (decimal)TrackFileCount / (decimal)TrackCount * 100; + } + } + } + + public static class ArtistStatisticsResourceMapper + { + public static ArtistStatisticsResource ToResource(this ArtistStatistics model) + { + if (model == null) return null; + + return new ArtistStatisticsResource + { + AlbumCount = model.AlbumCount, + TrackFileCount = model.TrackFileCount, + TrackCount = model.TrackCount, + TotalTrackCount = model.TotalTrackCount, + SizeOnDisk = model.SizeOnDisk + }; + } + } +} diff --git a/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs b/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs index 95d413384..c370321f5 100644 --- a/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs +++ b/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs @@ -10,6 +10,7 @@ namespace Lidarr.Api.V1.Config public string RecycleBin { get; set; } public bool AutoDownloadPropers { get; set; } public bool CreateEmptyArtistFolders { get; set; } + public bool DeleteEmptyFolders { get; set; } public FileDateType FileDate { get; set; } public bool SetPermissionsLinux { get; set; } @@ -35,6 +36,7 @@ namespace Lidarr.Api.V1.Config RecycleBin = model.RecycleBin, AutoDownloadPropers = model.AutoDownloadPropers, CreateEmptyArtistFolders = model.CreateEmptyArtistFolders, + DeleteEmptyFolders = model.DeleteEmptyFolders, FileDate = model.FileDate, SetPermissionsLinux = model.SetPermissionsLinux, diff --git a/src/Lidarr.Api.V1/History/HistoryModule.cs b/src/Lidarr.Api.V1/History/HistoryModule.cs index 9e72bc9de..779c4362a 100644 --- a/src/Lidarr.Api.V1/History/HistoryModule.cs +++ b/src/Lidarr.Api.V1/History/HistoryModule.cs @@ -69,18 +69,22 @@ namespace Lidarr.Api.V1.History var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum"); var includeTrack = Request.GetBooleanQueryParameter("includeTrack"); - if (pagingResource.FilterKey == "eventType") + var eventTypeFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "eventType"); + var albumIdFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "albumId"); + + if (eventTypeFilter != null) { - var filterValue = (HistoryEventType)Convert.ToInt32(pagingResource.FilterValue); - pagingSpec.FilterExpression = v => v.EventType == filterValue; + var filterValue = (HistoryEventType)Convert.ToInt32(eventTypeFilter.Value); + pagingSpec.FilterExpressions.Add(v => v.EventType == filterValue); } - if (pagingResource.FilterKey == "albumId") + if (albumIdFilter != null) { - int albumId = Convert.ToInt32(pagingResource.FilterValue); - pagingSpec.FilterExpression = h => h.AlbumId == albumId; + var albumId = Convert.ToInt32(albumIdFilter.Value); + pagingSpec.FilterExpressions.Add(h => h.AlbumId == albumId); } + return ApplyToPage(_historyService.Paged, pagingSpec, h => MapToResource(h, includeArtist, includeAlbum, includeTrack)); } diff --git a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj index 962328a7a..4fe43b484 100644 --- a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj +++ b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj @@ -88,6 +88,7 @@ + diff --git a/src/Lidarr.Api.V1/Logs/LogModule.cs b/src/Lidarr.Api.V1/Logs/LogModule.cs index aac359771..1316e9024 100644 --- a/src/Lidarr.Api.V1/Logs/LogModule.cs +++ b/src/Lidarr.Api.V1/Logs/LogModule.cs @@ -1,4 +1,5 @@ -using NzbDrone.Core.Instrumentation; +using System.Linq; +using NzbDrone.Core.Instrumentation; using Lidarr.Http; namespace Lidarr.Api.V1.Logs @@ -22,27 +23,29 @@ namespace Lidarr.Api.V1.Logs pageSpec.SortKey = "id"; } - if (pagingResource.FilterKey == "level") + var levelFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "level"); + + if (levelFilter != null) { - switch (pagingResource.FilterValue) + switch (levelFilter.Value) { - case "Fatal": - pageSpec.FilterExpression = h => h.Level == "Fatal"; + case "fatal": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal"); break; - case "Error": - pageSpec.FilterExpression = h => h.Level == "Fatal" || h.Level == "Error"; + case "error": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error"); break; - case "Warn": - pageSpec.FilterExpression = h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn"; + case "warn": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn"); break; - case "Info": - pageSpec.FilterExpression = h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info"; + case "info": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info"); break; - case "Debug": - pageSpec.FilterExpression = h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug"; + case "debug": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug"); break; - case "Trace": - pageSpec.FilterExpression = h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug" || h.Level == "Trace"; + case "trace": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug" || h.Level == "Trace"); break; } } @@ -57,4 +60,4 @@ namespace Lidarr.Api.V1.Logs return response; } } -} \ No newline at end of file +} diff --git a/src/Lidarr.Api.V1/Logs/LogResource.cs b/src/Lidarr.Api.V1/Logs/LogResource.cs index c95e42077..bd2503bf2 100644 --- a/src/Lidarr.Api.V1/Logs/LogResource.cs +++ b/src/Lidarr.Api.V1/Logs/LogResource.cs @@ -1,4 +1,4 @@ -using System; +using System; using NzbDrone.Core.Instrumentation; using Lidarr.Http.REST; @@ -24,11 +24,10 @@ namespace Lidarr.Api.V1.Logs return new LogResource { Id = model.Id, - Time = model.Time, Exception = model.Exception, ExceptionType = model.ExceptionType, - Level = model.Level, + Level = model.Level.ToLowerInvariant(), Logger = model.Logger, Message = model.Message }; diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs index 320c23c7f..d0ecefa37 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs @@ -69,7 +69,6 @@ namespace Lidarr.Api.V1.TrackFiles Language = model.Language, Quality = model.Quality, MediaInfo = model.MediaInfo.ToResource(), - QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(artist.Profile.Value, model.Quality), LanguageCutoffNotMet = upgradableSpecification.LanguageCutoffNotMet(artist.LanguageProfile.Value, model.Language) }; diff --git a/src/Lidarr.Api.V1/Wanted/CutoffModule.cs b/src/Lidarr.Api.V1/Wanted/CutoffModule.cs index 34b284b09..b58bc564a 100644 --- a/src/Lidarr.Api.V1/Wanted/CutoffModule.cs +++ b/src/Lidarr.Api.V1/Wanted/CutoffModule.cs @@ -1,3 +1,4 @@ +using System.Linq; using NzbDrone.Core.Datastore; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Music; @@ -36,14 +37,15 @@ namespace Lidarr.Api.V1.Wanted }; var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); + var filter = pagingResource.Filters.FirstOrDefault(f => f.Key == "monitored"); - if (pagingResource.FilterKey == "monitored" && pagingResource.FilterValue == "false") + if (filter != null && filter.Value == "false") { - pagingSpec.FilterExpression = v => v.Monitored == false || v.Artist.Monitored == false; + pagingSpec.FilterExpressions.Add(v => v.Monitored == false || v.Artist.Monitored == false); } else { - pagingSpec.FilterExpression = v => v.Monitored == true && v.Artist.Monitored == true; + pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Artist.Monitored == true); } var resource = ApplyToPage(_albumCutoffService.AlbumsWhereCutoffUnmet, pagingSpec, v => MapToResource(v, includeArtist)); diff --git a/src/Lidarr.Api.V1/Wanted/MissingModule.cs b/src/Lidarr.Api.V1/Wanted/MissingModule.cs index 0ecc5c1f9..d0ec478e8 100644 --- a/src/Lidarr.Api.V1/Wanted/MissingModule.cs +++ b/src/Lidarr.Api.V1/Wanted/MissingModule.cs @@ -1,3 +1,4 @@ +using System.Linq; using NzbDrone.Core.Datastore; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Music; @@ -32,14 +33,15 @@ namespace Lidarr.Api.V1.Wanted }; var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); + var monitoredFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "monitored"); - if (pagingResource.FilterKey == "monitored" && pagingResource.FilterValue == "false") + if (monitoredFilter != null && monitoredFilter.Value == "false") { - pagingSpec.FilterExpression = v => v.Monitored == false || v.Artist.Monitored == false; + pagingSpec.FilterExpressions.Add(v => v.Monitored == false || v.Artist.Monitored == false); } else { - pagingSpec.FilterExpression = v => v.Monitored == true && v.Artist.Monitored == true; + pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Artist.Monitored == true); } var resource = ApplyToPage(_albumService.AlbumsWithoutFiles, pagingSpec, v => MapToResource(v, includeArtist)); diff --git a/src/Lidarr.Http/Lidarr.Http.csproj b/src/Lidarr.Http/Lidarr.Http.csproj index 9d3579e33..4be66b7d9 100644 --- a/src/Lidarr.Http/Lidarr.Http.csproj +++ b/src/Lidarr.Http/Lidarr.Http.csproj @@ -108,6 +108,7 @@ + diff --git a/src/Lidarr.Http/PagingResource.cs b/src/Lidarr.Http/PagingResource.cs index 8b648fa4f..e123eba69 100644 --- a/src/Lidarr.Http/PagingResource.cs +++ b/src/Lidarr.Http/PagingResource.cs @@ -9,8 +9,7 @@ namespace Lidarr.Http public int PageSize { get; set; } public string SortKey { get; set; } public SortDirection SortDirection { get; set; } - public string FilterKey { get; set; } - public string FilterValue { get; set; } + public List Filters { get; set; } public int TotalRecords { get; set; } public List Records { get; set; } } diff --git a/src/Lidarr.Http/PagingResourceFilter.cs b/src/Lidarr.Http/PagingResourceFilter.cs new file mode 100644 index 000000000..9ce509e28 --- /dev/null +++ b/src/Lidarr.Http/PagingResourceFilter.cs @@ -0,0 +1,8 @@ +namespace Lidarr.Http +{ + public class PagingResourceFilter + { + public string Key { get; set; } + public string Value { get; set; } + } +} diff --git a/src/Lidarr.Http/REST/RestModule.cs b/src/Lidarr.Http/REST/RestModule.cs index 040658aa9..3fea34aed 100644 --- a/src/Lidarr.Http/REST/RestModule.cs +++ b/src/Lidarr.Http/REST/RestModule.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentValidation; @@ -14,6 +14,16 @@ namespace Lidarr.Http.REST private const string ROOT_ROUTE = "/"; private const string ID_ROUTE = @"/(?[\d]{1,10})"; + private HashSet EXCLUDED_KEYS = new HashSet(StringComparer.InvariantCultureIgnoreCase) + { + "page", + "pageSize", + "sortKey", + "sortDirection", + "filterKey", + "filterValue", + }; + private Action _deleteResource; private Func _getResourceById; private Func> _getResourceAll; @@ -226,6 +236,7 @@ namespace Lidarr.Http.REST { PageSize = pageSize, Page = page, + Filters = new List() }; if (Request.Query.SortKey != null) @@ -251,17 +262,39 @@ namespace Lidarr.Http.REST } } + // For backwards compatibility with v2 if (Request.Query.FilterKey != null) { - pagingResource.FilterKey = Request.Query.FilterKey.ToString(); + var filter = new PagingResourceFilter + { + Key = Request.Query.FilterKey.ToString() + }; if (Request.Query.FilterValue != null) { - pagingResource.FilterValue = Request.Query.FilterValue.ToString(); + filter.Value = Request.Query.FilterValue?.ToString(); } + + pagingResource.Filters.Add(filter); + } + + // v3 uses filters in key=value format + + foreach (var key in Request.Query) + { + if (EXCLUDED_KEYS.Contains(key)) + { + continue; + } + + pagingResource.Filters.Add(new PagingResourceFilter + { + Key = key, + Value = Request.Query[key] + }); } return pagingResource; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 91cf7bd20..b45ed8e5a 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -154,6 +154,13 @@ namespace NzbDrone.Core.Configuration set { SetValue("CreateEmptyArtistFolders", value); } } + public bool DeleteEmptyFolders + { + get { return GetValueBoolean("DeleteEmptyFolders", false); } + + set { SetValue("DeleteEmptyFolders", value); } + } + public FileDateType FileDate { get { return GetValueEnum("FileDate", FileDateType.None); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 60327fa01..fcf41e31f 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -26,6 +26,7 @@ namespace NzbDrone.Core.Configuration string RecycleBin { get; set; } bool AutoDownloadPropers { get; set; } bool CreateEmptyArtistFolders { get; set; } + bool DeleteEmptyFolders { get; set; } FileDateType FileDate { get; set; } bool SkipFreeSpaceCheckWhenImporting { get; set; } bool CopyUsingHardlinks { get; set; } diff --git a/src/NzbDrone.Core/Datastore/BasicRepository.cs b/src/NzbDrone.Core/Datastore/BasicRepository.cs index b2afafdc4..889416637 100644 --- a/src/NzbDrone.Core/Datastore/BasicRepository.cs +++ b/src/NzbDrone.Core/Datastore/BasicRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data; using System.Linq; @@ -254,10 +254,21 @@ namespace NzbDrone.Core.Datastore protected virtual SortBuilder GetPagedQuery(QueryBuilder query, PagingSpec pagingSpec) { - return query.Where(pagingSpec.FilterExpression) - .OrderBy(pagingSpec.OrderByClause(), pagingSpec.ToSortDirection()) - .Skip(pagingSpec.PagingOffset()) - .Take(pagingSpec.PageSize); + var filterExpressions = pagingSpec.FilterExpressions; + var sortQuery = query.Where(filterExpressions.FirstOrDefault()); + + if (filterExpressions.Count > 1) + { + // Start at the second item for the AndWhere clauses + for (var i = 1; i < filterExpressions.Count; i++) + { + sortQuery.AndWhere(filterExpressions[i]); + } + } + + return sortQuery.OrderBy(pagingSpec.OrderByClause(), pagingSpec.ToSortDirection()) + .Skip(pagingSpec.PagingOffset()) + .Take(pagingSpec.PageSize); } protected void ModelCreated(TModel model) diff --git a/src/NzbDrone.Core/Datastore/PagingSpec.cs b/src/NzbDrone.Core/Datastore/PagingSpec.cs index 63f8d719c..0c9179844 100644 --- a/src/NzbDrone.Core/Datastore/PagingSpec.cs +++ b/src/NzbDrone.Core/Datastore/PagingSpec.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq.Expressions; @@ -12,7 +12,12 @@ namespace NzbDrone.Core.Datastore public string SortKey { get; set; } public SortDirection SortDirection { get; set; } public List Records { get; set; } - public Expression> FilterExpression { get; set; } + public List>> FilterExpressions { get; set; } + + public PagingSpec() + { + FilterExpressions = new List>>(); + } } public enum SortDirection diff --git a/src/NzbDrone.Core/Extras/Files/ExtraFileService.cs b/src/NzbDrone.Core/Extras/Files/ExtraFileService.cs index 0a8bdbb10..8f7f10acd 100644 --- a/src/NzbDrone.Core/Extras/Files/ExtraFileService.cs +++ b/src/NzbDrone.Core/Extras/Files/ExtraFileService.cs @@ -27,7 +27,7 @@ namespace NzbDrone.Core.Extras.Files public abstract class ExtraFileService : IExtraFileService, IHandleAsync, - IHandleAsync + IHandle where TExtraFile : ExtraFile, new() { private readonly IExtraFileRepository _repository; @@ -103,7 +103,7 @@ namespace NzbDrone.Core.Extras.Files _repository.DeleteForArtist(message.Artist.Id); } - public void HandleAsync(TrackFileDeletedEvent message) + public void Handle(TrackFileDeletedEvent message) { var trackFile = message.TrackFile; diff --git a/src/NzbDrone.Core/IndexerSearch/AlbumSearchService.cs b/src/NzbDrone.Core/IndexerSearch/AlbumSearchService.cs index af7988d47..796120a2e 100644 --- a/src/NzbDrone.Core/IndexerSearch/AlbumSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/AlbumSearchService.cs @@ -78,32 +78,34 @@ namespace NzbDrone.Core.IndexerSearch { int artistId = message.ArtistId.Value; - albums = _albumService.AlbumsWithoutFiles(new PagingSpec + var pagingSpec = new PagingSpec { Page = 1, PageSize = 100000, SortDirection = SortDirection.Ascending, - SortKey = "Id", - FilterExpression = - v => - v.Monitored == true && - v.Artist.Monitored == true - }).Records.Where(e => e.ArtistId.Equals(artistId)).ToList(); + SortKey = "Id" + }; + + pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Artist.Monitored == true); + + albums = _albumService.AlbumsWithoutFiles(pagingSpec).Records.Where(e => e.ArtistId.Equals(artistId)).ToList(); + } else { - albums = _albumService.AlbumsWithoutFiles(new PagingSpec + var pagingSpec = new PagingSpec { Page = 1, PageSize = 100000, SortDirection = SortDirection.Ascending, - SortKey = "Id", - FilterExpression = - v => - v.Monitored == true && - v.Artist.Monitored == true - }).Records.ToList(); + SortKey = "Id" + }; + + pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Artist.Monitored == true); + + albums = _albumService.AlbumsWithoutFiles(pagingSpec).Records.ToList(); + } var queue = _queueService.GetQueue().Select(q => q.Album.Id); @@ -131,14 +133,17 @@ namespace NzbDrone.Core.IndexerSearch v.Artist.Monitored == true; } - var albums = _albumCutoffService.AlbumsWhereCutoffUnmet(new PagingSpec + var pagingSpec = new PagingSpec { Page = 1, PageSize = 100000, SortDirection = SortDirection.Ascending, - SortKey = "Id", - FilterExpression = filterExpression - }).Records.ToList(); + SortKey = "Id" + }; + + pagingSpec.FilterExpressions.Add(filterExpression); + + var albums = _albumCutoffService.AlbumsWhereCutoffUnmet(pagingSpec).Records.ToList(); var queue = _queueService.GetQueue().Select(q => q.Album.Id); var missing = albums.Where(e => !queue.Contains(e.Id)).ToList(); diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs index 8a198802b..f95e28ec2 100644 --- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -94,8 +94,10 @@ namespace NzbDrone.Core.MediaFiles { _logger.Debug("Artist folder doesn't exist: {0}", artist.Path); } + CleanMediaFiles(artist, new List()); CompletedScanning(artist); + return; } @@ -112,6 +114,7 @@ namespace NzbDrone.Core.MediaFiles _logger.Trace("Import decisions complete for: {0} [{1}]", artist, decisionsStopwatch.Elapsed); _importApprovedTracks.Import(decisions, false); + RemoveEmptyArtistFolder(artist.Path); CompletedScanning(artist); } @@ -183,7 +186,22 @@ namespace NzbDrone.Core.MediaFiles _logger.Warn(ex, "Unable to apply permissions to: " + path); _logger.Debug(ex, ex.Message); } - } + } + + private void RemoveEmptyArtistFolder(string path) + { + if (_configService.DeleteEmptyFolders) + { + if (_diskProvider.GetFiles(path, SearchOption.AllDirectories).Empty()) + { + _diskProvider.DeleteFolder(path, true); + } + else + { + _diskProvider.RemoveEmptySubfolders(path); + } + } + } public void Handle(ArtistUpdatedEvent message) { diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs index bd9763c3f..9315b12cf 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs @@ -4,7 +4,10 @@ using System.Net; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; using NzbDrone.Core.Music.Events; @@ -16,24 +19,29 @@ namespace NzbDrone.Core.MediaFiles void DeleteTrackFile(Artist artist, TrackFile trackFile); } - public class MediaFileDeletionService : IDeleteMediaFiles, IHandleAsync + public class MediaFileDeletionService : IDeleteMediaFiles, + IHandleAsync, + IHandle { private readonly IDiskProvider _diskProvider; private readonly IRecycleBinProvider _recycleBinProvider; private readonly IMediaFileService _mediaFileService; private readonly IArtistService _artistService; + private readonly IConfigService _configService; private readonly Logger _logger; public MediaFileDeletionService(IDiskProvider diskProvider, IRecycleBinProvider recycleBinProvider, IMediaFileService mediaFileService, IArtistService artistService, + IConfigService configService, Logger logger) { _diskProvider = diskProvider; _recycleBinProvider = recycleBinProvider; _mediaFileService = mediaFileService; _artistService = artistService; + _configService = configService; _logger = logger; } @@ -104,5 +112,29 @@ namespace NzbDrone.Core.MediaFiles } } } + + [EventHandleOrder(EventHandleOrder.Last)] + public void Handle(TrackFileDeletedEvent message) + { + if (message.Reason == DeleteMediaFileReason.Upgrade) + { + return; + } + + if (_configService.DeleteEmptyFolders) + { + var artist = message.TrackFile.Artist.Value; + var albumFolder = message.TrackFile.Path.GetParentPath(); + + if (_diskProvider.GetFiles(artist.Path, SearchOption.AllDirectories).Empty()) + { + _diskProvider.DeleteFolder(artist.Path, true); + } + else if (_diskProvider.GetFiles(albumFolder, SearchOption.AllDirectories).Empty()) + { + _diskProvider.RemoveEmptySubfolders(albumFolder); + } + } + } } } diff --git a/src/NzbDrone.Core/Messaging/EventHandleOrderAttribute.cs b/src/NzbDrone.Core/Messaging/EventHandleOrderAttribute.cs new file mode 100644 index 000000000..6af307ecd --- /dev/null +++ b/src/NzbDrone.Core/Messaging/EventHandleOrderAttribute.cs @@ -0,0 +1,22 @@ +using System; + +namespace NzbDrone.Core.Messaging +{ + [AttributeUsage(AttributeTargets.Method)] + public class EventHandleOrderAttribute : Attribute + { + public EventHandleOrder EventHandleOrder { get; set; } + + public EventHandleOrderAttribute(EventHandleOrder eventHandleOrder) + { + EventHandleOrder = eventHandleOrder; + } + } + + public enum EventHandleOrder + { + First, + Any, + Last + } +} diff --git a/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs b/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs index 0111a7342..ea3f6d600 100644 --- a/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs +++ b/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using NLog; using NzbDrone.Common; @@ -48,7 +49,11 @@ namespace NzbDrone.Core.Messaging.Events //call synchronous handlers first. - foreach (var handler in _serviceFactory.BuildAll>()) + var handlers = _serviceFactory.BuildAll>() + .OrderBy(GetEventHandleOrder) + .ToList(); + + foreach (var handler in handlers) { try { @@ -96,5 +101,25 @@ namespace NzbDrone.Core.Messaging.Events return string.Format("{0}<{1}>", eventType.Name.Remove(eventType.Name.IndexOf('`')), eventType.GetGenericArguments()[0].Name); } + + private int GetEventHandleOrder(IHandle eventHandler) where TEvent : class, IEvent + { + // TODO: Convert "Handle" to nameof(eventHandler.Handle) after .net 4.5 + var method = eventHandler.GetType().GetMethod("Handle", new Type[] { typeof(TEvent) }); + + if (method == null) + { + return (int)EventHandleOrder.Any; + } + + var attribute = method.GetCustomAttributes(typeof(EventHandleOrderAttribute), true).FirstOrDefault() as EventHandleOrderAttribute; + + if (attribute == null) + { + return (int)EventHandleOrder.Any; + } + + return (int)attribute.EventHandleOrder; + } } } diff --git a/src/NzbDrone.Core/Music/AlbumRepository.cs b/src/NzbDrone.Core/Music/AlbumRepository.cs index 9bb7037bf..117c63922 100644 --- a/src/NzbDrone.Core/Music/AlbumRepository.cs +++ b/src/NzbDrone.Core/Music/AlbumRepository.cs @@ -106,7 +106,7 @@ namespace NzbDrone.Core.Music string sortKey; string monitored = "(Albums.[Monitored] = 0) OR (Artists.[Monitored] = 0)"; - if (pagingSpec.FilterExpression.ToString().Contains("True")) + if (pagingSpec.FilterExpressions.FirstOrDefault().ToString().Contains("True")) { monitored = "(Albums.[Monitored] = 1) AND (Artists.[Monitored] = 1)"; } @@ -140,7 +140,7 @@ namespace NzbDrone.Core.Music { var monitored = 0; - if (pagingSpec.FilterExpression.ToString().Contains("True")) + if (pagingSpec.FilterExpressions.FirstOrDefault().ToString().Contains("True")) { monitored = 1; } @@ -167,7 +167,7 @@ namespace NzbDrone.Core.Music string sortKey; string monitored = "(Albums.[Monitored] = 0) OR (Artists.[Monitored] = 0)"; - if (pagingSpec.FilterExpression.ToString().Contains("True")) + if (pagingSpec.FilterExpressions.FirstOrDefault().ToString().Contains("True")) { monitored = "(Albums.[Monitored] = 1) AND (Artists.[Monitored] = 1)"; } @@ -202,7 +202,7 @@ namespace NzbDrone.Core.Music { var monitored = 0; - if (pagingSpec.FilterExpression.ToString().Contains("True")) + if (pagingSpec.FilterExpressions.FirstOrDefault().ToString().Contains("True")) { monitored = 1; } diff --git a/src/NzbDrone.Core/Music/TrackRepository.cs b/src/NzbDrone.Core/Music/TrackRepository.cs index 1bb8531f9..421c9ce60 100644 --- a/src/NzbDrone.Core/Music/TrackRepository.cs +++ b/src/NzbDrone.Core/Music/TrackRepository.cs @@ -125,7 +125,7 @@ namespace NzbDrone.Core.Music private SortBuilder GetMissingTracksQuery(PagingSpec pagingSpec, DateTime currentTime) { return Query.Join(JoinType.Inner, e => e.Artist, (e, s) => e.ArtistId == s.Id) - .Where(pagingSpec.FilterExpression) + .Where(pagingSpec.FilterExpressions.FirstOrDefault()) .AndWhere(e => e.TrackFileId == 0) .AndWhere(BuildAirDateUtcCutoffWhereClause(currentTime)) .OrderBy(pagingSpec.OrderByClause(), pagingSpec.ToSortDirection()) @@ -138,7 +138,7 @@ namespace NzbDrone.Core.Music { return Query.Join(JoinType.Inner, e => e.Artist, (e, s) => e.ArtistId == s.Id) .Join(JoinType.Left, e => e.TrackFile, (e, s) => e.TrackFileId == s.Id) - .Where(pagingSpec.FilterExpression) + .Where(pagingSpec.FilterExpressions.FirstOrDefault()) .AndWhere(e => e.TrackFileId != 0) .AndWhere(BuildQualityCutoffWhereClause(qualitiesBelowCutoff)) .OrderBy(pagingSpec.OrderByClause(), pagingSpec.ToSortDirection()) diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index ee4ff843b..4113248e8 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -757,6 +757,7 @@ + diff --git a/yarn.lock b/yarn.lock index a189b4bb4..219aee99f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6063,10 +6063,6 @@ react-tabs@2.2.1: classnames "^2.2.0" prop-types "^15.5.0" -react-tag-autocomplete@5.5.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/react-tag-autocomplete/-/react-tag-autocomplete-5.5.0.tgz#49841388b88323f6bccb0c10039bd0252875b49f" - react-tether@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/react-tether/-/react-tether-0.6.1.tgz#de64dd81a5e40053a9f275c4fef0beb0877b3e4e"