Medium Support (Multi-disc Albums), Quality Grouping (#121)

* Multi Disc Stage 1 - Backend Work

* Quality Group Functionality

* Fixed: Only show wanted album types on ArtistDetail page

* Add Media Count Column to ArtistDetail Page

* Parser updates for multidisc cases, other usenet release title formats

* Search for Tracks by Medium Number in Addition to Title and TrackNumber

* Medium Renaming Token for Track Naming

* fixup Codacy and Comment Cleanup

* fixup remove comments
This commit is contained in:
Qstick 2017-11-15 21:24:33 -05:00 committed by GitHub
commit 21428cba6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
154 changed files with 2946 additions and 701 deletions

View file

@ -43,7 +43,11 @@ class HistoryConnector extends Component {
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
const albumIds = selectUniqueIds(this.props.items, 'albumId');
this.props.fetchEpisodes({ albumIds });
if (albumIds.length) {
this.props.fetchEpisodes({ albumIds });
} else {
this.props.clearEpisodes();
}
}
}

View file

@ -52,7 +52,11 @@ class QueueConnector extends Component {
componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) {
const albumIds = selectUniqueIds(this.props.items, 'albumId');
this.props.fetchEpisodes({ albumIds });
if (albumIds.length) {
this.props.fetchEpisodes({ albumIds });
} else {
this.props.clearEpisodes();
}
}
}

View file

@ -44,7 +44,7 @@ class AddNewArtistSearchResult extends Component {
componentDidUpdate(prevProps) {
if (!prevProps.isExistingArtist && this.props.isExistingArtist) {
this.onAddSerisModalClose();
this.onAddArtistModalClose();
}
}
@ -55,7 +55,7 @@ class AddNewArtistSearchResult extends Component {
this.setState({ isNewAddArtistModalOpen: true });
}
onAddSerisModalClose = () => {
onAddArtistModalClose = () => {
this.setState({ isNewAddArtistModalOpen: false });
}
@ -183,7 +183,7 @@ class AddNewArtistSearchResult extends Component {
year={year}
overview={overview}
images={images}
onModalClose={this.onAddSerisModalClose}
onModalClose={this.onAddArtistModalClose}
/>
</Link>
);

View file

@ -2,13 +2,12 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearReleases } from 'Store/Actions/releaseActions';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import episodeEntities from 'Album/episodeEntities';
import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
function createMapStateToProps() {
@ -32,14 +31,38 @@ function createMapStateToProps() {
);
}
const mapDispatchToProps = {
clearReleases,
fetchTracks,
clearTracks,
fetchTrackFiles,
clearTrackFiles,
toggleEpisodeMonitored
};
function createMapDispatchToProps(dispatch, props) {
return {
dispatchCancelFetchReleases() {
dispatch(cancelFetchReleases());
},
dispatchClearReleases() {
dispatch(clearReleases());
},
dispatchFetchTracks({ artistId, albumId }) {
dispatch(fetchTracks({ artistId, albumId }));
},
dispatchClearTracks() {
dispatch(clearTracks());
},
onMonitorAlbumPress(monitored) {
const {
albumId,
episodeEntity
} = this.props;
dispatch(toggleEpisodeMonitored({
episodeEntity,
albumId,
monitored
}));
}
};
}
class EpisodeDetailsModalContentConnector extends Component {
@ -53,7 +76,8 @@ class EpisodeDetailsModalContentConnector extends Component {
// Clear pending releases here so we can reshow the search
// results even after switching tabs.
this._unpopulate();
this.props.clearReleases();
this.props.dispatchCancelFetchReleases();
this.props.dispatchClearReleases();
}
//
@ -62,40 +86,24 @@ class EpisodeDetailsModalContentConnector extends Component {
_populate() {
const artistId = this.props.artistId;
const albumId = this.props.albumId;
this.props.fetchTracks({ artistId, albumId });
// this.props.fetchTrackFiles({ artistId, albumId });
this.props.dispatchFetchTracks({ artistId, albumId });
}
_unpopulate() {
this.props.clearTracks();
// this.props.clearTrackFiles();
}
//
// Listeners
onMonitorAlbumPress = (monitored) => {
const {
albumId,
episodeEntity
} = this.props;
this.props.toggleEpisodeMonitored({
episodeEntity,
albumId,
monitored
});
this.props.dispatchClearTracks();
}
//
// Render
render() {
const {
dispatchClearReleases,
...otherProps
} = this.props;
return (
<EpisodeDetailsModalContent
{...this.props}
onMonitorAlbumPress={this.onMonitorAlbumPress}
/>
<EpisodeDetailsModalContent {...otherProps} />
);
}
}
@ -104,16 +112,14 @@ EpisodeDetailsModalContentConnector.propTypes = {
albumId: PropTypes.number.isRequired,
episodeEntity: PropTypes.string.isRequired,
artistId: PropTypes.number.isRequired,
fetchTracks: PropTypes.func.isRequired,
clearTracks: PropTypes.func.isRequired,
fetchTrackFiles: PropTypes.func.isRequired,
clearTrackFiles: PropTypes.func.isRequired,
clearReleases: PropTypes.func.isRequired,
toggleEpisodeMonitored: PropTypes.func.isRequired
dispatchFetchTracks: PropTypes.func.isRequired,
dispatchClearTracks: PropTypes.func.isRequired,
dispatchCancelFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired
};
EpisodeDetailsModalContentConnector.defaultProps = {
episodeEntity: episodeEntities.EPISODES
};
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeDetailsModalContentConnector);
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector);

View file

@ -3,20 +3,24 @@ import React from 'react';
import Label from 'Components/Label';
function EpisodeLanguage(props) {
const language = props.language;
const {
className,
language
} = props;
if (!language) {
return null;
}
return (
<Label>
<Label className={className}>
{language.name}
</Label>
);
}
EpisodeLanguage.propTypes = {
className: PropTypes.string,
language: PropTypes.object
};

View file

@ -24,6 +24,7 @@ function getTooltip(title, quality, size) {
function EpisodeQuality(props) {
const {
className,
title,
quality,
size,
@ -32,6 +33,7 @@ function EpisodeQuality(props) {
return (
<Label
className={className}
kind={isCutoffNotMet ? kinds.INVERSE : kinds.DEFAULT}
title={getTooltip(title, quality, size)}
>
@ -41,6 +43,7 @@ function EpisodeQuality(props) {
}
EpisodeQuality.propTypes = {
className: PropTypes.string,
title: PropTypes.string,
quality: PropTypes.object.isRequired,
size: PropTypes.number,

View file

@ -1,6 +1,5 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import titleCase from 'Utilities/String/titleCase';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
@ -14,6 +13,18 @@ import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConn
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import styles from './AlbumHistoryRow.css';
function getTitle(eventType) {
switch (eventType) {
case 'grabbed': return 'Grabbed';
case 'artistFolderImported': return 'Artist Folder Imported';
case 'downloadFolderImported': return 'Download Folder Imported';
case 'downloadFailed': return 'Download Failed';
case 'trackFileDeleted': return 'Track File Deleted';
case 'trackFileRenamed': return 'Track File Renamed';
default: return 'Unknown';
}
}
class AlbumHistoryRow extends Component {
//
@ -89,7 +100,7 @@ class AlbumHistoryRow extends Component {
name={icons.INFO}
/>
}
title={titleCase(eventType)}
title={getTitle(eventType)}
body={
<HistoryDetailsConnector
eventType={eventType}

View file

@ -15,7 +15,9 @@ function createMapStateToProps() {
createCommandsSelector(),
createDimensionsSelector(),
(tracks, episode, commands, dimensions) => {
const items = _.filter(tracks.items, { albumId: episode.id });
const filteredItems = _.filter(tracks.items, { albumId: episode.id });
const mediumSortedItems = _.orderBy(filteredItems, 'absoluteTrackNumber');
const items = _.orderBy(mediumSortedItems, 'mediumNumber');
return {
network: episode.label,

View file

@ -24,7 +24,8 @@ class TrackDetailRow extends Component {
const {
id,
title,
trackNumber,
mediumNumber,
absoluteTrackNumber,
duration,
columns,
trackFileId
@ -43,13 +44,24 @@ class TrackDetailRow extends Component {
return null;
}
if (name === 'trackNumber') {
if (name === 'medium') {
return (
<TableRowCell
key={name}
className={styles.trackNumber}
>
{trackNumber}
{mediumNumber}
</TableRowCell>
);
}
if (name === 'absoluteTrackNumber') {
return (
<TableRowCell
key={name}
className={styles.trackNumber}
>
{absoluteTrackNumber}
</TableRowCell>
);
}
@ -117,7 +129,8 @@ TrackDetailRow.propTypes = {
duration: PropTypes.number.isRequired,
trackFileId: PropTypes.number.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
trackNumber: PropTypes.number.isRequired
mediumNumber: PropTypes.number.isRequired,
absoluteTrackNumber: PropTypes.number.isRequired
};
export default TrackDetailRow;

View file

@ -63,6 +63,7 @@ class AlbumRow extends Component {
statistics,
duration,
releaseDate,
mediumCount,
title,
isSaving,
artistMonitored,
@ -131,6 +132,16 @@ class AlbumRow extends Component {
);
}
if (name === 'mediumCount') {
return (
<TableRowCell key={name}>
{
mediumCount
}
</TableRowCell>
);
}
if (name === 'trackCount') {
return (
<TableRowCell key={name}>
@ -203,6 +214,7 @@ AlbumRow.propTypes = {
artistId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
releaseDate: PropTypes.string.isRequired,
mediumCount: PropTypes.number.isRequired,
duration: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
isSaving: PropTypes.bool,

View file

@ -47,10 +47,18 @@
}
.info {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
}
.titleRow {
display: flex;
justify-content: space-between;
flex: 0 0 auto;
}
.titleContainer {
display: flex;
justify-content: space-between;
@ -111,6 +119,11 @@
font-family: $monoSpaceFontFamily;
}
.overview {
flex: 1 0 auto;
min-height: 0;
}
.contentContainer {
padding: 20px;
}

View file

@ -6,6 +6,7 @@ import formatBytes from 'Utilities/Number/formatBytes';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import fonts from 'Styles/Variables/fonts';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
@ -31,33 +32,8 @@ import ArtistTagsConnector from './ArtistTagsConnector';
import ArtistDetailsLinks from './ArtistDetailsLinks';
import styles from './ArtistDetails.css';
const albumTypes = [
{
name: 'album',
label: 'Album',
isVisible: true
},
{
name: 'ep',
label: 'EP',
isVisible: true
},
{
name: 'single',
label: 'Single',
isVisible: true
},
{
name: 'broadcast',
label: 'Broadcast',
isVisible: true
},
{
name: 'other',
label: 'Other',
isVisible: true
}
];
const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
function getFanartUrl(images) {
const fanartImage = _.find(images, { coverType: 'fanart' });
@ -174,6 +150,7 @@ class ArtistDetails extends Component {
links,
images,
albums,
primaryAlbumTypes,
alternateTitles,
tags,
isRefreshing,
@ -475,11 +452,9 @@ class ArtistDetails extends Component {
}
</div>
<div>
<div className={styles.overview}>
<TextTruncate
truncateText="…"
line={8}
line={Math.floor(200 / (defaultFontSize * lineHeight))}
text={overview}
/>
</div>
@ -495,26 +470,27 @@ class ArtistDetails extends Component {
{
!isFetching && episodesError &&
<div>Loading episodes failed</div>
<div>Loading albums failed</div>
}
{
!isFetching && trackFilesError &&
<div>Loading episode files failed</div>
<div>Loading track files failed</div>
}
{
isPopulated && !!albumTypes.length &&
isPopulated && !!primaryAlbumTypes.length &&
<div>
{
albumTypes.slice(0).map((season) => {
primaryAlbumTypes.slice(0).map((albumType) => {
return (
<ArtistDetailsSeasonConnector
key={season.name}
key={albumType}
artistId={id}
label={season.label}
{...season}
isExpanded={expandedState[season.name]}
name={albumType}
label={albumType}
{...albumType}
isExpanded={expandedState[albumType]}
onExpandPress={this.onExpandPress}
/>
);
@ -570,6 +546,7 @@ ArtistDetails.propTypes = {
links: PropTypes.arrayOf(PropTypes.object).isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
albums: PropTypes.arrayOf(PropTypes.object).isRequired,
primaryAlbumTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
isRefreshing: PropTypes.bool.isRequired,

View file

@ -47,7 +47,9 @@ $hoverScale: 1.05;
}
.info {
display: flex;
flex: 1 0 1px;
flex-direction: column;
overflow: hidden;
padding-left: 10px;
}
@ -75,6 +77,7 @@ $hoverScale: 1.05;
.details {
display: flex;
justify-content: space-between;
flex: 1 0 auto;
}
.overview {
@ -82,6 +85,7 @@ $hoverScale: 1.05;
flex: 0 1 1000px;
overflow: hidden;
min-height: 0;
}
@media only screen and (max-width: $breakpointSmall) {

View file

@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Truncate from 'react-truncate';
import TextTruncate from 'react-text-truncate';
import { icons } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
@ -176,16 +176,15 @@ class ArtistIndexOverview extends Component {
</div>
<div className={styles.details}>
<Link
className={styles.overview}
style={{
maxHeight: `${height}px`
}}
to={link}
>
<Truncate lines={Math.floor(height / (defaultFontSize * lineHeight))}>
{overview}
</Truncate>
<TextTruncate
line={Math.floor(height / (defaultFontSize * lineHeight))}
text={overview}
/>
</Link>
<ArtistIndexOverviewInfo

View file

@ -56,7 +56,9 @@ class CalendarConnector extends Component {
const albumIds = selectUniqueIds(items, 'id');
// const trackFileIds = selectUniqueIds(items, 'trackFileId');
this.props.fetchQueueDetails({ albumIds });
if (items.length) {
this.props.fetchQueueDetails({ albumIds });
}
// if (trackFileIds.length) {
// this.props.fetchTrackFiles({ trackFileIds });

View file

@ -27,6 +27,10 @@
background-color: #aaa;
}
.isHidden {
display: none;
}
.isMobile {
height: 50px;
border-bottom: 1px solid $borderColor;

View file

@ -28,6 +28,7 @@ class EnhancedSelectInputOption extends Component {
className,
isSelected,
isDisabled,
isHidden,
isMobile,
children
} = this.props;
@ -38,6 +39,7 @@ class EnhancedSelectInputOption extends Component {
className,
isSelected && styles.isSelected,
isDisabled && styles.isDisabled,
isHidden && styles.isHidden,
isMobile && styles.isMobile
)}
component="div"
@ -64,6 +66,7 @@ EnhancedSelectInputOption.propTypes = {
id: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired,
isHidden: PropTypes.bool.isRequired,
isMobile: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
onSelect: PropTypes.func.isRequired
@ -71,7 +74,8 @@ EnhancedSelectInputOption.propTypes = {
EnhancedSelectInputOption.defaultProps = {
className: styles.option,
isDisabled: false
isDisabled: false,
isHidden: false
};
export default EnhancedSelectInputOption;

View file

@ -5,6 +5,10 @@
/* Sizes */
.extraSmall {
max-width: $formGroupExtraSmallWidth;
}
.small {
max-width: $formGroupSmallWidth;
}

View file

@ -41,7 +41,7 @@ function FormGroup(props) {
FormGroup.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
size: PropTypes.string.isRequired,
size: PropTypes.oneOf(sizes.all).isRequired,
advancedSettings: PropTypes.bool.isRequired,
isAdvanced: PropTypes.bool.isRequired
};

View file

@ -1,7 +1,6 @@
.label {
display: flex;
justify-content: flex-end;
flex: 0 0 $formLabelWidth;
margin-right: $formLabelRightMarginWidth;
font-weight: bold;
line-height: 35px;
@ -20,3 +19,12 @@
justify-content: flex-start;
}
}
.small {
flex: 0 0 $formLabelSmallWidth;
}
.large {
flex: 0 0 $formLabelLargeWidth;
}

View file

@ -1,12 +1,14 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import { sizes } from 'Helpers/Props';
import styles from './FormLabel.css';
function FormLabel({
children,
className,
errorClassName,
size,
name,
hasError,
isAdvanced,
@ -17,6 +19,7 @@ function FormLabel({
{...otherProps}
className={classNames(
className,
styles[size],
hasError && errorClassName,
isAdvanced && styles.isAdvanced
)}
@ -31,6 +34,7 @@ FormLabel.propTypes = {
children: PropTypes.node.isRequired,
className: PropTypes.string,
errorClassName: PropTypes.string,
size: PropTypes.oneOf(sizes.all),
name: PropTypes.string,
hasError: PropTypes.bool,
isAdvanced: PropTypes.bool.isRequired
@ -39,7 +43,8 @@ FormLabel.propTypes = {
FormLabel.defaultProps = {
className: styles.label,
errorClassName: styles.hasError,
isAdvanced: false
isAdvanced: false,
size: sizes.LARGE
};
export default FormLabel;

View file

@ -22,20 +22,19 @@ class RootFolderSelectInput extends Component {
componentDidUpdate(prevProps) {
const {
name,
values,
isSaving,
saveError,
onChange
} = this.props;
const newRootFolderPath = this.state.newRootFolderPath;
if (
prevProps.isSaving &&
!isSaving &&
!saveError &&
values.length - prevProps.values.length === 1
newRootFolderPath
) {
const newRootFolderPath = this.state.newRootFolderPath;
onChange({ name, value: newRootFolderPath });
this.setState({ newRootFolderPath: '' });
}

View file

@ -33,7 +33,8 @@ function createMapStateToProps() {
values.push({
key: '',
value: '',
isDisabled: true
isDisabled: true,
isHidden: true
});
}
@ -64,6 +65,18 @@ class RootFolderSelectInputConnector extends Component {
//
// Lifecycle
componentWillMount() {
const {
value,
values,
onChange
} = this.props;
if (value == null && values[0].key === '') {
onChange({ name, value: '' });
}
}
componentDidMount() {
const {
name,

View file

@ -12,9 +12,9 @@ const messages = [
'Hum something loud while others stare',
'Loading humorous message... Please Wait',
'I could\'ve been faster in Python',
'Don\'t forget to rewind your episodes',
'Don\'t forget to rewind your tracks',
'Congratulations! you are the 1000th visitor.',
'HELP!, I\'m being held hostage and forced to write these stupid lines!',
'HELP! I\'m being held hostage and forced to write these stupid lines!',
'RE-calibrating the internet...',
'I\'ll be here all week',
'Don\'t forget to tip your waitress',

View file

@ -51,6 +51,18 @@
width: 1080px;
}
.extraLarge {
composes: modal;
width: 1440px;
}
@media only screen and (max-width: $breakpointExtraLarge) {
.modal.extraLarge {
width: 90%;
}
}
@media only screen and (max-width: $breakpointLarge) {
.modal.large {
width: 90%;
@ -71,9 +83,10 @@
.modal.small,
.modal.medium,
.modal.large {
.modal.large,
.modal.extraLarge {
max-height: 100%;
width: 100%;
height: 100%;
height: 100% !important;
}
}

View file

@ -139,6 +139,7 @@ class Modal extends Component {
render() {
const {
className,
style,
backdropClassName,
size,
children,
@ -166,6 +167,7 @@ class Modal extends Component {
className,
styles[size]
)}
style={style}
>
{children}
</div>
@ -180,6 +182,7 @@ class Modal extends Component {
Modal.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
backdropClassName: PropTypes.string,
size: PropTypes.oneOf(sizes.all),
children: PropTypes.node,

View file

@ -1,5 +1,3 @@
$modalBodyPadding: 30px;
.modalBody {
flex: 1 0 1px;
padding: $modalBodyPadding;

View file

@ -23,13 +23,13 @@ class PageHeader extends Component {
}
componentDidMount() {
this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.openKeyboardShortcutsModal);
this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal);
}
//
// Control
openKeyboardShortcutsModal = () => {
onOpenKeyboardShortcutsModal = () => {
this.setState({ isKeyboardShortcutsModalOpen: true });
}
@ -76,7 +76,9 @@ class PageHeader extends Component {
name={icons.HEART}
to="https://lidarr.audio/donate.html"
/>
<PageHeaderActionsMenuConnector />
<PageHeaderActionsMenuConnector
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
/>
</div>
<KeyboardShortcutsModal

View file

@ -11,6 +11,7 @@ import styles from './PageHeaderActionsMenu.css';
function PageHeaderActionsMenu(props) {
const {
formsAuth,
onKeyboardShortcutsPress,
onRestartPress,
onShutdownPress
} = props;
@ -25,6 +26,16 @@ function PageHeaderActionsMenu(props) {
</MenuButton>
<MenuContent>
<MenuItem onPress={onKeyboardShortcutsPress}>
<Icon
className={styles.itemIcon}
name={icons.KEYBOARD}
/>
Keyboard Shortcuts
</MenuItem>
<div className={styles.separator} />
<MenuItem onPress={onRestartPress}>
<Icon
className={styles.itemIcon}
@ -68,6 +79,7 @@ function PageHeaderActionsMenu(props) {
PageHeaderActionsMenu.propTypes = {
formsAuth: PropTypes.bool.isRequired,
onKeyboardShortcutsPress: PropTypes.func.isRequired,
onRestartPress: PropTypes.func.isRequired,
onShutdownPress: PropTypes.func.isRequired
};

View file

@ -26,6 +26,14 @@ function getState(status) {
}
}
function isAppDisconnected(disconnectedTime) {
if (!disconnectedTime) {
return false;
}
return Math.floor(new Date().getTime() / 1000) - disconnectedTime > 180;
}
function createMapStateToProps() {
return createSelector(
(state) => state.app.isReconnecting,
@ -66,6 +74,7 @@ class SignalRConnector extends Component {
this.signalRconnection = null;
this.retryInterval = 5;
this.retryTimeoutId = null;
this.disconnectedTime = null;
}
componentDidMount() {
@ -90,7 +99,7 @@ class SignalRConnector extends Component {
// Control
retryConnection = () => {
if (this.retryInterval >= 30) {
if (isAppDisconnected(this.disconnectedTime)) {
this.setState({
isDisconnected: true
});
@ -290,6 +299,9 @@ class SignalRConnector extends Component {
console.log(`SignalR: ${state}`);
if (state === 'connected') {
// Clear disconnected time
this.disconnectedTime = null;
// Repopulate the page (if a repopulator is set) to ensure things
// are in sync after reconnecting.
@ -322,6 +334,10 @@ class SignalRConnector extends Component {
return;
}
if (!this.disconnectedTime) {
this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
}
this.props.setAppValue({
isReconnecting: true
});
@ -332,11 +348,14 @@ class SignalRConnector extends Component {
return;
}
if (!this.disconnectedTime) {
this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
}
this.props.setAppValue({
isConnected: false,
isReconnecting: true
// Don't set isDisconnected yet, it'll be set it if it's disconnected
// for ~105 seconds (retry interval reaches 30 seconds)
isReconnecting: true,
isDisconnected: isAppDisconnected(this.disconnectedTime)
});
this.retryConnection();

View file

@ -8,7 +8,7 @@ import TableOptionsColumn from './TableOptionsColumn';
import styles from './TableOptionsColumnDragPreview.css';
const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
const formLabelWidth = parseInt(dimensions.formLabelWidth);
const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth);
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
@ -40,7 +40,7 @@ class TableOptionsColumnDragPreview extends Component {
// list item and the preview is wider than the drag handle.
const { x, y } = currentOffset;
const handleOffset = formGroupSmallWidth - formLabelWidth - formLabelRightMarginWidth - dragHandleWidth;
const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth;
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
const style = {

View file

@ -53,6 +53,14 @@ class Popover extends Component {
this.state = {
isOpen: false
};
this._closeTimeout = null;
}
componentWillUnmount() {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
}
//
@ -63,11 +71,17 @@ class Popover extends Component {
}
onMouseEnter = () => {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
this.setState({ isOpen: true });
}
onMouseLeave = () => {
this.setState({ isOpen: false });
this._closeTimeout = setTimeout(() => {
this.setState({ isOpen: false });
}, 100);
}
//
@ -98,24 +112,28 @@ class Popover extends Component {
{
this.state.isOpen &&
<div className={styles.popoverContainer}>
<div className={styles.popover}>
<div
className={classNames(
styles.arrow,
styles[position]
)}
/>
<div
className={styles.popoverContainer}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
<div className={styles.popover}>
<div
className={classNames(
styles.arrow,
styles[position]
)}
/>
<div className={styles.title}>
{title}
</div>
<div className={styles.title}>
{title}
</div>
<div className={styles.body}>
{body}
</div>
<div className={styles.body}>
{body}
</div>
</div>
</div>
}
</TetherComponent>
);

View file

@ -50,11 +50,17 @@ class Tooltip extends Component {
constructor(props, context) {
super(props, context);
this._closeTimeout = null;
this.state = {
isOpen: false
};
this._closeTimeout = null;
}
componentWillUnmount() {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
}
//
@ -83,6 +89,7 @@ class Tooltip extends Component {
render() {
const {
className,
anchor,
tooltip,
kind,
@ -97,6 +104,7 @@ class Tooltip extends Component {
{...tetherOptions[position]}
>
<span
className={className}
// onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
@ -137,6 +145,7 @@ class Tooltip extends Component {
}
Tooltip.propTypes = {
className: PropTypes.string,
anchor: PropTypes.node.isRequired,
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),

View file

@ -36,11 +36,13 @@ export const FILE = 'fa fa-file-o';
export const FILTER = 'fa fa-filter';
export const FOLDER = 'fa fa-folder-o';
export const FOLDER_OPEN = 'fa fa-folder-open';
export const GROUP = 'fa fa-object-group';
export const HEALTH = 'fa fa-medkit';
export const HEART = 'fa fa-heart';
export const HOUSEKEEPING = 'fa fa-home';
export const INFO = 'fa fa-info-circle';
export const INTERACTIVE = 'fa fa-user';
export const KEYBOARD = 'fa fa-keyboard-o';
export const LOGOUT = 'fa fa-sign-out';
export const MISSING = 'fa fa-exclamation-triangle';
export const MONITORED = 'fa fa-bookmark';
@ -82,6 +84,7 @@ export const SUBTRACT = 'fa fa-minus';
export const SYSTEM = 'fa fa-laptop';
export const TAGS = 'fa fa-tags';
export const TBA = 'fa fa-question-circle';
export const UNGROUP = 'fa fa-object-ungroup';
export const UNKNOWN = 'fa fa-question';
export const UNMONITORED = 'fa fa-bookmark-o';
export const UPDATE = 'fa fa-retweet';

View file

@ -1,5 +1,7 @@
export const EXTRA_SMALL = 'extraSmall';
export const SMALL = 'small';
export const MEDIUM = 'medium';
export const LARGE = 'large';
export const EXTRA_LARGE = 'extraLarge';
export const all = [SMALL, MEDIUM, LARGE];
export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE];

View file

@ -4,8 +4,15 @@
word-break: break-all;
}
.quality {
.quality,
.language {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
text-align: center;
}
.label {
composes: label from 'Components/Label.css';
pointer-events: none;
}

View file

@ -238,6 +238,7 @@ class InteractiveImportRow extends Component {
onPress={this.onSelectQualityPress}
>
<EpisodeQuality
className={styles.label}
quality={quality}
/>
</TableRowCellButton>
@ -247,6 +248,7 @@ class InteractiveImportRow extends Component {
onPress={this.onSelectLanguagePress}
>
<EpisodeLanguage
className={styles.label}
language={language}
/>
</TableRowCellButton>

View file

@ -70,10 +70,10 @@ class SelectQualityModalContent extends Component {
real
} = this.state;
const qualityOptions = items.map(({ quality }) => {
const qualityOptions = items.map(({ id, name }) => {
return {
key: quality.id,
value: quality.name
key: id,
value: name
};
});

View file

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import getQualities from 'Utilities/Quality/getQualities';
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
import SelectQualityModalContent from './SelectQualityModalContent';
@ -22,7 +23,7 @@ function createMapStateToProps() {
isFetching,
isPopulated,
error,
items: schema.items || []
items: getQualities(schema.items)
};
}
);

View file

@ -90,6 +90,15 @@ class NamingModal extends Component {
{ token: '{Album_CleanTitle}', example: 'Album_Title' }
];
const mediumTokens = [
{ token: '{medium:0}', example: '1' },
{ token: '{medium:00}', example: '01' }
];
const mediumFormatTokens = [
{ token: '{Medium Format}', example: 'CD' }
];
const trackTokens = [
{ token: '{track:0}', example: '1' },
{ token: '{track:00}', example: '01' }
@ -260,6 +269,48 @@ class NamingModal extends Component {
{
track &&
<div>
<FieldSet legend="Medium">
<div className={styles.groups}>
{
mediumTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenCase={this.state.case}
onInputChange={onInputChange}
/>
);
}
)
}
</div>
</FieldSet>
<FieldSet legend="Medium Format">
<div className={styles.groups}>
{
mediumFormatTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenCase={this.state.case}
onInputChange={onInputChange}
/>
);
}
)
}
</div>
</FieldSet>
<FieldSet legend="Track">
<div className={styles.groups}>
{

View file

@ -8,7 +8,7 @@ import LanguageProfileItem from './LanguageProfileItem';
import styles from './LanguageProfileItemDragPreview.css';
const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
const formLabelWidth = parseInt(dimensions.formLabelWidth);
const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth);
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
@ -40,7 +40,7 @@ class LanguageProfileItemDragPreview extends Component {
// list item and the preview is wider than the drag handle.
const { x, y } = currentOffset;
const handleOffset = formGroupSmallWidth - formLabelWidth - formLabelRightMarginWidth - dragHandleWidth;
const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth;
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
const style = {

View file

@ -1,20 +1,56 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, { Component } from 'react';
import { sizes } from 'Helpers/Props';
import Modal from 'Components/Modal/Modal';
import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector';
function EditQualityProfileModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditQualityProfileModalContentConnector
{...otherProps}
class EditQualityProfileModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
height: 'auto'
};
}
//
// Listeners
onContentHeightChange = (height) => {
if (this.state.height === 'auto' || height > this.state.height) {
this.setState({ height });
}
}
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
style={{ height: `${this.state.height}px` }}
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
onModalClose={onModalClose}
/>
</Modal>
);
>
<EditQualityProfileModalContentConnector
{...otherProps}
onContentHeightChange={this.onContentHeightChange}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
EditQualityProfileModal.propTypes = {

View file

@ -1,3 +1,18 @@
.formGroupsContainer {
display: flex;
flex-wrap: wrap;
}
.formGroupWrapper {
flex: 0 0 calc($formGroupSmallWidth - 100px);
}
.deleteButtonContainer {
margin-right: auto;
}
@media only screen and (max-width: $breakpointLarge) {
.formGroupsContainer {
display: block;
}
}

View file

@ -1,6 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import React, { Component } from 'react';
import Measure from 'react-measure';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -15,123 +17,223 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import QualityProfileItems from './QualityProfileItems';
import styles from './EditQualityProfileModalContent.css';
function EditQualityProfileModalContent(props) {
const {
isFetching,
error,
isSaving,
saveError,
qualities,
item,
isInUse,
onInputChange,
onCutoffChange,
onSavePress,
onModalClose,
onDeleteQualityProfilePress,
...otherProps
} = props;
const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding);
const {
id,
name,
cutoff,
items
} = item;
class EditQualityProfileModalContent extends Component {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? 'Edit Quality Profile' : 'Add Quality Profile'}
</ModalHeader>
//
// Lifecycle
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
constructor(props, context) {
super(props, context);
{
!isFetching && !!error &&
<div>Unable to add a new quality profile, please try again.</div>
}
this.state = {
headerHeight: 0,
bodyHeight: 0,
footerHeight: 0
};
}
{
!isFetching && !error &&
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>Name</FormLabel>
componentDidUpdate(prevProps, prevState) {
const {
headerHeight,
bodyHeight,
footerHeight
} = this.state;
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
if (
headerHeight > 0 &&
bodyHeight > 0 &&
footerHeight > 0 &&
(
headerHeight !== prevState.headerHeight ||
bodyHeight !== prevState.bodyHeight ||
footerHeight !== prevState.footerHeight
)
) {
const padding = MODAL_BODY_PADDING * 2;
<FormGroup>
<FormLabel>Cutoff</FormLabel>
this.props.onContentHeightChange(
headerHeight + bodyHeight + footerHeight + padding
);
}
}
<FormInputGroup
type={inputTypes.SELECT}
name="cutoff"
{...cutoff}
value={cutoff ? cutoff.value.id : 0}
values={qualities}
helpText="Once this quality is reached Lidarr will no longer download episodes"
onChange={onCutoffChange}
/>
</FormGroup>
//
// Listeners
<QualityProfileItems
qualityProfileItems={items.value}
errors={items.errors}
warnings={items.warnings}
{...otherProps}
/>
onHeaderMeasure = ({ height }) => {
if (height > this.state.headerHeight) {
this.setState({ headerHeight: height });
}
}
</Form>
}
</ModalBody>
<ModalFooter>
{
id &&
<div
className={styles.deleteButtonContainer}
title={isInUse && 'Can\'t delete a quality profile that is attached to a artist'}
>
<Button
kind={kinds.DANGER}
isDisabled={isInUse}
onPress={onDeleteQualityProfilePress}
>
Delete
</Button>
onBodyMeasure = ({ height }) => {
if (height > this.state.bodyHeight) {
this.setState({ bodyHeight: height });
}
}
onFooterMeasure = ({ height }) => {
if (height > this.state.footerHeight) {
this.setState({ footerHeight: height });
}
}
//
// Render
render() {
const {
editGroups,
isFetching,
error,
isSaving,
saveError,
qualities,
item,
isInUse,
onInputChange,
onCutoffChange,
onSavePress,
onModalClose,
onDeleteQualityProfilePress,
...otherProps
} = this.props;
const {
id,
name,
cutoff,
items
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onHeaderMeasure}
>
<ModalHeader>
{id ? 'Edit Quality Profile' : 'Add Quality Profile'}
</ModalHeader>
</Measure>
<ModalBody>
<Measure
whitelist={['height']}
onMeasure={this.onBodyMeasure}
>
<div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to add a new quality profile, please try again.</div>
}
{
!isFetching && !error &&
<Form
{...otherProps}
>
<div className={styles.formGroupsContainer}>
<div className={styles.formGroupWrapper}>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.small}>
Name
</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.small}>
Cutoff
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="cutoff"
{...cutoff}
values={qualities}
helpText="Once this quality is reached Sonarr will no longer download episodes"
onChange={onCutoffChange}
/>
</FormGroup>
</div>
<div className={styles.formGroupWrapper}>
<QualityProfileItems
editGroups={editGroups}
qualityProfileItems={items.value}
errors={items.errors}
warnings={items.warnings}
{...otherProps}
/>
</div>
</div>
</Form>
}
</div>
}
</Measure>
</ModalBody>
<Button
onPress={onModalClose}
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onFooterMeasure}
>
Cancel
</Button>
<ModalFooter>
{
id &&
<div
className={styles.deleteButtonContainer}
title={isInUse && 'Can\'t delete a quality profile that is attached to a series'}
>
<Button
kind={kinds.DANGER}
isDisabled={isInUse}
onPress={onDeleteQualityProfilePress}
>
Delete
</Button>
</div>
}
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</Measure>
</ModalContent>
);
}
}
EditQualityProfileModalContent.propTypes = {
editGroups: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
@ -142,6 +244,7 @@ EditQualityProfileModalContent.propTypes = {
onInputChange: PropTypes.func.isRequired,
onCutoffChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onContentHeightChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteQualityProfilePress: PropTypes.func
};

View file

@ -8,6 +8,29 @@ import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile }
import connectSection from 'Store/connectSection';
import EditQualityProfileModalContent from './EditQualityProfileModalContent';
function getQualityItemGroupId(qualityProfile) {
// Get items with an `id` and filter out null/undefined values
const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null);
return Math.max(1000, ...ids) + 1;
}
function parseIndex(index) {
const split = index.split('.');
if (split.length === 1) {
return [
null,
parseInt(split[0]) - 1
];
}
return [
parseInt(split[0]) - 1,
parseInt(split[1]) - 1
];
}
function createQualitiesSelector() {
return createSelector(
createProviderSettingsSelector(),
@ -17,12 +40,19 @@ function createQualitiesSelector() {
return [];
}
return _.reduceRight(items.value, (result, { allowed, quality }) => {
return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => {
if (allowed) {
result.push({
key: quality.id,
value: quality.name
});
if (id) {
result.push({
key: id,
value: name
});
} else {
result.push({
key: quality.id,
value: quality.name
});
}
}
return result;
@ -61,8 +91,10 @@ class EditQualityProfileModalContentConnector extends Component {
super(props, context);
this.state = {
dragIndex: null,
dropIndex: null
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null,
editGroups: true
};
}
@ -78,6 +110,33 @@ class EditQualityProfileModalContentConnector extends Component {
}
}
//
// Control
ensureCutoff = (qualityProfile) => {
const cutoff = qualityProfile.cutoff.value;
const cutoffItem = _.find(qualityProfile.items.value, (i) => {
if (!cutoff) {
return false;
}
return i.id === cutoff || (i.quality && i.quality.id === cutoff);
});
// If the cutoff isn't allowed anymore or there isn't a cutoff set one
if (!cutoff || !cutoffItem || !cutoffItem.allowed) {
const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
let cutoffId = null;
if (firstAllowed) {
cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id;
}
this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId });
}
}
//
// Listeners
@ -87,9 +146,17 @@ class EditQualityProfileModalContentConnector extends Component {
onCutoffChange = ({ name, value }) => {
const id = parseInt(value);
const item = _.find(this.props.item.items.value, (i) => i.quality.id === id);
const item = _.find(this.props.item.items.value, (i) => {
if (i.quality) {
return i.quality.id === id;
}
this.props.setQualityProfileValue({ name, value: item.quality });
return i.id === id;
});
const cutoffId = item.quality ? item.quality.id : item.id;
this.props.setQualityProfileValue({ name, value: cutoffId });
}
onSavePress = () => {
@ -98,58 +165,239 @@ class EditQualityProfileModalContentConnector extends Component {
onQualityProfileItemAllowedChange = (id, allowed) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id);
const item = _.find(qualityProfile.items.value, (i) => i.quality.id === id);
item.allowed = allowed;
this.props.setQualityProfileValue({
name: 'items',
value: qualityProfile.items.value
value: items
});
const cutoff = qualityProfile.cutoff.value;
// If the cutoff isn't allowed anymore or there isn't a cutoff set one
if (!cutoff || !_.find(qualityProfile.items.value, (i) => i.quality.id === cutoff.id).allowed) {
const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
this.props.setQualityProfileValue({ name: 'cutoff', value: firstAllowed ? firstAllowed.quality : null });
}
this.ensureCutoff(qualityProfile);
}
onQualityProfileItemDragMove = (dragIndex, dropIndex) => {
if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
onItemGroupAllowedChange = (id, allowed) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const item = _.find(qualityProfile.items.value, (i) => i.id === id);
item.allowed = allowed;
// Update each item in the group (for consistency only)
item.items.forEach((i) => {
i.allowed = allowed;
});
this.props.setQualityProfileValue({
name: 'items',
value: items
});
this.ensureCutoff(qualityProfile);
}
onItemGroupNameChange = (id, name) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const group = _.find(items, (i) => i.id === id);
group.name = name;
this.props.setQualityProfileValue({
name: 'items',
value: items
});
}
onCreateGroupPress = (id) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const item = _.find(items, (i) => i.quality && i.quality.id === id);
const index = items.indexOf(item);
const groupId = getQualityItemGroupId(qualityProfile);
const group = {
id: groupId,
name: item.quality.name,
allowed: item.allowed,
items: [
item
]
};
// Add the group in the same location the quality item was in.
items.splice(index, 1, group);
this.props.setQualityProfileValue({
name: 'items',
value: items
});
this.ensureCutoff(qualityProfile);
}
onDeleteGroupPress = (id) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const group = _.find(items, (i) => i.id === id);
const index = items.indexOf(group);
// Add the items in the same location the group was in
items.splice(index, 1, ...group.items);
this.props.setQualityProfileValue({
name: 'items',
value: items
});
this.ensureCutoff(qualityProfile);
}
onQualityProfileItemDragMove = (options) => {
const {
dragQualityIndex,
dropQualityIndex,
dropPosition
} = options;
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
if (
(dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) ||
(dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex)
) {
if (
this.state.dragQualityIndex != null &&
this.state.dropQualityIndex != null &&
this.state.dropPosition != null
) {
this.setState({
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null
});
}
return;
}
let adjustedDropQualityIndex = dropQualityIndex;
// Correct dragging out of a group to the position above
if (
dropPosition === 'above' &&
dragGroupIndex !== dropGroupIndex &&
dropGroupIndex != null
) {
// Add 1 to the group index and 2 to the item index so it's inserted above in the correct group
adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`;
}
// Correct inserting above outside a group
if (
dropPosition === 'above' &&
dragGroupIndex !== dropGroupIndex &&
dropGroupIndex == null
) {
// Add 2 to the item index so it's entered in the correct place
adjustedDropQualityIndex = `${dropItemIndex + 2}`;
}
// Correct inserting below a quality within the same group (when moving a lower item)
if (
dropPosition === 'below' &&
dragGroupIndex === dropGroupIndex &&
dropGroupIndex != null &&
dragItemIndex < dropItemIndex
) {
// Add 1 to the group index leave the item index
adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`;
}
// Correct inserting below a quality outside a group (when moving a lower item)
if (
dropPosition === 'below' &&
dragGroupIndex === dropGroupIndex &&
dropGroupIndex == null &&
dragItemIndex < dropItemIndex
) {
// Leave the item index so it's inserted below the item
adjustedDropQualityIndex = `${dropItemIndex}`;
}
if (
dragQualityIndex !== this.state.dragQualityIndex ||
adjustedDropQualityIndex !== this.state.dropQualityIndex ||
dropPosition !== this.state.dropPosition
) {
this.setState({
dragIndex,
dropIndex
dragQualityIndex,
dropQualityIndex: adjustedDropQualityIndex,
dropPosition
});
}
}
onQualityProfileItemDragEnd = ({ id }, didDrop) => {
onQualityProfileItemDragEnd = (didDrop) => {
const {
dragIndex,
dropIndex
dragQualityIndex,
dropQualityIndex
} = this.state;
if (didDrop && dropIndex !== null) {
if (didDrop && dropQualityIndex != null) {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex);
const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex);
const items = qualityProfile.items.value.splice(dragIndex, 1);
qualityProfile.items.value.splice(dropIndex, 0, items[0]);
let item = null;
let dropGroup = null;
// Get the group before moving anything so we know the correct place to drop it.
if (dropGroupIndex != null) {
dropGroup = items[dropGroupIndex];
}
if (dragGroupIndex == null) {
item = items.splice(dragItemIndex, 1)[0];
} else {
const group = items[dragGroupIndex];
item = group.items.splice(dragItemIndex, 1)[0];
// If the group is now empty, destroy it.
if (!group.items.length) {
items.splice(dragGroupIndex, 1);
}
}
if (dropGroupIndex == null) {
items.splice(dropItemIndex, 0, item);
} else {
dropGroup.items.splice(dropItemIndex, 0, item);
}
this.props.setQualityProfileValue({
name: 'items',
value: qualityProfile.items.value
value: items
});
this.ensureCutoff(qualityProfile);
}
this.setState({
dragIndex: null,
dropIndex: null
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null
});
}
onToggleEditGroupsMode = () => {
this.setState({ editGroups: !this.state.editGroups });
}
//
// Render
@ -165,9 +413,14 @@ class EditQualityProfileModalContentConnector extends Component {
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
onCutoffChange={this.onCutoffChange}
onCreateGroupPress={this.onCreateGroupPress}
onDeleteGroupPress={this.onDeleteGroupPress}
onQualityProfileItemAllowedChange={this.onQualityProfileItemAllowedChange}
onItemGroupAllowedChange={this.onItemGroupAllowedChange}
onItemGroupNameChange={this.onItemGroupNameChange}
onQualityProfileItemDragMove={this.onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd}
onToggleEditGroupsMode={this.onToggleEditGroupsMode}
/>
);
}

View file

@ -17,3 +17,10 @@
flex-wrap: wrap;
margin-top: 5px;
}
.tooltipLabel {
composes: label from 'Components/Label.css';
margin: 0;
border: none;
}

View file

@ -1,9 +1,10 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds } from 'Helpers/Props';
import { kinds, tooltipPositions } from 'Helpers/Props';
import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import Tooltip from 'Components/Tooltip/Tooltip';
import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
import styles from './QualityProfile.css';
@ -75,16 +76,54 @@ class QualityProfile extends Component {
return null;
}
const isCutoff = item.quality.id === cutoff.id;
if (item.quality) {
const isCutoff = item.quality.id === cutoff;
return (
<Label
key={item.quality.id}
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
>
{item.quality.name}
</Label>
);
}
const isCutoff = item.id === cutoff;
return (
<Label
key={item.quality.id}
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
>
{item.quality.name}
</Label>
<Tooltip
key={item.id}
className={styles.tooltipLabel}
anchor={
<Label
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
>
{item.name}
</Label>
}
tooltip={
<div>
{
item.items.map((groupItem) => {
return (
<Label
key={groupItem.quality.id}
kind={isCutoff ? kinds.INFO : kinds.default}
title={isCutoff ? 'Cutoff' : null}
>
{groupItem.quality.name}
</Label>
);
})
}
</div>
}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
);
})
}
@ -115,7 +154,7 @@ class QualityProfile extends Component {
QualityProfile.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
cutoff: PropTypes.object.isRequired,
cutoff: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isDeleting: PropTypes.bool.isRequired,
onConfirmDeleteQualityProfile: PropTypes.func.isRequired

View file

@ -5,25 +5,56 @@
border: 1px solid #aaa;
border-radius: 4px;
background: #fafafa;
&.isInGroup {
border-style: dashed;
}
}
.checkContainer {
.checkInputContainer {
position: relative;
margin-right: 4px;
margin-bottom: 7px;
margin-bottom: 5px;
margin-left: 8px;
}
.qualityName {
.checkInput {
composes: input from 'Components/Form/CheckInput.css';
margin-top: 5px;
}
.qualityNameContainer {
display: flex;
flex-grow: 1;
margin-bottom: 0;
margin-left: 2px;
font-weight: normal;
line-height: 36px;
line-height: $qualityProfileItemHeight;
cursor: pointer;
}
.qualityName {
&.isInGroup {
margin-left: 14px;
}
&.notAllowed {
color: #c6c6c6;
}
}
.createGroupButton {
composes: buton from 'Components/Link/IconButton.css';
display: flex;
justify-content: center;
flex-shrink: 0;
margin-right: 5px;
margin-left: 8px;
width: 20px;
}
.dragHandle {
display: flex;
align-items: center;
@ -42,3 +73,13 @@
.isDragging {
opacity: 0.25;
}
.isPreview {
.qualityName {
margin-left: 14px;
&.isInGroup {
margin-left: 28px;
}
}
}

View file

@ -3,6 +3,7 @@ import React, { Component } from 'react';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import CheckInput from 'Components/Form/CheckInput';
import styles from './QualityProfileItem.css';
@ -20,14 +21,27 @@ class QualityProfileItem extends Component {
onQualityProfileItemAllowedChange(qualityId, value);
}
onCreateGroupPress = () => {
const {
qualityId,
onCreateGroupPress
} = this.props;
onCreateGroupPress(qualityId);
}
//
// Render
render() {
const {
editGroups,
isPreview,
groupId,
name,
allowed,
isDragging,
isOverCurrent,
connectDragSource
} = this.props;
@ -36,18 +50,44 @@ class QualityProfileItem extends Component {
className={classNames(
styles.qualityProfileItem,
isDragging && styles.isDragging,
isPreview && styles.isPreview,
isOverCurrent && styles.isOverCurrent,
groupId && styles.isInGroup
)}
>
<label
className={styles.qualityName}
className={styles.qualityNameContainer}
>
<CheckInput
containerClassName={styles.checkContainer}
name={name}
value={allowed}
onChange={this.onAllowedChange}
/>
{name}
{
editGroups && !groupId && !isPreview &&
<IconButton
className={styles.createGroupButton}
name={icons.GROUP}
title="Group"
onPress={this.onCreateGroupPress}
/>
}
{
!editGroups &&
<CheckInput
className={styles.checkInput}
containerClassName={styles.checkInputContainer}
name={name}
value={allowed}
isDisabled={!!groupId}
onChange={this.onAllowedChange}
/>
}
<div className={classNames(
styles.qualityName,
groupId && styles.isInGroup,
!allowed && styles.notAllowed
)}
>
{name}
</div>
</label>
{
@ -55,6 +95,7 @@ class QualityProfileItem extends Component {
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
title="Create group"
name={icons.REORDER}
/>
</div>
@ -66,16 +107,23 @@ class QualityProfileItem extends Component {
}
QualityProfileItem.propTypes = {
editGroups: PropTypes.bool,
isPreview: PropTypes.bool,
groupId: PropTypes.number,
qualityId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
sortIndex: PropTypes.number.isRequired,
isDragging: PropTypes.bool.isRequired,
isOverCurrent: PropTypes.bool.isRequired,
isInGroup: PropTypes.bool,
connectDragSource: PropTypes.func,
onCreateGroupPress: PropTypes.func,
onQualityProfileItemAllowedChange: PropTypes.func
};
QualityProfileItem.defaultProps = {
isPreview: false,
isOverCurrent: false,
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};

View file

@ -7,8 +7,8 @@ import DragPreviewLayer from 'Components/DragPreviewLayer';
import QualityProfileItem from './QualityProfileItem';
import styles from './QualityProfileItemDragPreview.css';
const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
const formLabelWidth = parseInt(dimensions.formLabelWidth);
const formGroupExtraSmallWidth = parseInt(dimensions.formGroupExtraSmallWidth);
const formLabelSmallWidth = parseInt(dimensions.formLabelSmallWidth);
const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
@ -40,7 +40,7 @@ class QualityProfileItemDragPreview extends Component {
// list item and the preview is wider than the drag handle.
const { x, y } = currentOffset;
const handleOffset = formGroupSmallWidth - formLabelWidth - formLabelRightMarginWidth - dragHandleWidth;
const handleOffset = formGroupExtraSmallWidth - formLabelSmallWidth - formLabelRightMarginWidth - dragHandleWidth;
const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
const style = {
@ -51,12 +51,15 @@ class QualityProfileItemDragPreview extends Component {
};
const {
editGroups,
groupId,
qualityId,
name,
allowed,
sortIndex
allowed
} = item;
// TODO: Show a different preview for groups
return (
<DragPreviewLayer>
<div
@ -64,10 +67,11 @@ class QualityProfileItemDragPreview extends Component {
style={style}
>
<QualityProfileItem
qualityId={qualityId}
editGroups={editGroups}
isPreview={true}
qualityId={groupId || qualityId}
name={name}
allowed={allowed}
sortIndex={sortIndex}
isDragging={false}
/>
</div>

View file

@ -1,10 +1,10 @@
.qualityProfileItemDragSource {
padding: 4px 0;
padding: $qualityProfileItemDragSourcePadding 0;
}
.qualityProfileItemPlaceholder {
width: 100%;
height: 36px;
height: $qualityProfileItemHeight;
border: 1px dotted #aaa;
border-radius: 4px;
}

View file

@ -5,44 +5,86 @@ import { DragSource, DropTarget } from 'react-dnd';
import classNames from 'classnames';
import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
import QualityProfileItem from './QualityProfileItem';
import QualityProfileItemGroup from './QualityProfileItemGroup';
import styles from './QualityProfileItemDragSource.css';
const qualityProfileItemDragSource = {
beginDrag({ qualityId, name, allowed, sortIndex }) {
return {
beginDrag(props) {
const {
editGroups,
qualityIndex,
groupId,
qualityId,
name,
allowed,
sortIndex
allowed
} = props;
return {
editGroups,
qualityIndex,
groupId,
qualityId,
isGroup: !qualityId,
name,
allowed
};
},
endDrag(props, monitor, component) {
props.onQualityProfileItemDragEnd(monitor.getItem(), monitor.didDrop());
props.onQualityProfileItemDragEnd(monitor.didDrop());
}
};
const qualityProfileItemDropTarget = {
hover(props, monitor, component) {
const dragIndex = monitor.getItem().sortIndex;
const hoverIndex = props.sortIndex;
const {
qualityIndex: dragQualityIndex,
isGroup: isDragGroup
} = monitor.getItem();
const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
const dropQualityIndex = props.qualityIndex;
const isDropGroupItem = !!(props.qualityId && props.groupId);
// Use childNodeIndex to select the correct node to get the middle of so
// we don't bounce between above and below causing rapid setState calls.
const childNodeIndex = component.props.isOverCurrent && component.props.isDraggingUp ? 1 :0;
const componentDOMNode = findDOMNode(component).children[childNodeIndex];
const hoverBoundingRect = componentDOMNode.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
// Moving up, only trigger if drag position is above 50%
if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
// If we're hovering over a child don't trigger on the parent
if (!monitor.isOver({ shallow: true })) {
return;
}
// Moving down, only trigger if drag position is below 50%
if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
// Don't show targets for dropping on self
if (dragQualityIndex === dropQualityIndex) {
return;
}
props.onQualityProfileItemDragMove(dragIndex, hoverIndex);
// Don't allow a group to be dropped inside a group
if (isDragGroup && isDropGroupItem) {
return;
}
let dropPosition = null;
// Determine drop position based on position over target
if (hoverClientY > hoverMiddleY) {
dropPosition = 'below';
} else if (hoverClientY < hoverMiddleY) {
dropPosition = 'above';
} else {
return;
}
props.onQualityProfileItemDragMove({
dragQualityIndex,
dropQualityIndex,
dropPosition
});
}
};
@ -56,7 +98,8 @@ function collectDragSource(connect, monitor) {
function collectDropTarget(connect, monitor) {
return {
connectDropTarget: connect.dropTarget(),
isOver: monitor.isOver()
isOver: monitor.isOver(),
isOverCurrent: monitor.isOver({ shallow: true })
};
}
@ -67,25 +110,30 @@ class QualityProfileItemDragSource extends Component {
render() {
const {
editGroups,
groupId,
qualityId,
name,
allowed,
sortIndex,
items,
qualityIndex,
isDragging,
isDraggingUp,
isDraggingDown,
isOver,
isOverCurrent,
connectDragSource,
connectDropTarget,
onQualityProfileItemAllowedChange
onCreateGroupPress,
onDeleteGroupPress,
onQualityProfileItemAllowedChange,
onItemGroupAllowedChange,
onItemGroupNameChange,
onQualityProfileItemDragMove,
onQualityProfileItemDragEnd
} = this.props;
const isBefore = !isDragging && isDraggingUp && isOver;
const isAfter = !isDragging && isDraggingDown && isOver;
// if (isDragging && !isOver) {
// return null;
// }
const isBefore = !isDragging && isDraggingUp && isOverCurrent;
const isAfter = !isDragging && isDraggingDown && isOverCurrent;
return connectDropTarget(
<div
@ -105,16 +153,44 @@ class QualityProfileItemDragSource extends Component {
/>
}
<QualityProfileItem
qualityId={qualityId}
name={name}
allowed={allowed}
sortIndex={sortIndex}
isDragging={isDragging}
isOver={isOver}
connectDragSource={connectDragSource}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
/>
{
!!groupId && qualityId == null &&
<QualityProfileItemGroup
editGroups={editGroups}
groupId={groupId}
name={name}
allowed={allowed}
items={items}
qualityIndex={qualityIndex}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
connectDragSource={connectDragSource}
onDeleteGroupPress={onDeleteGroupPress}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onItemGroupAllowedChange={onItemGroupAllowedChange}
onItemGroupNameChange={onItemGroupNameChange}
onQualityProfileItemDragMove={onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
/>
}
{
qualityId != null &&
<QualityProfileItem
editGroups={editGroups}
groupId={groupId}
qualityId={qualityId}
name={name}
allowed={allowed}
qualityIndex={qualityIndex}
isDragging={isDragging}
isOverCurrent={isOverCurrent}
connectDragSource={connectDragSource}
onCreateGroupPress={onCreateGroupPress}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
/>
}
{
isAfter &&
@ -131,17 +207,25 @@ class QualityProfileItemDragSource extends Component {
}
QualityProfileItemDragSource.propTypes = {
qualityId: PropTypes.number.isRequired,
editGroups: PropTypes.bool.isRequired,
groupId: PropTypes.number,
qualityId: PropTypes.number,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
sortIndex: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
qualityIndex: PropTypes.string.isRequired,
isDragging: PropTypes.bool,
isDraggingUp: PropTypes.bool,
isDraggingDown: PropTypes.bool,
isOver: PropTypes.bool,
isOverCurrent: PropTypes.bool,
isInGroup: PropTypes.bool,
connectDragSource: PropTypes.func,
connectDropTarget: PropTypes.func,
onCreateGroupPress: PropTypes.func,
onDeleteGroupPress: PropTypes.func,
onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
onItemGroupAllowedChange: PropTypes.func,
onItemGroupNameChange: PropTypes.func,
onQualityProfileItemDragMove: PropTypes.func.isRequired,
onQualityProfileItemDragEnd: PropTypes.func.isRequired
};

View file

@ -0,0 +1,105 @@
.qualityProfileItemGroup {
width: 100%;
border: 1px solid #aaa;
border-radius: 4px;
background: #fafafa;
&.editGroups {
background: #fcfcfc;
}
}
.qualityProfileItemGroupInfo {
display: flex;
align-items: stretch;
width: 100%;
}
.checkInputContainer {
composes: checkInputContainer from './QualityProfileItem.css';
display: flex;
align-items: center;
}
.checkInput {
composes: checkInput from './QualityProfileItem.css';
}
.nameInput {
composes: text from 'Components/Form/TextInput.css';
margin-top: 4px;
margin-right: 10px;
}
.nameContainer {
display: flex;
align-items: center;
flex-grow: 1;
}
.name {
flex-shrink: 0;
&.notAllowed {
color: #c6c6c6;
}
}
.groupQualities {
display: flex;
justify-content: flex-end;
flex-grow: 1;
flex-wrap: wrap;
margin: 2px 0 2px 10px;
}
.qualityNameContainer {
display: flex;
align-items: stretch;
flex-grow: 1;
margin-bottom: 0;
margin-left: 2px;
font-weight: normal;
}
.qualityNameLabel {
composes: qualityNameContainer;
cursor: pointer;
}
.deleteGroupButton {
composes: buton from 'Components/Link/IconButton.css';
display: flex;
justify-content: center;
flex-shrink: 0;
margin-right: 5px;
margin-left: 8px;
width: 20px;
}
.dragHandle {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
width: $dragHandleWidth;
text-align: center;
cursor: grab;
}
.dragIcon {
top: 0;
}
.isDragging {
opacity: 0.25;
}
.items {
margin: 0 50px 0 35px;
}

View file

@ -0,0 +1,200 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import CheckInput from 'Components/Form/CheckInput';
import TextInput from 'Components/Form/TextInput';
import QualityProfileItemDragSource from './QualityProfileItemDragSource';
import styles from './QualityProfileItemGroup.css';
class QualityProfileItemGroup extends Component {
//
// Listeners
onAllowedChange = ({ value }) => {
const {
groupId,
onItemGroupAllowedChange
} = this.props;
onItemGroupAllowedChange(groupId, value);
}
onNameChange = ({ value }) => {
const {
groupId,
onItemGroupNameChange
} = this.props;
onItemGroupNameChange(groupId, value);
}
onDeleteGroupPress = ({ value }) => {
const {
groupId,
onDeleteGroupPress
} = this.props;
onDeleteGroupPress(groupId, value);
}
//
// Render
render() {
const {
editGroups,
groupId,
name,
allowed,
items,
qualityIndex,
isDragging,
isDraggingUp,
isDraggingDown,
connectDragSource,
onQualityProfileItemAllowedChange,
onQualityProfileItemDragMove,
onQualityProfileItemDragEnd
} = this.props;
return (
<div
className={classNames(
styles.qualityProfileItemGroup,
editGroups && styles.editGroups,
isDragging && styles.isDragging,
)}
>
<div className={styles.qualityProfileItemGroupInfo}>
{
editGroups &&
<div className={styles.qualityNameContainer}>
<IconButton
className={styles.deleteGroupButton}
name={icons.UNGROUP}
title="Ungroup"
onPress={this.onDeleteGroupPress}
/>
<TextInput
className={styles.nameInput}
name="name"
value={name}
onChange={this.onNameChange}
/>
</div>
}
{
!editGroups &&
<label
className={styles.qualityNameLabel}
>
<CheckInput
className={styles.checkInput}
containerClassName={styles.checkInputContainer}
name="allowed"
value={allowed}
onChange={this.onAllowedChange}
/>
<div className={styles.nameContainer}>
<div className={classNames(
styles.name,
!allowed && styles.notAllowed
)}
>
{name}
</div>
<div className={styles.groupQualities}>
{
items.map(({ quality }) => {
return (
<Label key={quality.id}>
{quality.name}
</Label>
);
}).reverse()
}
</div>
</div>
</label>
}
{
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
name={icons.REORDER}
title="Reorder"
/>
</div>
)
}
</div>
{
editGroups &&
<div className={styles.items}>
{
items.map(({ quality }, index) => {
return (
<QualityProfileItemDragSource
key={quality.id}
editGroups={editGroups}
groupId={groupId}
qualityId={quality.id}
name={quality.name}
allowed={allowed}
items={items}
qualityIndex={`${qualityIndex}.${index + 1}`}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
isInGroup={true}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onQualityProfileItemDragMove={onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
/>
);
}).reverse()
}
</div>
}
</div>
);
}
}
QualityProfileItemGroup.propTypes = {
editGroups: PropTypes.bool,
groupId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
qualityIndex: PropTypes.string.isRequired,
isDragging: PropTypes.bool.isRequired,
isDraggingUp: PropTypes.bool.isRequired,
isDraggingDown: PropTypes.bool.isRequired,
connectDragSource: PropTypes.func,
onItemGroupAllowedChange: PropTypes.func.isRequired,
onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
onItemGroupNameChange: PropTypes.func.isRequired,
onDeleteGroupPress: PropTypes.func.isRequired,
onQualityProfileItemDragMove: PropTypes.func.isRequired,
onQualityProfileItemDragEnd: PropTypes.func.isRequired
};
QualityProfileItemGroup.defaultProps = {
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default QualityProfileItemGroup;

View file

@ -1,6 +1,15 @@
.editGroupsButton {
composes: button from 'Components/Link/Button.css';
margin-top: 10px;
}
.editGroupsButtonIcon {
margin-right: 8px;
}
.qualities {
margin-top: 10px;
/* TODO: This should consider the number of qualities in the list */
min-height: 550px;
transition: min-height 200ms;
user-select: none;
}

View file

@ -1,5 +1,9 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Measure from 'react-measure';
import { icons, kinds, sizes } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputHelpText from 'Components/Form/FormInputHelpText';
@ -9,26 +13,69 @@ import styles from './QualityProfileItems.css';
class QualityProfileItems extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
qualitiesHeight: 0,
qualitiesHeightEditGroups: 0
};
}
componentDidMount() {
this.props.onToggleEditGroupsMode();
}
//
// Listeners
onMeasure = ({ height }) => {
if (this.props.editGroups) {
this.setState({
qualitiesHeightEditGroups: height
});
} else {
this.setState({ qualitiesHeight: height });
}
}
onToggleEditGroupsMode = () => {
this.props.onToggleEditGroupsMode();
}
//
// Render
render() {
const {
dragIndex,
dropIndex,
editGroups,
dropQualityIndex,
dropPosition,
qualityProfileItems,
errors,
warnings,
...otherProps
} = this.props;
const isDragging = dropIndex !== null;
const isDraggingUp = isDragging && dropIndex > dragIndex;
const isDraggingDown = isDragging && dropIndex < dragIndex;
const {
qualitiesHeight,
qualitiesHeightEditGroups
} = this.state;
const isDragging = dropQualityIndex !== null;
const isDraggingUp = isDragging && dropPosition === 'above';
const isDraggingDown = isDragging && dropPosition === 'below';
const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight;
return (
<FormGroup>
<FormLabel>Qualities</FormLabel>
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
Qualities
</FormLabel>
<div>
<FormInputHelpText
text="Qualities higher in the list are more preferred. Only checked qualities are wanted"
@ -60,27 +107,59 @@ class QualityProfileItems extends Component {
})
}
<div className={styles.qualities}>
{
qualityProfileItems.map(({ allowed, quality }, index) => {
return (
<QualityProfileItemDragSource
key={quality.id}
qualityId={quality.id}
name={quality.name}
allowed={allowed}
sortIndex={index}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
{...otherProps}
/>
);
}).reverse()
}
<Button
className={styles.editGroupsButton}
kind={kinds.PRIMARY}
onPress={this.onToggleEditGroupsMode}
>
<div>
<Icon
className={styles.editGroupsButtonIcon}
name={editGroups ? icons.REORDER : icons.GROUP}
/>
<QualityProfileItemDragPreview />
</div>
{
editGroups ? 'Done Editing Groups' : 'Edit Groups'
}
</div>
</Button>
<Measure
whitelist={['height']}
includeMargin={false}
onMeasure={this.onMeasure}
>
<div
className={styles.qualities}
style={{ minHeight: `${minHeight}px` }}
>
{
qualityProfileItems.map(({ id, name, allowed, quality, items }, index) => {
const identifier = quality ? quality.id : id;
return (
<QualityProfileItemDragSource
key={identifier}
editGroups={editGroups}
groupId={id}
qualityId={quality && quality.id}
name={quality ? quality.name : name}
allowed={allowed}
items={items}
qualityIndex={`${index + 1}`}
isInGroup={false}
isDragging={isDragging}
isDraggingUp={isDraggingUp}
isDraggingDown={isDraggingDown}
{...otherProps}
/>
);
}).reverse()
}
<QualityProfileItemDragPreview />
</div>
</Measure>
</div>
</FormGroup>
);
@ -88,11 +167,14 @@ class QualityProfileItems extends Component {
}
QualityProfileItems.propTypes = {
dragIndex: PropTypes.number,
dropIndex: PropTypes.number,
editGroups: PropTypes.bool.isRequired,
dragQualityIndex: PropTypes.string,
dropQualityIndex: PropTypes.string,
dropPosition: PropTypes.string,
qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object)
warnings: PropTypes.arrayOf(PropTypes.object),
onToggleEditGroupsMode: PropTypes.func.isRequired
};
QualityProfileItems.defaultProps = {

View file

@ -91,7 +91,6 @@ class QualityProfiles extends Component {
}
QualityProfiles.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,

View file

@ -7,11 +7,9 @@ import QualityProfiles from './QualityProfiles';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
(state) => state.settings.qualityProfiles,
(advancedSettings, qualityProfiles) => {
(qualityProfiles) => {
return {
advancedSettings,
...qualityProfiles
};
}

View file

@ -1,5 +1,5 @@
import $ from 'jquery';
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { set, update, updateItem } from '../baseActions';
function createFetchHandler(section, url) {
@ -12,13 +12,13 @@ function createFetchHandler(section, url) {
...otherPayload
} = payload;
const promise = $.ajax({
const { request, abortRequest } = createAjaxRequest({
url: id == null ? url : `${url}/${id}`,
data: otherPayload,
traditional: true
});
promise.done((data) => {
request.done((data) => {
dispatch(batchActions([
id == null ? update({ section, data }) : updateItem({ section, ...data }),
@ -31,14 +31,16 @@ function createFetchHandler(section, url) {
]));
});
promise.fail((xhr) => {
request.fail((xhr) => {
dispatch(set({
section,
isFetching: false,
isPopulated: false,
error: xhr
error: xhr.aborted ? null : xhr
}));
});
return abortRequest;
};
};
}

View file

@ -37,8 +37,8 @@ function createSaveProviderHandler(section, url, getFromState) {
ajaxOptions.method = 'PUT';
}
const { request, abortRequest } = createAjaxRequest()(ajaxOptions);
const { request, abortRequest } = createAjaxRequest(ajaxOptions);
abortCurrentRequests[section] = abortRequest;
request.done((data) => {

View file

@ -30,8 +30,8 @@ function createTestProviderHandler(section, url, getFromState) {
data: JSON.stringify(testData)
};
const { request, abortRequest } = createAjaxRequest()(ajaxOptions);
const { request, abortRequest } = createAjaxRequest(ajaxOptions);
abortCurrentRequests[section] = abortRequest;
request.done((data) => {

View file

@ -113,6 +113,7 @@ export const ALBUM_HISTORY_MARK_AS_FAILED = 'ALBUM_HISTORY_MARK_AS_FAILED';
// Releases
export const FETCH_RELEASES = 'FETCH_RELEASES';
export const CANCEL_FETCH_RELEASES = 'CANCEL_FETCH_RELEASES';
export const SET_RELEASES_SORT = 'SET_RELEASES_SORT';
export const CLEAR_RELEASES = 'CLEAR_RELEASES';
export const GRAB_RELEASE = 'GRAB_RELEASE';

View file

@ -18,7 +18,7 @@ const addArtistActionHandlers = {
abortCurrentRequest();
}
const { request, abortRequest } = createAjaxRequest()({
const { request, abortRequest } = createAjaxRequest({
url: '/artist/lookup',
data: {
term: payload.term

View file

@ -3,10 +3,27 @@ import createFetchHandler from './Creators/createFetchHandler';
import * as types from './actionTypes';
import { updateRelease } from './releaseActions';
let abortCurrentRequest = null;
const section = 'releases';
const fetchReleases = createFetchHandler(section, '/release');
const releaseActionHandlers = {
[types.FETCH_RELEASES]: createFetchHandler(section, '/release'),
[types.FETCH_RELEASES]: function(payload) {
return function(dispatch, getState) {
const abortRequest = fetchReleases(payload)(dispatch, getState);
abortCurrentRequest = abortRequest;
};
},
[types.CANCEL_FETCH_RELEASES]: function(payload) {
return function(dispatch, getState) {
if (abortCurrentRequest) {
abortCurrentRequest = abortCurrentRequest();
}
};
},
[types.GRAB_RELEASE]: function(payload) {
return function(dispatch, getState) {

View file

@ -3,6 +3,7 @@ import * as types from './actionTypes';
import releaseActionHandlers from './releaseActionHandlers';
export const fetchReleases = releaseActionHandlers[types.FETCH_RELEASES];
export const cancelFetchReleases = releaseActionHandlers[types.CANCEL_FETCH_RELEASES];
export const setReleasesSort = createAction(types.SET_RELEASES_SORT);
export const clearReleases = createAction(types.CLEAR_RELEASES);
export const grabRelease = releaseActionHandlers[types.GRAB_RELEASE];

View file

@ -37,6 +37,11 @@ export const defaultState = {
label: 'Release Date',
isVisible: true
},
{
name: 'mediumCount',
label: 'Media Count',
isVisible: false
},
{
name: 'trackCount',
label: 'Track Count',

View file

@ -11,14 +11,19 @@ export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
sortKey: 'trackNumber',
sortKey: 'mediumNumber',
sortDirection: sortDirections.DESCENDING,
items: [],
columns: [
{
name: 'trackNumber',
label: '#',
name: 'medium',
label: 'Medium',
isVisible: true
},
{
name: 'absoluteTrackNumber',
label: 'Track',
isVisible: true
},
{

View file

@ -19,16 +19,21 @@ module.exports = {
breakpointSmall: '768px',
breakpointMedium: '992px',
breakpointLarge: '1200px',
breakpointExtraLarge: '1450px',
// Form
formGroupExtraSmallWidth: '550px',
formGroupSmallWidth: '650px',
formGroupMediumWidth: '800px',
formGroupLargeWidth: '1200px',
formLabelWidth: '250px',
formLabelSmallWidth: '150px',
formLabelLargeWidth: '250px',
formLabelRightMarginWidth: '20px',
// Drag
dragHandleWidth: '40px',
qualityProfileItemHeight: '30px',
qualityProfileItemDragSourcePadding: '4px',
// Progress Bar
progressBarSmallHeight: '5px',
@ -38,6 +43,9 @@ module.exports = {
// Jump Bar
jumpBarItemHeight: '25px',
// Modal
modalBodyPadding: '30px',
// Artist
artistIndexColumnPadding: '20px',
artistIndexColumnPaddingSmallScreen: '10px',

View file

@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import getQualities from 'Utilities/Quality/getQualities';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import { deleteTrackFiles, updateTrackFiles } from 'Store/Actions/trackFileActions';
import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
@ -52,8 +53,8 @@ function createMapStateToProps() {
});
const languages = _.map(languageProfilesSchema.languages, 'language');
const qualities = _.map(qualityProfileSchema.items, 'quality');
const qualities = getQualities(qualityProfileSchema.items);
return {
items,
artistType: artist.artistType,
@ -90,18 +91,6 @@ function createMapDispatchToProps(dispatch, props) {
onDeletePress(trackFileIds) {
dispatch(deleteTrackFiles({ trackFileIds }));
},
onQualityChange(trackFileIds, qualityId) {
const quality = {
quality: _.find(this.props.qualities, { id: qualityId }),
revision: {
version: 1,
real: 0
}
};
dispatch(updateTrackFiles({ trackFileIds, quality }));
}
};
}

View file

@ -53,7 +53,7 @@ function TrackFileEditorRow(props) {
TrackFileEditorRow.propTypes = {
id: PropTypes.number.isRequired,
trackNumber: PropTypes.number.isRequired,
trackNumber: PropTypes.string.isRequired,
relativePath: PropTypes.string.isRequired,
language: PropTypes.object.isRequired,
quality: PropTypes.object.isRequired,

View file

@ -0,0 +1,16 @@
export default function getQualities(qualities) {
if (!qualities) {
return [];
}
return qualities.reduce((acc, item) => {
if (item.quality) {
acc.push(item.quality);
} else {
const groupQualities = item.items.map((i) => i.quality);
acc.push(...groupQualities);
}
return acc;
}, []);
}

View file

@ -1,32 +1,30 @@
import $ from 'jquery';
export default function createAjaxRequest() {
return function(ajaxOptions) {
const requestXHR = new window.XMLHttpRequest();
let aborted = false;
let complete = false;
export default function createAjaxRequest(ajaxOptions) {
const requestXHR = new window.XMLHttpRequest();
let aborted = false;
let complete = false;
function abortRequest() {
if (!complete) {
aborted = true;
requestXHR.abort();
}
function abortRequest() {
if (!complete) {
aborted = true;
requestXHR.abort();
}
}
const request = $.ajax({
xhr: () => requestXHR,
...ajaxOptions
}).then(null, (xhr, textStatus, errorThrown) => {
xhr.aborted = aborted;
const request = $.ajax({
xhr: () => requestXHR,
...ajaxOptions
}).then(null, (xhr, textStatus, errorThrown) => {
xhr.aborted = aborted;
return $.Deferred().reject(xhr, textStatus, errorThrown).promise();
}).always(() => {
complete = true;
});
return $.Deferred().reject(xhr, textStatus, errorThrown).promise();
}).always(() => {
complete = true;
});
return {
request,
abortRequest
};
return {
request,
abortRequest
};
}