New: Server Side UI Filtering, Error Boundaries (#501)

Co-Authored-By: Mark McDowall <markus101@users.noreply.github.com>
pull/505/head
Qstick 6 years ago committed by GitHub
parent a95191dc3b
commit 64a8d02f77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,4 +1,4 @@
// will download and run sonarr (server) in a non-windows enviroment
// will download and run lidarr (server) in a non-windows enviroment
// you can use this if you don't care about the server code and just want to work
// with the web code.

@ -6,7 +6,7 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import EpisodeLanguage from 'Album/EpisodeLanguage';
import EpisodeQuality from 'Album/EpisodeQuality';
import TrackQuality from 'Album/TrackQuality';
import ArtistNameLink from 'Artist/ArtistNameLink';
import BlacklistDetailsModal from './BlacklistDetailsModal';
import styles from './BlacklistRow.css';
@ -103,7 +103,7 @@ class BlacklistRow extends Component {
key={name}
className={styles.quality}
>
<EpisodeQuality
<TrackQuality
quality={quality}
/>
</TableRowCell>

@ -7,7 +7,7 @@ import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import AlbumTitleLink from 'Album/AlbumTitleLink';
import EpisodeLanguage from 'Album/EpisodeLanguage';
import EpisodeQuality from 'Album/EpisodeQuality';
import TrackQuality from 'Album/TrackQuality';
import ArtistNameLink from 'Artist/ArtistNameLink';
import HistoryEventTypeCell from './HistoryEventTypeCell';
import HistoryDetailsModal from './Details/HistoryDetailsModal';
@ -142,7 +142,7 @@ class HistoryRow extends Component {
if (name === 'quality') {
return (
<TableRowCell key={name}>
<EpisodeQuality
<TrackQuality
quality={quality}
isCutoffMet={qualityCutoffNotMet}
/>

@ -10,7 +10,7 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import AlbumTitleLink from 'Album/AlbumTitleLink';
import EpisodeQuality from 'Album/EpisodeQuality';
import TrackQuality from 'Album/TrackQuality';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import ArtistNameLink from 'Artist/ArtistNameLink';
import QueueStatusCell from './QueueStatusCell';
@ -177,7 +177,7 @@ class QueueRow extends Component {
if (name === 'quality') {
return (
<TableRowCell key={name}>
<EpisodeQuality
<TrackQuality
quality={quality}
/>
</TableRowCell>

@ -4,7 +4,7 @@ import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import InteractiveAlbumSearchModal from './Search/InteractiveAlbumSearchModal';
import InteractiveSearchModal from 'InteractiveSearch/InteractiveSearchModal';
import styles from './AlbumSearchCell.css';
class AlbumSearchCell extends Component {
@ -55,7 +55,7 @@ class AlbumSearchCell extends Component {
onPress={this.onManualSearchPress}
/>
<InteractiveAlbumSearchModal
<InteractiveSearchModal
isOpen={this.state.isDetailsModalOpen}
albumId={albumId}
onModalClose={this.onDetailsModalClose}

@ -22,7 +22,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import AlbumDetailsMediumConnector from './AlbumDetailsMediumConnector';
import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal';
import InteractiveAlbumSearchModal from 'Album/Search/InteractiveAlbumSearchModal';
import InteractiveSearchModal from 'InteractiveSearch/InteractiveSearchModal';
import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
import styles from './AlbumDetails.css';
@ -415,7 +415,7 @@ class AlbumDetails extends Component {
onModalClose={this.onManageTracksModalClose}
/>
<InteractiveAlbumSearchModal
<InteractiveSearchModal
isOpen={isInteractiveSearchModalOpen}
albumId={id}
onModalClose={this.onInteractiveSearchModalClose}

@ -5,7 +5,7 @@ import { icons, kinds, sizes } from 'Helpers/Props';
import Icon from 'Components/Icon';
import ProgressBar from 'Components/ProgressBar';
import QueueDetails from 'Activity/Queue/QueueDetails';
import EpisodeQuality from './EpisodeQuality';
import TrackQuality from './TrackQuality';
import styles from './EpisodeStatus.css';
function EpisodeStatus(props) {
@ -63,7 +63,7 @@ function EpisodeStatus(props) {
return (
<div className={styles.center}>
<EpisodeQuality
<TrackQuality
quality={quality}
size={trackFile.size}
isCutoffNotMet={isCutoffNotMet}

@ -22,7 +22,7 @@ function getTooltip(title, quality, size) {
return title;
}
function EpisodeQuality(props) {
function TrackQuality(props) {
const {
className,
title,
@ -42,7 +42,7 @@ function EpisodeQuality(props) {
);
}
EpisodeQuality.propTypes = {
TrackQuality.propTypes = {
className: PropTypes.string,
title: PropTypes.string,
quality: PropTypes.object.isRequired,
@ -50,8 +50,8 @@ EpisodeQuality.propTypes = {
isCutoffNotMet: PropTypes.bool
};
EpisodeQuality.defaultProps = {
TrackQuality.defaultProps = {
title: ''
};
export default EpisodeQuality;
export default TrackQuality;

@ -13,6 +13,7 @@ import FilterMenu from 'Components/Menu/FilterMenu';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import NoArtist from 'Artist/NoArtist';
import AlbumStudioFilterModalConnector from './AlbumStudioFilterModalConnector';
import AlbumStudioRowConnector from './AlbumStudioRowConnector';
import AlbumStudioFooter from './AlbumStudioFooter';
@ -130,6 +131,7 @@ class AlbumStudio extends Component {
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={AlbumStudioFilterModalConnector}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
@ -202,7 +204,7 @@ AlbumStudio.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
selectedFilterKey: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,

@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setAlbumStudioFilter } from 'Store/Actions/albumStudioActions';
import FilterModal from 'Components/Filter/FilterModal';
function createMapStateToProps() {
return createSelector(
(state) => state.artist.items,
(state) => state.albumStudio.filterBuilderProps,
(sectionItems, filterBuilderProps) => {
return {
sectionItems,
filterBuilderProps,
customFilterType: 'albumStudio'
};
}
);
}
const mapDispatchToProps = {
dispatchSetFilter: setAlbumStudioFilter
};
export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);

@ -9,7 +9,7 @@ function createMapStateToProps() {
return createSelector(
createArtistSelector(),
createTrackFileSelector(),
(artist, trackFile) => {
(artist = {}, trackFile) => {
return {
foreignArtistId: artist.foreignArtistId,
artistMonitored: artist.monitored,

@ -11,6 +11,7 @@ import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Label from 'Components/Label';
import Measure from 'Components/Measure';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';

@ -273,7 +273,7 @@ ArtistEditor.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
selectedFilterKey: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,

@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as artistEditorActions from 'Store/Actions/artistEditorActions';
import { setArtistEditorFilter } from 'Store/Actions/artistEditorActions';
import FilterModal from 'Components/Filter/FilterModal';
function createMapStateToProps() {
@ -10,22 +10,15 @@ function createMapStateToProps() {
(sectionItems, filterBuilderProps) => {
return {
sectionItems,
filterBuilderProps
filterBuilderProps,
customFilterType: 'artistEditor'
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onRemoveCustomFilterPress(payload) {
dispatch(artistEditorActions.removeArtistEditorCustomFilter(payload));
},
const mapDispatchToProps = {
dispatchSetFilter: setArtistEditorFilter
};
onSaveCustomFilterPress(payload) {
dispatch(artistEditorActions.saveArtistEditorCustomFilter(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal);
export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);

@ -119,4 +119,8 @@ ArtistEditorRow.propTypes = {
onSelectedChange: PropTypes.func.isRequired
};
ArtistEditorRow.defaultProps = {
tags: []
};
export default ArtistEditorRow;

@ -9,7 +9,7 @@ import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Popover from 'Components/Tooltip/Popover';
import EpisodeLanguage from 'Album/EpisodeLanguage';
import EpisodeQuality from 'Album/EpisodeQuality';
import TrackQuality from 'Album/TrackQuality';
import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import styles from './ArtistHistoryRow.css';
@ -100,7 +100,7 @@ class ArtistHistoryRow extends Component {
</TableRowCell>
<TableRowCell>
<EpisodeQuality
<TrackQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>

@ -215,7 +215,7 @@ class ArtistIndex extends Component {
} = this.state;
const ViewComponent = getViewComponent(view);
const isLoaded = !error && isPopulated && !!items.length && contentBody;
const isLoaded = !!(!error && isPopulated && items.length && contentBody);
const hasNoArtist = !totalItems;
return (
@ -382,7 +382,7 @@ ArtistIndex.propTypes = {
error: PropTypes.object,
totalItems: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,

@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as artistIndexActions from 'Store/Actions/artistIndexActions';
import { setArtistFilter } from 'Store/Actions/artistIndexActions';
import FilterModal from 'Components/Filter/FilterModal';
function createMapStateToProps() {
@ -10,22 +10,15 @@ function createMapStateToProps() {
(sectionItems, filterBuilderProps) => {
return {
sectionItems,
filterBuilderProps
filterBuilderProps,
customFilterType: 'artistIndex'
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onRemoveCustomFilterPress(payload) {
dispatch(artistIndexActions.removeArtistCustomFilter(payload));
},
const mapDispatchToProps = {
dispatchSetFilter: setArtistFilter
};
onSaveCustomFilterPress(payload) {
dispatch(artistIndexActions.saveArtistCustomFilter(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal);
export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);

@ -1,12 +1,12 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Measure from 'react-measure';
import { Grid, WindowScroller } from 'react-virtualized';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import dimensions from 'Styles/Variables/dimensions';
import { sortDirections } from 'Helpers/Props';
import Measure from 'Components/Measure';
import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
import ArtistIndexBanner from './ArtistIndexBanner';
import styles from './ArtistIndexBanners.css';

@ -27,7 +27,7 @@ function ArtistIndexFilterMenu(props) {
}
ArtistIndexFilterMenu.propTypes = {
selectedFilterKey: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired,

@ -2,12 +2,12 @@ import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Measure from 'react-measure';
import { Grid, WindowScroller } from 'react-virtualized';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import dimensions from 'Styles/Variables/dimensions';
import { sortDirections } from 'Helpers/Props';
import Measure from 'Components/Measure';
import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
import ArtistIndexOverview from './ArtistIndexOverview';
import styles from './ArtistIndexOverviews.css';

@ -1,12 +1,12 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Measure from 'react-measure';
import { Grid, WindowScroller } from 'react-virtualized';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import dimensions from 'Styles/Variables/dimensions';
import { sortDirections } from 'Helpers/Props';
import Measure from 'Components/Measure';
import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
import ArtistIndexPoster from './ArtistIndexPoster';
import styles from './ArtistIndexPosters.css';

@ -1,8 +1,8 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Measure from 'react-measure';
import { align, icons } from 'Helpers/Props';
import PageContent from 'Components/Page/PageContent';
import Measure from 'Components/Measure';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';

@ -56,6 +56,10 @@ class CalendarEvent extends Component {
colorImpairedMode
} = this.props;
if (!artist) {
return null;
}
const startTime = moment(releaseDate);
// const endTime = startTime.add(artist.runtime, 'minutes');
const downloading = !!(queueItem || grabbed);

@ -0,0 +1,62 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as sentry from '@sentry/browser';
class ErrorBoundary extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
error: null,
info: null
};
}
componentDidCatch(error, info) {
this.setState({
error,
info
});
sentry.captureException(error);
}
//
// Render
render() {
const {
children,
errorComponent: ErrorComponent,
...otherProps
} = this.props;
const {
error,
info
} = this.state;
if (error) {
return (
<ErrorComponent
error={error}
info={info}
{...otherProps}
/>
);
}
return children;
}
}
ErrorBoundary.propTypes = {
children: PropTypes.node.isRequired,
errorComponent: PropTypes.func.isRequired
};
export default ErrorBoundary;

@ -0,0 +1,38 @@
.container {
text-align: center;
}
.message {
margin: 50px 0;
text-align: center;
font-weight: 300;
font-size: 36px;
}
.imageContainer {
display: flex;
justify-content: center;
flex: 0 0 auto;
}
.image {
height: 350px;
}
.details {
margin: 20px;
text-align: left;
white-space: pre-wrap;
}
@media only screen and (max-width: $breakpointMedium) {
.image {
height: 250px;
}
}
@media only screen and (max-width: $breakpointSmall) {
.image {
height: 150px;
}
}

@ -0,0 +1,60 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './ErrorBoundaryError.css';
function ErrorBoundaryError(props) {
const {
className,
messageClassName,
detailsClassName,
message,
error,
info
} = props;
return (
<div className={className}>
<div className={messageClassName}>
{message}
</div>
<div className={styles.imageContainer}>
<img
className={styles.image}
src={`${window.Lidarr.urlBase}/Content/Images/error.png`}
/>
</div>
<details className={detailsClassName}>
{
error &&
<div>
{error.toString()}
</div>
}
<div className={styles.info}>
{info.componentStack}
</div>
</details>
</div>
);
}
ErrorBoundaryError.propTypes = {
className: PropTypes.string.isRequired,
messageClassName: PropTypes.string.isRequired,
detailsClassName: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
error: PropTypes.object.isRequired,
info: PropTypes.object.isRequired
};
ErrorBoundaryError.defaultProps = {
className: styles.container,
messageClassName: styles.message,
detailsClassName: styles.details,
message: 'There was an error loading this content'
};
export default ErrorBoundaryError;

@ -35,7 +35,7 @@ function createMapStateToProps() {
directories,
files,
paths: filteredPaths,
isWindowsService: true || systemStatus.isWindows && systemStatus.mode === 'service'
isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service'
};
}
);

@ -3,6 +3,7 @@ import React, { Component } from 'react';
import { inputTypes } from 'Helpers/Props';
import FormInputGroup from 'Components/Form/FormInputGroup';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
@ -34,6 +35,28 @@ class FilterBuilderModalContent extends Component {
};
}
componentDidUpdate(prevProps) {
const {
id,
customFilters,
isSaving,
saveError,
dispatchSetFilter,
onModalClose
} = this.props;
if (prevProps.isSaving && !isSaving && !saveError) {
if (id) {
dispatchSetFilter({ selectedFilterKey: id });
} else {
const last = customFilters[customFilters.length -1];
dispatchSetFilter({ selectedFilterKey: last.id });
}
onModalClose();
}
}
//
// Listeners
@ -70,9 +93,9 @@ class FilterBuilderModalContent extends Component {
onSaveFilterPress = () => {
const {
customFilterKey: key,
onSaveCustomFilterPress,
onModalClose
id,
customFilterType,
onSaveCustomFilterPress
} = this.props;
const {
@ -92,8 +115,12 @@ class FilterBuilderModalContent extends Component {
return;
}
onSaveCustomFilterPress({ key, label, filters });
onModalClose();
onSaveCustomFilterPress({
id,
type: customFilterType,
label,
filters
});
}
//
@ -103,6 +130,8 @@ class FilterBuilderModalContent extends Component {
const {
sectionItems,
filterBuilderProps,
isSaving,
saveError,
onModalClose
} = this.props;
@ -161,17 +190,17 @@ class FilterBuilderModalContent extends Component {
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={this.onSaveFilterPress}
>
Apply
</Button>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
@ -179,13 +208,18 @@ class FilterBuilderModalContent extends Component {
}
FilterBuilderModalContent.propTypes = {
customFilterKey: PropTypes.string,
id: PropTypes.number,
label: PropTypes.string.isRequired,
customFilterType: PropTypes.string.isRequired,
sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired,
onRemoveCustomFilterPress: PropTypes.func.isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
dispatchDeleteCustomFilter: PropTypes.func.isRequired,
onSaveCustomFilterPress: PropTypes.func.isRequired,
dispatchSetFilter: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

@ -1,28 +1,42 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveCustomFilter, deleteCustomFilter } from 'Store/Actions/customFilterActions';
import FilterBuilderModalContent from './FilterBuilderModalContent';
function createMapStateToProps() {
return createSelector(
(state, { customFilters }) => customFilters,
(state, { customFilterKey }) => customFilterKey,
(customFilters, customFilterKey) => {
if (customFilterKey) {
const customFilter = customFilters.find((c) => c.key === customFilterKey);
(state, { id }) => id,
(state) => state.customFilters.isSaving,
(state) => state.customFilters.saveError,
(customFilters, id, isSaving, saveError) => {
if (id) {
const customFilter = customFilters.find((c) => c.id === id);
return {
customFilterKey: customFilter.key,
id: customFilter.id,
label: customFilter.label,
filters: customFilter.filters
filters: customFilter.filters,
customFilters,
isSaving,
saveError
};
}
return {
label: '',
filters: []
filters: [],
customFilters,
isSaving,
saveError
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderModalContent);
const mapDispatchToProps = {
onSaveCustomFilterPress: saveCustomFilter,
dispatchDeleteCustomFilter: deleteCustomFilter
};
export default connect(createMapStateToProps, mapDispatchToProps)(FilterBuilderModalContent);

@ -1,11 +1,64 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds, filterBuilderTypes } from 'Helpers/Props';
import convertToBytes from 'Utilities/Number/convertToBytes';
import formatBytes from 'Utilities/Number/formatBytes';
import { kinds, filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props';
import TagInput, { tagShape } from 'Components/Form/TagInput';
import FilterBuilderRowValueTag from './FilterBuilderRowValueTag';
export const NAME = 'value';
function getTagDisplayValue(value, selectedFilterBuilderProp) {
if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) {
return formatBytes(value);
}
return value;
}
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);
case 'm':
return convertToBytes(value, 2, true);
case 'g':
return convertToBytes(value, 3, true);
case 't':
return convertToBytes(value, 4, true);
case 'kb':
return convertToBytes(value, 1, true);
case 'mb':
return convertToBytes(value, 2, true);
case 'gb':
return convertToBytes(value, 3, true);
case 'tb':
return convertToBytes(value, 4, true);
case 'kib':
return convertToBytes(value, 1, true);
case 'mib':
return convertToBytes(value, 2, true);
case 'gib':
return convertToBytes(value, 3, true);
case 'tib':
return convertToBytes(value, 4, true);
default:
return parseInt(value);
}
}
}
if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) {
return parseInt(input);
}
return input;
}
class FilterBuilderRowValue extends Component {
//
@ -18,17 +71,15 @@ class FilterBuilderRowValue extends Component {
onChange
} = this.props;
let id = tag.id;
let value = tag.id;
if (id == null) {
id = selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER ?
parseInt(tag.name) :
tag.name;
if (value == null) {
value = getValue(tag.name, selectedFilterBuilderProp);
}
onChange({
name: NAME,
value: [...filterValue, id]
value: [...filterValue, value]
});
}
@ -52,6 +103,7 @@ class FilterBuilderRowValue extends Component {
render() {
const {
filterValue,
selectedFilterBuilderProp,
tagList
} = this.props;
@ -68,7 +120,7 @@ class FilterBuilderRowValue extends Component {
}
return {
id,
name: id
name: getTagDisplayValue(id, selectedFilterBuilderProp)
};
});

@ -2,29 +2,70 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import styles from './CustomFilter.css';
class CustomFilter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDeleting: false
};
}
componentDidUpdate(prevProps) {
const {
isDeleting,
deleteError
} = this.props;
if (prevProps.isDeleting && !isDeleting && this.state.isDeleting && deleteError) {
this.setState({ isDeleting: false });
}
}
componentWillUnmount() {
const {
id,
selectedFilterKey,
dispatchSetFilter
} = this.props;
// Assume that delete and then unmounting means the delete was successful.
// Moving this check to a ancestor would be more accurate, but would have
// more boilerplate.
if (this.state.isDeleting && id === selectedFilterKey) {
dispatchSetFilter({ selectedFilterKey: 'all' });
}
}
//
// Listeners
onEditPress = () => {
const {
customFilterKey,
id,
onEditPress
} = this.props;
onEditPress(customFilterKey);
onEditPress(id);
}
onRemovePress = () => {
const {
customFilterKey,
onRemovePress
id,
dispatchDeleteCustomFilter
} = this.props;
onRemovePress({ key: customFilterKey });
this.setState({ isDeleting: true }, () => {
dispatchDeleteCustomFilter({ id });
});
}
//
@ -47,8 +88,9 @@ class CustomFilter extends Component {
onPress={this.onEditPress}
/>
<IconButton
<SpinnerIconButton
name={icons.REMOVE}
isSpinning={this.state.isDeleting}
onPress={this.onRemovePress}
/>
</div>
@ -58,10 +100,14 @@ class CustomFilter extends Component {
}
CustomFilter.propTypes = {
customFilterKey: PropTypes.string.isRequired,
id: PropTypes.number.isRequired,
label: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
dispatchSetFilter: PropTypes.func.isRequired,
onEditPress: PropTypes.func.isRequired,
onRemovePress: PropTypes.func.isRequired
dispatchDeleteCustomFilter: PropTypes.func.isRequired
};
export default CustomFilter;

@ -10,9 +10,13 @@ import styles from './CustomFiltersModalContent.css';
function CustomFiltersModalContent(props) {
const {
selectedFilterKey,
customFilters,
isDeleting,
deleteError,
dispatchDeleteCustomFilter,
dispatchSetFilter,
onAddCustomFilter,
onRemoveCustomFilterPress,
onEditCustomFilter,
onModalClose
} = props;
@ -29,10 +33,14 @@ function CustomFiltersModalContent(props) {
return (
<CustomFilter
key={index}
customFilterKey={customFilter.key}
id={customFilter.id}
label={customFilter.label}
filters={customFilter.filters}
onRemovePress={onRemoveCustomFilterPress}
selectedFilterKey={selectedFilterKey}
isDeleting={isDeleting}
deleteError={deleteError}
dispatchSetFilter={dispatchSetFilter}
dispatchDeleteCustomFilter={dispatchDeleteCustomFilter}
onEditPress={onEditCustomFilter}
/>
);
@ -58,9 +66,13 @@ function CustomFiltersModalContent(props) {
}
CustomFiltersModalContent.propTypes = {
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
dispatchDeleteCustomFilter: PropTypes.func.isRequired,
dispatchSetFilter: PropTypes.func.isRequired,
onAddCustomFilter: PropTypes.func.isRequired,
onRemoveCustomFilterPress: PropTypes.func.isRequired,
onEditCustomFilter: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteCustomFilter } from 'Store/Actions/customFilterActions';
import CustomFiltersModalContent from './CustomFiltersModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.customFilters.isDeleting,
(state) => state.customFilters.deleteError,
(isDeleting, deleteError) => {
return {
isDeleting,
deleteError
};
}
);
}
const mapDispatchToProps = {
dispatchDeleteCustomFilter: deleteCustomFilter
};
export default connect(createMapStateToProps, mapDispatchToProps)(CustomFiltersModalContent);

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import FilterBuilderModalContentConnector from './Builder/FilterBuilderModalContentConnector';
import CustomFiltersModalContent from './CustomFilters/CustomFiltersModalContent';
import CustomFiltersModalContentConnector from './CustomFilters/CustomFiltersModalContentConnector';
class FilterModal extends Component {
@ -14,7 +14,7 @@ class FilterModal extends Component {
this.state = {
filterBuilder: !props.customFilters.length,
customFilterKey: null
id: null
};
}
@ -27,17 +27,17 @@ class FilterModal extends Component {
});
}
onEditCustomFilter = (customFilterKey) => {
onEditCustomFilter = (id) => {
this.setState({
filterBuilder: true,
customFilterKey
id
});
}
onModalClose = () => {
this.setState({
filterBuilder: false,
customFilterKey: null
id: null
}, () => {
this.props.onModalClose();
});
@ -54,7 +54,7 @@ class FilterModal extends Component {
const {
filterBuilder,
customFilterKey
id
} = this.state;
return (
@ -66,10 +66,10 @@ class FilterModal extends Component {
filterBuilder ?
<FilterBuilderModalContentConnector
{...otherProps}
customFilterKey={customFilterKey}
id={id}
onModalClose={this.onModalClose}
/> :
<CustomFiltersModalContent
<CustomFiltersModalContentConnector
{...otherProps}
onAddCustomFilter={this.onAddCustomFilter}
onEditCustomFilter={this.onEditCustomFilter}

@ -2,7 +2,6 @@ import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Measure from 'react-measure';
import TetherComponent from 'react-tether';
import classNames from 'classnames';
import isMobileUtil from 'Utilities/isMobile';
@ -10,6 +9,7 @@ import * as keyCodes from 'Utilities/Constants/keyCodes';
import { icons, scrollDirections } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import Scroller from 'Components/Scroller/Scroller';

@ -62,13 +62,15 @@ class PathInput extends Component {
event.preventDefault();
const path = this.props.paths[0];
this.props.onChange({
name: this.props.name,
value: path.path
});
if (path.type !== 'file') {
this.props.onFetchPaths(path.path);
if (path) {
this.props.onChange({
name: this.props.name,
value: path.path
});
if (path.type !== 'file') {
this.props.onFetchPaths(path.path);
}
}
}
}

@ -39,6 +39,10 @@ class TagInput extends Component {
this._autosuggestRef = null;
}
componentWillUnmount() {
this.addTag.cancel();
}
//
// Control

@ -0,0 +1,38 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactMeasure from 'react-measure';
class Measure extends Component {
//
// Lifecycle
componentWillUnmount() {
this.onMeasure.cancel();
}
//
// Listeners
onMeasure = _.debounce((payload) => {
this.props.onMeasure(payload);
}, 250, { leading: true, trailing: false })
//
// Render
render() {
return (
<ReactMeasure
{...this.props}
/>
);
}
}
Measure.propTypes = {
onMeasure: PropTypes.func.isRequired
};
export default Measure;

@ -42,6 +42,7 @@ class FilterMenu extends Component {
customFilters,
buttonComponent: ButtonComponent,
filterModalConnectorComponent: FilterModalConnectorComponent,
filterModalConnectorComponentProps,
onFilterSelect,
...otherProps
} = this.props;
@ -74,6 +75,7 @@ class FilterMenu extends Component {
{
showCustomFilters &&
<FilterModalConnectorComponent
{...filterModalConnectorComponentProps}
isOpen={this.state.isFilterModalOpen}
selectedFilterKey={selectedFilterKey}
filters={filters}
@ -90,11 +92,12 @@ class FilterMenu extends Component {
FilterMenu.propTypes = {
className: PropTypes.string,
isDisabled: PropTypes.bool.isRequired,
selectedFilterKey: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
buttonComponent: PropTypes.func.isRequired,
filterModalConnectorComponent: PropTypes.func,
filterModalConnectorComponentProps: PropTypes.object,
onFilterSelect: PropTypes.func.isRequired
};

@ -42,8 +42,8 @@ class FilterMenuContent extends Component {
customFilters.map((filter) => {
return (
<FilterMenuItem
key={filter.key}
filterKey={filter.key}
key={filter.id}
filterKey={filter.id}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
@ -70,7 +70,7 @@ class FilterMenuContent extends Component {
}
FilterMenuContent.propTypes = {
selectedFilterKey: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
showCustomFilters: PropTypes.bool.isRequired,

@ -37,8 +37,8 @@ class FilterMenuItem extends Component {
}
FilterMenuItem.propTypes = {
filterKey: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.string.isRequired,
filterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
onPress: PropTypes.func.isRequired
};

@ -6,6 +6,8 @@ import elementClass from 'element-class';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import { sizes } from 'Helpers/Props';
import ErrorBoundary from 'Components/Error/ErrorBoundary';
import ModalError from './ModalError';
import styles from './Modal.css';
const openModals = [];
@ -153,7 +155,8 @@ class Modal extends Component {
backdropClassName,
size,
children,
isOpen
isOpen,
onModalClose
} = this.props;
if (!isOpen) {
@ -177,7 +180,12 @@ class Modal extends Component {
)}
style={style}
>
{children}
<ErrorBoundary
errorComponent={ModalError}
onModalClose={onModalClose}
>
{children}
</ErrorBoundary>
</div>
</div>
</div>,

@ -0,0 +1,15 @@
.message {
composes: message from 'Components/Error/ErrorBoundaryError.css';
margin: 0;
margin-bottom: 30px;
font-weight: normal;
font-size: 26px;
}
.details {
composes: details from 'Components/Error/ErrorBoundaryError.css';
margin: 0;
margin-top: 20px;
}

@ -0,0 +1,46 @@
import PropTypes from 'prop-types';
import React from 'react';
import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError';
import Button from 'Components/Link/Button';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import styles from './ModalError.css';
function ModalError(props) {
const {
onModalClose,
...otherProps
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Error
</ModalHeader>
<ModalBody>
<ErrorBoundaryError
messageClassName={styles.message}
detailsClassName={styles.details}
{...otherProps}
message='There was an error loading this item'
/>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>);
}
ModalError.propTypes = {
onModalClose: PropTypes.func.isRequired
};
export default ModalError;

@ -8,8 +8,11 @@ function ErrorPage(props) {
version,
isLocalStorageSupported,
artistError,
customFiltersError,
tagsError,
qualityProfilesError,
languageProfilesError,
metadataProfilesError,
uiSettingsError
} = props;
@ -19,10 +22,16 @@ function ErrorPage(props) {
errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
} else if (artistError) {
errorMessage = getErrorMessage(artistError, 'Failed to load artist from API');
} else if (customFiltersError) {
errorMessage = getErrorMessage(customFiltersError, 'Failed to load custom filters from API');
} else if (tagsError) {
errorMessage = getErrorMessage(artistError, 'Failed to load artist from API');
errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API');
} else if (qualityProfilesError) {
errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API');
} else if (languageProfilesError) {
errorMessage = getErrorMessage(languageProfilesError, 'Failed to load language profiles from API');
} else if (metadataProfilesError) {
errorMessage = getErrorMessage(metadataProfilesError, 'Failed to load metadata profiles from API');
} else if (uiSettingsError) {
errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API');
}
@ -44,8 +53,11 @@ ErrorPage.propTypes = {
version: PropTypes.string.isRequired,
isLocalStorageSupported: PropTypes.bool.isRequired,
artistError: PropTypes.object,
customFiltersError: PropTypes.object,
tagsError: PropTypes.object,
qualityProfilesError: PropTypes.object,
languageProfilesError: PropTypes.object,
metadataProfilesError: PropTypes.object,
uiSettingsError: PropTypes.object
};

@ -6,6 +6,7 @@ import { withRouter } from 'react-router-dom';
import { createSelector } from 'reselect';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
import { fetchArtist } from 'Store/Actions/artistActions';
import { fetchTags } from 'Store/Actions/tagActions';
import { fetchQualityProfiles, fetchLanguageProfiles, fetchMetadataProfiles, fetchUISettings, fetchImportLists } from 'Store/Actions/settingsActions';
@ -30,13 +31,15 @@ function testLocalStorage() {
function createMapStateToProps() {
return createSelector(
(state) => state.artist,
(state) => state.customFilters,
(state) => state.tags,
(state) => state.settings,
(state) => state.app,
createDimensionsSelector(),
(artist, tags, settings, app, dimensions) => {
(artist, customFilters, tags, settings, app, dimensions) => {
const isPopulated = (
artist.isPopulated &&
customFilters.isPopulated &&
tags.isPopulated &&
settings.qualityProfiles.isPopulated &&
settings.languageProfiles.isPopulated &&
@ -47,6 +50,7 @@ function createMapStateToProps() {
const hasError = !!(
artist.error ||
customFilters.error ||
tags.error ||
settings.qualityProfiles.error ||
settings.languageProfiles.error ||
@ -59,6 +63,7 @@ function createMapStateToProps() {
isPopulated,
hasError,
artistError: artist.error,
customFiltersError: tags.error,
tagsError: tags.error,
qualityProfilesError: settings.qualityProfiles.error,
languageProfilesError: settings.languageProfiles.error,
@ -80,6 +85,9 @@ function createMapDispatchToProps(dispatch, props) {
dispatchFetchArtist() {
dispatch(fetchArtist());
},
dispatchFetchCustomFilters() {
dispatch(fetchCustomFilters());
},
dispatchFetchTags() {
dispatch(fetchTags());
},
@ -126,6 +134,7 @@ class PageConnector extends Component {
componentDidMount() {
if (!this.props.isPopulated) {
this.props.dispatchFetchArtist();
this.props.dispatchFetchCustomFilters();
this.props.dispatchFetchTags();
this.props.dispatchFetchQualityProfiles();
this.props.dispatchFetchLanguageProfiles();
@ -190,6 +199,7 @@ PageConnector.propTypes = {
hasError: PropTypes.bool.isRequired,
isSidebarVisible: PropTypes.bool.isRequired,
dispatchFetchArtist: PropTypes.func.isRequired,
dispatchFetchCustomFilters: PropTypes.func.isRequired,
dispatchFetchTags: PropTypes.func.isRequired,
dispatchFetchQualityProfiles: PropTypes.func.isRequired,
dispatchFetchLanguageProfiles: PropTypes.func.isRequired,

@ -1,6 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import DocumentTitle from 'react-document-title';
import ErrorBoundary from 'Components/Error/ErrorBoundary';
import PageContentError from './PageContentError';
import styles from './PageContent.css';
function PageContent(props) {
@ -11,11 +13,13 @@ function PageContent(props) {
} = props;
return (
<DocumentTitle title={title ? `${title} - Lidarr` : 'Lidarr'}>
<div className={className}>
{children}
</div>
</DocumentTitle>
<ErrorBoundary errorComponent={PageContentError}>
<DocumentTitle title={title ? `${title} - Lidarr` : 'Lidarr'}>
<div className={className}>
{children}
</div>
</DocumentTitle>
</ErrorBoundary>
);
}

@ -0,0 +1,3 @@
.content {
composes: content from './PageContent.css';
}

@ -0,0 +1,19 @@
import React from 'react';
import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError';
import PageContentBodyConnector from './PageContentBodyConnector';
import styles from './PageContentError.css';
function PageContentError(props) {
return (
<div className={styles.content}>
<PageContentBodyConnector>
<ErrorBoundaryError
{...props}
message='There was an error loading this page'
/>
</PageContentBodyConnector>
</div>
);
}
export default PageContentError;

@ -1,8 +1,8 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Measure from 'react-measure';
import dimensions from 'Styles/Variables/dimensions';
import Measure from 'Components/Measure';
import PageJumpBarItem from './PageJumpBarItem';
import styles from './PageJumpBar.css';

@ -1,11 +1,11 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Measure from 'react-measure';
import classNames from 'classnames';
import { forEach } from 'Helpers/elementChildren';
import { align, icons } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import SpinnerIcon from 'Components/SpinnerIcon';
import Measure from 'Components/Measure';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';

@ -1,9 +1,9 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Measure from 'react-measure';
import { WindowScroller } from 'react-virtualized';
import { scrollDirections } from 'Helpers/Props';
import Measure from 'Components/Measure';
import Scroller from 'Components/Scroller/Scroller';
import VirtualTableBody from './VirtualTableBody';
import styles from './VirtualTable.css';

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

@ -1,4 +1,5 @@
export const BOOL = 'bool';
export const BYTES = 'bytes';
export const DATE = 'date';
export const DEFAULT = 'default';
export const INDEXER = 'indexer';

@ -9,7 +9,7 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Popover from 'Components/Tooltip/Popover';
import EpisodeQuality from 'Album/EpisodeQuality';
import TrackQuality from 'Album/TrackQuality';
import EpisodeLanguage from 'Album/EpisodeLanguage';
import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal';
import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal';
@ -248,7 +248,7 @@ class InteractiveImportRow extends Component {
{
!showQualityPlaceholder && !!quality &&
<EpisodeQuality
<TrackQuality
className={styles.label}
quality={quality}
/>

@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as releaseActions from 'Store/Actions/releaseActions';
import { setReleasesFilter } from 'Store/Actions/releaseActions';
import FilterModal from 'Components/Filter/FilterModal';
function createMapStateToProps() {
@ -10,7 +10,8 @@ function createMapStateToProps() {
(sectionItems, filterBuilderProps) => {
return {
sectionItems,
filterBuilderProps
filterBuilderProps,
customFilterType: 'releases'
};
}
);
@ -18,12 +19,10 @@ function createMapStateToProps() {
function createMapDispatchToProps(dispatch, props) {
return {
onRemoveCustomFilterPress(payload) {
dispatch(releaseActions.removeReleasesCustomFilter(payload));
},
dispatchSetFilter(payload) {
const action = setReleasesFilter;
onSaveCustomFilterPress(payload) {
dispatch(releaseActions.saveReleasesCustomFilter(payload));
dispatch(action(payload));
}
};
}

@ -1,9 +1,9 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import InteractiveAlbumSearchModalContentConnector from './InteractiveAlbumSearchModalContentConnector';
import InteractiveSearchModalContentConnector from './InteractiveSearchModalContentConnector';
function InteractiveAlbumSearchModal(props) {
function InteractiveSearchModal(props) {
const {
isOpen,
onModalClose,
@ -15,7 +15,7 @@ function InteractiveAlbumSearchModal(props) {
isOpen={isOpen}
onModalClose={onModalClose}
>
<InteractiveAlbumSearchModalContentConnector
<InteractiveSearchModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
@ -23,9 +23,9 @@ function InteractiveAlbumSearchModal(props) {
);
}
InteractiveAlbumSearchModal.propTypes = {
InteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default InteractiveAlbumSearchModal;
export default InteractiveSearchModal;

@ -13,8 +13,8 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector';
import InteractiveAlbumSearchRow from './InteractiveAlbumSearchRow';
import styles from './InteractiveAlbumSearchModalContent.css';
import InteractiveSearchRow from './InteractiveSearchRow';
import styles from './InteractiveSearchModalContent.css';
const columns = [
{
@ -75,7 +75,7 @@ const columns = [
}
];
class InteractiveAlbumSearchModalContent extends Component {
class InteractiveSearchModalContent extends Component {
//
// Render
@ -161,7 +161,7 @@ class InteractiveAlbumSearchModalContent extends Component {
{
items.map((item) => {
return (
<InteractiveAlbumSearchRow
<InteractiveSearchRow
key={item.guid}
{...item}
longDateFormat={longDateFormat}
@ -195,7 +195,7 @@ class InteractiveAlbumSearchModalContent extends Component {
}
}
InteractiveAlbumSearchModalContent.propTypes = {
InteractiveSearchModalContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
@ -203,7 +203,7 @@ InteractiveAlbumSearchModalContent.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.string.isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
@ -214,4 +214,4 @@ InteractiveAlbumSearchModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired
};
export default InteractiveAlbumSearchModalContent;
export default InteractiveSearchModalContent;

@ -5,7 +5,7 @@ import { createSelector } from 'reselect';
import * as releaseActions from 'Store/Actions/releaseActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import InteractiveAlbumSearchModalContent from './InteractiveAlbumSearchModalContent';
import InteractiveSearchModalContent from './InteractiveSearchModalContent';
function createMapStateToProps() {
return createSelector(
@ -51,7 +51,7 @@ function createMapDispatchToProps(dispatch, props) {
};
}
class InteractiveAlbumSearchModalContentConnector extends Component {
class InteractiveSearchModalContentConnector extends Component {
//
// Lifecycle
@ -81,18 +81,18 @@ class InteractiveAlbumSearchModalContentConnector extends Component {
} = this.props;
return (
<InteractiveAlbumSearchModalContent
<InteractiveSearchModalContent
{...otherProps}
/>
);
}
}
InteractiveAlbumSearchModalContentConnector.propTypes = {
InteractiveSearchModalContentConnector.propTypes = {
albumId: PropTypes.number,
dispatchFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired,
dispatchCancelFetchReleases: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveAlbumSearchModalContentConnector);
export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchModalContentConnector);

@ -10,10 +10,10 @@ import Link from 'Components/Link/Link';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Popover from 'Components/Tooltip/Popover';
import EpisodeQuality from 'Album/EpisodeQuality';
import TrackQuality from 'Album/TrackQuality';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import Peers from './Peers';
import styles from './InteractiveAlbumSearchRow.css';
import styles from './InteractiveSearchRow.css';
function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
if (isGrabbing) {
@ -39,7 +39,7 @@ function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
return 'Add to downloaded queue';
}
class InteractiveAlbumSearchRow extends Component {
class InteractiveSearchRow extends Component {
//
// Listeners
@ -120,7 +120,7 @@ class InteractiveAlbumSearchRow extends Component {
</TableRowCell>
<TableRowCell className={styles.quality}>
<EpisodeQuality
<TrackQuality
quality={quality}
/>
</TableRowCell>
@ -171,7 +171,7 @@ class InteractiveAlbumSearchRow extends Component {
}
}
InteractiveAlbumSearchRow.propTypes = {
InteractiveSearchRow.propTypes = {
guid: PropTypes.string.isRequired,
protocol: PropTypes.string.isRequired,
age: PropTypes.number.isRequired,
@ -196,9 +196,9 @@ InteractiveAlbumSearchRow.propTypes = {
onGrabPress: PropTypes.func.isRequired
};
InteractiveAlbumSearchRow.defaultProps = {
InteractiveSearchRow.defaultProps = {
isGrabbing: false,
isGrabbed: false
};
export default InteractiveAlbumSearchRow;
export default InteractiveSearchRow;

@ -1,10 +1,10 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Measure from 'react-measure';
import { icons } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import Measure from 'Components/Measure';
import PageSectionContent from 'Components/Page/PageSectionContent';
import DelayProfileDragSource from './DelayProfileDragSource';
import DelayProfileDragPreview from './DelayProfileDragPreview';

@ -1,11 +1,11 @@
import PropTypes from 'prop-types';
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';
import Measure from 'Components/Measure';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';

@ -1,12 +1,12 @@
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';
import Measure from 'Components/Measure';
import QualityProfileItemDragSource from './QualityProfileItemDragSource';
import QualityProfileItemDragPreview from './QualityProfileItemDragPreview';
import styles from './QualityProfileItems.css';

@ -1,65 +0,0 @@
import customFilterHandlers from 'Utilities/customFilterHandlers';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import generateUUIDv4 from 'Utilities/String/generateUUIDv4';
function createRemoveCustomFilterReducer(section) {
return (state, { payload }) => {
const newState = getSectionState(state, section);
const index = newState.customFilters.findIndex((c) => c.key === payload.key);
newState.customFilters = [...newState.customFilters];
newState.customFilters.splice(index, 1);
// Reset the selected filter to the first filter if the selected filter
// is being deleted.
// TODO: Server side collections need to have their collections refetched
if (newState.selectedFilterKey === payload.key) {
newState.selectedFilterKey = newState.filters[0].key;
}
return updateSectionState(state, section, newState);
};
}
function createSaveCustomFilterReducer(section) {
return (state, { payload }) => {
const newState = getSectionState(state, section);
const {
label,
filters
} = payload;
let key = payload.key;
newState.customFilters = [...newState.customFilters];
if (key) {
const index = newState.customFilters.findIndex((c) => c.key === key);
newState.customFilters.splice(index, 1, { key, label, filters });
} else {
key = generateUUIDv4();
newState.customFilters.push({
key,
label,
filters
});
}
// TODO: Server side collections need to have their collections refetched
newState.selectedFilterKey = key;
return updateSectionState(state, section, newState);
};
}
export default function createCustomFilterReducers(section, handlers) {
return {
[handlers[customFilterHandlers.REMOVE]]: createRemoveCustomFilterReducer(section),
[handlers[customFilterHandlers.SAVE]]: createSaveCustomFilterReducer(section)
};
}

@ -20,13 +20,13 @@ function createRemoveItemHandler(section, url) {
promise.done((data) => {
dispatch(batchActions([
removeItem({ section, id }),
set({
section,
isDeleting: false,
deleteError: null
})
}),
removeItem({ section, id })
]));
});

@ -21,10 +21,11 @@ function createSaveProviderHandler(section, url, options = {}) {
const {
id,
queryParams = {}
queryParams = {},
...otherPayload
} = payload;
const saveData = getProviderState(payload, getState, section);
const saveData = getProviderState({ id, ...otherPayload }, getState, section);
const ajaxOptions = {
url: `${url}?${$.param(queryParams, true)}`,

@ -2,7 +2,7 @@ import _ from 'lodash';
import $ from 'jquery';
import { createAction } from 'redux-actions';
import getMonitoringOptions from 'Utilities/Artist/getMonitoringOptions';
import { sortDirections } from 'Helpers/Props';
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
@ -29,7 +29,55 @@ export const defaultState = {
selectedFilterKey: 'all',
filters,
filterPredicates,
customFilters: []
filterBuilderProps: [
{
name: 'monitored',
label: 'Monitored',
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL
},
{
name: 'status',
label: 'Status',
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.ARTIST_STATUS
},
{
name: 'artistType',
label: 'Artist Type',
type: filterBuilderTypes.EXACT
},
{
name: 'qualityProfileId',
label: 'Quality Profile',
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.QUALITY_PROFILE
},
{
name: 'languageProfileId',
label: 'Language Profile',
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.LANGUAGE_PROFILE
},
{
name: 'metadataProfileId',
label: 'Metadata Profile',
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.METADATA_PROFILE
},
{
name: 'rootFolderPath',
label: 'Root Folder Path',
type: filterBuilderTypes.EXACT
},
{
name: 'tags',
label: 'Tags',
type: filterBuilderTypes.ARRAY,
valueType: filterBuilderValueTypes.TAG
}
]
};
export const persistState = [

@ -103,6 +103,20 @@ export const filterPredicates = {
const predicate = filterTypePredicates[type];
return predicate(item.ratings.value * 10, filterValue);
},
albumCount: function(item, filterValue, type) {
const predicate = filterTypePredicates[type];
const albumCount = item.statistics ? item.statistics.albumCount : 0;
return predicate(albumCount, filterValue);
},
sizeOnDisk: function(item, filterValue, type) {
const predicate = filterTypePredicates[type];
const sizeOnDisk = item.statistics ? item.statistics.sizeOnDisk : 0;
return predicate(sizeOnDisk, filterValue);
}
};

@ -1,12 +1,10 @@
import $ from 'jquery';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import customFilterHandlers from 'Utilities/customFilterHandlers';
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
import createCustomFilterReducers from './Creators/Reducers/createCustomFilterReducers';
import createHandleActions from './Creators/createHandleActions';
import { set, updateItem } from './baseActions';
import { filters, filterPredicates } from './artistActions';
@ -79,8 +77,7 @@ export const defaultState = {
type: filterBuilderTypes.ARRAY,
valueType: filterBuilderValueTypes.TAG
}
],
customFilters: []
]
};
export const persistState = [
@ -97,8 +94,6 @@ export const SET_ARTIST_EDITOR_SORT = 'artistEditor/setArtistEditorSort';
export const SET_ARTIST_EDITOR_FILTER = 'artistEditor/setArtistEditorFilter';
export const SAVE_ARTIST_EDITOR = 'artistEditor/saveArtistEditor';
export const BULK_DELETE_ARTIST = 'artistEditor/bulkDeleteArtist';
export const REMOVE_ARTIST_EDITOR_CUSTOM_FILTER = 'artistEditor/removeArtistEditorCustomFilter';
export const SAVE_ARTIST_EDITOR_CUSTOM_FILTER = 'artistEditor/saveArtistEditorCustomFilter';
//
// Action Creators
@ -107,8 +102,6 @@ export const setArtistEditorSort = createAction(SET_ARTIST_EDITOR_SORT);
export const setArtistEditorFilter = createAction(SET_ARTIST_EDITOR_FILTER);
export const saveArtistEditor = createThunk(SAVE_ARTIST_EDITOR);
export const bulkDeleteArtist = createThunk(BULK_DELETE_ARTIST);
export const removeArtistEditorCustomFilter = createAction(REMOVE_ARTIST_EDITOR_CUSTOM_FILTER);
export const saveArtistEditorCustomFilter = createAction(SAVE_ARTIST_EDITOR_CUSTOM_FILTER);
//
// Action Handlers
@ -193,11 +186,6 @@ export const actionHandlers = handleThunks({
export const reducers = createHandleActions({
[SET_ARTIST_EDITOR_SORT]: createSetClientSideCollectionSortReducer(section),
[SET_ARTIST_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section),
...createCustomFilterReducers(section, {
[customFilterHandlers.REMOVE]: REMOVE_ARTIST_EDITOR_CUSTOM_FILTER,
[customFilterHandlers.SAVE]: SAVE_ARTIST_EDITOR_CUSTOM_FILTER
})
[SET_ARTIST_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section)
}, defaultState, section);

@ -1,12 +1,10 @@
import moment from 'moment';
import { createAction } from 'redux-actions';
import customFilterHandlers from 'Utilities/customFilterHandlers';
import sortByName from 'Utilities/Array/sortByName';
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
import createCustomFilterReducers from './Creators/Reducers/createCustomFilterReducers';
import createHandleActions from './Creators/createHandleActions';
import { filters, filterPredicates } from './artistActions';
@ -292,7 +290,8 @@ export const defaultState = {
{
name: 'sizeOnDisk',
label: 'Size on Disk',
type: filterBuilderTypes.NUMBER
type: filterBuilderTypes.NUMBER,
valueType: filterBuilderValueTypes.BYTES
},
{
name: 'genres',
@ -324,8 +323,7 @@ export const defaultState = {
type: filterBuilderTypes.ARRAY,
valueType: filterBuilderValueTypes.TAG
}
],
customFilters: []
]
};
export const persistState = [
@ -350,8 +348,6 @@ export const SET_ARTIST_TABLE_OPTION = 'artistIndex/setArtistTableOption';
export const SET_ARTIST_POSTER_OPTION = 'artistIndex/setArtistPosterOption';
export const SET_ARTIST_BANNER_OPTION = 'artistIndex/setArtistBannerOption';
export const SET_ARTIST_OVERVIEW_OPTION = 'artistIndex/setArtistOverviewOption';
export const REMOVE_ARTIST_CUSTOM_FILTER = 'artistIndex/removeArtistCustomFilter';
export const SAVE_ARTIST_CUSTOM_FILTER = 'artistIndex/saveArtistCustomFilter';
//
// Action Creators
@ -363,8 +359,7 @@ export const setArtistTableOption = createAction(SET_ARTIST_TABLE_OPTION);
export const setArtistPosterOption = createAction(SET_ARTIST_POSTER_OPTION);
export const setArtistBannerOption = createAction(SET_ARTIST_BANNER_OPTION);
export const setArtistOverviewOption = createAction(SET_ARTIST_OVERVIEW_OPTION);
export const removeArtistCustomFilter = createAction(REMOVE_ARTIST_CUSTOM_FILTER);
export const saveArtistCustomFilter = createAction(SAVE_ARTIST_CUSTOM_FILTER);
//
// Reducers
@ -413,11 +408,6 @@ export const reducers = createHandleActions({
...payload
}
};
},
...createCustomFilterReducers(section, {
[customFilterHandlers.REMOVE]: REMOVE_ARTIST_CUSTOM_FILTER,
[customFilterHandlers.SAVE]: SAVE_ARTIST_CUSTOM_FILTER
})
}
}, defaultState, section);

@ -0,0 +1,55 @@
import { createThunk, handleThunks } from 'Store/thunks';
import createFetchHandler from './Creators/createFetchHandler';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createSaveProviderHandler from './Creators/createSaveProviderHandler';
import createHandleActions from './Creators/createHandleActions';
//
// Variables
export const section = 'customFilters';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
pendingChanges: {}
};
//
// Actions Types
export const FETCH_CUSTOM_FILTERS = 'customFilters/fetchCustomFilters';
export const SAVE_CUSTOM_FILTER = 'customFilters/saveCustomFilter';
export const DELETE_CUSTOM_FILTER = 'customFilters/deleteCustomFilter';
//
// Action Creators
export const fetchCustomFilters = createThunk(FETCH_CUSTOM_FILTERS);
export const saveCustomFilter = createThunk(SAVE_CUSTOM_FILTER);
export const deleteCustomFilter = createThunk(DELETE_CUSTOM_FILTER);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_CUSTOM_FILTERS]: createFetchHandler(section, '/customFilter'),
[SAVE_CUSTOM_FILTER]: createSaveProviderHandler(section, '/customFilter'),
[DELETE_CUSTOM_FILTER]: createRemoveItemHandler(section, '/customFilter')
});
//
// Reducers
export const reducers = createHandleActions({}, defaultState, section);

@ -2,6 +2,7 @@ import * as addArtist from './addArtistActions';
import * as app from './appActions';
import * as blacklist from './blacklistActions';
import * as captcha from './captchaActions';
import * as customFilters from './customFilterActions';
import * as devices from './deviceActions';
import * as calendar from './calendarActions';
import * as commands from './commandActions';
@ -35,6 +36,7 @@ export default [
captcha,
calendar,
commands,
customFilters,
devices,
albums,
trackFiles,

@ -10,6 +10,7 @@ import createHandleActions from './Creators/createHandleActions';
// Variables
export const section = 'oAuth';
const callbackUrl = `${window.location.origin}${window.Lidarr.urlBase}/oauth.html`;
//
// State
@ -64,6 +65,19 @@ function showOAuthWindow(url) {
return deferred.promise();
}
function executeIntermediateRequest(payload, ajaxOptions) {
return $.ajax(ajaxOptions).then((data) => {
return requestAction({
action: 'continueOAuth',
queryParams: {
...data,
callbackUrl
},
...payload
});
});
}
//
// Action Handlers
@ -72,7 +86,7 @@ export const actionHandlers = handleThunks({
[START_OAUTH]: function(getState, payload, dispatch) {
const actionPayload = {
action: 'startOAuth',
queryParams: { callbackUrl: `${window.location.origin}${window.Lidarr.urlBase}/oauth.html` },
queryParams: { callbackUrl },
...payload
};
@ -85,7 +99,16 @@ export const actionHandlers = handleThunks({
const promise = requestAction(actionPayload)
.then((response) => {
startResponse = response;
return showOAuthWindow(response.oauthUrl);
if (response.oauthUrl) {
return showOAuthWindow(response.oauthUrl);
}
return executeIntermediateRequest(payload, response).then((intermediateResponse) => {
startResponse = intermediateResponse;
return showOAuthWindow(intermediateResponse.oauthUrl);
});
})
.then((queryParams) => {
return requestAction({

@ -1,11 +1,9 @@
import $ from 'jquery';
import { createAction } from 'redux-actions';
import customFilterHandlers from 'Utilities/customFilterHandlers';
import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
import createCustomFilterReducers from './Creators/Reducers/createCustomFilterReducers';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
@ -45,8 +43,6 @@ export const defaultState = {
}
},
selectedFilterKey: 'all',
filters: [
{
key: 'all',
@ -143,9 +139,7 @@ export const defaultState = {
label: 'Rejections',
type: filterBuilderTypes.NUMBER
}
],
customFilters: []
]
};
export const persistState = [
@ -163,9 +157,6 @@ export const CLEAR_RELEASES = 'releases/clearReleases';
export const GRAB_RELEASE = 'releases/grabRelease';
export const UPDATE_RELEASE = 'releases/updateRelease';
export const SET_RELEASES_FILTER = 'releases/setReleasesFilter';
export const ADD_RELEASES_CUSTOM_FILTER = 'releases/addReleasesCustomFilter';
export const REMOVE_RELEASES_CUSTOM_FILTER = 'releases/removeReleasesCustomFilter';
export const SAVE_RELEASES_CUSTOM_FILTER = 'releases/saveReleasesCustomFilter';
//
// Action Creators
@ -177,9 +168,6 @@ export const clearReleases = createAction(CLEAR_RELEASES);
export const grabRelease = createThunk(GRAB_RELEASE);
export const updateRelease = createAction(UPDATE_RELEASE);
export const setReleasesFilter = createAction(SET_RELEASES_FILTER);
export const addReleasesCustomFilter = createAction(ADD_RELEASES_CUSTOM_FILTER);
export const removeReleasesCustomFilter = createAction(REMOVE_RELEASES_CUSTOM_FILTER);
export const saveReleasesCustomFilter = createAction(SAVE_RELEASES_CUSTOM_FILTER);
//
// Helpers
@ -266,11 +254,6 @@ export const reducers = createHandleActions({
},
[SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section),
[SET_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(section),
...createCustomFilterReducers(section, {
[customFilterHandlers.REMOVE]: REMOVE_RELEASES_CUSTOM_FILTER,
[customFilterHandlers.SAVE]: SAVE_RELEASES_CUSTOM_FILTER
})
[SET_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(section)
}, defaultState, section);

@ -0,0 +1,91 @@
import _ from 'lodash';
import * as sentry from '@sentry/browser';
import parseUrl from 'Utilities/String/parseUrl';
function cleanseUrl(url) {
const properties = parseUrl(url);
return `${properties.pathname}${properties.search}`;
}
function cleanseData(data) {
const result = _.cloneDeep(data);
result.transaction = cleanseUrl(result.transaction);
if (result.exception) {
result.exception.values.forEach((exception) => {
const stacktrace = exception.stacktrace;
if (stacktrace) {
stacktrace.frames.forEach((frame) => {
frame.filename = cleanseUrl(frame.filename);
});
}
});
}
result.request.url = cleanseUrl(result.request.url);
return result;
}
function identity(stuff) {
return stuff;
}
function createMiddleware() {
return (store) => (next) => (action) => {
try {
// Adds a breadcrumb for reporting later (if necessary).
sentry.addBreadcrumb({
category: 'redux',
message: action.type
});
return next(action);
} catch (err) {
console.error(`[sentry] Reporting error to Sentry: ${err}`);
// Send the report including breadcrumbs.
sentry.captureException(err, {
extra: {
action: identity(action),
state: identity(store.getState())
}
});
}
};
}
export default function createSentryMiddleware() {
const {
analytics,
branch,
version,
release,
isProduction
} = window.Lidarr;
if (!analytics) {
return;
}
const dsn = isProduction ? 'https://c3a5b33e08de4e18b7d0505e942dbc95@sentry.io/216290' :
'https://baede6f14da54cf48ff431479e400adf@sentry.io/1249427';
sentry.init({
dsn,
environment: isProduction ? 'production' : 'development',
release,
sendDefaultPii: true,
beforeSend: cleanseData
});
sentry.configureScope((scope) => {
scope.setTag('branch', branch);
scope.setTag('version', version);
});
return createMiddleware();
}

@ -1,15 +1,15 @@
import { applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import { routerMiddleware } from 'react-router-redux';
import sentryMiddleware from './sentryMiddleware';
import createSentryMiddleware from './createSentryMiddleware';
import persistState from './persistState';
export default function(history) {
const middlewares = [];
const ravenMiddleware = sentryMiddleware();
const sentryMiddleware = createSentryMiddleware();
if (ravenMiddleware) {
middlewares.push(ravenMiddleware);
if (sentryMiddleware) {
middlewares.push(sentryMiddleware);
}
middlewares.push(routerMiddleware(history));

@ -1,51 +0,0 @@
import _ from 'lodash';
import Raven from 'raven-js';
import createRavenMiddleware from 'raven-for-redux';
import parseUrl from 'Utilities/String/parseUrl';
function cleanseUrl(url) {
const properties = parseUrl(url);
return `${properties.pathname}${properties.search}`;
}
function cleanseData(data) {
const result = _.cloneDeep(data);
result.culprit = cleanseUrl(result.culprit);
result.request.url = cleanseUrl(result.request.url);
return result;
}
export default function sentryMiddleware() {
const {
analytics,
branch,
version,
release,
isProduction
} = window.Lidarr;
if (!analytics) {
return;
}
const dsn = isProduction ? 'https://c3a5b33e08de4e18b7d0505e942dbc95@sentry.io/216290' :
'https://baede6f14da54cf48ff431479e400adf@sentry.io/1249427';
Raven.config(
dsn,
{
environment: isProduction ? 'production' : 'development',
release,
tags: {
branch,
version
},
dataCallback: cleanseData
}
).install();
return createRavenMiddleware(Raven);
}

@ -94,12 +94,24 @@ function sort(items, state) {
return _.orderBy(items, clauses, orders);
}
function createCustomFiltersSelector(type, alternateType) {
return createSelector(
(state) => state.customFilters.items,
(customFilters) => {
return customFilters.filter((customFilter) => {
return customFilter.type === type || customFilter.type === alternateType;
});
}
);
}
function createClientSideCollectionSelector(section, uiSection) {
return createSelector(
(state) => _.get(state, section),
(state) => _.get(state, uiSection),
(sectionState, uiSectionState = {}) => {
const state = Object.assign({}, sectionState, uiSectionState);
createCustomFiltersSelector(section, uiSection),
(sectionState, uiSectionState = {}, customFilters) => {
const state = Object.assign({}, sectionState, uiSectionState, { customFilters });
const filtered = filter(state.items, state);
const sorted = sort(filtered, state);
@ -107,6 +119,7 @@ function createClientSideCollectionSelector(section, uiSection) {
return {
...sectionState,
...uiSectionState,
customFilters,
items: sorted,
totalItems: state.items.length
};

@ -4,6 +4,7 @@ import titleCase from 'Utilities/String/titleCase';
import FieldSet from 'Components/FieldSet';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import StartTime from './StartTime';
import styles from './About.css';
class About extends Component {
@ -19,7 +20,10 @@ class About extends Component {
migrationVersion,
appData,
startupPath,
mode
mode,
startTime,
timeFormat,
longDateFormat
} = this.props;
return (
@ -57,6 +61,17 @@ class About extends Component {
title="Mode"
data={titleCase(mode)}
/>
<DescriptionListItem
title="Uptime"
data={
<StartTime
startTime={startTime}
timeFormat={timeFormat}
longDateFormat={longDateFormat}
/>
}
/>
</DescriptionList>
</FieldSet>
);
@ -65,13 +80,16 @@ class About extends Component {
}
About.propTypes = {
version: PropTypes.string,
isMonoRuntime: PropTypes.bool,
runtimeVersion: PropTypes.string,
migrationVersion: PropTypes.number,
appData: PropTypes.string,
startupPath: PropTypes.string,
mode: PropTypes.string
version: PropTypes.string.isRequired,
isMonoRuntime: PropTypes.bool.isRequired,
runtimeVersion: PropTypes.string.isRequired,
migrationVersion: PropTypes.number.isRequired,
appData: PropTypes.string.isRequired,
startupPath: PropTypes.string.isRequired,
mode: PropTypes.string.isRequired,
startTime: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired
};
export default About;

@ -3,14 +3,18 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchStatus } from 'Store/Actions/systemActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import About from './About';
function createMapStateToProps() {
return createSelector(
(state) => state.system.status,
(status) => {
createUISettingsSelector(),
(status, uiSettings) => {
return {
...status.item
...status.item,
timeFormat: uiSettings.timeFormat,
longDateFormat: uiSettings.longDateFormat
};
}
);

@ -0,0 +1,93 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
function getUptime(startTime) {
return formatTimeSpan(moment().diff(startTime));
}
class StartTime extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
startTime,
timeFormat,
longDateFormat
} = props;
this._timeoutId = null;
this.state = {
uptime: getUptime(startTime),
startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
};
}
componentDidMount() {
this._timeoutId = setTimeout(this.onTimeout, 1000);
}
componentDidUpdate(prevProps) {
const {
startTime,
timeFormat,
longDateFormat
} = this.props;
if (
startTime !== prevProps.startTime ||
timeFormat !== prevProps.timeFormat ||
longDateFormat !== prevProps.longDateFormat
) {
this.setState({
uptime: getUptime(startTime),
startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true })
});
}
}
componentWillUnmount() {
if (this._timeoutId) {
this._timeoutId = clearTimeout(this._timeoutId);
}
}
//
// Listeners
onTimeout = () => {
this.setState({ uptime: getUptime(this.props.startTime) });
this._timeoutId = setTimeout(this.onTimeout, 1000);
}
//
// Render
render() {
const {
uptime,
startTime
} = this.state;
return (
<span title={startTime}>
{uptime}
</span>
);
}
}
StartTime.propTypes = {
startTime: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired
};
export default StartTime;

@ -70,9 +70,15 @@ class TrackFileEditorModalContent extends Component {
getSelectedIds = () => {
const selectedIds = getSelectedIds(this.state.selectedState);
return _.uniq(_.map(selectedIds, (id) => {
return _.find(this.props.items, { id }).trackFileId;
}));
return selectedIds.reduce((acc, id) => {
const matchingItem = this.props.items.find((item) => item.id === id);
if (matchingItem && !acc.includes(matchingItem.trackFileID)) {
acc.push(matchingItem.trackFileID);
}
return acc;
}, []);
}
//

@ -5,7 +5,7 @@ import Label from 'Components/Label';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import EpisodeQuality from 'Album/EpisodeQuality';
import TrackQuality from 'Album/TrackQuality';
import styles from './TrackFileEditorRow';
function TrackFileEditorRow(props) {
@ -42,7 +42,7 @@ function TrackFileEditorRow(props) {
</TableRowCell>
<TableRowCell>
<EpisodeQuality
<TrackQuality
quality={quality}
/>
</TableRowCell>

@ -3,7 +3,11 @@ export default function findSelectedFilters(selectedFilterKey, filters = [], cus
return [];
}
const selectedFilter = [...filters, ...customFilters].find((f) => f.key === selectedFilterKey);
let selectedFilter = filters.find((f) => f.key === selectedFilterKey);
if (!selectedFilter) {
selectedFilter = customFilters.find((f) => f.id === selectedFilterKey);
}
if (!selectedFilter) {
// TODO: throw in dev

@ -0,0 +1,15 @@
function convertToBytes(input, power, binaryPrefix) {
const size = Number(input);
if (isNaN(size)) {
return '';
}
const prefix = binaryPrefix ? 1024 : 1000;
const multiplier = Math.pow(prefix, power);
const result = size * multiplier;
return Math.round(result);
}
export default convertToBytes;

@ -2,9 +2,12 @@ import _ from 'lodash';
import getSectionState from 'Utilities/State/getSectionState';
function getProviderState(payload, getState, section) {
const id = payload.id;
const {
id,
...otherPayload
} = payload;
const state = getSectionState(getState(), section, true);
const pendingChanges = Object.assign({}, state.pendingChanges);
const pendingChanges = Object.assign({}, state.pendingChanges, otherPayload);
const pendingFields = state.pendingChanges.fields || {};
delete pendingChanges.fields;

@ -1,6 +0,0 @@
const customFilterHandlers = {
REMOVE: 'remove',
SAVE: 'save'
};
export default customFilterHandlers;

@ -26,6 +26,10 @@ function MissingRow(props) {
onSelectedChange
} = props;
if (!artist) {
return null;
}
return (
<TableRow>
<TableSelectCell

@ -22,6 +22,7 @@
"@fortawesome/free-regular-svg-icons": "5.3.1",
"@fortawesome/free-solid-svg-icons": "5.3.1",
"@fortawesome/react-fontawesome": "0.1.3",
"@sentry/browser": "4.0.4",
"autoprefixer": "9.1.5",
"babel-core": "6.26.3",
"babel-eslint": "9.0.0",
@ -73,17 +74,15 @@
"postcss-simple-vars": "5.0.1",
"prop-types": "15.6.2",
"qs": "6.5.2",
"raven-for-redux": "1.3.1",
"raven-js": "3.27.0",
"react": "16.5.1",
"react": "16.5.2",
"react-addons-shallow-compare": "15.6.2",
"react-async-script": "1.0.0",
"react-autosuggest": "9.4.1",
"react-autosuggest": "9.4.2",
"react-custom-scrollbars": "4.2.1",
"react-dnd": "5.0.0",
"react-dnd-html5-backend": "5.0.1",
"react-document-title": "2.0.3",
"react-dom": "16.5.1",
"react-dom": "16.5.2",
"react-google-recaptcha": "1.0.2",
"react-lazyload": "2.3.0",
"react-measure": "1.4.7",

@ -0,0 +1,49 @@
using System.Collections.Generic;
using NzbDrone.Core.CustomFilters;
using Lidarr.Http;
namespace Lidarr.Api.V1.CustomFilters
{
public class CustomFilterModule : LidarrRestModule<CustomFilterResource>
{
private readonly ICustomFilterService _customFilterService;
public CustomFilterModule(ICustomFilterService customFilterService)
{
_customFilterService = customFilterService;
GetResourceById = GetCustomFilter;
GetResourceAll = GetCustomFilters;
CreateResource = AddCustomFilter;
UpdateResource = UpdateCustomFilter;
DeleteResource = DeleteCustomResource;
}
private CustomFilterResource GetCustomFilter(int id)
{
return _customFilterService.Get(id).ToResource();
}
private List<CustomFilterResource> GetCustomFilters()
{
return _customFilterService.All().ToResource();
}
private int AddCustomFilter(CustomFilterResource resource)
{
var customFilter = _customFilterService.Add(resource.ToModel());
return customFilter.Id;
}
private void UpdateCustomFilter(CustomFilterResource resource)
{
_customFilterService.Update(resource.ToModel());
}
private void DeleteCustomResource(int id)
{
_customFilterService.Delete(id);
}
}
}

@ -0,0 +1,49 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.CustomFilters;
using Lidarr.Http.REST;
namespace Lidarr.Api.V1.CustomFilters
{
public class CustomFilterResource : RestResource
{
public string Type { get; set; }
public string Label { get; set; }
public List<dynamic> Filters { get; set; }
}
public static class CustomFilterResourceMapper
{
public static CustomFilterResource ToResource(this CustomFilter model)
{
if (model == null) return null;
return new CustomFilterResource
{
Id = model.Id,
Type = model.Type,
Label = model.Label,
Filters = Json.Deserialize<List<dynamic>>(model.Filters)
};
}
public static CustomFilter ToModel(this CustomFilterResource resource)
{
if (resource == null) return null;
return new CustomFilter
{
Id = resource.Id,
Type = resource.Type,
Label = resource.Label,
Filters = Json.ToJson(resource.Filters)
};
}
public static List<CustomFilterResource> ToResource(this IEnumerable<CustomFilter> filters)
{
return filters.Select(ToResource).ToList();
}
}
}

@ -97,6 +97,8 @@
<Compile Include="Commands\CommandResource.cs" />
<Compile Include="Config\MetadataProviderConfigModule.cs" />
<Compile Include="Config\MetadataProviderConfigResource.cs" />
<Compile Include="CustomFilters\CustomFilterModule.cs" />
<Compile Include="CustomFilters\CustomFilterResource.cs" />
<Compile Include="ImportLists\ImportListModule.cs" />
<Compile Include="ImportLists\ImportListResource.cs" />
<Compile Include="Profiles\Metadata\MetadataProfileModule.cs" />

@ -54,8 +54,8 @@
<HintPath>..\packages\DotNet4.SocksProxy.1.3.4.0\lib\net40\Org.Mentalis.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="SharpRaven, Version=2.2.0.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\SharpRaven.2.2.0\lib\net45\SharpRaven.dll</HintPath>
<Reference Include="SharpRaven, Version=2.4.0.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\SharpRaven.2.4.0\lib\net45\SharpRaven.dll</HintPath>
</Reference>
<Reference Include="SocksWebProxy, Version=1.3.4.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\DotNet4.SocksProxy.1.3.4.0\lib\net40\SocksWebProxy.dll</HintPath>
@ -66,6 +66,7 @@
<Reference Include="System.Configuration.Install" />
<Reference Include="System.Core" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.ServiceProcess" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Xml" />

@ -4,5 +4,5 @@
<package id="ICSharpCode.SharpZipLib.Patched" version="0.86.5" targetFramework="net461" />
<package id="Newtonsoft.Json" version="11.0.2" targetFramework="net461" />
<package id="NLog" version="4.5.4" targetFramework="net461" />
<package id="SharpRaven" version="2.2.0" targetFramework="net461" />
<package id="SharpRaven" version="2.4.0" targetFramework="net461" />
</packages>

@ -0,0 +1,11 @@
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.CustomFilters
{
public class CustomFilter : ModelBase
{
public string Type { get; set; }
public string Label { get; set; }
public string Filters { get; set; }
}
}

@ -0,0 +1,17 @@
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.CustomFilters
{
public interface ICustomFilterRepository : IBasicRepository<CustomFilter>
{
}
public class CustomFilterRepository : BasicRepository<CustomFilter>, ICustomFilterRepository
{
public CustomFilterRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
}
}

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

Loading…
Cancel
Save