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
pull/646/head
Qstick 6 years ago committed by GitHub
parent f126eafd26
commit 3f064c94b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -177,7 +177,7 @@
"no-undef": "error",
"no-undef-init": "off",
"no-undefined": "off",
"no-unused-vars": ["warn", { "args": "none" }],
"no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": true }],
"no-use-before-define": "error",
# Node.js and CommonJS
@ -205,14 +205,13 @@
"func-style": ["error", "declaration"],
"indent": ["error", 2, {"SwitchCase": 1}],
"key-spacing": ["error", {"beforeColon": false, "afterColon": true}],
"keyword-spacing": ["error", {before: true, after: true}],
"keyword-spacing": ["error", { "before": true, "after": true}],
"lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }],
"max-depth": ["error", {"maximum": 5}],
"max-nested-callbacks": ["error", 4],
"max-params": ["error", 7],
"max-statements": "off",
"max-statements-per-line": ["error", { "max": 1 }],
"new-cap": ["error", {"capIsNewExceptions": ["$.Deferred"]}],
"new-cap": ["error", {"capIsNewExceptions": ["$.Deferred", "DragDropContext", "DragLayer", "DragSource", "DropTarget"]}],
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
@ -223,7 +222,7 @@
"no-inline-comments": "off",
"no-lonely-if": "warn",
"no-mixed-spaces-and-tabs": "error",
"no-multiple-empty-lines": ["error", {max: 1}],
"no-multiple-empty-lines": ["error", { "max": 1 }],
"no-negated-condition": "warn",
"no-nested-ternary": "error",
"no-new-object": "error",

@ -14,6 +14,7 @@ module.exports = (ctx, configPath, options) => {
return Object.assign(acc, reload(vars));
}, {})
},
'postcss-color-function': {},
'postcss-nested': {},
autoprefixer: {
browsers: [

@ -1,9 +1,10 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import { align, icons } from 'Helpers/Props';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
@ -41,6 +42,18 @@ class Blacklist extends Component {
onPress={onClearBlacklistPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label="Options"
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector>

@ -3,6 +3,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import withCurrentPage from 'Components/withCurrentPage';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import * as blacklistActions from 'Store/Actions/blacklistActions';
import { executeCommand } from 'Store/Actions/commandActions';
@ -33,8 +34,19 @@ class BlacklistConnector extends Component {
// Lifecycle
componentDidMount() {
const {
useCurrentPage,
fetchBlacklist,
gotoBlacklistFirstPage
} = this.props;
registerPagePopulator(this.repopulate);
this.props.gotoBlacklistFirstPage();
if (useCurrentPage) {
fetchBlacklist();
} else {
gotoBlacklistFirstPage();
}
}
componentDidUpdate(prevProps) {
@ -44,6 +56,7 @@ class BlacklistConnector extends Component {
}
componentWillUnmount() {
this.props.clearBlacklist();
unregisterPagePopulator(this.repopulate);
}
@ -53,7 +66,6 @@ class BlacklistConnector extends Component {
repopulate = () => {
this.props.fetchBlacklist();
}
//
// Listeners
@ -93,6 +105,14 @@ class BlacklistConnector extends Component {
this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST });
}
onTableOptionChange = (payload) => {
this.props.setBlacklistTableOption(payload);
if (payload.pageSize) {
this.props.gotoBlacklistFirstPage();
}
}
//
// Render
@ -114,6 +134,7 @@ class BlacklistConnector extends Component {
}
BlacklistConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
isClearingBlacklistExecuting: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchBlacklist: PropTypes.func.isRequired,
@ -124,7 +145,10 @@ BlacklistConnector.propTypes = {
gotoBlacklistPage: PropTypes.func.isRequired,
setBlacklistSort: PropTypes.func.isRequired,
setBlacklistTableOption: PropTypes.func.isRequired,
clearBlacklist: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector);
export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector)
);

@ -5,7 +5,7 @@ import IconButton from 'Components/Link/IconButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import EpisodeLanguage from 'Album/EpisodeLanguage';
import TrackLanguage from 'Album/TrackLanguage';
import TrackQuality from 'Album/TrackQuality';
import ArtistNameLink from 'Artist/ArtistNameLink';
import BlacklistDetailsModal from './BlacklistDetailsModal';
@ -90,7 +90,7 @@ class BlacklistRow extends Component {
key={name}
className={styles.language}
>
<EpisodeLanguage
<TrackLanguage
language={language}
/>
</TableRowCell>

@ -0,0 +1,5 @@
.description {
composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css';
overflow-wrap: break-word;
}

@ -7,6 +7,7 @@ import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import styles from './HistoryDetails.css';
function getDetailedList(statusMessages) {
return (
@ -60,6 +61,7 @@ function HistoryDetails(props) {
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title="Name"
data={sourceTitle}
/>
@ -75,6 +77,7 @@ function HistoryDetails(props) {
{
!!releaseGroup &&
<DescriptionListItem
descriptionClassName={styles.description}
title="Release Group"
data={releaseGroup}
/>
@ -136,6 +139,7 @@ function HistoryDetails(props) {
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title="Name"
data={sourceTitle}
/>
@ -160,6 +164,7 @@ function HistoryDetails(props) {
return (
<DescriptionList>
<DescriptionListItem
descriptionClassName={styles.description}
title="Name"
data={sourceTitle}
/>
@ -167,6 +172,7 @@ function HistoryDetails(props) {
{
!!droppedPath &&
<DescriptionListItem
descriptionClassName={styles.description}
title="Source"
data={droppedPath}
/>
@ -175,6 +181,7 @@ function HistoryDetails(props) {
{
!!importedPath &&
<DescriptionListItem
descriptionClassName={styles.description}
title="Imported To"
data={importedPath}
/>

@ -5,6 +5,7 @@ import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
@ -75,6 +76,16 @@ class History extends Component {
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label="Options"
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}

@ -5,6 +5,7 @@ import { createSelector } from 'reselect';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import withCurrentPage from 'Components/withCurrentPage';
import * as historyActions from 'Store/Actions/historyActions';
import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions';
import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
@ -43,8 +44,19 @@ class HistoryConnector extends Component {
// Lifecycle
componentDidMount() {
const {
useCurrentPage,
fetchHistory,
gotoHistoryFirstPage
} = this.props;
registerPagePopulator(this.repopulate);
this.props.gotoHistoryFirstPage();
if (useCurrentPage) {
fetchHistory();
} else {
gotoHistoryFirstPage();
}
}
componentDidUpdate(prevProps) {
@ -138,6 +150,7 @@ class HistoryConnector extends Component {
}
HistoryConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchHistory: PropTypes.func.isRequired,
gotoHistoryFirstPage: PropTypes.func.isRequired,
@ -155,4 +168,6 @@ HistoryConnector.propTypes = {
clearTracks: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector);
export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector)
);

@ -6,7 +6,7 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import AlbumTitleLink from 'Album/AlbumTitleLink';
import EpisodeLanguage from 'Album/EpisodeLanguage';
import TrackLanguage from 'Album/TrackLanguage';
import TrackQuality from 'Album/TrackQuality';
import ArtistNameLink from 'Artist/ArtistNameLink';
import HistoryEventTypeCell from './HistoryEventTypeCell';
@ -131,7 +131,7 @@ class HistoryRow extends Component {
if (name === 'language') {
return (
<TableRowCell key={name}>
<EpisodeLanguage
<TrackLanguage
language={language}
isCutoffMet={languageCutoffNotMet}
/>

@ -3,9 +3,10 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import { icons } from 'Helpers/Props';
import { align, icons } from 'Helpers/Props';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
@ -16,7 +17,9 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import RemoveQueueItemsModal from './RemoveQueueItemsModal';
import QueueOptionsConnector from './QueueOptionsConnector';
import QueueRowConnector from './QueueRowConnector';
class Queue extends Component {
@ -42,22 +45,27 @@ class Queue extends Component {
// before albums start fetching or when albums start fetching.
if (
(
this.props.isFetching &&
nextProps.isPopulated &&
hasDifferentItems(this.props.items, nextProps.items)
) ||
(!this.props.isAlbumsFetching && nextProps.isAlbumsFetching)
this.props.isFetching &&
nextProps.isPopulated &&
hasDifferentItems(this.props.items, nextProps.items) &&
nextProps.items.some((e) => e.albumId)
) {
return false;
}
if (!this.props.isAlbumsFetching && nextProps.isAlbumsFetching) {
return false;
}
return true;
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
this.setState({ selectedState: {} });
this.setState((state) => {
return removeOldSelectedState(state, prevProps.items);
});
return;
}
@ -138,7 +146,7 @@ class Queue extends Component {
} = this.state;
const isRefreshing = isFetching || isAlbumsFetching || isCheckForFinishedDownloadExecuting;
const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length);
const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length || items.every((e) => !e.albumId));
const hasError = error || albumsError;
const selectedCount = this.getSelectedIds().length;
const disableSelectedActions = selectedCount === 0;
@ -172,6 +180,21 @@ class Queue extends Component {
onPress={this.onRemoveSelectedPress}
/>
</PageToolbarSection>
<PageToolbarSection
alignContent={align.RIGHT}
>
<TableOptionsModalWrapper
columns={columns}
{...otherProps}
optionsComponent={QueueOptionsConnector}
>
<PageToolbarButton
label="Options"
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector>
@ -203,6 +226,7 @@ class Queue extends Component {
allSelected={allSelected}
allUnselected={allUnselected}
{...otherProps}
optionsComponent={QueueOptionsConnector}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>

@ -5,6 +5,7 @@ import { createSelector } from 'reselect';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import withCurrentPage from 'Components/withCurrentPage';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { executeCommand } from 'Store/Actions/commandActions';
import * as queueActions from 'Store/Actions/queueActions';
@ -15,14 +16,16 @@ import Queue from './Queue';
function createMapStateToProps() {
return createSelector(
(state) => state.albums,
(state) => state.queue.options,
(state) => state.queue.paged,
createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD),
(albums, queue, isCheckForFinishedDownloadExecuting) => {
(albums, options, queue, isCheckForFinishedDownloadExecuting) => {
return {
isAlbumsFetching: albums.isFetching,
isAlbumsPopulated: albums.isPopulated,
albumsError: albums.error,
isCheckForFinishedDownloadExecuting,
...options,
...queue
};
}
@ -42,19 +45,37 @@ class QueueConnector extends Component {
// Lifecycle
componentDidMount() {
const {
useCurrentPage,
fetchQueue,
gotoQueueFirstPage
} = this.props;
registerPagePopulator(this.repopulate);
this.props.gotoQueueFirstPage();
if (useCurrentPage) {
fetchQueue();
} else {
gotoQueueFirstPage();
}
}
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
const albumIds = selectUniqueIds(this.props.items, 'albumId');
if (albumIds.length) {
this.props.fetchAlbums({ albumIds });
} else {
this.props.clearAlbums();
}
}
if (
this.props.includeUnknownArtistItems !==
prevProps.includeUnknownArtistItems
) {
this.repopulate();
}
}
@ -160,4 +181,6 @@ QueueConnector.propTypes = {
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueConnector);
export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(QueueConnector)
);

@ -0,0 +1,77 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import { inputTypes } from 'Helpers/Props';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
class QueueOptions extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
includeUnknownArtistItems: props.includeUnknownArtistItems
};
}
componentDidUpdate(prevProps) {
const {
includeUnknownArtistItems
} = this.props;
if (includeUnknownArtistItems !== prevProps.includeUnknownArtistItems) {
this.setState({
includeUnknownArtistItems
});
}
}
//
// Listeners
onOptionChange = ({ name, value }) => {
this.setState({
[name]: value
}, () => {
this.props.onOptionChange({
[name]: value
});
});
}
//
// Render
render() {
const {
includeUnknownArtistItems
} = this.state;
return (
<Fragment>
<FormGroup>
<FormLabel>Show Unknown Artist Items</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeUnknownArtistItems"
value={includeUnknownArtistItems}
helpText="Show items without a artist in the queue, this could include removed artists, movies or anything else in Lidarr's category"
onChange={this.onOptionChange}
/>
</FormGroup>
</Fragment>
);
}
}
QueueOptions.propTypes = {
includeUnknownArtistItems: PropTypes.bool.isRequired,
onOptionChange: PropTypes.func.isRequired
};
export default QueueOptions;

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setQueueOption } from 'Store/Actions/queueActions';
import QueueOptions from './QueueOptions';
function createMapStateToProps() {
return createSelector(
(state) => state.queue.options,
(options) => {
return options;
}
);
}
const mapDispatchToProps = {
onOptionChange: setQueueOption
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions);

@ -12,6 +12,7 @@ import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import AlbumTitleLink from 'Album/AlbumTitleLink';
import TrackLanguage from 'Album/TrackLanguage';
import TrackQuality from 'Album/TrackQuality';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import ArtistNameLink from 'Artist/ArtistNameLink';
@ -72,6 +73,7 @@ class QueueRow extends Component {
errorMessage,
artist,
album,
language,
quality,
protocol,
indexer,
@ -137,43 +139,58 @@ class QueueRow extends Component {
if (name === 'artist.sortName') {
return (
<TableRowCell key={name}>
<ArtistNameLink
foreignArtistId={artist.foreignArtistId}
artistName={artist.artistName}
/>
{
artist ?
<ArtistNameLink
foreignArtistId={artist.foreignArtistId}
artistName={artist.artistName}
/> :
title
}
</TableRowCell>
);
}
if (name === 'artist') {
if (name === 'album.title') {
return (
<TableRowCell key={name}>
<ArtistNameLink
foreignArtistId={artist.foreignArtistId}
artistName={artist.artistName}
/>
{
album ?
<AlbumTitleLink
foreignAlbumId={album.foreignAlbumId}
title={album.title}
disambiguation={album.disambiguation}
/> :
'-'
}
</TableRowCell>
);
}
if (name === 'album.title') {
if (name === 'album.releaseDate') {
if (album) {
return (
<RelativeDateCellConnector
key={name}
date={album.releaseDate}
/>
);
}
return (
<TableRowCell key={name}>
<AlbumTitleLink
foreignAlbumId={album.foreignAlbumId}
title={album.title}
disambiguation={album.disambiguation}
/>
-
</TableRowCell>
);
}
if (name === 'album.releaseDate') {
if (name === 'language') {
return (
<RelativeDateCellConnector
key={name}
date={album.releaseDate}
/>
<TableRowCell key={name}>
<TrackLanguage
language={language}
/>
</TableRowCell>
);
}
@ -326,8 +343,9 @@ QueueRow.propTypes = {
trackedDownloadStatus: PropTypes.string,
statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string,
artist: PropTypes.object.isRequired,
album: PropTypes.object.isRequired,
artist: PropTypes.object,
album: PropTypes.object,
language: PropTypes.object.isRequired,
quality: PropTypes.object.isRequired,
protocol: PropTypes.string.isRequired,
indexer: PropTypes.string,

@ -51,10 +51,6 @@ class QueueRowConnector extends Component {
// Render
render() {
if (!this.props.album) {
return null;
}
return (
<QueueRow
{...this.props}

@ -9,12 +9,19 @@ function createMapStateToProps() {
return createSelector(
(state) => state.app,
(state) => state.queue.status,
(app, status) => {
(state) => state.queue.options.includeUnknownArtistItems,
(app, status, includeUnknownArtistItems) => {
const {
count,
unknownCount
} = status.item;
return {
isConnected: app.isConnected,
isReconnecting: app.isReconnecting,
isPopulated: status.isPopulated,
...status.item
...status.item,
count: includeUnknownArtistItems ? count : count - unknownCount
};
}
);

@ -100,8 +100,8 @@ class AddNewArtist extends Component {
name="artistLookup"
value={term}
placeholder="eg. Breaking Benjamin, lidarr:854a1807-025b-42a8-ba8c-2a39717f1d25"
onChange={this.onSearchInputChange}
autoFocus={true}
onChange={this.onSearchInputChange}
/>
<Button

@ -74,7 +74,8 @@ class AddNewArtistModalContent extends Component {
showMetadataProfile,
isSmallScreen,
onModalClose,
onInputChange
onInputChange,
...otherProps
} = this.props;
return (
@ -86,7 +87,8 @@ class AddNewArtistModalContent extends Component {
<ModalBody>
<div className={styles.container}>
{
!isSmallScreen &&
isSmallScreen ?
null:
<div className={styles.poster}>
<ArtistPoster
className={styles.poster}
@ -97,15 +99,19 @@ class AddNewArtistModalContent extends Component {
}
<div className={styles.info}>
<div className={styles.overview}>
<TextTruncate
truncateText="…"
line={8}
text={overview}
/>
</div>
<Form>
{
overview ?
<div className={styles.overview}>
<TextTruncate
truncateText="…"
line={8}
text={overview}
/>
</div> :
null
}
<Form {...otherProps}>
<FormGroup>
<FormLabel>Root Folder</FormLabel>

@ -107,8 +107,11 @@ class AddNewArtistSearchResult extends Component {
{artistName}
{
!name.contains(year) && !!year &&
<span className={styles.year}>({year})</span>
!name.contains(year) && year ?
<span className={styles.year}>
({year})
</span> :
null
}
{
@ -117,13 +120,14 @@ class AddNewArtistSearchResult extends Component {
}
{
isExistingArtist &&
<Icon
className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE}
size={36}
title="Already in your library"
/>
isExistingArtist ?
<Icon
className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE}
size={36}
title="Already in your library"
/> :
null
}
</div>
@ -136,20 +140,22 @@ class AddNewArtistSearchResult extends Component {
</Label>
{
!!artistType &&
artistType ?
<Label size={sizes.LARGE}>
{artistType}
</Label>
</Label> :
null
}
{
status === 'ended' &&
status === 'ended' ?
<Label
kind={kinds.DANGER}
size={sizes.LARGE}
>
Ended
</Label>
</Label> :
null
}
</div>

@ -116,9 +116,11 @@ class ImportArtistFooter extends Component {
isQualityProfileIdMixed,
isLanguageProfileIdMixed,
isMetadataProfileIdMixed,
hasUnsearchedItems,
showLanguageProfile,
showMetadataProfile,
onImportPress,
onLookupPress,
onCancelLookupPress
} = this.props;
@ -238,6 +240,17 @@ class ImportArtistFooter extends Component {
</Button>
}
{
hasUnsearchedItems &&
<Button
className={styles.loadingButton}
kind={kinds.SUCCESS}
onPress={onLookupPress}
>
Start Processing
</Button>
}
{
isLookingUpArtist &&
<LoadingIndicator
@ -271,10 +284,12 @@ ImportArtistFooter.propTypes = {
isLanguageProfileIdMixed: PropTypes.bool.isRequired,
isMetadataProfileIdMixed: PropTypes.bool.isRequired,
isAlbumFolderMixed: PropTypes.bool.isRequired,
hasUnsearchedItems: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
showMetadataProfile: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onImportPress: PropTypes.func.isRequired,
onLookupPress: PropTypes.func.isRequired,
onCancelLookupPress: PropTypes.func.isRequired
};

@ -2,7 +2,7 @@ import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import ImportArtistFooter from './ImportArtistFooter';
import { cancelLookupArtist } from 'Store/Actions/importArtistActions';
import { lookupUnsearchedArtist, cancelLookupArtist } from 'Store/Actions/importArtistActions';
function isMixed(items, selectedIds, defaultValue, key) {
return _.some(items, (artist) => {
@ -35,6 +35,7 @@ function createMapStateToProps() {
const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId');
const isMetadataProfileIdMixed = isMixed(items, selectedIds, defaultMetadataProfileId, 'metadataProfileId');
const isAlbumFolderMixed = isMixed(items, selectedIds, defaultAlbumFolder, 'albumFolder');
const hasUnsearchedItems = !isLookingUpArtist && items.some((item) => !item.isPopulated);
return {
selectedCount: selectedIds.length,
@ -49,13 +50,15 @@ function createMapStateToProps() {
isQualityProfileIdMixed,
isLanguageProfileIdMixed,
isMetadataProfileIdMixed,
isAlbumFolderMixed
isAlbumFolderMixed,
hasUnsearchedItems
};
}
);
}
const mapDispatchToProps = {
onLookupPress: lookupUnsearchedArtist,
onCancelLookupPress: cancelLookupArtist
};

@ -110,7 +110,6 @@ ImportArtistRow.propTypes = {
selectedArtist: PropTypes.object,
isExistingArtist: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
queued: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
showMetadataProfile: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions';
import { setImportArtistValue } from 'Store/Actions/importArtistActions';
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
import ImportArtistRow from './ImportArtistRow';
@ -34,7 +34,6 @@ function createMapStateToProps() {
}
const mapDispatchToProps = {
queueLookupArtist,
setImportArtistValue
};
@ -82,7 +81,6 @@ ImportArtistRowConnector.propTypes = {
monitor: PropTypes.string,
albumFolder: PropTypes.bool,
items: PropTypes.arrayOf(PropTypes.object),
queueLookupArtist: PropTypes.func.isRequired,
setImportArtistValue: PropTypes.func.isRequired
};

@ -1,10 +1,12 @@
.artistNameContainer {
display: flex;
align-items: center;
flex: 0 1 auto;
overflow: hidden;
}
.artistName {
margin-right: 5px;
@add-mixin truncate;
}
.disambiguation {
@ -12,11 +14,6 @@
color: $disabledColor;
}
.year {
margin-left: 5px;
color: $disabledColor;
}
.existing {
margin-left: 5px;
}

@ -8,7 +8,6 @@ function ImportArtistName(props) {
const {
artistName,
disambiguation,
// year,
isExistingArtist
} = props;
@ -36,7 +35,6 @@ function ImportArtistName(props) {
ImportArtistName.propTypes = {
artistName: PropTypes.string.isRequired,
disambiguation: PropTypes.string,
// year: PropTypes.number.isRequired,
isExistingArtist: PropTypes.bool.isRequired
};

@ -30,8 +30,9 @@
}
.dropdownArrowContainer {
position: absolute;
right: 16px;
flex: 1 0 auto;
margin-left: 5px;
text-align: right;
}
.contentContainer {
@ -68,3 +69,13 @@
border-radius: 0;
}
.results {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
overflow-x: hidden;
overflow-y: scroll;
max-height: 165px;
}

@ -120,7 +120,7 @@ class ImportArtistSelectArtist extends Component {
isPopulated,
error,
items,
queued,
isQueued,
isLookingUpArtist
} = this.props;
@ -142,7 +142,7 @@ class ImportArtistSelectArtist extends Component {
onPress={this.onPress}
>
{
isLookingUpArtist && queued && !isPopulated &&
isLookingUpArtist && isQueued && !isPopulated &&
<LoadingIndicator
className={styles.loading}
size={20}
@ -170,7 +170,7 @@ class ImportArtistSelectArtist extends Component {
{
isPopulated && !selectedArtist &&
<div>
<div className={styles.noMatches}>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
@ -265,7 +265,7 @@ ImportArtistSelectArtist.propTypes = {
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
queued: PropTypes.bool.isRequired,
isQueued: PropTypes.bool.isRequired,
isLookingUpArtist: PropTypes.bool.isRequired,
onSearchInputChange: PropTypes.func.isRequired,
onArtistSelect: PropTypes.func.isRequired
@ -275,7 +275,7 @@ ImportArtistSelectArtist.defaultProps = {
isFetching: true,
isPopulated: false,
items: [],
queued: true
isQueued: true
};
export default ImportArtistSelectArtist;

@ -1,48 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteRootFolder } from 'Store/Actions/rootFolderActions';
import ImportArtistRootFolderRow from './ImportArtistRootFolderRow';
function createMapStateToProps() {
return createSelector(
() => {
return {
};
}
);
}
const mapDispatchToProps = {
deleteRootFolder
};
class ImportArtistRootFolderRowConnector extends Component {
//
// Listeners
onDeletePress = () => {
this.props.deleteRootFolder({ id: this.props.id });
}
//
// Render
render() {
return (
<ImportArtistRootFolderRow
{...this.props}
onDeletePress={this.onDeletePress}
/>
);
}
}
ImportArtistRootFolderRowConnector.propTypes = {
id: PropTypes.number.isRequired,
deleteRootFolder: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistRootFolderRowConnector);

@ -8,33 +8,9 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import ImportArtistRootFolderRowConnector from './ImportArtistRootFolderRowConnector';
import RootFolders from 'RootFolder/RootFolders';
import styles from './ImportArtistSelectFolder.css';
const rootFolderColumns = [
{
name: 'path',
label: 'Path',
isVisible: true
},
{
name: 'freeSpace',
label: 'Free Space',
isVisible: true
},
{
name: 'unmappedFolders',
label: 'Unmapped Folders',
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
class ImportArtistSelectFolder extends Component {
//
@ -107,26 +83,13 @@ class ImportArtistSelectFolder extends Component {
{
items.length > 0 ?
<div className={styles.recentFolders}>
<FieldSet legend="Recent Folders">
<Table
columns={rootFolderColumns}
>
<TableBody>
{
items.map((rootFolder) => {
return (
<ImportArtistRootFolderRowConnector
key={rootFolder.id}
id={rootFolder.id}
path={rootFolder.path}
freeSpace={rootFolder.freeSpace}
unmappedFolders={rootFolder.unmappedFolders}
/>
);
})
}
</TableBody>
</Table>
<FieldSet legend="Root Folders">
<RootFolders
isFetching={isFetching}
isPopulated={isPopulated}
error={error}
items={items}
/>
</FieldSet>
<Button
@ -178,8 +141,7 @@ ImportArtistSelectFolder.propTypes = {
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onNewRootFolderSelect: PropTypes.func.isRequired,
onDeleteRootFolderPress: PropTypes.func.isRequired
onNewRootFolderSelect: PropTypes.func.isRequired
};
export default ImportArtistSelectFolder;

@ -5,7 +5,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { push } from 'react-router-redux';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions';
import { fetchRootFolders, addRootFolder } from 'Store/Actions/rootFolderActions';
import ImportArtistSelectFolder from './ImportArtistSelectFolder';
function createMapStateToProps() {
@ -24,7 +24,6 @@ function createMapStateToProps() {
const mapDispatchToProps = {
fetchRootFolders,
addRootFolder,
deleteRootFolder,
push
};
@ -60,10 +59,6 @@ class ImportArtistSelectFolderConnector extends Component {
this.props.addRootFolder({ path });
}
onDeleteRootFolderPress = (id) => {
this.props.deleteRootFolder({ id });
}
//
// Render
@ -72,7 +67,6 @@ class ImportArtistSelectFolderConnector extends Component {
<ImportArtistSelectFolder
{...this.props}
onNewRootFolderSelect={this.onNewRootFolderSelect}
onDeleteRootFolderPress={this.onDeleteRootFolderPress}
/>
);
}
@ -84,7 +78,6 @@ ImportArtistSelectFolderConnector.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchRootFolders: PropTypes.func.isRequired,
addRootFolder: PropTypes.func.isRequired,
deleteRootFolder: PropTypes.func.isRequired,
push: PropTypes.func.isRequired
};

@ -4,7 +4,7 @@ import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import InteractiveSearchModal from 'InteractiveSearch/InteractiveSearchModal';
import AlbumInteractiveSearchModalConnector from './Search/AlbumInteractiveSearchModalConnector';
import styles from './AlbumSearchCell.css';
class AlbumSearchCell extends Component {
@ -37,6 +37,7 @@ class AlbumSearchCell extends Component {
render() {
const {
albumId,
albumTitle,
isSearching,
onSearchPress,
...otherProps
@ -55,9 +56,10 @@ class AlbumSearchCell extends Component {
onPress={this.onManualSearchPress}
/>
<InteractiveSearchModal
<AlbumInteractiveSearchModalConnector
isOpen={this.state.isDetailsModalOpen}
albumId={albumId}
albumTitle={albumTitle}
onModalClose={this.onDetailsModalClose}
{...otherProps}
/>

@ -32,13 +32,6 @@
color: $white;
}
.logo {
flex-shrink: 0;
margin-right: 35px;
width: 250px;
height: 97px;
}
.cover {
flex-shrink: 0;
margin-right: 35px;
@ -61,19 +54,33 @@
.titleContainer {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
}
.title {
margin-bottom: 5px;
font-weight: 300;
font-size: 50px;
line-height: 50px;
}
.toggleMonitoredContainer {
align-self: center;
margin-right: 10px;
}
.monitorToggleButton {
composes: toggleButton from 'Components/MonitorToggleButton.css';
width: 40px;
&:hover {
color: $iconButtonHoverLightColor;
}
}
.alternateTitlesIconContainer {
align-self: flex-end;
margin-left: 20px;
line-height: 50px;
}
.albumNavigationButtons {
@ -87,14 +94,19 @@
width: 30px;
color: #e1e2e3;
white-space: nowrap;
&:hover {
color: $iconButtonHoverLightColor;
}
}
.details {
margin-bottom: 8px;
font-weight: 300;
font-size: 20px;
}
.runtime {
.duration {
margin-right: 15px;
}
@ -115,7 +127,9 @@
.overview {
flex: 1 0 auto;
margin-top: 8px;
min-height: 0;
font-size: $intermediateFontSize;
}
.contentContainer {

@ -12,6 +12,7 @@ import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Label from 'Components/Label';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import Tooltip from 'Components/Tooltip/Tooltip';
import AlbumCover from 'Album/AlbumCover';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
@ -25,7 +26,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import AlbumDetailsMediumConnector from './AlbumDetailsMediumConnector';
import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal';
import InteractiveSearchModal from 'InteractiveSearch/InteractiveSearchModal';
import AlbumInteractiveSearchModalConnector from 'Album/Search/AlbumInteractiveSearchModalConnector';
import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
import AlbumDetailsLinks from './AlbumDetailsLinks';
import styles from './AlbumDetails.css';
@ -41,6 +42,28 @@ function getFanartUrl(images) {
}
}
function formatDuration(timeSpan) {
const duration = moment.duration(timeSpan);
const hours = duration.get('hours');
const minutes = duration.get('minutes');
let hoursText = 'Hours';
let minText = 'Minutes';
if (minutes === 1) {
minText = 'Minute';
}
if (hours === 0) {
return `${minutes} ${minText}`;
}
if (hours === 1) {
hoursText = 'Hour';
}
return `${hours} ${hoursText} ${minutes} ${minText}`;
}
function getExpandedState(newState) {
return {
allExpanded: newState.allSelected,
@ -144,6 +167,7 @@ class AlbumDetails extends Component {
foreignAlbumId,
title,
disambiguation,
duration,
overview,
albumType,
statistics = {},
@ -153,6 +177,7 @@ class AlbumDetails extends Component {
images,
links,
media,
isSaving,
isFetching,
isPopulated,
albumsError,
@ -162,6 +187,7 @@ class AlbumDetails extends Component {
previousAlbum,
nextAlbum,
isSearching,
onMonitorTogglePress,
onSearchPress
} = this.props;
@ -259,9 +285,22 @@ class AlbumDetails extends Component {
/>
<div className={styles.info}>
<div className={styles.titleContainer}>
<div className={styles.title}>
{title}{disambiguation ? ` (${disambiguation})` : ''}
<div className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
isSaving={isSaving}
size={40}
onPress={onMonitorTogglePress}
/>
</div>
<div className={styles.title}>
{title}{disambiguation ? ` (${disambiguation})` : ''}
</div>
</div>
<div className={styles.albumNavigationButtons}>
@ -293,6 +332,13 @@ class AlbumDetails extends Component {
<div className={styles.details}>
<div>
{
!!duration &&
<span className={styles.duration}>
{formatDuration(duration)}
</span>
}
<HeartRating
rating={ratings.value}
iconSize={20}
@ -456,9 +502,10 @@ class AlbumDetails extends Component {
onModalClose={this.onManageTracksModalClose}
/>
<InteractiveSearchModal
<AlbumInteractiveSearchModalConnector
isOpen={isInteractiveSearchModalOpen}
albumId={id}
albumTitle={title}
onModalClose={this.onInteractiveSearchModalClose}
/>
@ -487,6 +534,7 @@ AlbumDetails.propTypes = {
foreignAlbumId: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
disambiguation: PropTypes.string,
duration: PropTypes.number,
overview: PropTypes.string,
albumType: PropTypes.string.isRequired,
statistics: PropTypes.object.isRequired,
@ -497,6 +545,7 @@ AlbumDetails.propTypes = {
media: PropTypes.arrayOf(PropTypes.object).isRequired,
monitored: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
isSaving: PropTypes.bool.isRequired,
isSearching: PropTypes.bool,
isFetching: PropTypes.bool,
isPopulated: PropTypes.bool,
@ -506,6 +555,7 @@ AlbumDetails.propTypes = {
artist: PropTypes.object,
previousAlbum: PropTypes.object,
nextAlbum: PropTypes.object,
onMonitorTogglePress: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func,
onSearchPress: PropTypes.func.isRequired
};

@ -7,6 +7,7 @@ import { createSelector } from 'reselect';
import { findCommand } from 'Utilities/Command';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import { toggleAlbumsMonitored } from 'Store/Actions/albumActions';
import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
import { executeCommand } from 'Store/Actions/commandActions';
@ -64,7 +65,8 @@ const mapDispatchToProps = {
fetchTracks,
clearTracks,
fetchTrackFiles,
clearTrackFiles
clearTrackFiles,
toggleAlbumsMonitored
};
function getMonitoredReleases(props) {
@ -109,6 +111,13 @@ class AlbumDetailsConnector extends Component {
//
// Listeners
onMonitorTogglePress = (monitored) => {
this.props.toggleAlbumsMonitored({
albumIds: [this.props.id],
monitored
});
}
onSearchPress = () => {
this.props.executeCommand({
name: commandNames.ALBUM_SEARCH,
@ -123,6 +132,7 @@ class AlbumDetailsConnector extends Component {
return (
<AlbumDetails
{...this.props}
onMonitorTogglePress={this.onMonitorTogglePress}
onSearchPress={this.onSearchPress}
/>
);
@ -138,6 +148,7 @@ AlbumDetailsConnector.propTypes = {
clearTracks: PropTypes.func.isRequired,
fetchTrackFiles: PropTypes.func.isRequired,
clearTrackFiles: PropTypes.func.isRequired,
toggleAlbumsMonitored: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};

@ -0,0 +1,36 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AlbumInteractiveSearchModalContent from './AlbumInteractiveSearchModalContent';
function AlbumInteractiveSearchModal(props) {
const {
isOpen,
albumId,
albumTitle,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<AlbumInteractiveSearchModalContent
albumId={albumId}
albumTitle={albumTitle}
onModalClose={onModalClose}
/>
</Modal>
);
}
AlbumInteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
albumId: PropTypes.number.isRequired,
albumTitle: PropTypes.string.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AlbumInteractiveSearchModal;

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import AlbumInteractiveSearchModal from './AlbumInteractiveSearchModal';
function createMapDispatchToProps(dispatch, props) {
return {
onModalClose() {
dispatch(cancelFetchReleases());
dispatch(clearReleases());
props.onModalClose();
}
};
}
export default connect(null, createMapDispatchToProps)(AlbumInteractiveSearchModal);

@ -0,0 +1,47 @@
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 InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
function AlbumInteractiveSearchModalContent(props) {
const {
albumId,
albumTitle,
onModalClose
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Interactive Search {albumId != null && `- ${albumTitle}`}
</ModalHeader>
<ModalBody>
<InteractiveSearchConnector
type="album"
searchPayload={{
albumId
}}
/>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
AlbumInteractiveSearchModalContent.propTypes = {
albumId: PropTypes.number.isRequired,
albumTitle: PropTypes.string.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AlbumInteractiveSearchModalContent;

@ -3,7 +3,7 @@ import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
function EpisodeLanguage(props) {
function TrackLanguage(props) {
const {
className,
language,
@ -24,14 +24,14 @@ function EpisodeLanguage(props) {
);
}
EpisodeLanguage.propTypes = {
TrackLanguage.propTypes = {
className: PropTypes.string,
language: PropTypes.object,
isCutoffNotMet: PropTypes.bool
};
EpisodeLanguage.defaultProps = {
TrackLanguage.defaultProps = {
isCutoffNotMet: true
};
export default EpisodeLanguage;
export default TrackLanguage;

@ -12,6 +12,7 @@ function AppUpdatedModal(props) {
return (
<Modal
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<AppUpdatedModalContentConnector

@ -0,0 +1,6 @@
import React from 'react';
const ColorImpairedContext = React.createContext(false);
export const ColorImpairedConsumer = ColorImpairedContext.Consumer;
export default ColorImpairedContext;

@ -1,172 +1,25 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LazyLoad from 'react-lazyload';
import React from 'react';
import ArtistImage from './ArtistImage';
const bannerPlaceholder = 'data:image/png;base64,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';
function findBanner(images) {
return _.find(images, { coverType: 'banner' });
}
function getBannerUrl(banner, size) {
if (banner) {
if (banner.url.contains('lastWrite=') || (/^https?:/).test(banner.url)) {
// Remove protocol
let url = banner.url.replace(/^https?:/, '');
url = url.replace('banner.jpg', `banner-${size}.jpg`);
return url;
}
}
}
class ArtistBanner extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const pixelRatio = Math.floor(window.devicePixelRatio);
const {
images,
size
} = props;
const banner = findBanner(images);
this.state = {
pixelRatio,
banner,
bannerUrl: getBannerUrl(banner, pixelRatio * size),
isLoaded: false,
hasError: false
};
}
componentDidUpdate(prevProps) {
const {
images,
size
} = this.props;
const {
banner,
pixelRatio
} = this.state;
const nextBanner = findBanner(images);
if (nextBanner && (!banner || nextBanner.url !== banner.url)) {
this.setState({
banner: nextBanner,
bannerUrl: getBannerUrl(nextBanner, pixelRatio * size),
hasError: false,
isLoaded: true
});
} else if (!nextBanner && banner) {
this.setState({
banner: nextBanner,
bannerUrl: bannerPlaceholder,
hasError: false
});
}
}
//
// Listeners
onError = () => {
this.setState({ hasError: true });
}
onLoad = () => {
this.setState({
isLoaded: true,
hasError: false
});
}
//
// Render
render() {
const {
className,
style,
size,
lazy,
overflow
} = this.props;
const {
bannerUrl,
hasError,
isLoaded
} = this.state;
if (hasError || !bannerUrl) {
return (
<img
className={className}
style={style}
src={bannerPlaceholder}
/>
);
}
if (lazy) {
return (
<LazyLoad
height={size}
offset={100}
overflow={overflow}
placeholder={
<img
className={className}
style={style}
src={bannerPlaceholder}
/>
}
>
<img
className={className}
style={style}
src={bannerUrl}
onError={this.onError}
/>
</LazyLoad>
);
}
return (
<img
className={className}
style={style}
src={isLoaded ? bannerUrl : bannerPlaceholder}
onError={this.onError}
onLoad={this.onLoad}
/>
);
}
function ArtistBanner(props) {
return (
<ArtistImage
{...props}
coverType="banner"
placeholder={bannerPlaceholder}
/>
);
}
ArtistBanner.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
size: PropTypes.number.isRequired,
lazy: PropTypes.bool.isRequired,
overflow: PropTypes.bool.isRequired
size: PropTypes.number.isRequired
};
ArtistBanner.defaultProps = {
size: 70,
lazy: true,
overflow: false
size: 70
};
export default ArtistBanner;

@ -0,0 +1,199 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LazyLoad from 'react-lazyload';
function findImage(images, coverType) {
return images.find((image) => image.coverType === coverType);
}
function getUrl(image, coverType, size) {
if (image) {
// Remove protocol
let url = image.url.replace(/^https?:/, '');
url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`);
return url;
}
}
class ArtistImage extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const pixelRatio = Math.floor(window.devicePixelRatio);
const {
images,
coverType,
size
} = props;
const image = findImage(images, coverType);
this.state = {
pixelRatio,
image,
url: getUrl(image, coverType, pixelRatio * size),
isLoaded: false,
hasError: false
};
}
componentDidMount() {
if (!this.state.url && this.props.onError) {
this.props.onError();
}
}
componentDidUpdate() {
const {
images,
coverType,
placeholder,
size,
onError
} = this.props;
const {
image,
pixelRatio
} = this.state;
const nextImage = findImage(images, coverType);
if (nextImage && (!image || nextImage.url !== image.url)) {
this.setState({
image: nextImage,
url: getUrl(nextImage, coverType, pixelRatio * size),
hasError: false
// Don't reset isLoaded, as we want to immediately try to
// show the new image, whether an image was shown previously
// or the placeholder was shown.
});
} else if (!nextImage && image) {
this.setState({
image: nextImage,
url: placeholder,
hasError: false
});
if (onError) {
onError();
}
}
}
//
// Listeners
onError = () => {
this.setState({
hasError: true
});
if (this.props.onError) {
this.props.onError();
}
}
onLoad = () => {
this.setState({
isLoaded: true,
hasError: false
});
if (this.props.onLoad) {
this.props.onLoad();
}
}
//
// Render
render() {
const {
className,
style,
placeholder,
size,
lazy,
overflow
} = this.props;
const {
url,
hasError,
isLoaded
} = this.state;
if (hasError || !url) {
return (
<img
className={className}
style={style}
src={placeholder}
/>
);
}
if (lazy) {
return (
<LazyLoad
height={size}
offset={100}
overflow={overflow}
placeholder={
<img
className={className}
style={style}
src={placeholder}
/>
}
>
<img
className={className}
style={style}
src={url}
onError={this.onError}
onLoad={this.onLoad}
/>
</LazyLoad>
);
}
return (
<img
className={className}
style={style}
src={isLoaded ? url : placeholder}
onError={this.onError}
onLoad={this.onLoad}
/>
);
}
}
ArtistImage.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
coverType: PropTypes.string.isRequired,
placeholder: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
lazy: PropTypes.bool.isRequired,
overflow: PropTypes.bool.isRequired,
onError: PropTypes.func,
onLoad: PropTypes.func
};
ArtistImage.defaultProps = {
size: 250,
lazy: true,
overflow: false
};
export default ArtistImage;

@ -1,172 +1,25 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LazyLoad from 'react-lazyload';
import React from 'react';
import ArtistImage from './ArtistImage';
const posterPlaceholder = 'data:image/png;base64,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';
function findPoster(images) {
return _.find(images, { coverType: 'poster' });
}
function getPosterUrl(poster, size) {
if (poster) {
if (poster.url.contains('lastWrite=') || (/^https?:/).test(poster.url)) {
// Remove protocol
let url = poster.url.replace(/^https?:/, '');
url = url.replace('poster.jpg', `poster-${size}.jpg`);
return url;
}
}
}
class ArtistPoster extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const pixelRatio = Math.floor(window.devicePixelRatio);
const {
images,
size
} = props;
const poster = findPoster(images);
this.state = {
pixelRatio,
poster,
posterUrl: getPosterUrl(poster, pixelRatio * size),
isLoaded: false,
hasError: false
};
}
componentDidUpdate(prevProps) {
const {
images,
size
} = this.props;
const {
poster,
pixelRatio
} = this.state;
const nextPoster = findPoster(images);
if (nextPoster && (!poster || nextPoster.url !== poster.url)) {
this.setState({
poster: nextPoster,
posterUrl: getPosterUrl(nextPoster, pixelRatio * size),
hasError: false,
isLoaded: true
});
} else if (!nextPoster && poster) {
this.setState({
poster: nextPoster,
posterUrl: posterPlaceholder,
hasError: false
});
}
}
//
// Listeners
onError = () => {
this.setState({ hasError: true });
}
onLoad = () => {
this.setState({
isLoaded: true,
hasError: false
});
}
//
// Render
render() {
const {
className,
style,
size,
lazy,
overflow
} = this.props;
const {
posterUrl,
hasError,
isLoaded
} = this.state;
if (hasError || !posterUrl) {
return (
<img
className={className}
style={style}
src={posterPlaceholder}
/>
);
}
if (lazy) {
return (
<LazyLoad
height={size}
offset={100}
overflow={overflow}
placeholder={
<img
className={className}
style={style}
src={posterPlaceholder}
/>
}
>
<img
className={className}
style={style}
src={posterUrl}
onError={this.onError}
/>
</LazyLoad>
);
}
return (
<img
className={className}
style={style}
src={isLoaded ? posterUrl : posterPlaceholder}
onError={this.onError}
onLoad={this.onLoad}
/>
);
}
function ArtistPoster(props) {
return (
<ArtistImage
{...props}
coverType="poster"
placeholder={posterPlaceholder}
/>
);
}
ArtistPoster.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
size: PropTypes.number.isRequired,
lazy: PropTypes.bool.isRequired,
overflow: PropTypes.bool.isRequired
size: PropTypes.number.isRequired
};
ArtistPoster.defaultProps = {
size: 250,
lazy: true,
overflow: false
size: 250
};
export default ArtistPoster;

@ -108,6 +108,7 @@
}
.details {
margin-bottom: 8px;
font-weight: 300;
font-size: 20px;
}
@ -132,15 +133,11 @@
font-size: 17px;
}
.path {
vertical-align: text-top;
font-size: $defaultFontSize;
font-family: $monoSpaceFontFamily;
}
.overview {
flex: 1 0 auto;
margin-top: 8px;
min-height: 0;
font-size: $intermediateFontSize;
}
.contentContainer {

@ -11,7 +11,6 @@ import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Label from 'Components/Label';
import Measure from 'Components/Measure';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
@ -35,6 +34,7 @@ import ArtistTagsConnector from './ArtistTagsConnector';
import ArtistDetailsLinks from './ArtistDetailsLinks';
import styles from './ArtistDetails.css';
import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal';
import ArtistInteractiveSearchModalConnector from 'Artist/Search/ArtistInteractiveSearchModalConnector';
import Link from 'Components/Link/Link';
const defaultFontSize = parseInt(fonts.defaultFontSize);
@ -71,6 +71,7 @@ class ArtistDetails extends Component {
isDeleteArtistModalOpen: false,
isArtistHistoryModalOpen: false,
isInteractiveImportModalOpen: false,
isInteractiveSearchModalOpen: false,
allExpanded: false,
allCollapsed: false,
expandedState: {}
@ -104,6 +105,14 @@ class ArtistDetails extends Component {
this.setState({ isInteractiveImportModalOpen: false });
}
onInteractiveSearchPress = () => {
this.setState({ isInteractiveSearchModalOpen: true });
}
onInteractiveSearchModalClose = () => {
this.setState({ isInteractiveSearchModalOpen: false });
}
onEditArtistPress = () => {
this.setState({ isEditArtistModalOpen: true });
}
@ -181,7 +190,9 @@ class ArtistDetails extends Component {
isPopulated,
albumsError,
trackFilesError,
hasAlbums,
hasMonitoredAlbums,
hasTrackFiles,
previousArtist,
nextArtist,
onMonitorTogglePress,
@ -201,6 +212,7 @@ class ArtistDetails extends Component {
isDeleteArtistModalOpen,
isArtistHistoryModalOpen,
isInteractiveImportModalOpen,
isInteractiveSearchModalOpen,
allExpanded,
allCollapsed,
expandedState
@ -240,29 +252,41 @@ class ArtistDetails extends Component {
<PageToolbarButton
label="Search Monitored"
iconName={icons.SEARCH}
isDisabled={!monitored || !hasMonitoredAlbums}
isDisabled={!monitored || !hasMonitoredAlbums || !hasAlbums}
isSpinning={isSearching}
title={hasMonitoredAlbums ? undefined : 'No monitored albums for this artist'}
onPress={onSearchPress}
/>
<PageToolbarButton
label="Interactive Search"
iconName={icons.INTERACTIVE}
isDisabled={!monitored || !hasMonitoredAlbums || !hasAlbums}
isSpinning={isSearching}
title={hasMonitoredAlbums ? undefined : 'No monitored albums for this artist'}
onPress={this.onInteractiveSearchPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label="Preview Rename"
iconName={icons.ORGANIZE}
isDisabled={!hasTrackFiles}
onPress={this.onOrganizePress}
/>
<PageToolbarButton
label="Manage Tracks"
iconName={icons.TRACK_FILE}
isDisabled={!hasTrackFiles}
onPress={this.onManageTracksPress}
/>
<PageToolbarButton
label="History"
iconName={icons.HISTORY}
isDisabled={!hasAlbums}
onPress={this.onArtistHistoryPress}
/>
@ -609,6 +633,12 @@ class ArtistDetails extends Component {
showImportMode={false}
onModalClose={this.onInteractiveImportModalClose}
/>
<ArtistInteractiveSearchModalConnector
isOpen={isInteractiveSearchModalOpen}
artistId={id}
onModalClose={this.onInteractiveSearchModalClose}
/>
</PageContentBodyConnector>
</PageContent>
);
@ -638,7 +668,9 @@ ArtistDetails.propTypes = {
isPopulated: PropTypes.bool.isRequired,
albumsError: PropTypes.object,
trackFilesError: PropTypes.object,
hasAlbums: PropTypes.bool.isRequired,
hasMonitoredAlbums: PropTypes.bool.isRequired,
hasTrackFiles: PropTypes.bool.isRequired,
previousArtist: PropTypes.object.isRequired,
nextArtist: PropTypes.object.isRequired,
onMonitorTogglePress: PropTypes.func.isRequired,

@ -16,11 +16,55 @@ import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import ArtistDetails from './ArtistDetails';
const selectAlbums = createSelector(
(state) => state.albums,
(albums) => {
const {
items,
isFetching,
isPopulated,
error
} = albums;
const hasAlbums = !!items.length;
const hasMonitoredAlbums = items.some((e) => e.monitored);
return {
isAlbumsFetching: isFetching,
isAlbumsPopulated: isPopulated,
albumsError: error,
hasAlbums,
hasMonitoredAlbums
};
}
);
const selectTrackFiles = createSelector(
(state) => state.trackFiles,
(trackFiles) => {
const {
items,
isFetching,
isPopulated,
error
} = trackFiles;
const hasTrackFiles = !!items.length;
return {
isTrackFilesFetching: isFetching,
isTrackFilesPopulated: isPopulated,
trackFilesError: error,
hasTrackFiles
};
}
);
function createMapStateToProps() {
return createSelector(
(state, { foreignArtistId }) => foreignArtistId,
(state) => state.albums,
(state) => state.trackFiles,
selectAlbums,
selectTrackFiles,
(state) => state.settings.metadataProfiles,
createAllArtistSelector(),
createCommandsSelector(),
@ -40,6 +84,21 @@ function createMapStateToProps() {
return {};
}
const {
isAlbumsFetching,
isAlbumsPopulated,
albumsError,
hasAlbums,
hasMonitoredAlbums
} = albums;
const {
isTrackFilesFetching,
isTrackFilesPopulated,
trackFilesError,
hasTrackFiles
} = trackFiles;
const sortedAlbumTypes = _.orderBy(albumTypes);
const previousArtist = sortedArtist[artistIndex - 1] || _.last(sortedArtist);
@ -60,10 +119,9 @@ function createMapStateToProps() {
isRenamingArtistCommand.body.artistIds.indexOf(artist.id) > -1
);
const isFetching = albums.isFetching || trackFiles.isFetching;
const isPopulated = albums.isPopulated && trackFiles.isPopulated;
const albumsError = albums.error;
const trackFilesError = trackFiles.error;
const isFetching = isAlbumsFetching || isTrackFilesFetching;
const isPopulated = isAlbumsPopulated && isTrackFilesPopulated;
const alternateTitles = _.reduce(artist.alternateTitles, (acc, alternateTitle) => {
if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) &&
(alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) {
@ -73,8 +131,6 @@ function createMapStateToProps() {
return acc;
}, []);
const hasMonitoredAlbums = albums.items.some((e) => e.monitored);
return {
...artist,
albumTypes: sortedAlbumTypes,
@ -89,7 +145,9 @@ function createMapStateToProps() {
isPopulated,
albumsError,
trackFilesError,
hasAlbums,
hasMonitoredAlbums,
hasTrackFiles,
previousArtist,
nextArtist
};

@ -62,7 +62,7 @@
composes: menuContent from 'Components/Menu/MenuContent.css';
white-space: nowrap;
font-size: 14px;
font-size: $defaultFontSize;
}
.actionMenuIcon {

@ -34,8 +34,13 @@ class ArtistDetailsSeason extends Component {
}
componentDidUpdate(prevProps) {
if (prevProps.artistId !== this.props.artistId) {
const {
artistId
} = this.props;
if (prevProps.artistId !== artistId) {
this._expandByDefault();
return;
}
}
@ -51,7 +56,7 @@ class ArtistDetailsSeason extends Component {
const expand = _.some(items, (item) => {
return isAfter(item.releaseDate) ||
isAfter(item.releaseDate, { days: -30 });
isAfter(item.releaseDate, { days: -365 });
});
onExpandPress(name, expand);
@ -113,7 +118,6 @@ class ArtistDetailsSeason extends Component {
items,
columns,
isExpanded,
artistMonitored,
sortKey,
sortDirection,
onSortPress,
@ -235,7 +239,6 @@ ArtistDetailsSeason.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isExpanded: PropTypes.bool,
artistMonitored: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired,
onExpandPress: PropTypes.func.isRequired,

@ -4,14 +4,12 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { toggleAlbumsMonitored, setAlbumsTableOption, setAlbumsSort } from 'Store/Actions/albumActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import ArtistDetailsSeason from './ArtistDetailsSeason';
function createMapStateToProps() {

@ -85,9 +85,7 @@ class EditArtistModalContent extends Component {
</ModalHeader>
<ModalBody>
<Form
{...otherProps}
>
<Form {...otherProps}>
<FormGroup>
<FormLabel>Monitored</FormLabel>

@ -8,7 +8,7 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Popover from 'Components/Tooltip/Popover';
import EpisodeLanguage from 'Album/EpisodeLanguage';
import TrackLanguage from 'Album/TrackLanguage';
import TrackQuality from 'Album/TrackQuality';
import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
@ -68,8 +68,6 @@ class ArtistHistoryRow extends Component {
qualityCutoffNotMet,
date,
data,
fullArtist,
artist,
album
} = this.props;
@ -93,7 +91,7 @@ class ArtistHistoryRow extends Component {
</TableRowCell>
<TableRowCell>
<EpisodeLanguage
<TrackLanguage
language={language}
isCutoffNotMet={languageCutoffNotMet}
/>

@ -7,12 +7,14 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageJumpBar from 'Components/Page/PageJumpBar';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import NoArtist from 'Artist/NoArtist';
import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector';
import ArtistIndexTableOptionsConnector from './Table/ArtistIndexTableOptionsConnector';
import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal';
import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector';
import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal';
@ -187,6 +189,7 @@ class ArtistIndex extends Component {
error,
totalItems,
items,
columns,
selectedFilterKey,
filters,
customFilters,
@ -245,35 +248,52 @@ class ArtistIndex extends Component {
alignContent={align.RIGHT}
collapseButtons={false}
>
{
view === 'table' ?
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
optionsComponent={ArtistIndexTableOptionsConnector}
>
<PageToolbarButton
label="Options"
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper> :
null
}
{
view === 'posters' &&
view === 'posters' ?
<PageToolbarButton
label="Options"
iconName={icons.POSTER}
isDisabled={hasNoArtist}
onPress={this.onPosterOptionsPress}
/>
/> :
null
}
{
view === 'banners' &&
view === 'banners' ?
<PageToolbarButton
label="Options"
iconName={icons.POSTER}
isDisabled={hasNoArtist}
onPress={this.onBannerOptionsPress}
/>
/> :
null
}
{
view === 'overview' &&
<PageToolbarButton
label="Options"
iconName={icons.OVERVIEW}
isDisabled={hasNoArtist}
onPress={this.onOverviewOptionsPress}
/>
view === 'overview' ?
<PageToolbarButton
label="Options"
iconName={icons.OVERVIEW}
isDisabled={hasNoArtist}
onPress={this.onOverviewOptionsPress}
/> :
null
}
{
@ -382,6 +402,7 @@ ArtistIndex.propTypes = {
error: PropTypes.object,
totalItems: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: 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,

@ -9,7 +9,7 @@ import createCommandExecutingSelector from 'Store/Selectors/createCommandExecuti
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { fetchArtist } from 'Store/Actions/artistActions';
import scrollPositions from 'Store/scrollPositions';
import { setArtistSort, setArtistFilter, setArtistView } from 'Store/Actions/artistIndexActions';
import { setArtistSort, setArtistFilter, setArtistView, setArtistTableOption } from 'Store/Actions/artistIndexActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import withScrollPosition from 'Components/withScrollPosition';
@ -66,13 +66,41 @@ function createMapStateToProps() {
);
}
const mapDispatchToProps = {
fetchArtist,
setArtistSort,
setArtistFilter,
setArtistView,
executeCommand
};
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchArtist() {
dispatch(fetchArtist);
},
onTableOptionChange(payload) {
dispatch(setArtistTableOption(payload));
},
onSortSelect(sortKey) {
dispatch(setArtistSort({ sortKey }));
},
onFilterSelect(selectedFilterKey) {
dispatch(setArtistFilter({ selectedFilterKey }));
},
dispatchSetArtistView(view) {
dispatch(setArtistView({ view }));
},
onRefreshArtistPress() {
dispatch(executeCommand({
name: commandNames.REFRESH_ARTIST
}));
},
onRssSyncPress() {
dispatch(executeCommand({
name: commandNames.RSS_SYNC
}));
}
};
}
class ArtistIndexConnector extends Component {
@ -94,24 +122,16 @@ class ArtistIndexConnector extends Component {
}
componentDidMount() {
this.props.fetchArtist();
this.props.dispatchFetchArtist();
}
//
// Listeners
onSortSelect = (sortKey) => {
this.props.setArtistSort({ sortKey });
}
onFilterSelect = (selectedFilterKey) => {
this.props.setArtistFilter({ selectedFilterKey });
}
onViewSelect = (view) => {
// Reset the scroll position before changing the view
this.setState({ scrollTop: 0 }, () => {
this.props.setArtistView({ view });
this.props.dispatchSetArtistView(view);
});
}
@ -123,18 +143,6 @@ class ArtistIndexConnector extends Component {
});
}
onRefreshArtistPress = () => {
this.props.executeCommand({
name: commandNames.REFRESH_ARTIST
});
}
onRssSyncPress = () => {
this.props.executeCommand({
name: commandNames.RSS_SYNC
});
}
//
// Render
@ -143,12 +151,8 @@ class ArtistIndexConnector extends Component {
<ArtistIndex
{...this.props}
scrollTop={this.state.scrollTop}
onSortSelect={this.onSortSelect}
onFilterSelect={this.onFilterSelect}
onViewSelect={this.onViewSelect}
onScroll={this.onScroll}
onRefreshArtistPress={this.onRefreshArtistPress}
onRssSyncPress={this.onRssSyncPress}
/>
);
}
@ -158,14 +162,10 @@ ArtistIndexConnector.propTypes = {
isSmallScreen: PropTypes.bool.isRequired,
view: PropTypes.string.isRequired,
scrollTop: PropTypes.number.isRequired,
fetchArtist: PropTypes.func.isRequired,
setArtistSort: PropTypes.func.isRequired,
setArtistFilter: PropTypes.func.isRequired,
setArtistView: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
dispatchFetchArtist: PropTypes.func.isRequired
};
export default withScrollPosition(
connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexConnector),
connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexConnector),
'artistIndex'
);

@ -34,12 +34,20 @@
composes: legendItemColor;
background-color: $dangerColor;
&:global(.colorImpaired) {
background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px);
}
}
.missingUnmonitored {
composes: legendItemColor;
background-color: $warningColor;
&:global(.colorImpaired) {
background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px);
}
}
.statistics {

@ -1,6 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import formatBytes from 'Utilities/Number/formatBytes';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import styles from './ArtistIndexFooter.css';
@ -40,79 +42,105 @@ function ArtistIndexFooter({ artist }) {
});
return (
<div className={styles.footer}>
<div>
<div className={styles.legendItem}>
<div className={styles.continuing} />
<div>Continuing (All tracks downloaded)</div>
</div>
<div className={styles.legendItem}>
<div className={styles.ended} />
<div>Ended (All tracks downloaded)</div>
</div>
<div className={styles.legendItem}>
<div className={styles.missingMonitored} />
<div>Missing Tracks (Artist monitored)</div>
</div>
<div className={styles.legendItem}>
<div className={styles.missingUnmonitored} />
<div>Missing Tracks (Artist not monitored)</div>
</div>
</div>
<div className={styles.statistics}>
<DescriptionList>
<DescriptionListItem
title="Artist"
data={count}
/>
<DescriptionListItem
title="Ended"
data={ended}
/>
<DescriptionListItem
title="Continuing"
data={continuing}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Monitored"
data={monitored}
/>
<DescriptionListItem
title="Unmonitored"
data={count - monitored}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Tracks"
data={tracks}
/>
<DescriptionListItem
title="Files"
data={trackFiles}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Total File Size"
data={formatBytes(totalFileSize)}
/>
</DescriptionList>
</div>
</div>
<ColorImpairedConsumer>
{(enableColorImpairedMode) => {
return (
<div className={styles.footer}>
<div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.continuing,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>Continuing (All tracks downloaded)</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.ended,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>Ended (All tracks downloaded)</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.missingMonitored,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>Missing Tracks (Artist monitored)</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.missingUnmonitored,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>Missing Tracks (Artist not monitored)</div>
</div>
</div>
<div className={styles.statistics}>
<DescriptionList>
<DescriptionListItem
title="Artist"
data={count}
/>
<DescriptionListItem
title="Ended"
data={ended}
/>
<DescriptionListItem
title="Continuing"
data={continuing}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Monitored"
data={monitored}
/>
<DescriptionListItem
title="Unmonitored"
data={count - monitored}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Tracks"
data={tracks}
/>
<DescriptionListItem
title="Files"
data={trackFiles}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Total File Size"
data={formatBytes(totalFileSize)}
/>
</DescriptionList>
</div>
</div>
);
}}
</ColorImpairedConsumer>
);
}

@ -26,10 +26,27 @@ $hoverScale: 1.05;
.link {
composes: link from 'Components/Link/Link.css';
position: relative;
display: block;
height: 70px;
background-color: $defaultColor;
}
.overlayTitle {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
width: 100%;
height: 100%;
color: $offWhite;
text-align: center;
font-size: 20px;
}
.nextAiring {
background-color: #fafbfc;
text-align: center;
@ -49,6 +66,7 @@ $hoverScale: 1.05;
position: absolute;
top: 0;
right: 0;
z-index: 1;
width: 0;
height: 0;
border-width: 0 25px 25px 0;

@ -22,6 +22,7 @@ class ArtistIndexPoster extends Component {
super(props, context);
this.state = {
hasPosterError: false,
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false
};
@ -49,6 +50,18 @@ class ArtistIndexPoster extends Component {
this.setState({ isDeleteArtistModalOpen: false });
}
onPosterLoad = () => {
if (this.state.hasPosterError) {
this.setState({ hasPosterError: false });
}
}
onPosterLoadError = () => {
if (!this.state.hasPosterError) {
this.setState({ hasPosterError: true });
}
}
//
// Render
@ -90,6 +103,7 @@ class ArtistIndexPoster extends Component {
} = statistics;
const {
hasPosterError,
isEditArtistModalOpen,
isDeleteArtistModalOpen
} = this.state;
@ -153,7 +167,17 @@ class ArtistIndexPoster extends Component {
size={250}
lazy={false}
overflow={true}
onError={this.onPosterLoadError}
onLoad={this.onPosterLoad}
/>
{
hasPosterError &&
<div className={styles.overlayTitle}>
{artistName}
</div>
}
</Link>
</div>

@ -10,6 +10,20 @@
flex: 4 0 110px;
}
.banner {
flex: 0 0 379px;
}
.bannerGrow {
flex-grow: 1;
}
.artistType {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 100px;
}
.qualityProfileId,
.languageProfileId,
.metadataProfileId {
@ -40,7 +54,6 @@
flex: 0 0 150px;
}
.artistType,
.trackCount {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';

@ -1,109 +1,86 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import React from 'react';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import hasGrowableColumns from './hasGrowableColumns';
import ArtistIndexTableOptionsConnector from './ArtistIndexTableOptionsConnector';
import styles from './ArtistIndexHeader.css';
class ArtistIndexHeader extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isTableOptionsModalOpen: false
};
}
//
// Listeners
onTableOptionsPress = () => {
this.setState({ isTableOptionsModalOpen: true });
}
onTableOptionsModalClose = () => {
this.setState({ isTableOptionsModalOpen: false });
}
//
// Render
render() {
const {
showSearchAction,
columns,
onTableOptionChange,
...otherProps
} = this.props;
return (
<VirtualTableHeader>
{
columns.map((column) => {
const {
name,
label,
isSortable,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
{...otherProps}
>
<IconButton
name={icons.ADVANCED_SETTINGS}
onPress={this.onTableOptionsPress}
/>
</VirtualTableHeaderCell>
);
}
function ArtistIndexHeader(props) {
const {
showBanners,
columns,
onTableOptionChange,
...otherProps
} = props;
return (
<VirtualTableHeader>
{
columns.map((column) => {
const {
name,
label,
isSortable,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={isSortable}
isSortable={false}
{...otherProps}
>
{label}
<TableOptionsModalWrapper
columns={columns}
optionsComponent={ArtistIndexTableOptionsConnector}
onTableOptionChange={onTableOptionChange}
>
<IconButton
name={icons.ADVANCED_SETTINGS}
/>
</TableOptionsModalWrapper>
</VirtualTableHeaderCell>
);
})
}
<TableOptionsModal
isOpen={this.state.isTableOptionsModalOpen}
columns={columns}
optionsComponent={ArtistIndexTableOptionsConnector}
onTableOptionChange={onTableOptionChange}
onModalClose={this.onTableOptionsModalClose}
/>
</VirtualTableHeader>
);
}
}
return (
<VirtualTableHeaderCell
key={name}
className={classNames(
styles[name],
name === 'sortName' && showBanners && styles.banner,
name === 'sortName' && showBanners && !hasGrowableColumns(columns) && styles.bannerGrow
)}
name={name}
isSortable={isSortable}
{...otherProps}
>
{label}
</VirtualTableHeaderCell>
);
})
}
</VirtualTableHeader>
);
}
ArtistIndexHeader.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired
onTableOptionChange: PropTypes.func.isRequired,
showBanners: PropTypes.bool.isRequired
};
export default ArtistIndexHeader;

@ -1,19 +1,69 @@
.status {
.cell {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
display: flex;
align-items: center;
}
.status {
composes: cell;
flex: 0 0 60px;
}
.sortName {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
composes: cell;
flex: 4 0 110px;
}
.artistType {
composes: cell;
flex: 0 0 100px;
}
.banner {
flex: 0 0 379px;
}
.bannerGrow {
flex-grow: 1;
}
.link {
composes: link from 'Components/Link/Link.css';
position: relative;
display: block;
height: 70px;
background-color: $defaultColor;
}
.bannerImage {
width: 379px;
height: 70px;
}
.overlayTitle {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
width: 100%;
height: 100%;
color: $offWhite;
text-align: center;
font-size: 20px;
}
.qualityProfileId,
.languageProfileId,
.metadataProfileId {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
composes: cell;
flex: 1 0 125px;
}
@ -22,19 +72,19 @@
.lastAlbum,
.added,
.genres {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
composes: cell;
flex: 0 0 180px;
}
.albumCount {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
composes: cell;
flex: 0 0 100px;
}
.trackProgress {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
composes: cell;
display: flex;
justify-content: center;
@ -42,21 +92,20 @@
flex-direction: column;
}
.artistType,
.trackCount {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
composes: cell;
flex: 0 0 130px;
}
.path {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
composes: cell;
flex: 1 0 150px;
}
.sizeOnDisk {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
composes: cell;
flex: 0 0 120px;
}
@ -68,21 +117,21 @@
}
.tags {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
composes: cell;
flex: 1 0 60px;
}
.useSceneNumbering {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
composes: cell;
flex: 0 0 145px;
}
.actions {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
composes: cell;
flex: 0 1 90px;
flex: 0 0 90px;
}
.checkInput {

@ -1,10 +1,12 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import getProgressBarKind from 'Utilities/Artist/getProgressBarKind';
import formatBytes from 'Utilities/Number/formatBytes';
import { icons } from 'Helpers/Props';
import HeartRating from 'Components/HeartRating';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
import TagListConnector from 'Components/TagListConnector';
@ -16,6 +18,8 @@ import ArtistNameLink from 'Artist/ArtistNameLink';
import AlbumTitleLink from 'Album/AlbumTitleLink';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import ArtistBanner from 'Artist/ArtistBanner';
import hasGrowableColumns from './hasGrowableColumns';
import ArtistStatusCell from './ArtistStatusCell';
import styles from './ArtistIndexRow.css';
@ -28,6 +32,7 @@ class ArtistIndexRow extends Component {
super(props, context);
this.state = {
hasBannerError: false,
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false
};
@ -57,6 +62,18 @@ class ArtistIndexRow extends Component {
//
}
onBannerLoad = () => {
if (this.state.hasBannerError) {
this.setState({ hasBannerError: false });
}
}
onBannerLoadError = () => {
if (!this.state.hasBannerError) {
this.setState({ hasBannerError: true });
}
}
//
// Render
@ -80,6 +97,8 @@ class ArtistIndexRow extends Component {
ratings,
path,
tags,
images,
showBanners,
showSearchAction,
columns,
isRefreshingArtist,
@ -97,6 +116,7 @@ class ArtistIndexRow extends Component {
} = statistics;
const {
hasBannerError,
isEditArtistModalOpen,
isDeleteArtistModalOpen
} = this.state;
@ -130,12 +150,40 @@ class ArtistIndexRow extends Component {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
className={classNames(
styles[name],
showBanners && styles.banner,
showBanners && !hasGrowableColumns(columns) && styles.bannerGrow
)}
>
<ArtistNameLink
foreignArtistId={foreignArtistId}
artistName={artistName}
/>
{
showBanners ?
<Link
className={styles.link}
to={`/artist/${foreignArtistId}`}
>
<ArtistBanner
className={styles.bannerImage}
images={images}
lazy={false}
overflow={true}
onError={this.onBannerLoadError}
onLoad={this.onBannerLoad}
/>
{
hasBannerError &&
<div className={styles.overlayTitle}>
{artistName}
</div>
}
</Link> :
<ArtistNameLink
foreignArtistId={foreignArtistId}
artistName={artistName}
/>
}
</VirtualTableRowCell>
);
}
@ -424,6 +472,8 @@ ArtistIndexRow.propTypes = {
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
ratings: PropTypes.object.isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
showBanners: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isRefreshingArtist: PropTypes.bool.isRequired,

@ -43,7 +43,8 @@ class ArtistIndexTable extends Component {
rowRenderer = ({ key, rowIndex, style }) => {
const {
items,
columns
columns,
showBanners
} = this.props;
const artist = items[rowIndex];
@ -58,6 +59,7 @@ class ArtistIndexTable extends Component {
languageProfileId={artist.languageProfileId}
qualityProfileId={artist.qualityProfileId}
metadataProfileId={artist.metadataProfileId}
showBanners={showBanners}
/>
);
}
@ -72,6 +74,7 @@ class ArtistIndexTable extends Component {
filters,
sortKey,
sortDirection,
showBanners,
isSmallScreen,
scrollTop,
contentBody,
@ -88,11 +91,12 @@ class ArtistIndexTable extends Component {
scrollIndex={this.state.scrollIndex}
contentBody={contentBody}
isSmallScreen={isSmallScreen}
rowHeight={38}
rowHeight={showBanners ? 70 : 38}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<ArtistIndexHeaderConnector
showBanners={showBanners}
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
@ -116,6 +120,7 @@ ArtistIndexTable.propTypes = {
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
showBanners: PropTypes.bool.isRequired,
scrollTop: PropTypes.number.isRequired,
jumpToCharacter: PropTypes.string,
contentBody: PropTypes.object.isRequired,

@ -11,7 +11,8 @@ function createMapStateToProps() {
(dimensions, artist) => {
return {
isSmallScreen: dimensions.isSmallScreen,
...artist
...artist,
showBanners: artist.tableOptions.showBanners
};
}
);

@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import React, { Component, Fragment } from 'react';
import { inputTypes } from 'Helpers/Props';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
@ -14,15 +14,23 @@ class ArtistIndexTableOptions extends Component {
super(props, context);
this.state = {
showBanners: props.showBanners,
showSearchAction: props.showSearchAction
};
}
componentDidUpdate(prevProps) {
const { showSearchAction } = this.props;
const {
showBanners,
showSearchAction
} = this.props;
if (showSearchAction !== prevProps.showSearchAction) {
if (
showBanners !== prevProps.showBanners ||
showSearchAction !== prevProps.showSearchAction
) {
this.setState({
showBanners,
showSearchAction
});
}
@ -49,26 +57,42 @@ class ArtistIndexTableOptions extends Component {
render() {
const {
showBanners,
showSearchAction
} = this.state;
return (
<FormGroup>
<FormLabel>Show Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText="Show search button"
onChange={this.onTableOptionChange}
/>
</FormGroup>
<Fragment>
<FormGroup>
<FormLabel>Show Banners</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showBanners"
value={showBanners}
helpText="Show banners instead of names"
onChange={this.onTableOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText="Show search button on hover"
onChange={this.onTableOptionChange}
/>
</FormGroup>
</Fragment>
);
}
}
ArtistIndexTableOptions.propTypes = {
showBanners: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired
};

@ -0,0 +1,17 @@
const growableColumns = [
'qualityProfileId',
'languageProfileId',
'path',
'tags'
];
export default function hasGrowableColumns(columns) {
return columns.some((column) => {
const {
name,
isVisible
} = column;
return growableColumns.includes(name) && isVisible;
});
}

@ -0,0 +1,33 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ArtistInteractiveSearchModalContent from './ArtistInteractiveSearchModalContent';
function ArtistInteractiveSearchModal(props) {
const {
isOpen,
artistId,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<ArtistInteractiveSearchModalContent
artistId={artistId}
onModalClose={onModalClose}
/>
</Modal>
);
}
ArtistInteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
artistId: PropTypes.number.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistInteractiveSearchModal;

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import ArtistInteractiveSearchModal from './ArtistInteractiveSearchModal';
function createMapDispatchToProps(dispatch, props) {
return {
onModalClose() {
dispatch(cancelFetchReleases());
dispatch(clearReleases());
props.onModalClose();
}
};
}
export default connect(null, createMapDispatchToProps)(ArtistInteractiveSearchModal);

@ -0,0 +1,45 @@
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 InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
function ArtistInteractiveSearchModalContent(props) {
const {
artistId,
onModalClose
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Interactive Search
</ModalHeader>
<ModalBody>
<InteractiveSearchConnector
type="artist"
searchPayload={{
artistId
}}
/>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
ArtistInteractiveSearchModalContent.propTypes = {
artistId: PropTypes.number.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistInteractiveSearchModalContent;

@ -3,15 +3,18 @@
overflow-x: hidden;
padding: 5px;
border-bottom: 1px solid $borderColor;
font-size: 14px;
font-size: $defaultFontSize;
&:hover {
background-color: $tableRowHoverBackgroundColor;
}
}
.status {
width: 10px;
.eventWrapper {
display: flex;
flex: 1 0 1px;
overflow-x: hidden;
padding-left: 6px;
border-left-width: 4px;
border-left-style: solid;
}
@ -24,6 +27,7 @@
.time {
flex: 0 0 120px;
margin-right: 10px;
border: none !important;
}
.artistName,
@ -80,16 +84,16 @@
@media only screen and (max-width: $breakpointSmall) {
.event {
position: relative;
flex-wrap: wrap;
padding-left: 10px;
flex-direction: column;
}
.eventWrapper {
display: block;
flex: 0 0 auto;
}
.status {
position: absolute;
top: 7%;
left: 0;
height: 86%;
.date {
margin-left: 10px;
}
.date,

@ -49,7 +49,8 @@ class AgendaEvent extends Component {
queueItem,
showDate,
timeFormat,
longDateFormat
longDateFormat,
colorImpairedMode
} = this.props;
const startTime = moment(releaseDate);
@ -74,8 +75,9 @@ class AgendaEvent extends Component {
<div
className={classNames(
styles.status,
styles[statusStyle]
styles.eventWrapper,
styles[statusStyle],
colorImpairedMode && 'colorImpaired'
)}
/>

@ -15,7 +15,8 @@ function createMapStateToProps() {
artist,
queueItem,
timeFormat: uiSettings.timeFormat,
longDateFormat: uiSettings.longDateFormat
longDateFormat: uiSettings.longDateFormat,
colorImpairedMode: uiSettings.enableColorImpairedMode
};
}
);

@ -41,8 +41,20 @@ class CalendarConnector extends Component {
}
componentDidMount() {
const {
useCurrentPage,
fetchCalendar,
gotoCalendarToday
} = this.props;
registerPagePopulator(this.repopulate);
this.props.gotoCalendarToday();
if (useCurrentPage) {
fetchCalendar();
} else {
gotoCalendarToday();
}
this.scheduleUpdate();
}

@ -10,7 +10,8 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import FilterMenu from 'Components/Menu/FilterMenu';
import NoArtist from 'Artist/NoArtist';
import CalendarLinkModal from './iCal/CalendarLinkModal';
import Legend from './Legend/Legend';
import CalendarOptionsModal from './Options/CalendarOptionsModal';
import LegendConnector from './Legend/LegendConnector';
import CalendarConnector from './CalendarConnector';
import styles from './CalendarPage.css';
@ -26,6 +27,7 @@ class CalendarPage extends Component {
this.state = {
isCalendarLinkModalOpen: false,
isOptionsModalOpen: false,
width: 0
};
}
@ -48,6 +50,23 @@ class CalendarPage extends Component {
this.setState({ isCalendarLinkModalOpen: false });
}
onOptionsPress = () => {
this.setState({ isOptionsModalOpen: true });
}
onOptionsModalClose = () => {
this.setState({ isOptionsModalOpen: false });
}
onSearchMissingPress = () => {
const {
missingAlbumIds,
onSearchMissingPress
} = this.props;
onSearchMissingPress(missingAlbumIds);
}
//
// Render
@ -56,17 +75,20 @@ class CalendarPage extends Component {
selectedFilterKey,
filters,
hasArtist,
colorImpairedMode,
missingAlbumIds,
isSearchingForMissing,
useCurrentPage,
onFilterSelect
} = this.props;
const isMeasured = this.state.width > 0;
const {
isCalendarLinkModalOpen,
isOptionsModalOpen
} = this.state;
let PageComponent = 'div';
const isMeasured = this.state.width > 0;
if (isMeasured) {
PageComponent = hasArtist ? CalendarConnector : NoArtist;
}
const PageComponent = hasArtist ? CalendarConnector : NoArtist;
return (
<PageContent title="Calendar">
@ -77,9 +99,23 @@ class CalendarPage extends Component {
iconName={icons.CALENDAR}
onPress={this.onGetCalendarLinkPress}
/>
<PageToolbarButton
label="Search for Missing"
iconName={icons.SEARCH}
isDisabled={!missingAlbumIds.length}
isSpinning={isSearchingForMissing}
onPress={this.onSearchMissingPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label="Options"
iconName={icons.POSTER}
onPress={this.onOptionsPress}
/>
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={!hasArtist}
@ -99,19 +135,31 @@ class CalendarPage extends Component {
whitelist={['width']}
onMeasure={this.onMeasure}
>
<PageComponent />
{
isMeasured ?
<PageComponent
useCurrentPage={useCurrentPage}
/> :
<div />
}
</Measure>
{
hasArtist &&
<Legend colorImpairedMode={colorImpairedMode} />
<LegendConnector />
}
</PageContentBodyConnector>
<CalendarLinkModal
isOpen={this.state.isCalendarLinkModalOpen}
isOpen={isCalendarLinkModalOpen}
onModalClose={this.onGetCalendarLinkModalClose}
/>
<CalendarOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={this.onOptionsModalClose}
/>
</PageContent>
);
}
@ -121,7 +169,10 @@ CalendarPage.propTypes = {
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
hasArtist: PropTypes.bool.isRequired,
colorImpairedMode: PropTypes.bool.isRequired,
missingAlbumIds: PropTypes.arrayOf(PropTypes.number).isRequired,
isSearchingForMissing: PropTypes.bool.isRequired,
useCurrentPage: PropTypes.bool.isRequired,
onSearchMissingPress: PropTypes.func.isRequired,
onDaysCountChange: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
};

@ -1,22 +1,80 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
import moment from 'moment';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import withCurrentPage from 'Components/withCurrentPage';
import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
import createArtistCountSelector from 'Store/Selectors/createArtistCountSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import CalendarPage from './CalendarPage';
function createMissingAlbumIdsSelector() {
return createSelector(
(state) => state.calendar.start,
(state) => state.calendar.end,
(state) => state.calendar.items,
(state) => state.queue.details.items,
(start, end, albums, queueDetails) => {
return albums.reduce((acc, album) => {
const releaseDate = album.releaseDate;
if (
album.percentOfTracks < 100 &&
moment(releaseDate).isAfter(start) &&
moment(releaseDate).isBefore(end) &&
isBefore(album.releaseDate) &&
!queueDetails.some((details) => !!details.album && details.album.id === album.id)
) {
acc.push(album.id);
}
return acc;
}, []);
}
);
}
function createIsSearchingSelector() {
return createSelector(
(state) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(commands.find((command) => {
return command.id === searchMissingCommandId;
}));
}
);
}
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
(state) => state.calendar.selectedFilterKey,
(state) => state.calendar.filters,
createArtistCountSelector(),
createUISettingsSelector(),
(calendar, artistCount, uiSettings) => {
createMissingAlbumIdsSelector(),
createIsSearchingSelector(),
(
selectedFilterKey,
filters,
artistCount,
uiSettings,
missingAlbumIds,
isSearchingForMissing
) => {
return {
selectedFilterKey: calendar.selectedFilterKey,
filters: calendar.filters,
showUpcoming: calendar.showUpcoming,
selectedFilterKey,
filters,
colorImpairedMode: uiSettings.enableColorImpairedMode,
hasArtist: !!artistCount
hasArtist: !!artistCount,
missingAlbumIds,
isSearchingForMissing
};
}
);
@ -24,6 +82,9 @@ function createMapStateToProps() {
function createMapDispatchToProps(dispatch, props) {
return {
onSearchMissingPress(albumIds) {
dispatch(searchMissing({ albumIds }));
},
onDaysCountChange(dayCount) {
dispatch(setCalendarDaysCount({ dayCount }));
},
@ -34,4 +95,6 @@ function createMapDispatchToProps(dispatch, props) {
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage);
export default withCurrentPage(
connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage)
);

@ -22,7 +22,7 @@
.artistName {
color: #3a3f51;
font-size: 14px;
font-size: $defaultFontSize;
}
.absoluteEpisodeNumber {
@ -53,7 +53,7 @@
border-left-color: $gray;
&:global(.colorImpaired) {
background: repeating-linear-gradient(45deg, transparent, transparent 5px, #eee 5px, #eee 10px);
background: repeating-linear-gradient(45deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
}
}
@ -61,7 +61,7 @@
border-left-color: $dangerColor;
&:global(.colorImpaired) {
background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
}
}
@ -69,6 +69,6 @@
border-left-color: $blue;
&:global(.colorImpaired) {
background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
}
}

@ -4,7 +4,6 @@ import React, { Component } from 'react';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import getStatusStyle from 'Calendar/getStatusStyle';
import albumEntities from 'Album/albumEntities';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import CalendarEventQueueDetails from './CalendarEventQueueDetails';

@ -1,9 +1,29 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons, kinds } from 'Helpers/Props';
import LegendItem from './LegendItem';
import LegendIconItem from './LegendIconItem';
import styles from './Legend.css';
function Legend({ colorImpairedMode }) {
function Legend(props) {
const {
showCutoffUnmetIcon,
colorImpairedMode
} = props;
const iconsToShow = [];
if (showCutoffUnmetIcon) {
iconsToShow.push(
<LegendIconItem
name="Cutoff Not Met"
icon={icons.TRACK_FILE}
kind={kinds.WARNING}
tooltip="Quality or language cutoff has not been met"
/>
);
}
return (
<div className={styles.legend}>
<div>
@ -47,11 +67,24 @@ function Legend({ colorImpairedMode }) {
colorImpairedMode={colorImpairedMode}
/>
</div>
<div>
{iconsToShow[0]}
</div>
{
iconsToShow.length > 1 &&
<div>
{iconsToShow[1]}
{iconsToShow[2]}
</div>
}
</div>
);
}
Legend.propTypes = {
showCutoffUnmetIcon: PropTypes.bool.isRequired,
colorImpairedMode: PropTypes.bool.isRequired
};

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import Legend from './Legend';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
createUISettingsSelector(),
(calendarOptions, uiSettings) => {
return {
...calendarOptions,
colorImpairedMode: uiSettings.enableColorImpairedMode
};
}
);
}
export default connect(createMapStateToProps)(Legend);

@ -0,0 +1,10 @@
.legendIconItem {
margin: 3px 0;
margin-right: 6px;
width: 150px;
cursor: default;
}
.icon {
margin-right: 5px;
}

@ -0,0 +1,37 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import styles from './LegendIconItem.css';
function LegendIconItem(props) {
const {
name,
icon,
kind,
tooltip
} = props;
return (
<div
className={styles.legendIconItem}
title={tooltip}
>
<Icon
className={styles.icon}
name={icon}
kind={kind}
/>
{name}
</div>
);
}
LegendIconItem.propTypes = {
name: PropTypes.string.isRequired,
icon: PropTypes.object.isRequired,
kind: PropTypes.string.isRequired,
tooltip: PropTypes.string.isRequired
};
export default LegendIconItem;

@ -1,13 +1,12 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import InteractiveSearchModalContentConnector from './InteractiveSearchModalContentConnector';
import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector';
function InteractiveSearchModal(props) {
function CalendarOptionsModal(props) {
const {
isOpen,
onModalClose,
...otherProps
onModalClose
} = props;
return (
@ -15,17 +14,16 @@ function InteractiveSearchModal(props) {
isOpen={isOpen}
onModalClose={onModalClose}
>
<InteractiveSearchModalContentConnector
{...otherProps}
<CalendarOptionsModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
InteractiveSearchModal.propTypes = {
CalendarOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default InteractiveSearchModal;
export default CalendarOptionsModal;

@ -0,0 +1,216 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Button from 'Components/Link/Button';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
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 { firstDayOfWeekOptions, weekColumnOptions, timeFormatOptions } from 'Settings/UI/UISettings';
class CalendarOptionsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
} = props;
this.state = {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
};
}
componentDidUpdate(prevProps) {
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
} = this.props;
if (
prevProps.firstDayOfWeek !== firstDayOfWeek ||
prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader ||
prevProps.timeFormat !== timeFormat ||
prevProps.enableColorImpairedMode !== enableColorImpairedMode
) {
this.setState({
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
});
}
}
//
// Listeners
onOptionInputChange = ({ name, value }) => {
const {
dispatchSetCalendarOption
} = this.props;
dispatchSetCalendarOption({ [name]: value });
}
onGlobalInputChange = ({ name, value }) => {
const {
dispatchSaveUISettings
} = this.props;
const setting = { [name]: value };
this.setState(setting, () => {
dispatchSaveUISettings(setting);
});
}
onLinkFocus = (event) => {
event.target.select();
}
//
// Render
render() {
const {
collapseMultipleAlbums,
showCutoffUnmetIcon,
onModalClose
} = this.props;
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Calendar Options
</ModalHeader>
<ModalBody>
<FieldSet legend="Local">
<Form>
<FormGroup>
<FormLabel>Collapse Multiple Albums</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="collapseMultipleAlbums"
value={collapseMultipleAlbums}
helpText="Collapse multiple albums releasing on the same day"
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Icon for Cutoff Unmet</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showCutoffUnmetIcon"
value={showCutoffUnmetIcon}
helpText="Show icon for files when the cutoff hasn't been met"
onChange={this.onOptionInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
<FieldSet legend="Global">
<Form>
<FormGroup>
<FormLabel>First Day of Week</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="firstDayOfWeek"
values={firstDayOfWeekOptions}
value={firstDayOfWeek}
onChange={this.onGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Week Column Header</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="calendarWeekColumnHeader"
values={weekColumnOptions}
value={calendarWeekColumnHeader}
onChange={this.onGlobalInputChange}
helpText="Shown above each column when week is the active view"
/>
</FormGroup>
<FormGroup>
<FormLabel>Time Format</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="timeFormat"
values={timeFormatOptions}
value={timeFormat}
onChange={this.onGlobalInputChange}
/>
</FormGroup><FormGroup>
<FormLabel>Enable Color-Impaired Mode</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableColorImpairedMode"
value={enableColorImpairedMode}
helpText="Altered style to allow color-impaired users to better distinguish color coded information"
onChange={this.onGlobalInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
CalendarOptionsModalContent.propTypes = {
collapseMultipleAlbums: PropTypes.bool.isRequired,
showCutoffUnmetIcon: PropTypes.bool.isRequired,
firstDayOfWeek: PropTypes.number.isRequired,
calendarWeekColumnHeader: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
enableColorImpairedMode: PropTypes.bool.isRequired,
dispatchSetCalendarOption: PropTypes.func.isRequired,
dispatchSaveUISettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CalendarOptionsModalContent;

@ -0,0 +1,25 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setCalendarOption } from 'Store/Actions/calendarActions';
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
import { saveUISettings } from 'Store/Actions/settingsActions';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
(state) => state.settings.ui.item,
(options, uiSettings) => {
return {
...options,
...uiSettings
};
}
);
}
const mapDispatchToProps = {
dispatchSetCalendarOption: setCalendarOption,
dispatchSaveUISettings: saveUISettings
};
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent);

@ -19,8 +19,10 @@ function getTagDisplayValue(value, selectedFilterBuilderProp) {
function getValue(input, selectedFilterBuilderProp) {
if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) {
const match = input.match(/^(\d+)([kmgt](i?b)?)$/i);
if (match && match.length > 1) {
const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i);
switch (unit.toLowerCase()) {
case 'k':
return convertToBytes(value, 1, true);
@ -118,6 +120,7 @@ class FilterBuilderRowValue extends Component {
name: tag && tag.name
};
}
return {
id,
name: getTagDisplayValue(id, selectedFilterBuilderProp)

@ -12,7 +12,7 @@ function createMapStateToProps() {
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
const {
isFetchingSchema: isFetching,
isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
schema

@ -44,7 +44,7 @@ class AlbumReleaseSelectInputConnector extends Component {
albumReleases
} = this.props;
let updatedReleases = _.map(albumReleases.value, (e) => ({ ...e, monitored: false }));
const updatedReleases = _.map(albumReleases.value, (e) => ({ ...e, monitored: false }));
_.find(updatedReleases, { foreignReleaseId: value }).monitored = true;
this.props.onChange({ name, value: updatedReleases });

@ -0,0 +1,58 @@
.input {
composes: input from 'Components/Form/Input.css';
}
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}
.inputWrapper {
display: flex;
}
.inputContainer {
position: relative;
flex-grow: 1;
}
.container {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
}
.inputContainerOpen {
.container {
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;
}
}
.list {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
.listItem {
padding: 0 16px;
}
.match {
font-weight: bold;
}
.highlighted {
background-color: $menuItemHoverBackgroundColor;
}

@ -0,0 +1,162 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames';
import jdu from 'jdu';
import styles from './AutoCompleteInput.css';
class AutoCompleteInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
suggestions: []
};
}
//
// Control
getSuggestionValue(item) {
return item;
}
renderSuggestion(item) {
return item;
}
//
// Listeners
onInputChange = (event, { newValue }) => {
this.props.onChange({
name: this.props.name,
value: newValue
});
}
onInputKeyDown = (event) => {
const {
name,
value,
onChange
} = this.props;
const { suggestions } = this.state;
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== this.props.value
) {
event.preventDefault();
if (value) {
onChange({
name,
value: suggestions[0]
});
}
}
}
onInputBlur = () => {
this.setState({ suggestions: [] });
}
onSuggestionsFetchRequested = ({ value }) => {
const { values } = this.props;
const lowerCaseValue = jdu.replace(value).toLowerCase();
const filteredValues = values.filter((v) => {
return jdu.replace(v).toLowerCase().contains(lowerCaseValue);
});
this.setState({ suggestions: filteredValues });
}
onSuggestionsClearRequested = () => {
this.setState({ suggestions: [] });
}
//
// Render
render() {
const {
className,
inputClassName,
name,
value,
placeholder,
hasError,
hasWarning
} = this.props;
const { suggestions } = this.state;
const inputProps = {
className: classNames(
inputClassName,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onBlur: this.onInputBlur
};
const theme = {
container: styles.inputContainer,
containerOpen: styles.inputContainerOpen,
suggestionsContainer: styles.container,
suggestionsList: styles.list,
suggestion: styles.listItem,
suggestionHighlighted: styles.highlighted
};
return (
<div className={className}>
<Autosuggest
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
</div>
);
}
}
AutoCompleteInput.propTypes = {
className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.string).isRequired,
placeholder: PropTypes.string,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
onChange: PropTypes.func.isRequired
};
AutoCompleteInput.defaultProps = {
className: styles.inputWrapper,
inputClassName: styles.input,
value: ''
};
export default AutoCompleteInput;

@ -0,0 +1,3 @@
.validationFailures {
margin-bottom: 20px;
}

@ -2,37 +2,42 @@ import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import Alert from 'Components/Alert';
import styles from './Form.css';
function Form({ children, validationErrors, validationWarnings, ...otherProps }) {
return (
<div>
<div>
{
validationErrors.map((error, index) => {
return (
<Alert
key={index}
kind={kinds.DANGER}
>
{error.errorMessage}
</Alert>
);
})
}
{
validationErrors.length || validationWarnings.length ?
<div className={styles.validationFailures}>
{
validationErrors.map((error, index) => {
return (
<Alert
key={index}
kind={kinds.DANGER}
>
{error.errorMessage}
</Alert>
);
})
}
{
validationWarnings.map((warning, index) => {
return (
<Alert
key={index}
kind={kinds.WARNING}
>
{warning.errorMessage}
</Alert>
);
})
}
</div>
{
validationWarnings.map((warning, index) => {
return (
<Alert
key={index}
kind={kinds.WARNING}
>
{warning.errorMessage}
</Alert>
);
})
}
</div> :
null
}
{children}
</div>

@ -2,9 +2,11 @@ import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes } from 'Helpers/Props';
import Link from 'Components/Link/Link';
import AutoCompleteInput from './AutoCompleteInput';
import CaptchaInputConnector from './CaptchaInputConnector';
import CheckInput from './CheckInput';
import DeviceInputConnector from './DeviceInputConnector';
import KeyValueListInput from './KeyValueListInput';
import MonitorAlbumsSelectInput from './MonitorAlbumsSelectInput';
import NumberInput from './NumberInput';
import OAuthInputConnector from './OAuthInputConnector';
@ -25,6 +27,9 @@ import styles from './FormInputGroup.css';
function getComponent(type) {
switch (type) {
case inputTypes.AUTO_COMPLETE:
return AutoCompleteInput;
case inputTypes.CAPTCHA:
return CaptchaInputConnector;
@ -34,6 +39,9 @@ function getComponent(type) {
case inputTypes.DEVICE:
return DeviceInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.MONITOR_ALBUMS_SELECT:
return MonitorAlbumsSelectInput;

@ -0,0 +1,21 @@
.inputContainer {
composes: input from 'Components/Form/Input.css';
position: relative;
min-height: 35px;
height: auto;
&.isFocused {
outline: 0;
border-color: $inputFocusBorderColor;
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
}
}
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}

@ -0,0 +1,152 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import KeyValueListInputItem from './KeyValueListInputItem';
import styles from './KeyValueListInput.css';
class KeyValueListInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isFocused: false
};
}
//
// Listeners
onItemChange = (index, itemValue) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
if (index == null) {
newValue.push(itemValue);
} else {
newValue.splice(index, 1, itemValue);
}
onChange({
name,
value: newValue
});
}
onRemoveItem = (index) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
newValue.splice(index, 1);
onChange({
name,
value: newValue
});
}
onFocus = () => {
this.setState({
isFocused: true
});
}
onBlur = () => {
this.setState({
isFocused: false
});
const {
name,
value,
onChange
} = this.props;
const newValue = value.reduce((acc, v) => {
if (v.key || v.value) {
acc.push(v);
}
return acc;
}, []);
if (newValue.length !== value.length) {
onChange({
name,
value: newValue
});
}
}
//
// Render
render() {
const {
className,
value,
keyPlaceholder,
valuePlaceholder
} = this.props;
const { isFocused } = this.state;
return (
<div className={classNames(
className,
isFocused && styles.isFocused
)}
>
{
[...value, { key: '', value: '' }].map((v, index) => {
return (
<KeyValueListInputItem
key={index}
index={index}
keyValue={v.key}
value={v.value}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
isNew={index === value.length}
onChange={this.onItemChange}
onRemove={this.onRemoveItem}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
);
})
}
</div>
);
}
}
KeyValueListInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.object).isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
keyPlaceholder: PropTypes.string,
valuePlaceholder: PropTypes.string,
onChange: PropTypes.func.isRequired
};
KeyValueListInput.defaultProps = {
className: styles.inputContainer,
value: []
};
export default KeyValueListInput;

@ -0,0 +1,14 @@
.itemContainer {
display: flex;
margin-bottom: 3px;
border-bottom: 1px solid $inputBorderColor;
&:last-child {
margin-bottom: 0;
}
}
.keyInput,
.valueInput {
border: none;
}

@ -0,0 +1,117 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import TextInput from './TextInput';
import styles from './KeyValueListInputItem.css';
class KeyValueListInputItem extends Component {
//
// Listeners
onKeyChange = ({ value: keyValue }) => {
const {
index,
value,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
}
onValueChange = ({ value }) => {
// TODO: Validate here or validate at a lower level component
const {
index,
keyValue,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
}
onRemovePress = () => {
const {
index,
onRemove
} = this.props;
onRemove(index);
}
onFocus = () => {
this.props.onFocus();
}
onBlur = () => {
this.props.onBlur();
}
//
// Render
render() {
const {
keyValue,
value,
keyPlaceholder,
valuePlaceholder,
isNew
} = this.props;
return (
<div className={styles.itemContainer}>
<TextInput
className={styles.keyInput}
name="key"
value={keyValue}
placeholder={keyPlaceholder}
onChange={this.onKeyChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
<TextInput
className={styles.valueInput}
name="value"
value={value}
placeholder={valuePlaceholder}
onChange={this.onValueChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
{
!isNew &&
<IconButton
name={icons.REMOVE}
tabIndex={-1}
onPress={this.onRemovePress}
/>
}
</div>
);
}
}
KeyValueListInputItem.propTypes = {
index: PropTypes.number,
keyValue: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
keyPlaceholder: PropTypes.string.isRequired,
valuePlaceholder: PropTypes.string.isRequired,
isNew: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
onFocus: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired
};
KeyValueListInputItem.defaultProps = {
keyPlaceholder: 'Key',
valuePlaceholder: 'Value'
};
export default KeyValueListInputItem;

@ -1,17 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import monitorOptions from 'Utilities/Artist/monitorOptions';
import SelectInput from './SelectInput';
const monitorOptions = [
{ key: 'all', value: 'All Albums' },
{ key: 'future', value: 'Future Albums' },
{ key: 'missing', value: 'Missing Albums' },
{ key: 'existing', value: 'Existing Albums' },
{ key: 'first', value: 'Only First Album' },
{ key: 'latest', value: 'Only Latest Album' },
{ key: 'none', value: 'None' }
];
function MonitorAlbumsSelectInput(props) {
const {
includeNoChange,

@ -2,44 +2,91 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextInput from './TextInput';
function parseValue(props, value) {
const {
isFloat,
min,
max
} = props;
if (value == null || value === '') {
return min;
}
let newValue = isFloat ? parseFloat(value) : parseInt(value);
if (min != null && newValue != null && newValue < min) {
newValue = min;
} else if (max != null && newValue != null && newValue > max) {
newValue = max;
}
return newValue;
}
class NumberInput extends Component {
//
// Listeners
// Lifecycle
onChange = ({ name, value }) => {
let newValue = null;
constructor(props, context) {
super(props, context);
this.state = {
value: props.value == null ? '' : props.value.toString(),
isFocused: false
};
}
if (value) {
newValue = this.props.isFloat ? parseFloat(value) : parseInt(value);
componentDidUpdate(prevProps, prevState) {
const { value } = this.props;
if (value !== prevProps.value && !this.state.isFocused) {
this.setState({
value: value == null ? '' : value.toString()
});
}
}
//
// Listeners
onChange = ({ name, value }) => {
this.setState({ value });
this.props.onChange({
name,
value: newValue
value: parseValue(this.props, value)
});
}
onFocus = () => {
this.setState({ isFocused: true });
}
onBlur = () => {
const {
name,
value,
min,
max,
onChange
} = this.props;
let newValue = value;
const { value } = this.state;
const parsedValue = parseValue(this.props, value);
const stringValue = parsedValue == null ? '' : parsedValue.toString();
if (min != null && newValue != null && newValue < min) {
newValue = min;
} else if (max != null && newValue != null && newValue > max) {
newValue = max;
if (stringValue === value) {
this.setState({ isFocused: false });
} else {
this.setState({
value: stringValue,
isFocused: false
});
}
onChange({
name,
value: newValue
value: parsedValue
});
}
@ -47,18 +94,16 @@ class NumberInput extends Component {
// Render
render() {
const {
value,
...otherProps
} = this.props;
const value = this.state.value;
return (
<TextInput
{...this.props}
type="number"
value={value == null ? '' : value}
{...otherProps}
onChange={this.onChange}
onBlur={this.onBlur}
onFocus={this.onFocus}
/>
);
}

@ -14,9 +14,7 @@
}
.freeSpace {
@add-mixin truncate;
flex: 1 0 0;
flex: 0 0 auto;
margin-left: 15px;
color: $gray;
text-align: right;

@ -33,7 +33,10 @@ class TagInputTag extends Component {
} = this.props;
return (
<Link onPress={this.onDelete}>
<Link
tabIndex={-1}
onPress={this.onDelete}
>
<Label kind={kind}>
{tag.name}
</Label>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save