diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 04f7a609f..a8850b16f 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,5 +1,6 @@ import AlbumAppState from './AlbumAppState'; import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState'; +import CalendarAppState from './CalendarAppState'; import HistoryAppState from './HistoryAppState'; import QueueAppState from './QueueAppState'; import SettingsAppState from './SettingsAppState'; @@ -52,6 +53,7 @@ interface AppState { app: AppSectionState; artist: ArtistAppState; artistIndex: ArtistIndexAppState; + calendar: CalendarAppState; history: HistoryAppState; queue: QueueAppState; settings: SettingsAppState; diff --git a/frontend/src/App/State/CalendarAppState.ts b/frontend/src/App/State/CalendarAppState.ts new file mode 100644 index 000000000..503d2c25b --- /dev/null +++ b/frontend/src/App/State/CalendarAppState.ts @@ -0,0 +1,10 @@ +import Album from 'Album/Album'; +import AppSectionState, { + AppSectionFilterState, +} from 'App/State/AppSectionState'; + +interface CalendarAppState + extends AppSectionState, + AppSectionFilterState {} + +export default CalendarAppState; diff --git a/frontend/src/Calendar/CalendarFilterModal.tsx b/frontend/src/Calendar/CalendarFilterModal.tsx new file mode 100644 index 000000000..e26b2928b --- /dev/null +++ b/frontend/src/Calendar/CalendarFilterModal.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setCalendarFilter } from 'Store/Actions/calendarActions'; + +function createCalendarSelector() { + return createSelector( + (state: AppState) => state.calendar.items, + (calendar) => { + return calendar; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.calendar.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface CalendarFilterModalProps { + isOpen: boolean; +} + +export default function CalendarFilterModal(props: CalendarFilterModalProps) { + const sectionItems = useSelector(createCalendarSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'calendar'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setCalendarFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js index 3a4603e82..bf7f46c10 100644 --- a/frontend/src/Calendar/CalendarPage.js +++ b/frontend/src/Calendar/CalendarPage.js @@ -14,6 +14,7 @@ import { align, icons } from 'Helpers/Props'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; import translate from 'Utilities/String/translate'; import CalendarConnector from './CalendarConnector'; +import CalendarFilterModal from './CalendarFilterModal'; import CalendarLinkModal from './iCal/CalendarLinkModal'; import LegendConnector from './Legend/LegendConnector'; import CalendarOptionsModal from './Options/CalendarOptionsModal'; @@ -78,6 +79,7 @@ class CalendarPage extends Component { const { selectedFilterKey, filters, + customFilters, hasArtist, artistError, artistIsFetching, @@ -137,7 +139,8 @@ class CalendarPage extends Component { isDisabled={!hasArtist} selectedFilterKey={selectedFilterKey} filters={filters} - customFilters={[]} + customFilters={customFilters} + filterModalConnectorComponent={CalendarFilterModal} onFilterSelect={onFilterSelect} /> @@ -204,6 +207,7 @@ class CalendarPage extends Component { CalendarPage.propTypes = { selectedFilterKey: PropTypes.string.isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, hasArtist: PropTypes.bool.isRequired, artistError: PropTypes.object, artistIsFetching: PropTypes.bool.isRequired, diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js index d0e7e87af..03ae0ad64 100644 --- a/frontend/src/Calendar/CalendarPageConnector.js +++ b/frontend/src/Calendar/CalendarPageConnector.js @@ -5,6 +5,7 @@ import * as commandNames from 'Commands/commandNames'; import withCurrentPage from 'Components/withCurrentPage'; import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; import { executeCommand } from 'Store/Actions/commandActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import createArtistCountSelector from 'Store/Selectors/createArtistCountSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; @@ -59,6 +60,7 @@ function createMapStateToProps() { return createSelector( (state) => state.calendar.selectedFilterKey, (state) => state.calendar.filters, + createCustomFiltersSelector('calendar'), createArtistCountSelector(), createUISettingsSelector(), createMissingAlbumIdsSelector(), @@ -67,6 +69,7 @@ function createMapStateToProps() { ( selectedFilterKey, filters, + customFilters, artistCount, uiSettings, missingAlbumIds, @@ -76,6 +79,7 @@ function createMapStateToProps() { return { selectedFilterKey, filters, + customFilters, colorImpairedMode: uiSettings.enableColorImpairedMode, hasArtist: !!artistCount.count, artistError: artistCount.error, diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js index d473f1368..e13ff4672 100644 --- a/frontend/src/Store/Actions/calendarActions.js +++ b/frontend/src/Store/Actions/calendarActions.js @@ -4,9 +4,10 @@ import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; import * as calendarViews from 'Calendar/calendarViews'; import * as commandNames from 'Commands/commandNames'; -import { filterTypes } from 'Helpers/Props'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; import translate from 'Utilities/String/translate'; import { set, update } from './baseActions'; import { executeCommandHelper } from './commandActions'; @@ -54,8 +55,8 @@ export const defaultState = { label: () => translate('All'), filters: [ { - key: 'monitored', - value: false, + key: 'unmonitored', + value: [true], type: filterTypes.EQUAL } ] @@ -65,19 +66,35 @@ export const defaultState = { label: () => translate('MonitoredOnly'), filters: [ { - key: 'monitored', - value: true, + key: 'unmonitored', + value: [false], type: filterTypes.EQUAL } ] } + ], + + filterBuilderProps: [ + { + name: 'unmonitored', + label: () => translate('IncludeUnmonitored'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'tags', + label: () => translate('Tags'), + type: filterBuilderTypes.CONTAINS, + valueType: filterBuilderValueTypes.TAG + } ] }; export const persistState = [ 'calendar.view', 'calendar.selectedFilterKey', - 'calendar.options' + 'calendar.options', + 'calendar.customFilters' ]; // @@ -189,6 +206,10 @@ function isRangePopulated(start, end, state) { return false; } +function getCustomFilters(state, type) { + return state.customFilters.items.filter((customFilter) => customFilter.type === type); +} + // // Action Creators @@ -210,7 +231,8 @@ export const actionHandlers = handleThunks({ [FETCH_CALENDAR]: function(getState, payload, dispatch) { const state = getState(); const calendar = state.calendar; - const unmonitored = calendar.selectedFilterKey === 'all'; + const customFilters = getCustomFilters(state, section); + const selectedFilters = findSelectedFilters(calendar.selectedFilterKey, calendar.filters, customFilters); const { time = calendar.time, @@ -237,13 +259,26 @@ export const actionHandlers = handleThunks({ dispatch(set(attrs)); + const requestParams = { + start, + end + }; + + selectedFilters.forEach((selectedFilter) => { + if (selectedFilter.key === 'unmonitored') { + requestParams.unmonitored = selectedFilter.value.includes(true); + } + + if (selectedFilter.key === 'tags') { + requestParams.tags = selectedFilter.value.join(','); + } + }); + + requestParams.unmonitored = requestParams.unmonitored ?? false; + const promise = createAjaxRequest({ url: '/calendar', - data: { - unmonitored, - start, - end - } + data: requestParams }).request; promise.done((data) => { diff --git a/src/Lidarr.Api.V1/Calendar/CalendarController.cs b/src/Lidarr.Api.V1/Calendar/CalendarController.cs index d499607be..ee88c5285 100644 --- a/src/Lidarr.Api.V1/Calendar/CalendarController.cs +++ b/src/Lidarr.Api.V1/Calendar/CalendarController.cs @@ -5,10 +5,12 @@ using Lidarr.Api.V1.Albums; using Lidarr.Http; using Lidarr.Http.Extensions; using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; using NzbDrone.Core.ArtistStats; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.MediaCover; using NzbDrone.Core.Music; +using NzbDrone.Core.Tags; using NzbDrone.SignalR; namespace Lidarr.Api.V1.Calendar @@ -16,25 +18,59 @@ namespace Lidarr.Api.V1.Calendar [V1ApiController] public class CalendarController : AlbumControllerWithSignalR { + private readonly IArtistService _artistService; + private readonly ITagService _tagService; + public CalendarController(IAlbumService albumService, + IArtistService artistService, IArtistStatisticsService artistStatisticsService, IMapCoversToLocal coverMapper, IUpgradableSpecification upgradableSpecification, + ITagService tagService, IBroadcastSignalRMessage signalRBroadcaster) : base(albumService, artistStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster) { + _artistService = artistService; + _tagService = tagService; } [HttpGet] - public List GetCalendar(DateTime? start, DateTime? end, bool unmonitored = false, bool includeArtist = false) + [Produces("application/json")] + public List GetCalendar(DateTime? start, DateTime? end, bool unmonitored = false, bool includeArtist = false, string tags = "") { // TODO: Add Album Image support to AlbumControllerWithSignalR var includeAlbumImages = Request.GetBooleanQueryParameter("includeAlbumImages"); var startUse = start ?? DateTime.Today; var endUse = end ?? DateTime.Today.AddDays(2); + var albums = _albumService.AlbumsBetweenDates(startUse, endUse, unmonitored); + var allArtists = _artistService.GetAllArtists(); + var parsedTags = new List(); + var result = new List(); - var resources = MapToResource(_albumService.AlbumsBetweenDates(startUse, endUse, unmonitored), includeArtist); + if (tags.IsNotNullOrWhiteSpace()) + { + parsedTags.AddRange(tags.Split(',').Select(_tagService.GetTag).Select(t => t.Id)); + } + + foreach (var album in albums) + { + var artist = allArtists.SingleOrDefault(s => s.Id == album.ArtistId); + + if (artist == null) + { + continue; + } + + if (parsedTags.Any() && parsedTags.None(artist.Tags.Contains)) + { + continue; + } + + result.Add(album); + } + + var resources = MapToResource(result, includeArtist); return resources.OrderBy(e => e.ReleaseDate).ToList(); }