New: Release Profiles, Frontend updates (#580)

* New: Release Profiles - UI Updates

* New: Release Profiles - API Changes

* New: Release Profiles - Test Updates

* New: Release Profiles - Backend Updates

* New: Interactive Artist Search

* New: Change Montiored on Album Details Page

* New: Show Duration on Album Details Page

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

* Fixed: Sort search input by sortTitle

* Fixed: Queue columnLabel throwing JS error
This commit is contained in:
Qstick 2019-02-23 17:39:11 -05:00 committed by GitHub
parent f126eafd26
commit 3f064c94b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
409 changed files with 6882 additions and 3176 deletions

View file

@ -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)

View file

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

View file

@ -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 });

View file

@ -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;
}

View file

@ -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;

View file

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

View file

@ -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>

View file

@ -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;

View file

@ -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';
}

View file

@ -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;

View file

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

View file

@ -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;

View file

@ -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,

View file

@ -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 {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
value: props.value == null ? '' : props.value.toString(),
isFocused: false
};
}
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 }) => {
let newValue = null;
if (value) {
newValue = this.props.isFloat ? parseFloat(value) : parseInt(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}
/>
);
}

View file

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

View file

@ -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>

View file

@ -127,6 +127,7 @@ class TextInput extends Component {
hasError,
hasWarning,
hasButton,
step,
onBlur
} = this.props;
@ -146,6 +147,7 @@ class TextInput extends Component {
)}
name={name}
value={value}
step={step}
onChange={this.onChange}
onFocus={this.onFocus}
onBlur={onBlur}
@ -168,6 +170,7 @@ TextInput.propTypes = {
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
hasButton: PropTypes.bool,
step: PropTypes.number,
onChange: PropTypes.func.isRequired,
onFocus: PropTypes.func,
onBlur: PropTypes.func,

View file

@ -6,10 +6,18 @@
color: inherit;
}
.disabled {
color: $disabledColor;
}
.info {
color: $infoColor;
}
.pink {
color: $pink;
}
.success {
color: $successColor;
}

View file

@ -30,6 +30,15 @@
}
}
.disabled {
border-color: $disabledColor;
background-color: $disabledColor;
&.outline {
color: $disabledColor;
}
}
.info {
border-color: $infoColor;
background-color: $infoColor;
@ -92,7 +101,7 @@
.large {
padding: 3px 7px;
font-weight: bold;
font-size: 14px;
font-size: $defaultFontSize;
}
/** Outline **/

View file

@ -24,7 +24,6 @@ function IconButton(props) {
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled}
{...otherProps}
>
<Icon

View file

@ -91,8 +91,8 @@ class ArtistSearchInput extends Component {
//
// Listeners
onChange = (event, { newValue }) => {
if (!newValue) {
onChange = (event, { newValue, method }) => {
if (method === 'up' || method === 'down') {
return;
}
@ -117,6 +117,7 @@ class ArtistSearchInput extends Component {
if (!suggestions.length || highlightedSectionIndex && (event.key !== 'ArrowDown' || event.key !== 'ArrowUp')) {
this.props.onGoToAddNewArtist(value);
this._autosuggest.input.blur();
this.reset();
return;
}
@ -129,6 +130,9 @@ class ArtistSearchInput extends Component {
} else {
this.goToArtist(suggestions[highlightedSuggestionIndex]);
}
this._autosuggest.input.blur();
this.reset();
}
onBlur = () => {
@ -142,9 +146,15 @@ class ArtistSearchInput extends Component {
// Check the title first and if there isn't a match fallback to
// the alternate titles and finally the tags.
if (value.length === 1) {
return (
artist.cleanName.startsWith(lowerCaseValue) ||
artist.tags.some((tag) => tag.cleanLabel.startsWith(lowerCaseValue))
);
}
return (
artist.cleanName.contains(lowerCaseValue) ||
// artist.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.contains(lowerCaseValue)) ||
artist.tags.some((tag) => tag.cleanLabel.contains(lowerCaseValue))
);
});
@ -153,7 +163,9 @@ class ArtistSearchInput extends Component {
}
onSuggestionsClearRequested = () => {
this.reset();
this.setState({
suggestions: []
});
}
onSuggestionSelected = (event, { suggestion }) => {

View file

@ -58,10 +58,10 @@ function createCleanArtistSelector() {
})
};
}).sort((a, b) => {
if (a.cleanName < b.cleanName) {
if (a.sortName < b.sortName) {
return -1;
}
if (a.cleanName > b.cleanName) {
if (a.sortName > b.sortName) {
return 1;
}

View file

@ -4,14 +4,19 @@
align-items: center;
flex: 0 0 auto;
height: $headerHeight;
background-color: #00a65b;
background-color: $themeAlternateBlue;
color: $white;
}
.logoContainer {
display: flex;
justify-content: center;
align-items: center;
flex: 0 0 $sidebarWidth;
padding-left: 20px;
}
.logoLink {
line-height: 0;
}
.logo {

View file

@ -51,7 +51,10 @@ class PageHeader extends Component {
return (
<div className={styles.header}>
<div className={styles.logoContainer}>
<Link to={`${window.Lidarr.urlBase}/`}>
<Link
className={styles.logoLink}
to={`${window.Lidarr.urlBase}/`}
>
<img
className={styles.logo}
src={`${window.Lidarr.urlBase}/Content/Images/logo.svg`}

View file

@ -2,8 +2,9 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import locationShape from 'Helpers/Props/Shapes/locationShape';
import SignalRConnector from 'Components/SignalRConnector';
import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector';
import ColorImpairedContext from 'App/ColorImpairedContext';
import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector';
import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector';
import PageHeader from './Header/PageHeader';
import PageSidebar from './Sidebar/PageSidebar';
import styles from './Page.css';
@ -73,39 +74,42 @@ class Page extends Component {
children,
isSmallScreen,
isSidebarVisible,
enableColorImpairedMode,
onSidebarToggle,
onSidebarVisibleChange
} = this.props;
return (
<div className={className}>
<SignalRConnector />
<ColorImpairedContext.Provider value={enableColorImpairedMode}>
<div className={className}>
<SignalRConnector />
<PageHeader
onSidebarToggle={onSidebarToggle}
/>
<div className={styles.main}>
<PageSidebar
location={location}
isSmallScreen={isSmallScreen}
isSidebarVisible={isSidebarVisible}
onSidebarVisibleChange={onSidebarVisibleChange}
<PageHeader
onSidebarToggle={onSidebarToggle}
/>
{children}
<div className={styles.main}>
<PageSidebar
location={location}
isSmallScreen={isSmallScreen}
isSidebarVisible={isSidebarVisible}
onSidebarVisibleChange={onSidebarVisibleChange}
/>
{children}
</div>
<AppUpdatedModalConnector
isOpen={this.state.isUpdatedModalOpen}
onModalClose={this.onUpdatedModalClose}
/>
<ConnectionLostModalConnector
isOpen={this.state.isConnectionLostModalOpen}
onModalClose={this.onConnectionLostModalClose}
/>
</div>
<AppUpdatedModalConnector
isOpen={this.state.isUpdatedModalOpen}
onModalClose={this.onUpdatedModalClose}
/>
<ConnectionLostModalConnector
isOpen={this.state.isConnectionLostModalOpen}
onModalClose={this.onConnectionLostModalClose}
/>
</div>
</ColorImpairedContext.Provider>
);
}
}
@ -118,6 +122,7 @@ Page.propTypes = {
isSidebarVisible: PropTypes.bool.isRequired,
isUpdated: PropTypes.bool.isRequired,
isDisconnected: PropTypes.bool.isRequired,
enableColorImpairedMode: PropTypes.bool.isRequired,
onResize: PropTypes.func.isRequired,
onSidebarToggle: PropTypes.func.isRequired,
onSidebarVisibleChange: PropTypes.func.isRequired

View file

@ -1,4 +1,3 @@
/* eslint max-params: 0 */
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
@ -33,30 +32,45 @@ function createMapStateToProps() {
(state) => state.artist,
(state) => state.customFilters,
(state) => state.tags,
(state) => state.settings,
(state) => state.settings.ui,
(state) => state.settings.qualityProfiles,
(state) => state.settings.languageProfiles,
(state) => state.settings.metadataProfiles,
(state) => state.settings.importLists,
(state) => state.app,
createDimensionsSelector(),
(artist, customFilters, tags, settings, app, dimensions) => {
(
artist,
customFilters,
tags,
uiSettings,
qualityProfiles,
languageProfiles,
metadataProfiles,
importLists,
app,
dimensions
) => {
const isPopulated = (
artist.isPopulated &&
customFilters.isPopulated &&
tags.isPopulated &&
settings.qualityProfiles.isPopulated &&
settings.languageProfiles.isPopulated &&
settings.metadataProfiles.isPopulated &&
settings.importLists.isPopulated &&
settings.ui.isPopulated
qualityProfiles.isPopulated &&
languageProfiles.isPopulated &&
metadataProfiles.isPopulated &&
importLists.isPopulated &&
uiSettings.isPopulated
);
const hasError = !!(
artist.error ||
customFilters.error ||
tags.error ||
settings.qualityProfiles.error ||
settings.languageProfiles.error ||
settings.metadataProfiles.error ||
settings.importLists.error ||
settings.ui.error
qualityProfiles.error ||
languageProfiles.error ||
metadataProfiles.error ||
importLists.error ||
uiSettings.error
);
return {
@ -65,13 +79,14 @@ function createMapStateToProps() {
artistError: artist.error,
customFiltersError: tags.error,
tagsError: tags.error,
qualityProfilesError: settings.qualityProfiles.error,
languageProfilesError: settings.languageProfiles.error,
metadataProfilesError: settings.metadataProfiles.error,
importListsError: settings.importLists.error,
uiSettingsError: settings.ui.error,
qualityProfilesError: qualityProfiles.error,
languageProfilesError: languageProfiles.error,
metadataProfilesError: metadataProfiles.error,
importListsError: importLists.error,
uiSettingsError: uiSettings.error,
isSmallScreen: dimensions.isSmallScreen,
isSidebarVisible: app.isSidebarVisible,
enableColorImpairedMode: uiSettings.item.enableColorImpairedMode,
version: app.version,
isUpdated: app.isUpdated,
isDisconnected: app.isDisconnected

View file

@ -22,6 +22,19 @@
justify-content: flex-end;
}
.overflowMenuButton {
composes: menuButton from 'Components/Menu/ToolbarMenuButton.css';
}
.overflowMenuItemIcon {
margin-right: 8px;
}
@media only screen and (max-width: $breakpointSmall) {
.overflowMenuButton {
&::after {
margin-left: 0;
content: '\25BE';
}
}
}

View file

@ -160,6 +160,7 @@ class PageToolbarSection extends Component {
!!overflowItems.length &&
<Menu>
<ToolbarMenuButton
className={styles.overflowMenuButton}
iconName={icons.OVERFLOW}
text="More"
/>
@ -179,14 +180,13 @@ class PageToolbarSection extends Component {
return (
<MenuItem
key={label}
isDisabled={isDisabled}
isDisabled={isDisabled || isSpinning}
{...otherProps}
>
<SpinnerIcon
className={styles.overflowMenuItemIcon}
name={iconName}
spinningName={spinningName}
isDisabled={isDisabled}
isSpinning={isSpinning}
/>
{label}

View file

@ -47,6 +47,10 @@
.danger {
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);
}
}
.success {
@ -59,6 +63,10 @@
.warning {
background-color: $warningColor;
&:global(.colorImpaired) {
background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px);
}
}
.info {

View file

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import { kinds, sizes } from 'Helpers/Props';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import styles from './ProgressBar.css';
function ProgressBar(props) {
@ -23,55 +24,65 @@ function ProgressBar(props) {
const actualWidth = width ? `${width}px` : '100%';
return (
<div
className={classNames(
containerClassName,
styles[size]
)}
title={title}
style={{ width: actualWidth }}
>
{
showText && !!width &&
<ColorImpairedConsumer>
{(enableColorImpairedMode) => {
return (
<div
className={styles.backTextContainer}
className={classNames(
containerClassName,
styles[size]
)}
title={title}
style={{ width: actualWidth }}
>
<div className={styles.backText}>
<div>
{progressText}
</div>
</div>
</div>
}
{
showText && width ?
<div
className={styles.backTextContainer}
style={{ width: actualWidth }}
>
<div className={styles.backText}>
<div>
{progressText}
</div>
</div>
</div> :
null
}
<div
className={classNames(
className,
styles[kind]
)}
aria-valuenow={progress}
aria-valuemin="0"
aria-valuemax="100"
style={{ width: progressPercent }}
/>
{
showText &&
<div
className={styles.frontTextContainer}
style={{ width: progressPercent }}
>
<div
className={styles.frontText}
style={{ width: actualWidth }}
>
<div>
{progressText}
</div>
</div>
className={classNames(
className,
styles[kind],
enableColorImpairedMode && 'colorImpaired'
)}
aria-valuenow={progress}
aria-valuemin="0"
aria-valuemax="100"
style={{ width: progressPercent }}
/>
{
showText ?
<div
className={styles.frontTextContainer}
style={{ width: progressPercent }}
>
<div
className={styles.frontText}
style={{ width: actualWidth }}
>
<div>
{progressText}
</div>
</div>
</div> :
null
}
</div>
}
</div>
);
}}
</ColorImpairedConsumer>
);
}

View file

@ -11,6 +11,7 @@ import { setAppValue, setVersion } from 'Store/Actions/appActions';
import { update, updateItem, removeItem } from 'Store/Actions/baseActions';
import { fetchHealth } from 'Store/Actions/systemActions';
import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchTags, fetchTagDetails } from 'Store/Actions/tagActions';
function getState(status) {
@ -70,6 +71,7 @@ const mapDispatchToProps = {
dispatchFetchHealth: fetchHealth,
dispatchFetchQueue: fetchQueue,
dispatchFetchQueueDetails: fetchQueueDetails,
dispatchFetchRootFolders: fetchRootFolders,
dispatchFetchTags: fetchTags,
dispatchFetchTagDetails: fetchTagDetails
};
@ -202,6 +204,7 @@ class SignalRConnector extends Component {
if (body.action === 'updated') {
this.props.dispatchUpdateItem({ section, ...body.resource });
// Repopulate the page to handle recently imported file
repopulatePage('trackFileUpdated');
} else if (body.action === 'deleted') {
@ -278,6 +281,10 @@ class SignalRConnector extends Component {
// No-op for now, we may want this later
}
handleRootfolder = () => {
this.props.dispatchFetchRootFolders();
}
handleTag = (body) => {
if (body.action === 'sync') {
this.props.dispatchFetchTags();
@ -386,6 +393,7 @@ SignalRConnector.propTypes = {
dispatchFetchHealth: PropTypes.func.isRequired,
dispatchFetchQueue: PropTypes.func.isRequired,
dispatchFetchQueueDetails: PropTypes.func.isRequired,
dispatchFetchRootFolders: PropTypes.func.isRequired,
dispatchFetchTags: PropTypes.func.isRequired,
dispatchFetchTagDetails: PropTypes.func.isRequired
};

View file

@ -1,4 +1,3 @@
/* eslint max-params: 0 */
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CheckInput from 'Components/Form/CheckInput';

View file

@ -1,10 +1,10 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import React from 'react';
import { icons, scrollDirections } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import Scroller from 'Components/Scroller/Scroller';
import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TableHeader from './TableHeader';
import TableHeaderCell from './TableHeaderCell';
import TableSelectAllHeaderCell from './TableSelectAllHeaderCell';
@ -25,123 +25,95 @@ function getTableHeaderCellProps(props) {
}, {});
}
class Table extends Component {
function Table(props) {
const {
className,
selectAll,
columns,
optionsComponent,
pageSize,
canModifyColumns,
children,
onSortPress,
onTableOptionChange,
...otherProps
} = props;
//
// Lifecycle
return (
<Scroller
className={styles.tableContainer}
scrollDirection={scrollDirections.HORIZONTAL}
>
<table className={className}>
<TableHeader>
{
selectAll &&
<TableSelectAllHeaderCell {...otherProps} />
}
constructor(props, context) {
super(props, context);
{
columns.map((column) => {
const {
name,
isVisible
} = column;
this.state = {
isTableOptionsModalOpen: false
};
}
if (!isVisible) {
return null;
}
//
// Listeners
onTableOptionsPress = () => {
this.setState({ isTableOptionsModalOpen: true });
}
onTableOptionsModalClose = () => {
this.setState({ isTableOptionsModalOpen: false });
}
//
// Render
render() {
const {
className,
selectAll,
columns,
pageSize,
canModifyColumns,
children,
onSortPress,
onTableOptionChange,
...otherProps
} = this.props;
return (
<Scroller
className={styles.tableContainer}
scrollDirection={scrollDirections.HORIZONTAL}
>
<table className={className}>
<TableHeader>
{
selectAll &&
<TableSelectAllHeaderCell {...otherProps} />
}
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if ((name === 'actions' || name === 'details') && onTableOptionChange) {
return (
<TableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
{...otherProps}
if (
(name === 'actions' || name === 'details') &&
onTableOptionChange
) {
return (
<TableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
{...otherProps}
>
<TableOptionsModalWrapper
columns={columns}
optionsComponent={optionsComponent}
pageSize={pageSize}
canModifyColumns={canModifyColumns}
onTableOptionChange={onTableOptionChange}
>
<IconButton
name={icons.ADVANCED_SETTINGS}
onPress={this.onTableOptionsPress}
/>
</TableHeaderCell>
);
}
return (
<TableHeaderCell
key={column.name}
onSortPress={onSortPress}
{...getTableHeaderCellProps(otherProps)}
{...column}
>
{column.label}
</TableOptionsModalWrapper>
</TableHeaderCell>
);
})
}
}
{
!!onTableOptionChange &&
<TableOptionsModal
isOpen={this.state.isTableOptionsModalOpen}
columns={columns}
pageSize={pageSize}
canModifyColumns={canModifyColumns}
onTableOptionChange={onTableOptionChange}
onModalClose={this.onTableOptionsModalClose}
/>
}
return (
<TableHeaderCell
key={column.name}
onSortPress={onSortPress}
{...getTableHeaderCellProps(otherProps)}
{...column}
>
{column.label}
</TableHeaderCell>
);
})
}
</TableHeader>
{children}
</table>
</Scroller>
);
}
</TableHeader>
{children}
</table>
</Scroller>
);
}
Table.propTypes = {
className: PropTypes.string,
selectAll: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
optionsComponent: PropTypes.func,
pageSize: PropTypes.number,
canModifyColumns: PropTypes.bool,
children: PropTypes.node,

View file

@ -30,6 +30,7 @@ class TableHeaderCell extends Component {
const {
className,
name,
columnLabel,
isSortable,
isVisible,
isModifiable,
@ -49,10 +50,11 @@ class TableHeaderCell extends Component {
return (
isSortable ?
<Link
{...otherProps}
component="th"
className={className}
title={columnLabel}
onPress={this.onPress}
{...otherProps}
>
{children}
@ -75,7 +77,7 @@ class TableHeaderCell extends Component {
TableHeaderCell.propTypes = {
className: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
columnLabel: PropTypes.string,
isSortable: PropTypes.bool,
isVisible: PropTypes.bool,
isModifiable: PropTypes.bool,

View file

@ -75,6 +75,4 @@ TableOptionsColumnDragPreview.propTypes = {
})
};
/* eslint-disable new-cap */
export default DragLayer(collectDragLayer)(TableOptionsColumnDragPreview);
/* eslint-enable new-cap */

View file

@ -153,7 +153,6 @@ TableOptionsColumnDragSource.propTypes = {
onColumnDragEnd: PropTypes.func.isRequired
};
/* eslint-disable new-cap */
export default DropTarget(
TABLE_COLUMN,
columnDropTarget,
@ -163,4 +162,3 @@ export default DropTarget(
columnDragSource,
collectDragSource
)(TableOptionsColumnDragSource));
/* eslint-enable new-cap */

View file

@ -249,6 +249,4 @@ TableOptionsModal.defaultProps = {
canModifyColumns: true
};
/* eslint-disable new-cap */
export default DragDropContext(HTML5Backend)(TableOptionsModal);
/* eslint-enable new-cap */

View file

@ -0,0 +1,61 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import TableOptionsModal from './TableOptionsModal';
class TableOptionsModalWrapper 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 {
columns,
children,
...otherProps
} = this.props;
return (
<Fragment>
{
React.cloneElement(children, { onPress: this.onTableOptionsPress })
}
<TableOptionsModal
{...otherProps}
isOpen={this.state.isTableOptionsModalOpen}
columns={columns}
onModalClose={this.onTableOptionsModalClose}
/>
</Fragment>
);
}
}
TableOptionsModalWrapper.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
children: PropTypes.node.isRequired
};
export default TableOptionsModalWrapper;

View file

@ -100,5 +100,6 @@
}
.body {
overflow: auto;
padding: 10px;
}

View file

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TetherComponent from 'react-tether';
import classNames from 'classnames';
import isMobileUtil from 'Utilities/isMobile';
import { tooltipPositions } from 'Helpers/Props';
import styles from './Popover.css';
@ -67,7 +68,9 @@ class Popover extends Component {
// Listeners
onClick = () => {
this.setState({ isOpen: !this.state.isOpen });
if (isMobileUtil()) {
this.setState({ isOpen: !this.state.isOpen });
}
}
onMouseEnter = () => {
@ -105,7 +108,7 @@ class Popover extends Component {
>
<span
className={className}
// onClick={this.onClick}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
@ -114,28 +117,28 @@ class Popover extends Component {
{
this.state.isOpen &&
<div
className={styles.popoverContainer}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
<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 className={styles.body}>
{body}
</div>
</div>
</div>
</div>
}
</TetherComponent>
);

View file

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TetherComponent from 'react-tether';
import classNames from 'classnames';
import isMobileUtil from 'Utilities/isMobile';
import { kinds, tooltipPositions } from 'Helpers/Props';
import styles from './Tooltip.css';
@ -67,12 +68,14 @@ class Tooltip extends Component {
// Listeners
onClick = () => {
this.setState({ isOpen: !this.state.isOpen });
if (isMobileUtil()) {
this.setState({ isOpen: !this.state.isOpen });
}
}
onMouseEnter = () => {
if (this._closeTimeout) {
clearTimeout(this._closeTimeout);
this._closeTimeout = clearTimeout(this._closeTimeout);
}
this.setState({ isOpen: true });
@ -105,7 +108,7 @@ class Tooltip extends Component {
>
<span
className={className}
// onClick={this.onClick}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>

View file

@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import React from 'react';
function withCurrentPage(WrappedComponent) {
function CurrentPage(props) {
const {
history
} = props;
return (
<WrappedComponent
{...props}
useCurrentPage={history.action === 'POP'}
/>
);
}
CurrentPage.propTypes = {
history: PropTypes.object.isRequired
};
return CurrentPage;
}
export default withCurrentPage;