+ {
+ [...value, { key: '', value: '' }].map((v, index) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+ }
+}
+
+KeyValueListInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.arrayOf(PropTypes.object).isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ keyPlaceholder: PropTypes.string,
+ valuePlaceholder: PropTypes.string,
+ onChange: PropTypes.func.isRequired
+};
+
+KeyValueListInput.defaultProps = {
+ className: styles.inputContainer,
+ value: []
+};
+
+export default KeyValueListInput;
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css b/frontend/src/Components/Form/KeyValueListInputItem.css
new file mode 100644
index 000000000..f77ea3470
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInputItem.css
@@ -0,0 +1,14 @@
+.itemContainer {
+ display: flex;
+ margin-bottom: 3px;
+ border-bottom: 1px solid $inputBorderColor;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.keyInput,
+.valueInput {
+ border: none;
+}
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.js b/frontend/src/Components/Form/KeyValueListInputItem.js
new file mode 100644
index 000000000..4e465f3a9
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInputItem.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import TextInput from './TextInput';
+import styles from './KeyValueListInputItem.css';
+
+class KeyValueListInputItem extends Component {
+
+ //
+ // Listeners
+
+ onKeyChange = ({ value: keyValue }) => {
+ const {
+ index,
+ value,
+ onChange
+ } = this.props;
+
+ onChange(index, { key: keyValue, value });
+ }
+
+ onValueChange = ({ value }) => {
+ // TODO: Validate here or validate at a lower level component
+
+ const {
+ index,
+ keyValue,
+ onChange
+ } = this.props;
+
+ onChange(index, { key: keyValue, value });
+ }
+
+ onRemovePress = () => {
+ const {
+ index,
+ onRemove
+ } = this.props;
+
+ onRemove(index);
+ }
+
+ onFocus = () => {
+ this.props.onFocus();
+ }
+
+ onBlur = () => {
+ this.props.onBlur();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ keyValue,
+ value,
+ keyPlaceholder,
+ valuePlaceholder,
+ isNew
+ } = this.props;
+
+ return (
+
diff --git a/frontend/src/Components/Menu/FilterMenu.css b/frontend/src/Components/Menu/FilterMenu.css
index 34991aed9..881dbe26c 100644
--- a/frontend/src/Components/Menu/FilterMenu.css
+++ b/frontend/src/Components/Menu/FilterMenu.css
@@ -1,5 +1,5 @@
.filterMenu {
- composes: menu from './Menu.css';
+ composes: menu from '~./Menu.css';
}
@media only screen and (max-width: $breakpointSmall) {
diff --git a/frontend/src/Components/Menu/Menu.js b/frontend/src/Components/Menu/Menu.js
index da778bb7a..946b7e0ec 100644
--- a/frontend/src/Components/Menu/Menu.js
+++ b/frontend/src/Components/Menu/Menu.js
@@ -38,6 +38,9 @@ class Menu extends Component {
constructor(props, context) {
super(props, context);
+ this._menuRef = {};
+ this._menuContentRef = {};
+
this.state = {
isMenuOpen: false,
maxHeight: 0
@@ -60,7 +63,7 @@ class Menu extends Component {
return;
}
- const menu = ReactDOM.findDOMNode(this.refs.menu);
+ const menu = ReactDOM.findDOMNode(this._menuRef.current);
if (!menu) {
return;
@@ -73,9 +76,13 @@ class Menu extends Component {
}
setMaxHeight() {
- this.setState({
- maxHeight: this.getMaxHeight()
- });
+ const maxHeight = this.getMaxHeight();
+
+ if (maxHeight !== this.state.maxHeight) {
+ this.setState({
+ maxHeight
+ });
+ }
}
_addListener() {
@@ -99,10 +106,10 @@ class Menu extends Component {
// Listeners
onWindowClick = (event) => {
- const menu = ReactDOM.findDOMNode(this.refs.menu);
- const menuContent = ReactDOM.findDOMNode(this.refs.menuContent);
+ const menu = ReactDOM.findDOMNode(this._menuRef.current);
+ const menuContent = ReactDOM.findDOMNode(this._menuContentRef.current);
- if (!menu) {
+ if (!menu || !menuContent) {
return;
}
@@ -116,7 +123,17 @@ class Menu extends Component {
this.setMaxHeight();
}
- onWindowScroll = () => {
+ onWindowScroll = (event) => {
+ if (!this._menuContentRef.current) {
+ return;
+ }
+
+ const menuContent = ReactDOM.findDOMNode(this._menuContentRef.current);
+
+ if (menuContent && menuContent.contains(event.target)) {
+ return;
+ }
+
this.setMaxHeight();
}
@@ -158,35 +175,46 @@ class Menu extends Component {
}
);
- const content = React.cloneElement(
- childrenArray[1],
- {
- ref: 'menuContent',
- alignMenu,
- maxHeight,
- isOpen: isMenuOpen
- }
- );
-
return (
-
- {button}
-
+ renderTarget={
+ (ref) => {
+ this._menuRef = ref;
- {
- isMenuOpen &&
- content
+ return (
+
+ {button}
+
+ );
+ }
}
-
+ renderElement={
+ (ref) => {
+ this._menuContentRef = ref;
+
+ if (!isMenuOpen) {
+ return null;
+ }
+
+ return React.cloneElement(
+ childrenArray[1],
+ {
+ ref,
+ alignMenu,
+ maxHeight,
+ isOpen: isMenuOpen
+ }
+ );
+ }
+ }
+ />
);
}
}
diff --git a/frontend/src/Components/Menu/PageMenuButton.css b/frontend/src/Components/Menu/PageMenuButton.css
index e6954f600..d979a1708 100644
--- a/frontend/src/Components/Menu/PageMenuButton.css
+++ b/frontend/src/Components/Menu/PageMenuButton.css
@@ -1,5 +1,5 @@
.menuButton {
- composes: menuButton from './MenuButton.css';
+ composes: menuButton from '~./MenuButton.css';
&:hover {
color: #666;
diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.css b/frontend/src/Components/Menu/ToolbarMenuButton.css
index c8a905e17..71e966c71 100644
--- a/frontend/src/Components/Menu/ToolbarMenuButton.css
+++ b/frontend/src/Components/Menu/ToolbarMenuButton.css
@@ -1,11 +1,16 @@
.menuButton {
- composes: menuButton from './MenuButton.css';
+ composes: menuButton from '~./MenuButton.css';
+ padding-top: 4px;
width: $toolbarButtonWidth;
height: $toolbarHeight;
text-align: center;
}
-.label {
- composes: label from 'Components/Page/Toolbar/PageToolbarButton.css';
+.labelContainer {
+ composes: labelContainer from '~Components/Page/Toolbar/PageToolbarButton.css';
+}
+
+.label {
+ composes: label from '~Components/Page/Toolbar/PageToolbarButton.css';
}
diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.js b/frontend/src/Components/Menu/ToolbarMenuButton.js
index b80d6eaa3..fe06793f6 100644
--- a/frontend/src/Components/Menu/ToolbarMenuButton.js
+++ b/frontend/src/Components/Menu/ToolbarMenuButton.js
@@ -22,8 +22,10 @@ function ToolbarMenuButton(props) {
size={21}
/>
-
diff --git a/frontend/src/Components/Modal/ModalError.css b/frontend/src/Components/Modal/ModalError.css
index 54dbdbc63..1556240c6 100644
--- a/frontend/src/Components/Modal/ModalError.css
+++ b/frontend/src/Components/Modal/ModalError.css
@@ -1,5 +1,5 @@
.message {
- composes: message from 'Components/Error/ErrorBoundaryError.css';
+ composes: message from '~Components/Error/ErrorBoundaryError.css';
margin: 0;
margin-bottom: 30px;
@@ -8,7 +8,7 @@
}
.details {
- composes: details from 'Components/Error/ErrorBoundaryError.css';
+ composes: details from '~Components/Error/ErrorBoundaryError.css';
margin: 0;
margin-top: 20px;
diff --git a/frontend/src/Components/MonitorToggleButton.css b/frontend/src/Components/MonitorToggleButton.css
index 794af1e98..09b64f1ab 100644
--- a/frontend/src/Components/MonitorToggleButton.css
+++ b/frontend/src/Components/MonitorToggleButton.css
@@ -1,5 +1,5 @@
.toggleButton {
- composes: button from 'Components/Link/IconButton.css';
+ composes: button from '~Components/Link/IconButton.css';
padding: 0;
font-size: inherit;
diff --git a/frontend/src/Components/Page/ErrorPage.css b/frontend/src/Components/Page/ErrorPage.css
index e62a82a6b..c72e73673 100644
--- a/frontend/src/Components/Page/ErrorPage.css
+++ b/frontend/src/Components/Page/ErrorPage.css
@@ -1,5 +1,5 @@
.page {
- composes: page from './Page.css';
+ composes: page from '~./Page.css';
margin-top: 20px;
text-align: center;
diff --git a/frontend/src/Components/Page/ErrorPage.js b/frontend/src/Components/Page/ErrorPage.js
index 018e98ca1..b19ec0888 100644
--- a/frontend/src/Components/Page/ErrorPage.js
+++ b/frontend/src/Components/Page/ErrorPage.js
@@ -11,7 +11,8 @@ function ErrorPage(props) {
customFiltersError,
tagsError,
qualityProfilesError,
- uiSettingsError
+ uiSettingsError,
+ systemStatusError
} = props;
let errorMessage = 'Failed to load Radarr';
@@ -28,6 +29,8 @@ function ErrorPage(props) {
errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API');
} else if (uiSettingsError) {
errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API');
+ } else if (systemStatusError) {
+ errorMessage = getErrorMessage(uiSettingsError, 'Failed to load system status from API');
}
return (
@@ -50,7 +53,8 @@ ErrorPage.propTypes = {
customFiltersError: PropTypes.object,
tagsError: PropTypes.object,
qualityProfilesError: PropTypes.object,
- uiSettingsError: PropTypes.object
+ uiSettingsError: PropTypes.object,
+ systemStatusError: PropTypes.object
};
export default ErrorPage;
diff --git a/frontend/src/Components/Page/Header/MovieSearchInput.js b/frontend/src/Components/Page/Header/MovieSearchInput.js
index fa49f0716..6246fd375 100644
--- a/frontend/src/Components/Page/Header/MovieSearchInput.js
+++ b/frontend/src/Components/Page/Header/MovieSearchInput.js
@@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
-import jdu from 'jdu';
+import Fuse from 'fuse.js';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
@@ -10,6 +10,21 @@ import styles from './MovieSearchInput.css';
const ADD_NEW_TYPE = 'addNew';
+const fuseOptions = {
+ shouldSort: true,
+ includeMatches: true,
+ threshold: 0.3,
+ location: 0,
+ distance: 100,
+ maxPatternLength: 32,
+ minMatchCharLength: 1,
+ keys: [
+ 'title',
+ 'alternateTitles.title',
+ 'tags.label'
+ ]
+};
+
class MovieSearchInput extends Component {
//
@@ -69,16 +84,15 @@ class MovieSearchInput extends Component {
return (
);
}
- goToMovie(movie) {
+ goToMovie(item) {
this.setState({ value: '' });
- this.props.onGoToMovie(movie.titleSlug);
+ this.props.onGoToMovie(item.item.titleSlug);
}
reset() {
@@ -140,26 +154,8 @@ class MovieSearchInput extends Component {
}
onSuggestionsFetchRequested = ({ value }) => {
- const lowerCaseValue = jdu.replace(value).toLowerCase();
-
- const suggestions = this.props.movie.filter((movie) => {
- // Check the title first and if there isn't a match fallback to
- // the alternate titles and finally the tags.
-
- if (value.length === 1) {
- return (
- movie.cleanTitle.startsWith(lowerCaseValue) ||
- movie.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.startsWith(lowerCaseValue)) ||
- movie.tags.some((tag) => tag.cleanLabel.startsWith(lowerCaseValue))
- );
- }
-
- return (
- movie.cleanTitle.contains(lowerCaseValue) ||
- movie.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.contains(lowerCaseValue)) ||
- movie.tags.some((tag) => tag.cleanLabel.contains(lowerCaseValue))
- );
- });
+ const fuse = new Fuse(this.props.movies, fuseOptions);
+ const suggestions = fuse.search(value);
this.setState({ suggestions });
}
@@ -209,7 +205,7 @@ class MovieSearchInput extends Component {
const inputProps = {
ref: this.setInputRef,
className: styles.input,
- name: 'seriesSearch',
+ name: 'movieSearch',
value,
placeholder: 'Search',
autoComplete: 'off',
@@ -255,7 +251,7 @@ class MovieSearchInput extends Component {
}
MovieSearchInput.propTypes = {
- movie: PropTypes.arrayOf(PropTypes.object).isRequired,
+ movies: PropTypes.arrayOf(PropTypes.object).isRequired,
onGoToMovie: PropTypes.func.isRequired,
onGoToAddNewMovie: PropTypes.func.isRequired,
bindShortcut: PropTypes.func.isRequired
diff --git a/frontend/src/Components/Page/Header/MovieSearchInputConnector.js b/frontend/src/Components/Page/Header/MovieSearchInputConnector.js
index 10f3f52d7..54482a6ab 100644
--- a/frontend/src/Components/Page/Header/MovieSearchInputConnector.js
+++ b/frontend/src/Components/Page/Header/MovieSearchInputConnector.js
@@ -1,35 +1,14 @@
import { connect } from 'react-redux';
-import { push } from 'react-router-redux';
+import { push } from 'connected-react-router';
import { createSelector } from 'reselect';
-import jdu from 'jdu';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import MovieSearchInput from './MovieSearchInput';
-function createCleanTagsSelector() {
- return createSelector(
- createTagsSelector(),
- (tags) => {
- return tags.map((tag) => {
- const {
- id,
- label
- } = tag;
-
- return {
- id,
- label,
- cleanLabel: jdu.replace(label).toLowerCase()
- };
- });
- }
- );
-}
-
function createCleanMovieSelector() {
return createSelector(
createAllMoviesSelector(),
- createCleanTagsSelector(),
+ createTagsSelector(),
(allMovies, allTags) => {
return allMovies.map((movie) => {
const {
@@ -46,27 +25,11 @@ function createCleanMovieSelector() {
titleSlug,
sortTitle,
images,
- cleanTitle: jdu.replace(title).toLowerCase(),
- alternateTitles: alternateTitles.map((alternateTitle) => {
- return {
- title: alternateTitle.title,
- sortTitle: alternateTitle.sortTitle,
- cleanTitle: jdu.replace(alternateTitle.title).toLowerCase()
- };
- }),
+ alternateTitles,
tags: tags.map((id) => {
return allTags.find((tag) => tag.id === id);
})
};
- }).sort((a, b) => {
- if (a.sortTitle < b.sortTitle) {
- return -1;
- }
- if (a.sortTitle > b.sortTitle) {
- return 1;
- }
-
- return 0;
});
}
);
@@ -75,9 +38,9 @@ function createCleanMovieSelector() {
function createMapStateToProps() {
return createSelector(
createCleanMovieSelector(),
- (movie) => {
+ (movies) => {
return {
- movie
+ movies
};
}
);
diff --git a/frontend/src/Components/Page/Header/MovieSearchResult.js b/frontend/src/Components/Page/Header/MovieSearchResult.js
index 83211a766..8708fb151 100644
--- a/frontend/src/Components/Page/Header/MovieSearchResult.js
+++ b/frontend/src/Components/Page/Header/MovieSearchResult.js
@@ -5,38 +5,22 @@ import Label from 'Components/Label';
import MoviePoster from 'Movie/MoviePoster';
import styles from './MovieSearchResult.css';
-function findMatchingAlternateTitle(alternateTitles, cleanQuery) {
- return alternateTitles.find((alternateTitle) => {
- return alternateTitle.cleanTitle.contains(cleanQuery);
- });
-}
-
-function getMatchingTag(tags, cleanQuery) {
- return tags.find((tag) => {
- return tag.cleanLabel.contains(cleanQuery);
- });
-}
-
function MovieSearchResult(props) {
const {
- cleanQuery,
+ match,
title,
- cleanTitle,
images,
alternateTitles,
tags
} = props;
- const titleContains = cleanTitle.contains(cleanQuery);
let alternateTitle = null;
let tag = null;
- if (!titleContains) {
- alternateTitle = findMatchingAlternateTitle(alternateTitles, cleanQuery);
- }
-
- if (!titleContains && !alternateTitle) {
- tag = getMatchingTag(tags, cleanQuery);
+ if (match.key === 'alternateTitles.cleanTitle') {
+ alternateTitle = alternateTitles[match.arrayIndex];
+ } else if (match.key === 'tags.label') {
+ tag = tags[match.arrayIndex];
}
return (
@@ -55,14 +39,15 @@ function MovieSearchResult(props) {
{
- !!alternateTitle &&
+ alternateTitle ?