New: Release Profiles, Frontend updates (#580)

* New: Release Profiles - UI Updates

* New: Release Profiles - API Changes

* New: Release Profiles - Test Updates

* New: Release Profiles - Backend Updates

* New: Interactive Artist Search

* New: Change Montiored on Album Details Page

* New: Show Duration on Album Details Page

* Fixed: Manual Import not working if no albums are Missing

* Fixed: Sort search input by sortTitle

* Fixed: Queue columnLabel throwing JS error
This commit is contained in:
Qstick
2019-02-23 17:39:11 -05:00
committed by GitHub
parent f126eafd26
commit 3f064c94b9
409 changed files with 6882 additions and 3176 deletions
@@ -3,3 +3,7 @@
justify-content: flex-end;
margin-bottom: 10px;
}
.filteredMessage {
margin-top: 10px;
}
@@ -0,0 +1,210 @@
import PropTypes from 'prop-types';
import React from 'react';
import { align, icons, sortDirections } from 'Helpers/Props';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Icon from 'Components/Icon';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageMenuButton from 'Components/Menu/PageMenuButton';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector';
import InteractiveSearchRow from './InteractiveSearchRow';
import styles from './InteractiveSearch.css';
const columns = [
{
name: 'protocol',
label: 'Source',
isSortable: true,
isVisible: true
},
{
name: 'age',
label: 'Age',
isSortable: true,
isVisible: true
},
{
name: 'title',
label: 'Title',
isSortable: true,
isVisible: true
},
{
name: 'indexer',
label: 'Indexer',
isSortable: true,
isVisible: true
},
{
name: 'size',
label: 'Size',
isSortable: true,
isVisible: true
},
{
name: 'peers',
label: 'Peers',
isSortable: true,
isVisible: true
},
{
name: 'languageWeight',
label: 'Language',
isSortable: true,
isVisible: true
},
{
name: 'qualityWeight',
label: 'Quality',
isSortable: true,
isVisible: true
},
{
name: 'preferredWordScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: 'Preferred word score'
}),
isSortable: true,
isVisible: true
},
{
name: 'rejections',
label: React.createElement(Icon, {
name: icons.DANGER,
title: 'Rejections'
}),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
},
{
name: 'releaseWeight',
label: React.createElement(Icon, { name: icons.DOWNLOAD }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
}
];
function InteractiveSearch(props) {
const {
searchPayload,
isFetching,
isPopulated,
error,
totalReleasesCount,
items,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
type,
longDateFormat,
timeFormat,
onSortPress,
onFilterSelect,
onGrabPress
} = props;
return (
<div>
<div className={styles.filterMenuContainer}>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
buttonComponent={PageMenuButton}
filterModalConnectorComponent={InteractiveSearchFilterModalConnector}
filterModalConnectorComponentProps={{ type }}
onFilterSelect={onFilterSelect}
/>
</div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>
Unable to load results for this album search. Try again later
</div>
}
{
!isFetching && isPopulated && !totalReleasesCount &&
<div>
No results found
</div>
}
{
!!totalReleasesCount && isPopulated && !items.length &&
<div>
All results are hidden by the applied filter
</div>
}
{
isPopulated && !!items.length &&
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{
items.map((item) => {
return (
<InteractiveSearchRow
key={item.guid}
{...item}
searchPayload={searchPayload}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
onGrabPress={onGrabPress}
/>
);
})
}
</TableBody>
</Table>
}
{
totalReleasesCount !== items.length && !!items.length &&
<div className={styles.filteredMessage}>
Some results are hidden by the applied filter
</div>
}
</div>
);
}
InteractiveSearch.propTypes = {
searchPayload: PropTypes.object.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
totalReleasesCount: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
type: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onSortPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onGrabPress: PropTypes.func.isRequired
};
export default InteractiveSearch;
@@ -3,14 +3,14 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as releaseActions from 'Store/Actions/releaseActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import InteractiveSearchModalContent from './InteractiveSearchModalContent';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import InteractiveSearch from './InteractiveSearch';
function createMapStateToProps() {
function createMapStateToProps(appState, { type }) {
return createSelector(
(state) => state.releases.items.length,
createClientSideCollectionSelector('releases'),
createClientSideCollectionSelector('releases', `releases.${type}`),
createUISettingsSelector(),
(totalReleasesCount, releases, uiSettings) => {
return {
@@ -25,16 +25,8 @@ function createMapStateToProps() {
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchReleases({ albumId }) {
dispatch(releaseActions.fetchReleases({ albumId }));
},
dispatchCancelFetchReleases() {
dispatch(releaseActions.cancelFetchReleases());
},
dispatchClearReleases() {
dispatch(releaseActions.clearReleases());
dispatchFetchReleases(payload) {
dispatch(releaseActions.fetchReleases(payload));
},
onSortPress(sortKey, sortDirection) {
@@ -42,33 +34,37 @@ function createMapDispatchToProps(dispatch, props) {
},
onFilterSelect(selectedFilterKey) {
dispatch(releaseActions.setReleasesFilter({ selectedFilterKey }));
const action = props.type === 'album' ?
releaseActions.setAlbumReleasesFilter :
releaseActions.setArtistReleasesFilter;
dispatch(action({ selectedFilterKey }));
},
onGrabPress(guid, indexerId) {
dispatch(releaseActions.grabRelease({ guid, indexerId }));
onGrabPress(payload) {
dispatch(releaseActions.grabRelease(payload));
}
};
}
class InteractiveSearchModalContentConnector extends Component {
class InteractiveSearchConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
albumId
searchPayload,
isPopulated,
dispatchFetchReleases
} = this.props;
this.props.dispatchFetchReleases({
albumId
});
}
// If search results are not yet isPopulated fetch them,
// otherwise re-show the existing props.
componentWillUnmount() {
this.props.dispatchCancelFetchReleases();
this.props.dispatchClearReleases();
if (!isPopulated) {
dispatchFetchReleases(searchPayload);
}
}
//
@@ -81,18 +77,18 @@ class InteractiveSearchModalContentConnector extends Component {
} = this.props;
return (
<InteractiveSearchModalContent
<InteractiveSearch
{...otherProps}
/>
);
}
}
InteractiveSearchModalContentConnector.propTypes = {
albumId: PropTypes.number,
dispatchFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired,
dispatchCancelFetchReleases: PropTypes.func.isRequired
InteractiveSearchConnector.propTypes = {
searchPayload: PropTypes.object.isRequired,
isPopulated: PropTypes.bool.isRequired,
dispatchFetchReleases: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchModalContentConnector);
export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector);
@@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setReleasesFilter } from 'Store/Actions/releaseActions';
import { setAlbumReleasesFilter, setArtistReleasesFilter } from 'Store/Actions/releaseActions';
import FilterModal from 'Components/Filter/FilterModal';
function createMapStateToProps() {
@@ -20,7 +20,9 @@ function createMapStateToProps() {
function createMapDispatchToProps(dispatch, props) {
return {
dispatchSetFilter(payload) {
const action = setReleasesFilter;
const action = props.type === 'album' ?
setAlbumReleasesFilter:
setArtistReleasesFilter;
dispatch(action(payload));
}
@@ -1,31 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import InteractiveSearchModalContentConnector from './InteractiveSearchModalContentConnector';
function InteractiveSearchModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<InteractiveSearchModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
InteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default InteractiveSearchModal;
@@ -1,217 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
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';
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 InteractiveSearchRow from './InteractiveSearchRow';
import styles from './InteractiveSearchModalContent.css';
const columns = [
{
name: 'protocol',
label: 'Source',
isSortable: true,
isVisible: true
},
{
name: 'age',
label: 'Age',
isSortable: true,
isVisible: true
},
{
name: 'title',
label: 'Title',
isSortable: true,
isVisible: true
},
{
name: 'indexer',
label: 'Indexer',
isSortable: true,
isVisible: true
},
{
name: 'size',
label: 'Size',
isSortable: true,
isVisible: true
},
{
name: 'peers',
label: 'Peers',
isSortable: true,
isVisible: true
},
{
name: 'qualityWeight',
label: 'Quality',
isSortable: true,
isVisible: true
},
{
name: 'rejections',
label: React.createElement(Icon, { name: icons.DANGER }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
},
{
name: 'releaseWeight',
label: React.createElement(Icon, { name: icons.DOWNLOAD }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
}
];
class InteractiveSearchModalContent extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
totalReleasesCount,
items,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
longDateFormat,
timeFormat,
onSortPress,
onFilterSelect,
onGrabPress,
onModalClose
} = this.props;
const hasItems = !!items.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Interactive Album Search
</ModalHeader>
<ModalBody>
{
<div>
<div className={styles.filterMenuContainer}>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
buttonComponent={PageMenuButton}
filterModalConnectorComponent={InteractiveSearchFilterModalConnector}
onFilterSelect={onFilterSelect}
/>
</div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>
Unable to load results for this album search. Try again later.
</div>
}
{
!isFetching && isPopulated && !totalReleasesCount &&
<div>
No results found.
</div>
}
{
!!totalReleasesCount && isPopulated && !items.length &&
<div>
All results are hidden by {filters.length > 1 ? 'filters' : 'a filter'}.
</div>
}
{
!!items.length &&
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
>
<TableBody>
{
items.map((item) => {
return (
<InteractiveSearchRow
key={item.guid}
{...item}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
onGrabPress={onGrabPress}
/>
);
})
}
</TableBody>
</Table>
}
{
totalReleasesCount !== items.length && !!items.length &&
<div>
Some results are hidden by {filters.length > 1 ? 'filters' : 'a filter'}.
</div>
}
</div>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
InteractiveSearchModalContent.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.oneOfType([PropTypes.string, PropTypes.number]).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
};
export default InteractiveSearchModalContent;
@@ -4,12 +4,25 @@
word-break: break-all;
}
.quality {
.quality,
.language {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
text-align: center;
}
.language {
width: 100px;
}
.preferredWordScore {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 55px;
font-weight: bold;
cursor: default;
}
.rejected,
.download {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
@@ -7,9 +7,11 @@ import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Popover from 'Components/Tooltip/Popover';
import TrackLanguage from 'Album/TrackLanguage';
import TrackQuality from 'Album/TrackQuality';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import Peers from './Peers';
@@ -41,6 +43,17 @@ function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
class InteractiveSearchRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isConfirmGrabModalOpen: false
};
}
//
// Listeners
@@ -49,9 +62,37 @@ class InteractiveSearchRow extends Component {
guid,
indexerId,
onGrabPress
}= this.props;
} = this.props;
onGrabPress(guid, indexerId);
onGrabPress({
guid,
indexerId
});
}
onConfirmGrabPress = () => {
this.setState({ isConfirmGrabModalOpen: true });
}
onGrabConfirm = () => {
this.setState({ isConfirmGrabModalOpen: false });
const {
guid,
indexerId,
searchPayload,
onGrabPress
} = this.props;
onGrabPress({
guid,
indexerId,
...searchPayload
});
}
onGrabCancel = () => {
this.setState({ isConfirmGrabModalOpen: false });
}
//
@@ -71,6 +112,8 @@ class InteractiveSearchRow extends Component {
seeders,
leechers,
quality,
language,
preferredWordScore,
rejections,
downloadAllowed,
isGrabbing,
@@ -119,10 +162,17 @@ class InteractiveSearchRow extends Component {
}
</TableRowCell>
<TableRowCell className={styles.language}>
<TrackLanguage language={language} />
</TableRowCell>
<TableRowCell className={styles.quality}>
<TrackQuality
quality={quality}
/>
<TrackQuality quality={quality} />
</TableRowCell>
<TableRowCell className={styles.preferredWordScore}>
{preferredWordScore > 0 && `+${preferredWordScore}`}
{preferredWordScore < 0 && preferredWordScore}
</TableRowCell>
<TableRowCell className={styles.rejected}>
@@ -161,10 +211,20 @@ class InteractiveSearchRow extends Component {
kind={grabError || !downloadAllowed ? kinds.DANGER : kinds.DEFAULT}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isSpinning={isGrabbing}
onPress={this.onGrabPress}
onPress={downloadAllowed ? this.onGrabPress : this.onConfirmGrabPress}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={this.state.isConfirmGrabModalOpen}
kind={kinds.WARNING}
title="Grab Release"
message={`Lidarr was unable to determine which artist and album this release was for. Lidarr may be unable to automatically import this release. Do you want to grab '${title}'?`}
confirmLabel="Grab"
onConfirm={this.onGrabConfirm}
onCancel={this.onGrabCancel}
/>
</TableRow>
);
}
@@ -185,6 +245,8 @@ InteractiveSearchRow.propTypes = {
seeders: PropTypes.number,
leechers: PropTypes.number,
quality: PropTypes.object.isRequired,
language: PropTypes.object.isRequired,
preferredWordScore: PropTypes.number.isRequired,
rejections: PropTypes.arrayOf(PropTypes.string).isRequired,
downloadAllowed: PropTypes.bool.isRequired,
isGrabbing: PropTypes.bool.isRequired,
@@ -192,10 +254,12 @@ InteractiveSearchRow.propTypes = {
grabError: PropTypes.string,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
searchPayload: PropTypes.object.isRequired,
onGrabPress: PropTypes.func.isRequired
};
InteractiveSearchRow.defaultProps = {
rejections: [],
isGrabbing: false,
isGrabbed: false
};