mirror of
https://github.com/lidarr/lidarr.git
synced 2025-07-07 21:42:16 -07:00
New: Server Side UI Filtering, Error Boundaries (#501)
Co-Authored-By: Mark McDowall <markus101@users.noreply.github.com>
This commit is contained in:
parent
a95191dc3b
commit
64a8d02f77
110 changed files with 1564 additions and 431 deletions
62
frontend/src/Components/Error/ErrorBoundary.js
Normal file
62
frontend/src/Components/Error/ErrorBoundary.js
Normal file
|
@ -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;
|
38
frontend/src/Components/Error/ErrorBoundaryError.css
Normal file
38
frontend/src/Components/Error/ErrorBoundaryError.css
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
60
frontend/src/Components/Error/ErrorBoundaryError.js
Normal file
60
frontend/src/Components/Error/ErrorBoundaryError.js
Normal file
|
@ -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) {
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value: path.path
|
||||
});
|
||||
|
||||
if (path.type !== 'file') {
|
||||
this.props.onFetchPaths(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
|
||||
|
||||
|
|
38
frontend/src/Components/Measure.js
Normal file
38
frontend/src/Components/Measure.js
Normal file
|
@ -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>,
|
||||
|
|
15
frontend/src/Components/Modal/ModalError.css
Normal file
15
frontend/src/Components/Modal/ModalError.css
Normal file
|
@ -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;
|
||||
}
|
46
frontend/src/Components/Modal/ModalError.js
Normal file
46
frontend/src/Components/Modal/ModalError.js
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
3
frontend/src/Components/Page/PageContentError.css
Normal file
3
frontend/src/Components/Page/PageContentError.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.content {
|
||||
composes: content from './PageContent.css';
|
||||
}
|
19
frontend/src/Components/Page/PageContentError.js
Normal file
19
frontend/src/Components/Page/PageContentError.js
Normal file
|
@ -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';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue