New: Artist History Modal in Artist Details Page

This commit is contained in:
Qstick 2017-12-13 22:38:27 -05:00
parent 0981260887
commit 7e4a8c8ff7
17 changed files with 659 additions and 96 deletions

View File

@ -1,3 +0,0 @@
.absoluteEpisodeNumber {
margin-left: 5px;
}

View File

@ -1,15 +1,12 @@
import PropTypes from 'prop-types';
import React from 'react';
import padNumber from 'Utilities/Number/padNumber';
import styles from './SeasonEpisodeNumber.css';
import EpisodeNumber from './EpisodeNumber';
function SeasonEpisodeNumber(props) {
const {
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
airDate,
artistType
artistType,
...otherProps
} = props;
if (artistType === 'daily' && airDate) {
@ -18,32 +15,16 @@ function SeasonEpisodeNumber(props) {
);
}
if (artistType === 'anime') {
return (
<span>
{seasonNumber}x{padNumber(episodeNumber, 2)}
{
absoluteEpisodeNumber &&
<span className={styles.absoluteEpisodeNumber}>
({absoluteEpisodeNumber})
</span>
}
</span>
);
}
return (
<span>
{seasonNumber}x{padNumber(episodeNumber, 2)}
</span>
<EpisodeNumber
seriesType={artistType}
showSeasonNumber={true}
{...otherProps}
/>
);
}
SeasonEpisodeNumber.propTypes = {
seasonNumber: PropTypes.number.isRequired,
episodeNumber: PropTypes.number.isRequired,
absoluteEpisodeNumber: PropTypes.number,
airDate: PropTypes.string,
artistType: PropTypes.string
};

View File

@ -26,6 +26,7 @@ import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfil
import ArtistPoster from 'Artist/ArtistPoster';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal';
import ArtistAlternateTitles from './ArtistAlternateTitles';
import ArtistDetailsSeasonConnector from './ArtistDetailsSeasonConnector';
import ArtistTagsConnector from './ArtistTagsConnector';
@ -92,6 +93,7 @@ class ArtistDetails extends Component {
isManageEpisodesOpen: false,
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false,
isArtistHistoryModalOpen: false,
allExpanded: false,
allCollapsed: false,
expandedState: {}
@ -136,6 +138,14 @@ class ArtistDetails extends Component {
this.setState({ isDeleteArtistModalOpen: false });
}
onArtistHistoryPress = () => {
this.setState({ isArtistHistoryModalOpen: true });
}
onArtistHistoryModalClose = () => {
this.setState({ isArtistHistoryModalOpen: false });
}
onExpandAllPress = () => {
const {
allExpanded,
@ -197,6 +207,7 @@ class ArtistDetails extends Component {
isManageEpisodesOpen,
isEditArtistModalOpen,
isDeleteArtistModalOpen,
isArtistHistoryModalOpen,
allExpanded,
allCollapsed,
expandedState
@ -254,6 +265,12 @@ class ArtistDetails extends Component {
onPress={this.onManageEpisodesPress}
/>
<PageToolbarButton
label="History"
iconName={icons.HISTORY}
onPress={this.onArtistHistoryPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
@ -540,6 +557,12 @@ class ArtistDetails extends Component {
onModalClose={this.onManageEpisodesModalClose}
/>
<ArtistHistoryModal
isOpen={isArtistHistoryModalOpen}
artistId={id}
onModalClose={this.onArtistHistoryModalClose}
/>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={id}

View File

@ -171,71 +171,6 @@ class ArtistDetailsSeason extends Component {
}
</Link>
{
isSmallScreen ?
<Menu
className={styles.actionsMenu}
alignMenu={align.RIGHT}
enforceMaxHeight={false}
>
<MenuButton>
<Icon
name={icons.ACTIONS}
size={22}
/>
</MenuButton>
<MenuContent className={styles.actionsMenuContent}>
<MenuItem
isDisabled={isSearching}
onPress={onSearchPress}
>
<SpinnerIcon
className={styles.actionMenuIcon}
name={icons.SEARCH}
isSpinning={isSearching}
/>
Search
</MenuItem>
<MenuItem
onPress={this.onOrganizePress}
>
<Icon
className={styles.actionMenuIcon}
name={icons.ORGANIZE}
/>
Preview Rename
</MenuItem>
<MenuItem
onPress={this.onManageEpisodesPress}
>
<Icon
className={styles.actionMenuIcon}
name={icons.EPISODE_FILE}
/>
Manage Tracks
</MenuItem>
</MenuContent>
</Menu> :
<div className={styles.actions}>
<SpinnerIconButton
className={styles.actionButton}
name={icons.SEARCH}
title="Search for album"
size={24}
isSpinning={isSearching}
onPress={onSearchPress}
/>
</div>
}
</div>
<div>

View File

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ArtistHistoryModalContentConnector from './ArtistHistoryModalContentConnector';
function ArtistHistoryModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ArtistHistoryModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
ArtistHistoryModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistHistoryModal;

View File

@ -0,0 +1,132 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import ArtistHistoryRowConnector from './ArtistHistoryRowConnector';
const columns = [
{
name: 'eventType',
isVisible: true
},
{
name: 'album',
label: 'Album',
isVisible: true
},
{
name: 'sourceTitle',
label: 'Source Title',
isVisible: true
},
{
name: 'quality',
label: 'Quality',
isVisible: true
},
{
name: 'date',
label: 'Date',
isVisible: true
},
{
name: 'details',
label: 'Details',
isVisible: true
},
{
name: 'actions',
label: 'Actions',
isVisible: true
}
];
class ArtistHistoryModalContent extends Component {
//
// Render
render() {
const {
albumId,
isFetching,
isPopulated,
error,
items,
onMarkAsFailedPress,
onModalClose
} = this.props;
const fullArtist = albumId == null;
const hasItems = !!items.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
History
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to load history.</div>
}
{
isPopulated && !hasItems && !error &&
<div>No history.</div>
}
{
isPopulated && hasItems && !error &&
<Table columns={columns}>
<TableBody>
{
items.map((item) => {
return (
<ArtistHistoryRowConnector
key={item.id}
fullArtist={fullArtist}
{...item}
onMarkAsFailedPress={onMarkAsFailedPress}
/>
);
})
}
</TableBody>
</Table>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
ArtistHistoryModalContent.propTypes = {
albumId: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistHistoryModalContent;

View File

@ -0,0 +1,81 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchArtistHistory, clearArtistHistory, artistHistoryMarkAsFailed } from 'Store/Actions/artistHistoryActions';
import ArtistHistoryModalContent from './ArtistHistoryModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.artistHistory,
(artistHistory) => {
return artistHistory;
}
);
}
const mapDispatchToProps = {
fetchArtistHistory,
clearArtistHistory,
artistHistoryMarkAsFailed
};
class ArtistHistoryModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
artistId,
albumId
} = this.props;
this.props.fetchArtistHistory({
artistId,
albumId
});
}
componentWillUnmount() {
this.props.clearArtistHistory();
}
//
// Listeners
onMarkAsFailedPress = (historyId) => {
const {
artistId,
albumId
} = this.props;
this.props.artistHistoryMarkAsFailed({
historyId,
artistId,
albumId
});
}
//
// Render
render() {
return (
<ArtistHistoryModalContent
{...this.props}
onMarkAsFailedPress={this.onMarkAsFailedPress}
/>
);
}
}
ArtistHistoryModalContentConnector.propTypes = {
artistId: PropTypes.number.isRequired,
albumId: PropTypes.number,
fetchArtistHistory: PropTypes.func.isRequired,
clearArtistHistory: PropTypes.func.isRequired,
artistHistoryMarkAsFailed: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ArtistHistoryModalContentConnector);

View File

@ -0,0 +1,6 @@
.details,
.actions {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 65px;
}

View File

@ -0,0 +1,160 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Popover from 'Components/Tooltip/Popover';
import EpisodeQuality from 'Album/EpisodeQuality';
import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import styles from './ArtistHistoryRow.css';
function getTitle(eventType) {
switch (eventType) {
case 'grabbed': return 'Grabbed';
case 'artistFolderImported': return 'Artist Folder Imported';
case 'downloadFolderImported': return 'Download Folder Imported';
case 'downloadFailed': return 'Download Failed';
case 'trackFileDeleted': return 'Track File Deleted';
case 'trackFileRenamed': return 'Track File Renamed';
default: return 'Unknown';
}
}
class ArtistHistoryRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isMarkAsFailedModalOpen: false
};
}
//
// Listeners
onMarkAsFailedPress = () => {
this.setState({ isMarkAsFailedModalOpen: true });
}
onConfirmMarkAsFailed = () => {
this.props.onMarkAsFailedPress(this.props.id);
this.setState({ isMarkAsFailedModalOpen: false });
}
onMarkAsFailedModalClose = () => {
this.setState({ isMarkAsFailedModalOpen: false });
}
//
// Render
render() {
const {
eventType,
sourceTitle,
quality,
qualityCutoffNotMet,
date,
data,
fullArtist,
artist,
album
} = this.props;
const {
isMarkAsFailedModalOpen
} = this.state;
return (
<TableRow>
<HistoryEventTypeCell
eventType={eventType}
data={data}
/>
<TableRowCell key={name}>
{album.title}
</TableRowCell>
<TableRowCell>
{sourceTitle}
</TableRowCell>
<TableRowCell>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
<RelativeDateCellConnector
date={date}
/>
<TableRowCell className={styles.details}>
<Popover
anchor={
<Icon
name={icons.INFO}
/>
}
title={getTitle(eventType)}
body={
<HistoryDetailsConnector
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
/>
}
position={tooltipPositions.LEFT}
/>
</TableRowCell>
<TableRowCell className={styles.actions}>
{
eventType === 'grabbed' &&
<IconButton
name={icons.REMOVE}
onPress={this.onMarkAsFailedPress}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={isMarkAsFailedModalOpen}
kind={kinds.DANGER}
title="Mark as Failed"
message={`Are you sure you want to mark '${sourceTitle}' as failed?`}
confirmLabel="Mark as Failed"
onConfirm={this.onConfirmMarkAsFailed}
onCancel={this.onMarkAsFailedModalClose}
/>
</TableRow>
);
}
}
ArtistHistoryRow.propTypes = {
id: PropTypes.number.isRequired,
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
quality: PropTypes.object.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired,
date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
fullArtist: PropTypes.bool.isRequired,
artist: PropTypes.object.isRequired,
album: PropTypes.object.isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired
};
export default ArtistHistoryRow;

View File

@ -0,0 +1,26 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import ArtistHistoryRow from './ArtistHistoryRow';
function createMapStateToProps() {
return createSelector(
createArtistSelector(),
createEpisodeSelector(),
(artist, album) => {
return {
artist,
album
};
}
);
}
const mapDispatchToProps = {
fetchHistory,
markAsFailed
};
export default connect(createMapStateToProps, mapDispatchToProps)(ArtistHistoryRow);

View File

@ -39,6 +39,7 @@ export const FOLDER_OPEN = 'fa fa-folder-open';
export const GROUP = 'fa fa-object-group';
export const HEALTH = 'fa fa-medkit';
export const HEART = 'fa fa-heart';
export const HISTORY = 'fa fa-history';
export const HOUSEKEEPING = 'fa fa-home';
export const INFO = 'fa fa-info-circle';
export const INTERACTIVE = 'fa fa-user';

View File

@ -0,0 +1,104 @@
import $ from 'jquery';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { createThunk, handleThunks } from 'Store/thunks';
import createHandleActions from './Creators/createHandleActions';
import { set, update } from './baseActions';
//
// Variables
export const section = 'artistHistory';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
items: []
};
//
// Actions Types
export const FETCH_ARTIST_HISTORY = 'artistHistory/fetchArtistHistory';
export const CLEAR_ARTIST_HISTORY = 'artistHistory/clearArtistHistory';
export const ARTIST_HISTORY_MARK_AS_FAILED = 'artistHistory/artistHistoryMarkAsFailed';
//
// Action Creators
export const fetchArtistHistory = createThunk(FETCH_ARTIST_HISTORY);
export const clearArtistHistory = createAction(CLEAR_ARTIST_HISTORY);
export const artistHistoryMarkAsFailed = createThunk(ARTIST_HISTORY_MARK_AS_FAILED);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_ARTIST_HISTORY]: function(getState, payload, dispatch) {
dispatch(set({ section, isFetching: true }));
const promise = $.ajax({
url: '/history/artist',
data: payload
});
promise.done((data) => {
dispatch(batchActions([
update({ section, data }),
set({
section,
isFetching: false,
isPopulated: true,
error: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isFetching: false,
isPopulated: false,
error: xhr
}));
});
},
[ARTIST_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) {
const {
historyId,
artistId,
albumId
} = payload;
const promise = $.ajax({
url: '/history/failed',
method: 'POST',
data: {
id: historyId
}
});
promise.done(() => {
dispatch(fetchArtistHistory({ artistId, albumId }));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[CLEAR_ARTIST_HISTORY]: (state) => {
return Object.assign({}, state, defaultState);
}
}, defaultState, section);

View File

@ -19,6 +19,7 @@ import * as rootFolders from './rootFolderActions';
import * as albumStudio from './albumStudioActions';
import * as artist from './artistActions';
import * as artistEditor from './artistEditorActions';
import * as artistHistory from './artistHistoryActions';
import * as artistIndex from './artistIndexActions';
import * as settings from './settingsActions';
import * as system from './systemActions';
@ -48,6 +49,7 @@ export default [
albumStudio,
artist,
artistEditor,
artistHistory,
artistIndex,
settings,
system,

View File

@ -31,6 +31,12 @@ export const defaultState = {
recentFolders: [],
importMode: 'move',
sortPredicates: {
relativePath: function(item, direction) {
const relativePath = item.relativePath;
return relativePath.toLowerCase();
},
artist: function(item, direction) {
const artist = item.artist;

View File

@ -31,6 +31,7 @@ namespace Lidarr.Api.V1.History
GetResourcePaged = GetHistory;
Get["/since"] = x => GetHistorySince();
Get["/artist"] = x => GetArtistHistory();
Post["/failed"] = x => MarkAsFailed();
}
@ -109,6 +110,38 @@ namespace Lidarr.Api.V1.History
return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeArtist, includeAlbum, includeTrack)).ToList();
}
private List<HistoryResource> GetArtistHistory()
{
var queryArtistId = Request.Query.ArtistId;
var queryAlbumId = Request.Query.AlbumId;
var queryEventType = Request.Query.EventType;
if (!queryArtistId.HasValue)
{
throw new BadRequestException("artistId is missing");
}
int artistId = Convert.ToInt32(queryArtistId.Value);
HistoryEventType? eventType = null;
var includeArtist = Request.GetBooleanQueryParameter("includeArtist");
var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum");
var includeTrack = Request.GetBooleanQueryParameter("includeTrack");
if (queryEventType.HasValue)
{
eventType = (HistoryEventType)Convert.ToInt32(queryEventType.Value);
}
if (queryAlbumId.HasValue)
{
int albumId = Convert.ToInt32(queryAlbumId.Value);
return _historyService.GetByAlbum(artistId, albumId, eventType).Select(h => MapToResource(h, includeArtist, includeAlbum, includeTrack)).ToList();
}
return _historyService.GetByArtist(artistId, eventType).Select(h => MapToResource(h, includeArtist, includeAlbum, includeTrack)).ToList();
}
private Response MarkAsFailed()
{
var id = (int)Request.Form.Id;

View File

@ -14,6 +14,8 @@ namespace NzbDrone.Core.History
History MostRecentForAlbum(int albumId);
History MostRecentForDownloadId(string downloadId);
List<History> FindByDownloadId(string downloadId);
List<History> GetByArtist(int artistId, HistoryEventType? eventType);
List<History> GetByAlbum(int artistId, int albumId, HistoryEventType? eventType);
List<History> FindDownloadHistory(int idArtistId, QualityModel quality);
void DeleteForArtist(int artistId);
List<History> Since(DateTime date, HistoryEventType? eventType);
@ -48,6 +50,36 @@ namespace NzbDrone.Core.History
return Query.Where(h => h.DownloadId == downloadId);
}
public List<History> GetByArtist(int artistId, HistoryEventType? eventType)
{
var query = Query.Where(h => h.ArtistId == artistId);
if (eventType.HasValue)
{
query.AndWhere(h => h.EventType == eventType);
}
query.OrderByDescending(h => h.Date);
return query;
}
public List<History> GetByAlbum(int artistId, int albumId, HistoryEventType? eventType)
{
var query = Query.Join<History, Album>(JoinType.Inner, h => h.Album, (h, e) => h.AlbumId == e.Id)
.Where(h => h.ArtistId == artistId)
.AndWhere(h => h.AlbumId == albumId);
if (eventType.HasValue)
{
query.AndWhere(h => h.EventType == eventType);
}
query.OrderByDescending(h => h.Date);
return query;
}
public List<History> FindDownloadHistory(int idArtistId, QualityModel quality)
{
return Query.Where(h =>

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Marr.Data.QGen;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore;
@ -13,7 +14,7 @@ using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Profiles.Qualities;
using NzbDrone.Core.Profiles.Languages;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Music;
using NzbDrone.Core.Music.Events;
namespace NzbDrone.Core.History
@ -24,6 +25,8 @@ namespace NzbDrone.Core.History
History MostRecentForAlbum(int episodeId);
History MostRecentForDownloadId(string downloadId);
History Get(int historyId);
List<History> GetByArtist(int artistId, HistoryEventType? eventType);
List<History> GetByAlbum(int artistId, int albumId, HistoryEventType? eventType);
List<History> Find(string downloadId, HistoryEventType eventType);
List<History> FindByDownloadId(string downloadId);
List<History> Since(DateTime date, HistoryEventType? eventType);
@ -66,6 +69,16 @@ namespace NzbDrone.Core.History
return _historyRepository.Get(historyId);
}
public List<History> GetByArtist(int artistId, HistoryEventType? eventType)
{
return _historyRepository.GetByArtist(artistId, eventType);
}
public List<History> GetByAlbum(int artistId, int albumId, HistoryEventType? eventType)
{
return _historyRepository.GetByAlbum(artistId, albumId, eventType);
}
public List<History> Find(string downloadId, HistoryEventType eventType)
{
return _historyRepository.FindByDownloadId(downloadId).Where(c => c.EventType == eventType).ToList();