mirror of
https://github.com/Radarr/Radarr.git
synced 2025-04-23 22:17:15 -04:00
New: Loads of Backend Updates to Clients and Indexers
This commit is contained in:
parent
c48838e5b6
commit
8a9e2dc90d
345 changed files with 5859 additions and 2669 deletions
|
@ -19,7 +19,8 @@ const cssVarsFiles = [
|
|||
'../src/Styles/Variables/colors',
|
||||
'../src/Styles/Variables/dimensions',
|
||||
'../src/Styles/Variables/fonts',
|
||||
'../src/Styles/Variables/animations'
|
||||
'../src/Styles/Variables/animations',
|
||||
'../src/Styles/Variables/zIndexes'
|
||||
].map(require.resolve);
|
||||
|
||||
const plugins = [
|
||||
|
|
|
@ -42,13 +42,13 @@ class Queue extends Component {
|
|||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
// Don't update when fetching has completed if items have changed,
|
||||
// before episodes start fetching or when episodes start fetching.
|
||||
// before movies start fetching or when movies start fetching.
|
||||
|
||||
if (
|
||||
this.props.isFetching &&
|
||||
nextProps.isPopulated &&
|
||||
hasDifferentItems(this.props.items, nextProps.items) &&
|
||||
nextProps.items.some((e) => e.episodeId)
|
||||
nextProps.items.some((e) => e.movieId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
@ -139,7 +139,6 @@ class Queue extends Component {
|
|||
} = this.state;
|
||||
|
||||
const isRefreshing = isFetching || isCheckForFinishedDownloadExecuting;
|
||||
const isAllPopulated = isPopulated && !items.length;
|
||||
const hasError = error;
|
||||
const selectedCount = this.getSelectedIds().length;
|
||||
const disableSelectedActions = selectedCount === 0;
|
||||
|
@ -192,7 +191,7 @@ class Queue extends Component {
|
|||
|
||||
<PageContentBodyConnector>
|
||||
{
|
||||
isRefreshing && !isAllPopulated &&
|
||||
isRefreshing && !isPopulated &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
|
@ -211,7 +210,7 @@ class Queue extends Component {
|
|||
}
|
||||
|
||||
{
|
||||
isAllPopulated && !hasError && !!items.length &&
|
||||
isPopulated && !hasError && !!items.length &&
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
|
@ -228,7 +227,7 @@ class Queue extends Component {
|
|||
return (
|
||||
<QueueRowConnector
|
||||
key={item.id}
|
||||
episodeId={item.episodeId}
|
||||
movieId={item.movieId}
|
||||
isSelected={selectedState[item.id]}
|
||||
columns={columns}
|
||||
{...item}
|
||||
|
|
|
@ -76,7 +76,7 @@ function QueueDetails(props) {
|
|||
return (
|
||||
<Icon
|
||||
name={icons.DOWNLOADING}
|
||||
title={`Episode is downloading - ${progress.toFixed(1)}% ${title}`}
|
||||
title={`Movie is downloading - ${progress.toFixed(1)}% ${title}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,18 +14,18 @@ class QueueOptions extends Component {
|
|||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
includeUnknownSeriesItems: props.includeUnknownSeriesItems
|
||||
includeUnknownMovieItems: props.includeUnknownMovieItems
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
includeUnknownSeriesItems
|
||||
includeUnknownMovieItems
|
||||
} = this.props;
|
||||
|
||||
if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) {
|
||||
if (includeUnknownMovieItems !== prevProps.includeUnknownMovieItems) {
|
||||
this.setState({
|
||||
includeUnknownSeriesItems
|
||||
includeUnknownMovieItems
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -48,19 +48,19 @@ class QueueOptions extends Component {
|
|||
|
||||
render() {
|
||||
const {
|
||||
includeUnknownSeriesItems
|
||||
includeUnknownMovieItems
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<FormGroup>
|
||||
<FormLabel>Show Unknown Series Items</FormLabel>
|
||||
<FormLabel>Show Unknown Movie Items</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includeUnknownSeriesItems"
|
||||
value={includeUnknownSeriesItems}
|
||||
helpText="Show items without a series in the queue, this could include removed series, movies or anything else in Sonarr's category"
|
||||
name="includeUnknownMovieItems"
|
||||
value={includeUnknownMovieItems}
|
||||
helpText="Show items without a movie in the queue, this could include removed movie, movies or anything else in Radarr's category"
|
||||
onChange={this.onOptionChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
@ -70,7 +70,7 @@ class QueueOptions extends Component {
|
|||
}
|
||||
|
||||
QueueOptions.propTypes = {
|
||||
includeUnknownSeriesItems: PropTypes.bool.isRequired,
|
||||
includeUnknownMovieItems: PropTypes.bool.isRequired,
|
||||
onOptionChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import IconButton from 'Components/Link/IconButton';
|
|||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
// import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
|
||||
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
||||
|
@ -67,8 +67,7 @@ class QueueRow extends Component {
|
|||
trackedDownloadStatus,
|
||||
statusMessages,
|
||||
errorMessage,
|
||||
series,
|
||||
episode,
|
||||
movie,
|
||||
quality,
|
||||
protocol,
|
||||
indexer,
|
||||
|
@ -130,37 +129,28 @@ class QueueRow extends Component {
|
|||
);
|
||||
}
|
||||
|
||||
if (name === 'series.sortTitle') {
|
||||
if (name === 'movie.sortTitle') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieTitleLink
|
||||
titleSlug={series.titleSlug}
|
||||
title={series.title}
|
||||
titleSlug={movie.titleSlug}
|
||||
title={movie.title}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'series') {
|
||||
if (name === 'movie') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieTitleLink
|
||||
titleSlug={series.titleSlug}
|
||||
title={series.title}
|
||||
titleSlug={movie.titleSlug}
|
||||
title={movie.title}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'episode.airDateUtc') {
|
||||
return (
|
||||
<RelativeDateCellConnector
|
||||
key={name}
|
||||
date={episode.airDateUtc}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'quality') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
|
@ -303,8 +293,7 @@ QueueRow.propTypes = {
|
|||
trackedDownloadStatus: PropTypes.string,
|
||||
statusMessages: PropTypes.arrayOf(PropTypes.object),
|
||||
errorMessage: PropTypes.string,
|
||||
series: PropTypes.object.isRequired,
|
||||
episode: PropTypes.object.isRequired,
|
||||
movie: PropTypes.object.isRequired,
|
||||
quality: PropTypes.object.isRequired,
|
||||
protocol: PropTypes.string.isRequired,
|
||||
indexer: PropTypes.string,
|
||||
|
|
|
@ -12,14 +12,14 @@ function createMapStateToProps() {
|
|||
return createSelector(
|
||||
createMovieSelector(),
|
||||
createUISettingsSelector(),
|
||||
(series, uiSettings) => {
|
||||
(movie, uiSettings) => {
|
||||
const result = _.pick(uiSettings, [
|
||||
'showRelativeDates',
|
||||
'shortDateFormat',
|
||||
'timeFormat'
|
||||
]);
|
||||
|
||||
result.series = series;
|
||||
result.movie = movie;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ class QueueRowConnector extends Component {
|
|||
|
||||
QueueRowConnector.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
episode: PropTypes.object,
|
||||
movie: PropTypes.object,
|
||||
grabQueueItem: PropTypes.func.isRequired,
|
||||
removeQueueItem: PropTypes.func.isRequired
|
||||
};
|
||||
|
|
|
@ -116,6 +116,7 @@ function QueueStatusCell(props) {
|
|||
title={title}
|
||||
body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle}
|
||||
position={tooltipPositions.RIGHT}
|
||||
canFlip={false}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
.tether {
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.button {
|
||||
composes: link from '~Components/Link/Link.css';
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 16px;
|
||||
|
@ -35,9 +30,10 @@
|
|||
}
|
||||
|
||||
.contentContainer {
|
||||
z-index: $popperZIndex;
|
||||
margin-top: 4px;
|
||||
padding: 0 8px;
|
||||
width: 400px;
|
||||
/* 400px container witdh with 8px padding on each side */
|
||||
width: 384px;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import TetherComponent from 'react-tether';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import getUniqueElememtId from 'Utilities/getUniqueElementId';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Portal from 'Components/Portal';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
|
@ -12,19 +13,6 @@ import ImportMovieSearchResultConnector from './ImportMovieSearchResultConnector
|
|||
import ImportMovieTitle from './ImportMovieTitle';
|
||||
import styles from './ImportMovieSelectMovie.css';
|
||||
|
||||
const tetherOptions = {
|
||||
skipMoveElement: true,
|
||||
constraints: [
|
||||
{
|
||||
to: 'window',
|
||||
attachment: 'together',
|
||||
pin: true
|
||||
}
|
||||
],
|
||||
attachment: 'top center',
|
||||
targetAttachment: 'bottom center'
|
||||
};
|
||||
|
||||
class ImportMovieSelectMovie extends Component {
|
||||
|
||||
//
|
||||
|
@ -34,8 +22,9 @@ class ImportMovieSelectMovie extends Component {
|
|||
super(props, context);
|
||||
|
||||
this._movieLookupTimeout = null;
|
||||
this._buttonRef = {};
|
||||
this._contentRef = {};
|
||||
this._scheduleUpdate = null;
|
||||
this._buttonId = getUniqueElememtId();
|
||||
this._contentId = getUniqueElememtId();
|
||||
|
||||
this.state = {
|
||||
term: props.id,
|
||||
|
@ -43,6 +32,12 @@ class ImportMovieSelectMovie extends Component {
|
|||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this._scheduleUpdate) {
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
|
@ -58,8 +53,8 @@ class ImportMovieSelectMovie extends Component {
|
|||
// Listeners
|
||||
|
||||
onWindowClick = (event) => {
|
||||
const button = ReactDOM.findDOMNode(this._buttonRef.current);
|
||||
const content = ReactDOM.findDOMNode(this._contentRef.current);
|
||||
const button = document.getElementById(this._buttonId);
|
||||
const content = document.getElementById(this._contentId);
|
||||
|
||||
if (!button || !content) {
|
||||
return;
|
||||
|
@ -127,150 +122,158 @@ class ImportMovieSelectMovie extends Component {
|
|||
error.responseJSON.message;
|
||||
|
||||
return (
|
||||
<TetherComponent
|
||||
classes={{
|
||||
element: styles.tether
|
||||
}}
|
||||
{...tetherOptions}
|
||||
renderTarget={
|
||||
(ref) => {
|
||||
this._buttonRef = ref;
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div
|
||||
ref={ref}
|
||||
id={this._buttonId}
|
||||
>
|
||||
<Link
|
||||
ref={ref}
|
||||
className={styles.button}
|
||||
component="div"
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{
|
||||
isLookingUpMovie && isQueued && !isPopulated ?
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<Link
|
||||
className={styles.button}
|
||||
component="div"
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{
|
||||
isLookingUpMovie && isQueued && !isPopulated ?
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
{
|
||||
isPopulated && selectedMovie && isExistingMovie ?
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && selectedMovie && isExistingMovie ?
|
||||
{
|
||||
isPopulated && selectedMovie ?
|
||||
<ImportMovieTitle
|
||||
title={selectedMovie.title}
|
||||
year={selectedMovie.year}
|
||||
studio={selectedMovie.studio}
|
||||
isExistingMovie={isExistingMovie}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !selectedMovie ?
|
||||
<div className={styles.noMatches}>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && selectedMovie ?
|
||||
<ImportMovieTitle
|
||||
title={selectedMovie.title}
|
||||
year={selectedMovie.year}
|
||||
network={selectedMovie.network}
|
||||
isExistingMovie={isExistingMovie}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !selectedMovie ?
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
/>
|
||||
|
||||
No match found!
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error ?
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
title={errorMessage}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
{
|
||||
!isFetching && !!error ?
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
title={errorMessage}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
|
||||
Search failed, please try again later.
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<div className={styles.dropdownArrowContainer}>
|
||||
<Icon
|
||||
name={icons.CARET_DOWN}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
|
||||
<Portal>
|
||||
<Popper
|
||||
placement="bottom"
|
||||
modifiers={{
|
||||
preventOverflow: {
|
||||
boundariesElement: 'viewport'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={this._contentId}
|
||||
className={styles.contentContainer}
|
||||
style={style}
|
||||
>
|
||||
{
|
||||
this.state.isOpen ?
|
||||
<div className={styles.content}>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchIconContainer}>
|
||||
<Icon name={icons.SEARCH} />
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
className={styles.searchInput}
|
||||
name={`${name}_textInput`}
|
||||
value={this.state.term}
|
||||
onChange={this.onSearchInputChange}
|
||||
/>
|
||||
|
||||
<FormInputButton
|
||||
kind={kinds.DEFAULT}
|
||||
spinnerIcon={icons.REFRESH}
|
||||
canSpin={true}
|
||||
isSpinning={isFetching}
|
||||
onPress={this.onRefreshPress}
|
||||
>
|
||||
<Icon name={icons.REFRESH} />
|
||||
</FormInputButton>
|
||||
</div>
|
||||
|
||||
<div className={styles.results}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<ImportMovieSearchResultConnector
|
||||
key={item.tvdbId}
|
||||
tmdbId={item.tmdbId}
|
||||
title={item.title}
|
||||
year={item.year}
|
||||
studio={item.studio}
|
||||
onPress={this.onSeriesSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<div className={styles.dropdownArrowContainer}>
|
||||
<Icon
|
||||
name={icons.CARET_DOWN}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
renderElement={
|
||||
(ref) => {
|
||||
this._contentRef = ref;
|
||||
|
||||
if (!this.state.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.contentContainer}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchIconContainer}>
|
||||
<Icon name={icons.SEARCH} />
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
className={styles.searchInput}
|
||||
name={`${name}_textInput`}
|
||||
value={this.state.term}
|
||||
onChange={this.onSearchInputChange}
|
||||
/>
|
||||
|
||||
<FormInputButton
|
||||
kind={kinds.DEFAULT}
|
||||
spinnerIcon={icons.REFRESH}
|
||||
canSpin={true}
|
||||
isSpinning={isFetching}
|
||||
onPress={this.onRefreshPress}
|
||||
>
|
||||
<Icon name={icons.REFRESH} />
|
||||
</FormInputButton>
|
||||
</div>
|
||||
|
||||
<div className={styles.results}>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<ImportMovieSearchResultConnector
|
||||
key={item.tmdbId}
|
||||
tmdbId={item.tmdbId}
|
||||
title={item.title}
|
||||
year={item.year}
|
||||
studio={item.studio}
|
||||
onPress={this.onMovieSelect}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
import classNames from 'classnames';
|
||||
import jdu from 'jdu';
|
||||
import styles from './AutoCompleteInput.css';
|
||||
import AutoSuggestInput from './AutoSuggestInput';
|
||||
|
||||
class AutoCompleteInput extends Component {
|
||||
|
||||
|
@ -39,31 +37,6 @@ class AutoCompleteInput extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
onInputKeyDown = (event) => {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
const { suggestions } = this.state;
|
||||
|
||||
if (
|
||||
event.key === 'Tab' &&
|
||||
suggestions.length &&
|
||||
suggestions[0] !== this.props.value
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
if (value) {
|
||||
onChange({
|
||||
name,
|
||||
value: suggestions[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onInputBlur = () => {
|
||||
this.setState({ suggestions: [] });
|
||||
}
|
||||
|
@ -88,74 +61,37 @@ class AutoCompleteInput extends Component {
|
|||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
inputClassName,
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
hasError,
|
||||
hasWarning
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const { suggestions } = this.state;
|
||||
|
||||
const inputProps = {
|
||||
className: classNames(
|
||||
inputClassName,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
),
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
autoComplete: 'off',
|
||||
spellCheck: false,
|
||||
onChange: this.onInputChange,
|
||||
onKeyDown: this.onInputKeyDown,
|
||||
onBlur: this.onInputBlur
|
||||
};
|
||||
|
||||
const theme = {
|
||||
container: styles.inputContainer,
|
||||
containerOpen: styles.inputContainerOpen,
|
||||
suggestionsContainer: styles.container,
|
||||
suggestionsList: styles.list,
|
||||
suggestion: styles.listItem,
|
||||
suggestionHighlighted: styles.highlighted
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Autosuggest
|
||||
id={name}
|
||||
inputProps={inputProps}
|
||||
theme={theme}
|
||||
suggestions={suggestions}
|
||||
getSuggestionValue={this.getSuggestionValue}
|
||||
renderSuggestion={this.renderSuggestion}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
/>
|
||||
</div>
|
||||
<AutoSuggestInput
|
||||
{...otherProps}
|
||||
name={name}
|
||||
value={value}
|
||||
suggestions={suggestions}
|
||||
getSuggestionValue={this.getSuggestionValue}
|
||||
renderSuggestion={this.renderSuggestion}
|
||||
onInputBlur={this.onInputBlur}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AutoCompleteInput.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
inputClassName: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
values: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
hasError: PropTypes.bool,
|
||||
hasWarning: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
AutoCompleteInput.defaultProps = {
|
||||
className: styles.inputWrapper,
|
||||
inputClassName: styles.input,
|
||||
value: ''
|
||||
};
|
||||
|
||||
|
|
|
@ -10,25 +10,20 @@
|
|||
composes: hasWarning from '~Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.container {
|
||||
.suggestionsContainer {
|
||||
@add-mixin scrollbar;
|
||||
@add-mixin scrollbarTrack;
|
||||
@add-mixin scrollbarThumb;
|
||||
}
|
||||
|
||||
.inputContainerOpen {
|
||||
.container {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
.suggestionsContainerOpen {
|
||||
z-index: $popperZIndex;
|
||||
|
||||
.suggestionsContainer {
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
width: 100%;
|
||||
|
@ -39,20 +34,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
.suggestionsList {
|
||||
margin: 5px 0;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
.suggestion {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.match {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
.suggestionHighlighted {
|
||||
background-color: $menuItemHoverBackgroundColor;
|
||||
}
|
257
frontend/src/Components/Form/AutoSuggestInput.js
Normal file
257
frontend/src/Components/Form/AutoSuggestInput.js
Normal file
|
@ -0,0 +1,257 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import classNames from 'classnames';
|
||||
import Portal from 'Components/Portal';
|
||||
import styles from './AutoSuggestInput.css';
|
||||
|
||||
class AutoSuggestInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._scheduleUpdate = null;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
this._scheduleUpdate &&
|
||||
prevProps.suggestions !== this.props.suggestions
|
||||
) {
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
renderInputComponent = (inputProps) => {
|
||||
const { renderInputComponent } = this.props;
|
||||
|
||||
return (
|
||||
<Reference>
|
||||
{({ ref }) => {
|
||||
if (renderInputComponent) {
|
||||
return renderInputComponent(inputProps, ref);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<input
|
||||
{...inputProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Reference>
|
||||
);
|
||||
}
|
||||
|
||||
renderSuggestionsContainer = ({ containerProps, children }) => {
|
||||
return (
|
||||
<Portal>
|
||||
<Popper
|
||||
placement='bottom-start'
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: this.onComputeMaxHeight
|
||||
},
|
||||
flip: {
|
||||
padding: this.props.minHeight
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ ref: popperRef, style, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popperRef}
|
||||
style={style}
|
||||
className={children ? styles.suggestionsContainerOpen : undefined}
|
||||
>
|
||||
<div
|
||||
{...containerProps}
|
||||
style={{
|
||||
maxHeight: style.maxHeight
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onComputeMaxHeight = (data) => {
|
||||
const {
|
||||
top,
|
||||
bottom,
|
||||
width
|
||||
} = data.offsets.reference;
|
||||
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if ((/^botton/).test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom;
|
||||
} else {
|
||||
data.styles.maxHeight = top;
|
||||
}
|
||||
|
||||
data.styles.width = width;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
onInputChange = (event, { newValue }) => {
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value: newValue
|
||||
});
|
||||
}
|
||||
|
||||
onInputKeyDown = (event) => {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
suggestions,
|
||||
onChange
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
event.key === 'Tab' &&
|
||||
suggestions.length &&
|
||||
suggestions[0] !== this.props.value
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
if (value) {
|
||||
onChange({
|
||||
name,
|
||||
value: suggestions[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
forwardedRef,
|
||||
className,
|
||||
inputContainerClassName,
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
suggestions,
|
||||
hasError,
|
||||
hasWarning,
|
||||
getSuggestionValue,
|
||||
renderSuggestion,
|
||||
onInputChange,
|
||||
onInputKeyDown,
|
||||
onInputFocus,
|
||||
onInputBlur,
|
||||
onSuggestionsFetchRequested,
|
||||
onSuggestionsClearRequested,
|
||||
onSuggestionSelected,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const inputProps = {
|
||||
className: classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
),
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
autoComplete: 'off',
|
||||
spellCheck: false,
|
||||
onChange: onInputChange || this.onInputChange,
|
||||
onKeyDown: onInputKeyDown || this.onInputKeyDown,
|
||||
onFocus: onInputFocus,
|
||||
onBlur: onInputBlur
|
||||
};
|
||||
|
||||
const theme = {
|
||||
container: inputContainerClassName,
|
||||
containerOpen: styles.suggestionsContainerOpen,
|
||||
suggestionsContainer: styles.suggestionsContainer,
|
||||
suggestionsList: styles.suggestionsList,
|
||||
suggestion: styles.suggestion,
|
||||
suggestionHighlighted: styles.suggestionHighlighted
|
||||
};
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Autosuggest
|
||||
{...otherProps}
|
||||
ref={forwardedRef}
|
||||
id={name}
|
||||
inputProps={inputProps}
|
||||
theme={theme}
|
||||
suggestions={suggestions}
|
||||
getSuggestionValue={getSuggestionValue}
|
||||
renderInputComponent={this.renderInputComponent}
|
||||
renderSuggestionsContainer={this.renderSuggestionsContainer}
|
||||
renderSuggestion={renderSuggestion}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
/>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AutoSuggestInput.propTypes = {
|
||||
forwardedRef: PropTypes.func,
|
||||
className: PropTypes.string.isRequired,
|
||||
inputContainerClassName: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
|
||||
placeholder: PropTypes.string,
|
||||
suggestions: PropTypes.array.isRequired,
|
||||
hasError: PropTypes.bool,
|
||||
hasWarning: PropTypes.bool,
|
||||
enforceMaxHeight: PropTypes.bool.isRequired,
|
||||
minHeight: PropTypes.number.isRequired,
|
||||
maxHeight: PropTypes.number.isRequired,
|
||||
getSuggestionValue: PropTypes.func.isRequired,
|
||||
renderInputComponent: PropTypes.func,
|
||||
renderSuggestion: PropTypes.func.isRequired,
|
||||
onInputChange: PropTypes.func,
|
||||
onInputKeyDown: PropTypes.func,
|
||||
onInputFocus: PropTypes.func,
|
||||
onInputBlur: PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: PropTypes.func.isRequired,
|
||||
onSuggestionSelected: PropTypes.func,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
AutoSuggestInput.defaultProps = {
|
||||
className: styles.input,
|
||||
inputContainerClassName: styles.inputContainer,
|
||||
enforceMaxHeight: true,
|
||||
minHeight: 50,
|
||||
maxHeight: 200
|
||||
};
|
||||
|
||||
export default AutoSuggestInput;
|
|
@ -2,7 +2,7 @@
|
|||
display: flex;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
composes: inputContainer from '~./TagInput.css';
|
||||
.input {
|
||||
composes: input from '~./TagInput.css';
|
||||
composes: hasButton from '~Components/Form/Input.css';
|
||||
}
|
||||
|
|
|
@ -47,6 +47,7 @@ class DeviceInput extends Component {
|
|||
render() {
|
||||
const {
|
||||
className,
|
||||
name,
|
||||
items,
|
||||
selectedDevices,
|
||||
hasError,
|
||||
|
@ -58,7 +59,8 @@ class DeviceInput extends Component {
|
|||
return (
|
||||
<div className={className}>
|
||||
<TagInput
|
||||
className={styles.inputContainer}
|
||||
inputContainerClassName={styles.input}
|
||||
name={name}
|
||||
tags={selectedDevices}
|
||||
tagList={items}
|
||||
allowNew={true}
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
.tether {
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.enhancedSelect {
|
||||
composes: input from '~Components/Form/Input.css';
|
||||
composes: link from '~Components/Link/Link.css';
|
||||
|
@ -44,10 +40,13 @@
|
|||
}
|
||||
|
||||
.optionsContainer {
|
||||
z-index: $popperZIndex;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.options {
|
||||
composes: scroller from '~Components/Scroller/Scroller.css';
|
||||
|
||||
border: 1px solid $inputBorderColor;
|
||||
border-radius: 4px;
|
||||
background-color: $white;
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import TetherComponent from 'react-tether';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import classNames from 'classnames';
|
||||
import getUniqueElememtId from 'Utilities/getUniqueElementId';
|
||||
import isMobileUtil from 'Utilities/isMobile';
|
||||
import * as keyCodes from 'Utilities/Constants/keyCodes';
|
||||
import { icons, scrollDirections } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Portal from 'Components/Portal';
|
||||
import Link from 'Components/Link/Link';
|
||||
import Measure from 'Components/Measure';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
|
@ -17,19 +18,6 @@ import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue
|
|||
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
|
||||
import styles from './EnhancedSelectInput.css';
|
||||
|
||||
const tetherOptions = {
|
||||
skipMoveElement: true,
|
||||
constraints: [
|
||||
{
|
||||
to: 'window',
|
||||
attachment: 'together',
|
||||
pin: true
|
||||
}
|
||||
],
|
||||
attachment: 'top left',
|
||||
targetAttachment: 'bottom left'
|
||||
};
|
||||
|
||||
function isArrowKey(keyCode) {
|
||||
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
|
||||
}
|
||||
|
@ -87,8 +75,9 @@ class EnhancedSelectInput extends Component {
|
|||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._buttonRef = {};
|
||||
this._optionsRef = {};
|
||||
this._scheduleUpdate = null;
|
||||
this._buttonId = getUniqueElememtId();
|
||||
this._optionsId = getUniqueElememtId();
|
||||
|
||||
this.state = {
|
||||
isOpen: false,
|
||||
|
@ -99,6 +88,10 @@ class EnhancedSelectInput extends Component {
|
|||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this._scheduleUpdate) {
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
|
||||
if (prevProps.value !== this.props.value) {
|
||||
this.setState({
|
||||
selectedIndex: getSelectedIndex(this.props)
|
||||
|
@ -120,9 +113,26 @@ class EnhancedSelectInput extends Component {
|
|||
//
|
||||
// Listeners
|
||||
|
||||
onComputeMaxHeight = (data) => {
|
||||
const {
|
||||
top,
|
||||
bottom
|
||||
} = data.offsets.reference;
|
||||
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if ((/^botton/).test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom;
|
||||
} else {
|
||||
data.styles.maxHeight = top;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
onWindowClick = (event) => {
|
||||
const button = ReactDOM.findDOMNode(this._buttonRef.current);
|
||||
const options = ReactDOM.findDOMNode(this._optionsRef.current);
|
||||
const button = document.getElementById(this._buttonId);
|
||||
const options = document.getElementById(this._optionsId);
|
||||
|
||||
if (!button || this.state.isMobile) {
|
||||
return;
|
||||
|
@ -266,96 +276,110 @@ class EnhancedSelectInput extends Component {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<TetherComponent
|
||||
classes={{
|
||||
element: styles.tether
|
||||
}}
|
||||
{...tetherOptions}
|
||||
renderTarget={
|
||||
(ref) => {
|
||||
this._buttonRef = ref;
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div
|
||||
ref={ref}
|
||||
id={this._buttonId}
|
||||
>
|
||||
<Measure
|
||||
whitelist={['width']}
|
||||
onMeasure={this.onMeasure}
|
||||
>
|
||||
<div ref={ref}>
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onBlur={this.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
<SelectedValueComponent
|
||||
{...selectedValueOptions}
|
||||
{...selectedOption}
|
||||
isDisabled={isDisabled}
|
||||
onBlur={this.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
<SelectedValueComponent
|
||||
{...selectedValueOptions}
|
||||
{...selectedOption}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{selectedOption ? selectedOption.value : null}
|
||||
</SelectedValueComponent>
|
||||
{selectedOption ? selectedOption.value : null}
|
||||
</SelectedValueComponent>
|
||||
|
||||
<div
|
||||
className={isDisabled ?
|
||||
styles.dropdownArrowContainerDisabled :
|
||||
styles.dropdownArrowContainer
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={icons.CARET_DOWN}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
className={isDisabled ?
|
||||
styles.dropdownArrowContainerDisabled :
|
||||
styles.dropdownArrowContainer
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={icons.CARET_DOWN}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</Measure>
|
||||
);
|
||||
}
|
||||
}
|
||||
renderElement={
|
||||
(ref) => {
|
||||
this._optionsRef = ref;
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
<Portal>
|
||||
<Popper
|
||||
placement="bottom-start"
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: this.onComputeMaxHeight
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
if (!isOpen || isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.optionsContainer}
|
||||
style={{
|
||||
minWidth: width
|
||||
}}
|
||||
>
|
||||
<div className={styles.options}>
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={this._optionsId}
|
||||
className={styles.optionsContainer}
|
||||
style={{
|
||||
...style,
|
||||
minWidth: width
|
||||
}}
|
||||
>
|
||||
{
|
||||
values.map((v, index) => {
|
||||
return (
|
||||
<OptionComponent
|
||||
key={v.key}
|
||||
id={v.key}
|
||||
isSelected={index === selectedIndex}
|
||||
{...v}
|
||||
isMobile={false}
|
||||
onSelect={this.onSelect}
|
||||
>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
})
|
||||
isOpen && !isMobile ?
|
||||
<Scroller
|
||||
className={styles.options}
|
||||
style={{
|
||||
maxHeight: style.maxHeight
|
||||
}}
|
||||
>
|
||||
{
|
||||
values.map((v, index) => {
|
||||
return (
|
||||
<OptionComponent
|
||||
key={v.key}
|
||||
id={v.key}
|
||||
isSelected={index === selectedIndex}
|
||||
{...v}
|
||||
isMobile={false}
|
||||
onSelect={this.onSelect}
|
||||
>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Scroller> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
|
||||
{
|
||||
isMobile &&
|
||||
|
|
|
@ -1,66 +1,16 @@
|
|||
.path {
|
||||
composes: input from '~Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasError {
|
||||
composes: hasError from '~Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasWarning {
|
||||
composes: hasWarning from '~Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasFileBrowser {
|
||||
composes: input from '~./AutoSuggestInput.css';
|
||||
composes: hasButton from '~Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.pathInputWrapper {
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pathInputContainer {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.pathContainer {
|
||||
@add-mixin scrollbar;
|
||||
@add-mixin scrollbarTrack;
|
||||
@add-mixin scrollbarThumb;
|
||||
}
|
||||
|
||||
.pathInputContainerOpen {
|
||||
.pathContainer {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
width: 100%;
|
||||
border: 1px solid $inputBorderColor;
|
||||
border-radius: 4px;
|
||||
background-color: $white;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
|
||||
}
|
||||
}
|
||||
|
||||
.pathList {
|
||||
margin: 5px 0;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.pathListItem {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.pathMatch {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pathHighlighted {
|
||||
background-color: $menuItemHoverBackgroundColor;
|
||||
}
|
||||
|
||||
.fileBrowserButton {
|
||||
composes: button from '~./FormInputButton.css';
|
||||
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
import classNames from 'classnames';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||
import AutoSuggestInput from './AutoSuggestInput';
|
||||
import FormInputButton from './FormInputButton';
|
||||
import styles from './PathInput.css';
|
||||
|
||||
|
@ -16,6 +15,8 @@ class PathInput extends Component {
|
|||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._node = document.getElementById('portal-root');
|
||||
|
||||
this.state = {
|
||||
isFileBrowserModalOpen: false
|
||||
};
|
||||
|
@ -106,56 +107,30 @@ class PathInput extends Component {
|
|||
render() {
|
||||
const {
|
||||
className,
|
||||
inputClassName,
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
paths,
|
||||
includeFiles,
|
||||
hasError,
|
||||
hasWarning,
|
||||
hasFileBrowser,
|
||||
onChange
|
||||
onChange,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const inputProps = {
|
||||
className: classNames(
|
||||
inputClassName,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
hasFileBrowser && styles.hasFileBrowser
|
||||
),
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
autoComplete: 'off',
|
||||
spellCheck: false,
|
||||
onChange: this.onInputChange,
|
||||
onKeyDown: this.onInputKeyDown,
|
||||
onBlur: this.onInputBlur
|
||||
};
|
||||
|
||||
const theme = {
|
||||
container: styles.pathInputContainer,
|
||||
containerOpen: styles.pathInputContainerOpen,
|
||||
suggestionsContainer: styles.pathContainer,
|
||||
suggestionsList: styles.pathList,
|
||||
suggestion: styles.pathListItem,
|
||||
suggestionHighlighted: styles.pathHighlighted
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Autosuggest
|
||||
id={name}
|
||||
inputProps={inputProps}
|
||||
theme={theme}
|
||||
<AutoSuggestInput
|
||||
{...otherProps}
|
||||
className={hasFileBrowser ? styles.hasFileBrowser : undefined}
|
||||
name={name}
|
||||
value={value}
|
||||
suggestions={paths}
|
||||
getSuggestionValue={this.getSuggestionValue}
|
||||
renderSuggestion={this.renderSuggestion}
|
||||
onInputKeyDown={this.onInputKeyDown}
|
||||
onInputBlur={this.onInputBlur}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
{
|
||||
|
@ -185,14 +160,10 @@ class PathInput extends Component {
|
|||
|
||||
PathInput.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
inputClassName: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
paths: PropTypes.array.isRequired,
|
||||
includeFiles: PropTypes.bool.isRequired,
|
||||
hasError: PropTypes.bool,
|
||||
hasWarning: PropTypes.bool,
|
||||
hasFileBrowser: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onFetchPaths: PropTypes.func.isRequired,
|
||||
|
@ -200,8 +171,7 @@ PathInput.propTypes = {
|
|||
};
|
||||
|
||||
PathInput.defaultProps = {
|
||||
className: styles.pathInputWrapper,
|
||||
inputClassName: styles.path,
|
||||
className: styles.inputWrapper,
|
||||
value: '',
|
||||
hasFileBrowser: true
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.inputContainer {
|
||||
composes: input from '~Components/Form/Input.css';
|
||||
.input {
|
||||
composes: input from '~./AutoSuggestInput.css';
|
||||
|
||||
position: relative;
|
||||
padding: 0;
|
||||
|
@ -13,20 +13,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.hasError {
|
||||
composes: hasError from '~Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasWarning {
|
||||
composes: hasWarning from '~Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.tags {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
.internalInput {
|
||||
flex: 1 1 0%;
|
||||
margin-left: 3px;
|
||||
min-width: 20%;
|
||||
|
@ -35,44 +22,3 @@
|
|||
height: 21px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.suggestionsContainer {
|
||||
@add-mixin scrollbar;
|
||||
@add-mixin scrollbarTrack;
|
||||
@add-mixin scrollbarThumb;
|
||||
}
|
||||
|
||||
.containerOpen {
|
||||
.suggestionsContainer {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
left: -1px;
|
||||
z-index: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: 1px;
|
||||
max-height: 110px;
|
||||
border: 1px solid $inputBorderColor;
|
||||
border-radius: 4px;
|
||||
background-color: $white;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionsList {
|
||||
margin: 5px 0;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
padding: 0 16px;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background-color: $menuItemHoverBackgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionHighlighted {
|
||||
background-color: $menuItemHoverBackgroundColor;
|
||||
}
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
import classNames from 'classnames';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import tagShape from 'Helpers/Props/Shapes/tagShape';
|
||||
import AutoSuggestInput from './AutoSuggestInput';
|
||||
import TagInputInput from './TagInputInput';
|
||||
import TagInputTag from './TagInputTag';
|
||||
import styles from './TagInput.css';
|
||||
|
||||
function getTag(value, selectedIndex, suggestions, allowNew) {
|
||||
if (selectedIndex == null && value) {
|
||||
const existingTag = _.find(suggestions, { name: value });
|
||||
const existingTag = suggestions.find((suggestion) => suggestion.name === value);
|
||||
|
||||
if (existingTag) {
|
||||
return existingTag;
|
||||
|
@ -184,7 +184,7 @@ class TagInput extends Component {
|
|||
//
|
||||
// Render
|
||||
|
||||
renderInputComponent = (inputProps) => {
|
||||
renderInputComponent = (inputProps, forwardedRef) => {
|
||||
const {
|
||||
tags,
|
||||
kind,
|
||||
|
@ -194,6 +194,7 @@ class TagInput extends Component {
|
|||
|
||||
return (
|
||||
<TagInputInput
|
||||
forwardedRef={forwardedRef}
|
||||
tags={tags}
|
||||
kind={kind}
|
||||
inputProps={inputProps}
|
||||
|
@ -208,10 +209,8 @@ class TagInput extends Component {
|
|||
render() {
|
||||
const {
|
||||
className,
|
||||
inputClassName,
|
||||
placeholder,
|
||||
hasError,
|
||||
hasWarning
|
||||
inputContainerClassName,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
|
@ -220,48 +219,30 @@ class TagInput extends Component {
|
|||
isFocused
|
||||
} = this.state;
|
||||
|
||||
const inputProps = {
|
||||
className: inputClassName,
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
autoComplete: 'off',
|
||||
spellCheck: false,
|
||||
onChange: this.onInputChange,
|
||||
onKeyDown: this.onInputKeyDown,
|
||||
onFocus: this.onInputFocus,
|
||||
onBlur: this.onInputBlur
|
||||
};
|
||||
|
||||
const theme = {
|
||||
container: classNames(
|
||||
className,
|
||||
isFocused && styles.isFocused,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
),
|
||||
containerOpen: styles.containerOpen,
|
||||
suggestionsContainer: styles.suggestionsContainer,
|
||||
suggestionsList: styles.suggestionsList,
|
||||
suggestion: styles.suggestion,
|
||||
suggestionHighlighted: styles.suggestionHighlighted
|
||||
};
|
||||
|
||||
return (
|
||||
<Autosuggest
|
||||
ref={this._setAutosuggestRef}
|
||||
id={name}
|
||||
inputProps={inputProps}
|
||||
theme={theme}
|
||||
<AutoSuggestInput
|
||||
{...otherProps}
|
||||
forwardedRef={this._setAutosuggestRef}
|
||||
className={styles.internalInput}
|
||||
inputContainerClassName={classNames(
|
||||
inputContainerClassName,
|
||||
isFocused && styles.isFocused,
|
||||
)}
|
||||
value={value}
|
||||
suggestions={suggestions}
|
||||
getSuggestionValue={this.getSuggestionValue}
|
||||
shouldRenderSuggestions={this.shouldRenderSuggestions}
|
||||
focusInputOnSuggestionClick={false}
|
||||
renderSuggestion={this.renderSuggestion}
|
||||
renderInputComponent={this.renderInputComponent}
|
||||
onInputChange={this.onInputChange}
|
||||
onInputKeyDown={this.onInputKeyDown}
|
||||
onInputFocus={this.onInputFocus}
|
||||
onInputBlur={this.onInputBlur}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -269,7 +250,7 @@ class TagInput extends Component {
|
|||
|
||||
TagInput.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
inputClassName: PropTypes.string.isRequired,
|
||||
inputContainerClassName: PropTypes.string.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
|
||||
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
|
||||
allowNew: PropTypes.bool.isRequired,
|
||||
|
@ -285,8 +266,8 @@ TagInput.propTypes = {
|
|||
};
|
||||
|
||||
TagInput.defaultProps = {
|
||||
className: styles.inputContainer,
|
||||
inputClassName: styles.input,
|
||||
className: styles.internalInput,
|
||||
inputContainerClassName: styles.input,
|
||||
allowNew: true,
|
||||
kind: kinds.INFO,
|
||||
placeholder: '',
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
.inputContainer {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
left: -1px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 16px;
|
||||
|
|
|
@ -23,6 +23,7 @@ class TagInputInput extends Component {
|
|||
|
||||
render() {
|
||||
const {
|
||||
forwardedRef,
|
||||
className,
|
||||
tags,
|
||||
inputProps,
|
||||
|
@ -33,6 +34,7 @@ class TagInputInput extends Component {
|
|||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={className}
|
||||
component="div"
|
||||
onMouseDown={this.onMouseDown}
|
||||
|
@ -59,6 +61,7 @@ class TagInputInput extends Component {
|
|||
}
|
||||
|
||||
TagInputInput.propTypes = {
|
||||
forwardedRef: PropTypes.func,
|
||||
className: PropTypes.string.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
|
||||
inputProps: PropTypes.object.isRequired,
|
||||
|
|
|
@ -71,6 +71,7 @@
|
|||
.success {
|
||||
border-color: $successColor;
|
||||
background-color: $successColor;
|
||||
color: #eee;
|
||||
|
||||
&.outline {
|
||||
color: $successColor;
|
||||
|
@ -101,7 +102,7 @@
|
|||
.large {
|
||||
padding: 3px 7px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
font-size: $defaultFontSize;
|
||||
}
|
||||
|
||||
/** Outline **/
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
.tether {
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: relative;
|
||||
}
|
||||
|
|
|
@ -1,32 +1,31 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import TetherComponent from 'react-tether';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import getUniqueElememtId from 'Utilities/getUniqueElementId';
|
||||
import { align } from 'Helpers/Props';
|
||||
import Portal from 'Components/Portal';
|
||||
import styles from './Menu.css';
|
||||
|
||||
const baseTetherOptions = {
|
||||
skipMoveElement: true,
|
||||
constraints: [
|
||||
{
|
||||
to: 'window',
|
||||
attachment: 'together',
|
||||
pin: true
|
||||
const sharedPopperOptions = {
|
||||
modifiers: {
|
||||
preventOverflow: {
|
||||
padding: 0
|
||||
},
|
||||
flip: {
|
||||
padding: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const tetherOptions = {
|
||||
const popperOptions = {
|
||||
[align.RIGHT]: {
|
||||
...baseTetherOptions,
|
||||
attachment: 'top right',
|
||||
targetAttachment: 'bottom right'
|
||||
...sharedPopperOptions,
|
||||
placement: 'bottom-end'
|
||||
},
|
||||
|
||||
[align.LEFT]: {
|
||||
...baseTetherOptions,
|
||||
attachment: 'top left',
|
||||
targetAttachment: 'bottom left'
|
||||
...sharedPopperOptions,
|
||||
placement: 'bottom-start'
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -38,8 +37,8 @@ class Menu extends Component {
|
|||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._menuRef = {};
|
||||
this._menuContentRef = {};
|
||||
this._scheduleUpdate = null;
|
||||
this._menuButtonId = getUniqueElememtId();
|
||||
|
||||
this.state = {
|
||||
isMenuOpen: false,
|
||||
|
@ -51,6 +50,12 @@ class Menu extends Component {
|
|||
this.setMaxHeight();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this._scheduleUpdate) {
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._removeListener();
|
||||
}
|
||||
|
@ -63,13 +68,13 @@ class Menu extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
const menu = ReactDOM.findDOMNode(this._menuRef.current);
|
||||
const menuButton = document.getElementById(this._menuButtonId);
|
||||
|
||||
if (!menu) {
|
||||
if (!menuButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { bottom } = menu.getBoundingClientRect();
|
||||
const { bottom } = menuButton.getBoundingClientRect();
|
||||
const maxHeight = window.innerHeight - bottom;
|
||||
|
||||
return maxHeight;
|
||||
|
@ -106,14 +111,13 @@ class Menu extends Component {
|
|||
// Listeners
|
||||
|
||||
onWindowClick = (event) => {
|
||||
const menu = ReactDOM.findDOMNode(this._menuRef.current);
|
||||
const menuContent = ReactDOM.findDOMNode(this._menuContentRef.current);
|
||||
const menuButton = document.getElementById(this._menuButtonId);
|
||||
|
||||
if (!menu || !menuContent) {
|
||||
if (!menuButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((!menu.contains(event.target) || menuContent.contains(event.target)) && this.state.isMenuOpen) {
|
||||
if (!menuButton.contains(event.target) && this.state.isMenuOpen) {
|
||||
this.setState({ isMenuOpen: false });
|
||||
this._removeListener();
|
||||
}
|
||||
|
@ -124,17 +128,9 @@ class Menu extends Component {
|
|||
}
|
||||
|
||||
onWindowScroll = (event) => {
|
||||
if (!this._menuContentRef.current) {
|
||||
return;
|
||||
if (this.state.isMenuOpen) {
|
||||
this.setMaxHeight();
|
||||
}
|
||||
|
||||
const menuContent = ReactDOM.findDOMNode(this._menuContentRef.current);
|
||||
|
||||
if (menuContent && menuContent.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setMaxHeight();
|
||||
}
|
||||
|
||||
onMenuButtonPress = () => {
|
||||
|
@ -176,45 +172,39 @@ class Menu extends Component {
|
|||
);
|
||||
|
||||
return (
|
||||
<TetherComponent
|
||||
classes={{
|
||||
element: styles.tether
|
||||
}}
|
||||
{...tetherOptions[alignMenu]}
|
||||
renderTarget={
|
||||
(ref) => {
|
||||
this._menuRef = ref;
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div
|
||||
ref={ref}
|
||||
id={this._menuButtonId}
|
||||
className={className}
|
||||
>
|
||||
{button}
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
>
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
renderElement={
|
||||
(ref) => {
|
||||
this._menuContentRef = ref;
|
||||
<Portal>
|
||||
<Popper {...popperOptions[alignMenu]}>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
if (!isMenuOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return React.cloneElement(
|
||||
childrenArray[1],
|
||||
{
|
||||
ref,
|
||||
alignMenu,
|
||||
maxHeight,
|
||||
isOpen: isMenuOpen
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
/>
|
||||
return React.cloneElement(
|
||||
childrenArray[1],
|
||||
{
|
||||
forwardedRef: ref,
|
||||
style: {
|
||||
...style,
|
||||
maxHeight
|
||||
},
|
||||
isOpen: isMenuOpen
|
||||
}
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
.menuContent {
|
||||
z-index: $popperZIndex;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $toolbarMenuItemBackgroundColor;
|
||||
|
|
|
@ -10,30 +10,37 @@ class MenuContent extends Component {
|
|||
|
||||
render() {
|
||||
const {
|
||||
forwardedRef,
|
||||
className,
|
||||
children,
|
||||
maxHeight
|
||||
style,
|
||||
isOpen
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={className}
|
||||
style={{
|
||||
maxHeight: maxHeight ? `${maxHeight}px` : undefined
|
||||
}}
|
||||
style={style}
|
||||
>
|
||||
<Scroller className={styles.scroller}>
|
||||
{children}
|
||||
</Scroller>
|
||||
{
|
||||
isOpen ?
|
||||
<Scroller className={styles.scroller}>
|
||||
{children}
|
||||
</Scroller> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MenuContent.propTypes = {
|
||||
forwardedRef: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node.isRequired,
|
||||
maxHeight: PropTypes.number
|
||||
style: PropTypes.object,
|
||||
isOpen: PropTypes.bool
|
||||
};
|
||||
|
||||
MenuContent.defaultProps = {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
.separator {
|
||||
overflow: hidden;
|
||||
min-height: 1px;
|
||||
height: 1px;
|
||||
background-color: $themeDarkColor;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
.modalContainer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
z-index: $modalZIndex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ class Modal extends Component {
|
|||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._node = document.getElementById('modal-root');
|
||||
this._node = document.getElementById('portal-root');
|
||||
this._backgroundRef = null;
|
||||
this._modalId = getUniqueElememtId();
|
||||
}
|
||||
|
|
|
@ -10,13 +10,13 @@
|
|||
|
||||
.logoContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0 0 $sidebarWidth;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.logoFull {
|
||||
width: 144px;
|
||||
height: 48px;
|
||||
.logoLink {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
|
|
|
@ -45,17 +45,19 @@ class PageHeader extends Component {
|
|||
|
||||
render() {
|
||||
const {
|
||||
onSidebarToggle,
|
||||
isSmallScreen
|
||||
onSidebarToggle
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.logoContainer}>
|
||||
<Link to={`${window.Radarr.urlBase}/`}>
|
||||
<Link
|
||||
className={styles.logoLink}
|
||||
to={`${window.Radarr.urlBase}/`}
|
||||
>
|
||||
<img
|
||||
className={isSmallScreen ? styles.logo : styles.logoFull}
|
||||
src={isSmallScreen ? `${window.Radarr.urlBase}/Content/Images/logo.png` : `${window.Radarr.urlBase}/Content/Images/logo-full.png`}
|
||||
className={styles.logo}
|
||||
src={`${window.Radarr.urlBase}/Content/Images/logo.svg`}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -93,7 +95,6 @@ class PageHeader extends Component {
|
|||
|
||||
PageHeader.propTypes = {
|
||||
onSidebarToggle: PropTypes.func.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
bindShortcut: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
|
|
|
@ -18,4 +18,3 @@
|
|||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -86,7 +86,6 @@ class Page extends Component {
|
|||
|
||||
<PageHeader
|
||||
onSidebarToggle={onSidebarToggle}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
|
||||
<div className={styles.main}>
|
||||
|
|
|
@ -31,4 +31,3 @@
|
|||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
18
frontend/src/Components/Portal.js
Normal file
18
frontend/src/Components/Portal.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
function Portal(props) {
|
||||
const { children, target } = props;
|
||||
return ReactDOM.createPortal(children, target);
|
||||
}
|
||||
|
||||
Portal.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
target: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
Portal.defaultProps = {
|
||||
target: document.getElementById('portal-root')
|
||||
};
|
||||
|
||||
export default Portal;
|
|
@ -47,6 +47,10 @@
|
|||
|
||||
.danger {
|
||||
background-color: $dangerColor;
|
||||
|
||||
&:global(.colorImpaired) {
|
||||
background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.success {
|
||||
|
@ -59,6 +63,10 @@
|
|||
|
||||
.warning {
|
||||
background-color: $warningColor;
|
||||
|
||||
&:global(.colorImpaired) {
|
||||
background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
|
|
|
@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
|
||||
import styles from './ProgressBar.css';
|
||||
|
||||
function ProgressBar(props) {
|
||||
|
@ -23,55 +24,65 @@ function ProgressBar(props) {
|
|||
const actualWidth = width ? `${width}px` : '100%';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
containerClassName,
|
||||
styles[size]
|
||||
)}
|
||||
title={title}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
{
|
||||
showText && !!width &&
|
||||
<ColorImpairedConsumer>
|
||||
{(enableColorImpairedMode) => {
|
||||
return (
|
||||
<div
|
||||
className={styles.backTextContainer}
|
||||
className={classNames(
|
||||
containerClassName,
|
||||
styles[size]
|
||||
)}
|
||||
title={title}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
<div className={styles.backText}>
|
||||
<div>
|
||||
{progressText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
showText && width ?
|
||||
<div
|
||||
className={styles.backTextContainer}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
<div className={styles.backText}>
|
||||
<div>
|
||||
{progressText}
|
||||
</div>
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind]
|
||||
)}
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
style={{ width: progressPercent }}
|
||||
/>
|
||||
{
|
||||
showText &&
|
||||
<div
|
||||
className={styles.frontTextContainer}
|
||||
style={{ width: progressPercent }}
|
||||
>
|
||||
<div
|
||||
className={styles.frontText}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
<div>
|
||||
{progressText}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
enableColorImpairedMode && 'colorImpaired'
|
||||
)}
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
style={{ width: progressPercent }}
|
||||
/>
|
||||
|
||||
{
|
||||
showText ?
|
||||
<div
|
||||
className={styles.frontTextContainer}
|
||||
style={{ width: progressPercent }}
|
||||
>
|
||||
<div
|
||||
className={styles.frontText}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
<div>
|
||||
{progressText}
|
||||
</div>
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</ColorImpairedConsumer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -223,10 +223,6 @@ class SignalRConnector extends Component {
|
|||
this.props.dispatchUpdate({ section: 'queue.status', data: body.resource });
|
||||
}
|
||||
|
||||
handleRootfolder = () => {
|
||||
this.props.dispatchFetchRootFolders();
|
||||
}
|
||||
|
||||
handleVersion = (body) => {
|
||||
const version = body.Version;
|
||||
|
||||
|
@ -237,6 +233,10 @@ class SignalRConnector extends Component {
|
|||
// No-op for now, we may want this later
|
||||
}
|
||||
|
||||
handleRootfolder = () => {
|
||||
this.props.dispatchFetchRootFolders();
|
||||
}
|
||||
|
||||
handleTag = (body) => {
|
||||
if (body.action === 'sync') {
|
||||
this.props.dispatchFetchTags();
|
||||
|
|
|
@ -1,97 +1,3 @@
|
|||
.tether {
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.popoverContainer {
|
||||
margin: 10px 15px;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: relative;
|
||||
background-color: $white;
|
||||
box-shadow: 0 5px 10px $popoverShadowColor;
|
||||
}
|
||||
|
||||
.arrow,
|
||||
.arrow::after {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-width: 11px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.arrow::after {
|
||||
border-width: 10px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.top {
|
||||
bottom: -11px;
|
||||
left: 50%;
|
||||
margin-left: -11px;
|
||||
border-top-color: $popoverArrowBorderColor;
|
||||
border-bottom-width: 0;
|
||||
|
||||
&::after {
|
||||
bottom: 1px;
|
||||
margin-left: -10px;
|
||||
border-top-color: $white;
|
||||
border-bottom-width: 0;
|
||||
content: ' ';
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
top: 50%;
|
||||
left: -11px;
|
||||
margin-top: -11px;
|
||||
border-right-color: $popoverArrowBorderColor;
|
||||
border-left-width: 0;
|
||||
|
||||
&::after {
|
||||
bottom: -10px;
|
||||
left: 1px;
|
||||
border-right-color: $white;
|
||||
border-left-width: 0;
|
||||
content: ' ';
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
top: -11px;
|
||||
left: 50%;
|
||||
margin-left: -11px;
|
||||
border-top-width: 0;
|
||||
border-bottom-color: $popoverArrowBorderColor;
|
||||
|
||||
&::after {
|
||||
top: 1px;
|
||||
margin-left: -10px;
|
||||
border-top-width: 0;
|
||||
border-bottom-color: $white;
|
||||
content: ' ';
|
||||
}
|
||||
}
|
||||
|
||||
.left {
|
||||
top: 50%;
|
||||
right: -11px;
|
||||
margin-top: -11px;
|
||||
border-right-width: 0;
|
||||
border-left-color: $popoverArrowBorderColor;
|
||||
|
||||
&::after {
|
||||
right: 1px;
|
||||
bottom: -10px;
|
||||
border-right-width: 0;
|
||||
border-left-color: $white;
|
||||
content: ' ';
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid $popoverTitleBorderColor;
|
||||
|
@ -103,3 +9,7 @@
|
|||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.tooltipBody {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
@ -1,171 +1,37 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import TetherComponent from 'react-tether';
|
||||
import classNames from 'classnames';
|
||||
import isMobileUtil from 'Utilities/isMobile';
|
||||
import { tooltipPositions } from 'Helpers/Props';
|
||||
import React from 'react';
|
||||
import Tooltip from './Tooltip';
|
||||
import styles from './Popover.css';
|
||||
|
||||
const baseTetherOptions = {
|
||||
skipMoveElement: true,
|
||||
constraints: [
|
||||
{
|
||||
to: 'window',
|
||||
attachment: 'together',
|
||||
pin: true
|
||||
}
|
||||
]
|
||||
};
|
||||
function Popover(props) {
|
||||
const {
|
||||
title,
|
||||
body,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const tetherOptions = {
|
||||
[tooltipPositions.TOP]: {
|
||||
...baseTetherOptions,
|
||||
attachment: 'bottom center',
|
||||
targetAttachment: 'top center'
|
||||
},
|
||||
return (
|
||||
<Tooltip
|
||||
{...otherProps}
|
||||
bodyClassName={styles.tooltipBody}
|
||||
tooltip={
|
||||
<div>
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
[tooltipPositions.RIGHT]: {
|
||||
...baseTetherOptions,
|
||||
attachment: 'middle left',
|
||||
targetAttachment: 'middle right'
|
||||
},
|
||||
|
||||
[tooltipPositions.BOTTOM]: {
|
||||
...baseTetherOptions,
|
||||
attachment: 'top center',
|
||||
targetAttachment: 'bottom center'
|
||||
},
|
||||
|
||||
[tooltipPositions.LEFT]: {
|
||||
...baseTetherOptions,
|
||||
attachment: 'middle right',
|
||||
targetAttachment: 'middle left'
|
||||
}
|
||||
};
|
||||
|
||||
class Popover extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isOpen: false
|
||||
};
|
||||
|
||||
this._closeTimeout = null;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._closeTimeout) {
|
||||
this._closeTimeout = clearTimeout(this._closeTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onClick = () => {
|
||||
if (isMobileUtil()) {
|
||||
this.setState({ isOpen: !this.state.isOpen });
|
||||
}
|
||||
}
|
||||
|
||||
onMouseEnter = () => {
|
||||
if (this._closeTimeout) {
|
||||
this._closeTimeout = clearTimeout(this._closeTimeout);
|
||||
}
|
||||
|
||||
this.setState({ isOpen: true });
|
||||
}
|
||||
|
||||
onMouseLeave = () => {
|
||||
this._closeTimeout = setTimeout(() => {
|
||||
this.setState({ isOpen: false });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
anchor,
|
||||
title,
|
||||
body,
|
||||
position
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TetherComponent
|
||||
classes={{
|
||||
element: styles.tether
|
||||
}}
|
||||
{...tetherOptions[position]}
|
||||
renderTarget={
|
||||
(ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
{anchor}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
renderElement={
|
||||
(ref) => {
|
||||
if (!this.state.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.popoverContainer}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<div className={styles.popover}>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.arrow,
|
||||
styles[position]
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
<div className={styles.body}>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Popover.propTypes = {
|
||||
className: PropTypes.string,
|
||||
anchor: PropTypes.node.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
|
||||
position: PropTypes.oneOf(tooltipPositions.all)
|
||||
};
|
||||
|
||||
Popover.defaultProps = {
|
||||
position: tooltipPositions.TOP
|
||||
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired
|
||||
};
|
||||
|
||||
export default Popover;
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
.tether {
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.tooltipContainer {
|
||||
z-index: $popperZIndex;
|
||||
margin: 10px 15px;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,48 +1,12 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import TetherComponent from 'react-tether';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import classNames from 'classnames';
|
||||
import isMobileUtil from 'Utilities/isMobile';
|
||||
import { kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import Portal from 'Components/Portal';
|
||||
import styles from './Tooltip.css';
|
||||
|
||||
const baseTetherOptions = {
|
||||
skipMoveElement: true,
|
||||
constraints: [
|
||||
{
|
||||
to: 'window',
|
||||
attachment: 'together',
|
||||
pin: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const tetherOptions = {
|
||||
[tooltipPositions.TOP]: {
|
||||
...baseTetherOptions,
|
||||
attachment: 'bottom center',
|
||||
targetAttachment: 'top center'
|
||||
},
|
||||
|
||||
[tooltipPositions.RIGHT]: {
|
||||
...baseTetherOptions,
|
||||
attachment: 'middle left',
|
||||
targetAttachment: 'middle right'
|
||||
},
|
||||
|
||||
[tooltipPositions.BOTTOM]: {
|
||||
...baseTetherOptions,
|
||||
attachment: 'top center',
|
||||
targetAttachment: 'bottom center'
|
||||
},
|
||||
|
||||
[tooltipPositions.LEFT]: {
|
||||
...baseTetherOptions,
|
||||
attachment: 'middle right',
|
||||
targetAttachment: 'middle left'
|
||||
}
|
||||
};
|
||||
|
||||
class Tooltip extends Component {
|
||||
|
||||
//
|
||||
|
@ -51,11 +15,18 @@ class Tooltip extends Component {
|
|||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._scheduleUpdate = null;
|
||||
this._closeTimeout = null;
|
||||
|
||||
this.state = {
|
||||
isOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
this._closeTimeout = null;
|
||||
componentDidUpdate() {
|
||||
if (this._scheduleUpdate && this.state.isOpen) {
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -64,9 +35,40 @@ class Tooltip extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
computeMaxSize = (data) => {
|
||||
const {
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left
|
||||
} = data.offsets.reference;
|
||||
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if ((/^top/).test(data.placement)) {
|
||||
data.styles.maxHeight = top - 20;
|
||||
} else if ((/^bottom/).test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom - 20;
|
||||
} else if ((/^right/).test(data.placement)) {
|
||||
data.styles.maxWidth = windowWidth - right - 30;
|
||||
} else {
|
||||
data.styles.maxWidth = left - 30;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMeasure = ({ width }) => {
|
||||
this.setState({ width });
|
||||
}
|
||||
|
||||
onClick = () => {
|
||||
if (isMobileUtil()) {
|
||||
this.setState({ isOpen: !this.state.isOpen });
|
||||
|
@ -93,20 +95,18 @@ class Tooltip extends Component {
|
|||
render() {
|
||||
const {
|
||||
className,
|
||||
bodyClassName,
|
||||
anchor,
|
||||
tooltip,
|
||||
kind,
|
||||
position
|
||||
position,
|
||||
canFlip
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TetherComponent
|
||||
classes={{
|
||||
element: styles.tether
|
||||
}}
|
||||
{...tetherOptions[position]}
|
||||
renderTarget={
|
||||
(ref) => (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
|
@ -116,59 +116,91 @@ class Tooltip extends Component {
|
|||
>
|
||||
{anchor}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
renderElement={
|
||||
(ref) => {
|
||||
if (!this.state.isOpen) {
|
||||
return;
|
||||
}
|
||||
)}
|
||||
</Reference>
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.tooltipContainer}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<Portal>
|
||||
<Popper
|
||||
placement={position}
|
||||
// Disable events to improve performance when many tooltips
|
||||
// are shown (Quality Definitions for example).
|
||||
eventsEnabled={false}
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: this.computeMaxSize
|
||||
},
|
||||
preventOverflow: {
|
||||
// Fixes positioning for tooltips in the queue
|
||||
// and likely others.
|
||||
escapeWithReference: true
|
||||
},
|
||||
flip: {
|
||||
enabled: canFlip
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ ref, style, placement, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.tooltip,
|
||||
styles[kind]
|
||||
)}
|
||||
ref={ref}
|
||||
className={styles.tooltipContainer}
|
||||
style={style}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.arrow,
|
||||
styles[kind],
|
||||
styles[position]
|
||||
)}
|
||||
/>
|
||||
{
|
||||
this.state.isOpen ?
|
||||
<div
|
||||
className={classNames(
|
||||
styles.tooltip,
|
||||
styles[kind]
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.arrow,
|
||||
styles[kind],
|
||||
styles[placement.split('-')[0]]
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className={styles.body}>
|
||||
{tooltip}
|
||||
</div>
|
||||
<div
|
||||
className={bodyClassName}
|
||||
>
|
||||
{tooltip}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Tooltip.propTypes = {
|
||||
className: PropTypes.string,
|
||||
bodyClassName: PropTypes.string.isRequired,
|
||||
anchor: PropTypes.node.isRequired,
|
||||
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
|
||||
kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),
|
||||
position: PropTypes.oneOf(tooltipPositions.all)
|
||||
position: PropTypes.oneOf(tooltipPositions.all),
|
||||
canFlip: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
Tooltip.defaultProps = {
|
||||
bodyClassName: styles.body,
|
||||
kind: kinds.DEFAULT,
|
||||
position: tooltipPositions.TOP
|
||||
position: tooltipPositions.TOP,
|
||||
canFlip: true
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
|
||||
.added,
|
||||
.inCinemas,
|
||||
.physicalRelease,
|
||||
.genres {
|
||||
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
|
||||
|
||||
|
|
|
@ -38,7 +38,8 @@ function EditIndexerModalContent(props) {
|
|||
implementationName,
|
||||
name,
|
||||
enableRss,
|
||||
enableSearch,
|
||||
enableAutomaticSearch,
|
||||
enableInteractiveSearch,
|
||||
supportsRss,
|
||||
supportsSearch,
|
||||
fields
|
||||
|
@ -63,9 +64,7 @@ function EditIndexerModalContent(props) {
|
|||
|
||||
{
|
||||
!isFetching && !error &&
|
||||
<Form
|
||||
{...otherProps}
|
||||
>
|
||||
<Form {...otherProps}>
|
||||
<FormGroup>
|
||||
<FormLabel>Name</FormLabel>
|
||||
|
||||
|
@ -91,15 +90,29 @@ function EditIndexerModalContent(props) {
|
|||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Enable Search</FormLabel>
|
||||
<FormLabel>Enable Automatic Search</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enableSearch"
|
||||
name="enableAutomaticSearch"
|
||||
helpText={supportsSearch.value ? 'Will be used when automatic searches are performed via the UI or by Radarr' : undefined}
|
||||
helpTextWarning={supportsSearch.value ? undefined : 'Search is not supported with this indexer'}
|
||||
isDisabled={!supportsSearch.value}
|
||||
{...enableSearch}
|
||||
{...enableAutomaticSearch}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Enable Interactive Search</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enableInteractiveSearch"
|
||||
helpText={supportsSearch.value ? 'Will be used when interactive search is used' : undefined}
|
||||
helpTextWarning={supportsSearch.value ? undefined : 'Search is not supported with this indexer'}
|
||||
isDisabled={!supportsSearch.value}
|
||||
{...enableInteractiveSearch}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
|
|
@ -55,7 +55,8 @@ class Indexer extends Component {
|
|||
id,
|
||||
name,
|
||||
enableRss,
|
||||
enableSearch,
|
||||
enableAutomaticSearch,
|
||||
enableInteractiveSearch,
|
||||
supportsRss,
|
||||
supportsSearch
|
||||
} = this.props;
|
||||
|
@ -80,14 +81,21 @@ class Indexer extends Component {
|
|||
}
|
||||
|
||||
{
|
||||
supportsSearch && enableSearch &&
|
||||
supportsSearch && enableAutomaticSearch &&
|
||||
<Label kind={kinds.SUCCESS}>
|
||||
Search
|
||||
Automatic Search
|
||||
</Label>
|
||||
}
|
||||
|
||||
{
|
||||
!enableRss && !enableSearch &&
|
||||
supportsSearch && enableInteractiveSearch &&
|
||||
<Label kind={kinds.SUCCESS}>
|
||||
Interactive Search
|
||||
</Label>
|
||||
}
|
||||
|
||||
{
|
||||
!enableRss && !enableAutomaticSearch && !enableInteractiveSearch &&
|
||||
<Label
|
||||
kind={kinds.DISABLED}
|
||||
outline={true}
|
||||
|
@ -122,7 +130,8 @@ Indexer.propTypes = {
|
|||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
enableRss: PropTypes.bool.isRequired,
|
||||
enableSearch: PropTypes.bool.isRequired,
|
||||
enableAutomaticSearch: PropTypes.bool.isRequired,
|
||||
enableInteractiveSearch: PropTypes.bool.isRequired,
|
||||
supportsRss: PropTypes.bool.isRequired,
|
||||
supportsSearch: PropTypes.bool.isRequired,
|
||||
onConfirmDeleteIndexer: PropTypes.func.isRequired
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
|
||||
function QualityDefinitionLimits(props) {
|
||||
const {
|
||||
bytes,
|
||||
message
|
||||
} = props;
|
||||
|
||||
if (!bytes) {
|
||||
return <div>{message}</div>;
|
||||
}
|
||||
|
||||
const thirty = formatBytes(bytes * 30);
|
||||
const fourtyFive = formatBytes(bytes * 45);
|
||||
const sixty = formatBytes(bytes * 60);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>30 Minutes: {thirty}</div>
|
||||
<div>45 Minutes: {fourtyFive}</div>
|
||||
<div>60 Minutes: {sixty}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
QualityDefinitionLimits.propTypes = {
|
||||
bytes: PropTypes.number,
|
||||
message: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default QualityDefinitionLimits;
|
|
@ -98,6 +98,12 @@ export const defaultState = {
|
|||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'outputPath',
|
||||
label: 'Output Path',
|
||||
isSortable: false,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'estimatedCompletionTime',
|
||||
label: 'Timeleft',
|
||||
|
|
|
@ -6,12 +6,12 @@ function createProfileInUseSelector(profileProp) {
|
|||
return createSelector(
|
||||
(state, { id }) => id,
|
||||
createAllMoviesSelector(),
|
||||
(id, series) => {
|
||||
(id, movies) => {
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.some(series, { [profileProp]: id });
|
||||
return _.some(movies, { [profileProp]: id });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -162,7 +162,7 @@ module.exports = {
|
|||
popoverTitleBackgroundColor: '#f7f7f7',
|
||||
popoverTitleBorderColor: '#ebebeb',
|
||||
popoverShadowColor: 'rgba(0, 0, 0, 0.2)',
|
||||
popoverArrowBorderColor: 'rgba(0, 0, 0, 0.25)',
|
||||
popoverArrowBorderColor: '#fff',
|
||||
|
||||
popoverTitleBackgroundInverseColor: '#3a3f51',
|
||||
popoverTitleBorderInverseColor: '#4f566f',
|
||||
|
|
4
frontend/src/Styles/Variables/zIndexes.js
Normal file
4
frontend/src/Styles/Variables/zIndexes.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
modalZIndex: 1000,
|
||||
popperZIndex: 2000
|
||||
};
|
|
@ -48,7 +48,7 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<div id="modal-root"></div>
|
||||
<div id="portal-root"></div>
|
||||
<div id="root" class="root"></div>
|
||||
</body>
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
"gulp-stripbom": "1.0.4",
|
||||
"gulp-watch": "5.0.1",
|
||||
"gulp-wrap": "0.15.0",
|
||||
"history": "4.9.0",
|
||||
"history": "4.7.2",
|
||||
"jdu": "1.0.0",
|
||||
"jquery": "3.4.0",
|
||||
"loader-utils": "^1.1.0",
|
||||
|
@ -96,11 +96,11 @@
|
|||
"react-google-recaptcha": "1.0.5",
|
||||
"react-lazyload": "2.5.0",
|
||||
"react-measure": "1.4.7",
|
||||
"react-popper": "1.3.3",
|
||||
"react-redux": "6.0.1",
|
||||
"react-router-dom": "4.3.1",
|
||||
"react-slider": "0.11.2",
|
||||
"react-tabs": "3.0.0",
|
||||
"react-tether": "2.0.1",
|
||||
"react-text-truncate": "0.14.1",
|
||||
"react-virtualized": "9.21.0",
|
||||
"redux": "4.0.1",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Indexers;
|
||||
|
||||
namespace NzbDrone.Api.Indexers
|
||||
{
|
||||
|
@ -14,7 +14,7 @@ namespace NzbDrone.Api.Indexers
|
|||
base.MapToResource(resource, definition);
|
||||
|
||||
resource.EnableRss = definition.EnableRss;
|
||||
resource.EnableSearch = definition.EnableSearch;
|
||||
resource.EnableSearch = definition.EnableAutomaticSearch || definition.EnableInteractiveSearch;
|
||||
resource.SupportsRss = definition.SupportsRss;
|
||||
resource.SupportsSearch = definition.SupportsSearch;
|
||||
resource.Protocol = definition.Protocol;
|
||||
|
@ -25,7 +25,8 @@ namespace NzbDrone.Api.Indexers
|
|||
base.MapToModel(definition, resource);
|
||||
|
||||
definition.EnableRss = resource.EnableRss;
|
||||
definition.EnableSearch = resource.EnableSearch;
|
||||
definition.EnableAutomaticSearch = resource.EnableSearch;
|
||||
definition.EnableInteractiveSearch = resource.EnableSearch;
|
||||
}
|
||||
|
||||
protected override void Validate(IndexerDefinition definition, bool includeWarnings)
|
||||
|
@ -34,4 +35,4 @@ namespace NzbDrone.Api.Indexers
|
|||
base.Validate(definition, includeWarnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,7 +63,7 @@ namespace NzbDrone.Api.Indexers
|
|||
}
|
||||
try
|
||||
{
|
||||
_downloadService.DownloadReport(remoteMovie, false);
|
||||
_downloadService.DownloadReport(remoteMovie);
|
||||
}
|
||||
catch (ReleaseDownloadException ex)
|
||||
{
|
||||
|
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Radarr.Http.REST;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Parser;
|
||||
using NzbDrone.Core.Qualities;
|
||||
using NzbDrone.Core.Indexers;
|
||||
|
|
|
@ -8,6 +8,7 @@ using NzbDrone.Core.Qualities;
|
|||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.Movies.AlternativeTitles;
|
||||
using NzbDrone.Core.Parser;
|
||||
using NzbDrone.Core.Languages;
|
||||
|
||||
namespace NzbDrone.Api.Movies
|
||||
{
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Parser;
|
||||
using Radarr.Http;
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ using System.Linq;
|
|||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Api.Qualities;
|
||||
using Radarr.Http.REST;
|
||||
using NzbDrone.Core.Parser;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Profiles;
|
||||
using NzbDrone.Core.Qualities;
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NzbDrone.Core.CustomFormats;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Parser;
|
||||
using NzbDrone.Core.Profiles;
|
||||
using NzbDrone.Core.Qualities;
|
||||
|
|
|
@ -106,7 +106,7 @@ namespace NzbDrone.Api.Queue
|
|||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
_downloadService.DownloadReport(pendingRelease.RemoteMovie, false);
|
||||
_downloadService.DownloadReport(pendingRelease.RemoteMovie);
|
||||
|
||||
return resource.AsResponse();
|
||||
}
|
||||
|
|
|
@ -19,7 +19,8 @@ namespace NzbDrone.Api.RootFolders
|
|||
MappedNetworkDriveValidator mappedNetworkDriveValidator,
|
||||
StartupFolderValidator startupFolderValidator,
|
||||
SystemFolderValidator systemFolderValidator,
|
||||
FolderWritableValidator folderWritableValidator)
|
||||
FolderWritableValidator folderWritableValidator
|
||||
)
|
||||
: base(signalRBroadcaster)
|
||||
{
|
||||
_rootFolderService = rootFolderService;
|
||||
|
@ -54,7 +55,7 @@ namespace NzbDrone.Api.RootFolders
|
|||
|
||||
private List<RootFolderResource> GetRootFolders()
|
||||
{
|
||||
return _rootFolderService.AllWithSpace().ToResource();
|
||||
return _rootFolderService.AllWithUnmappedFolders().ToResource();
|
||||
}
|
||||
|
||||
private void DeleteFolder(int id)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.ServiceProcess;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
@ -36,7 +36,7 @@ namespace NzbDrone.Common.Test
|
|||
{
|
||||
if (Subject.ServiceExist(TEMP_SERVICE_NAME))
|
||||
{
|
||||
Subject.UnInstall(TEMP_SERVICE_NAME);
|
||||
Subject.Uninstall(TEMP_SERVICE_NAME);
|
||||
}
|
||||
|
||||
if (Subject.IsServiceRunning(ALWAYS_INSTALLED_SERVICE))
|
||||
|
@ -65,7 +65,7 @@ namespace NzbDrone.Common.Test
|
|||
Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse("Service already installed");
|
||||
Subject.Install(TEMP_SERVICE_NAME);
|
||||
Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeTrue();
|
||||
Subject.UnInstall(TEMP_SERVICE_NAME);
|
||||
Subject.Uninstall(TEMP_SERVICE_NAME);
|
||||
Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse();
|
||||
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
|
@ -76,7 +76,7 @@ namespace NzbDrone.Common.Test
|
|||
[ManualTest]
|
||||
public void UnInstallService()
|
||||
{
|
||||
Subject.UnInstall(ServiceProvider.SERVICE_NAME);
|
||||
Subject.Uninstall(ServiceProvider.SERVICE_NAME);
|
||||
Subject.ServiceExist(ServiceProvider.SERVICE_NAME).Should().BeFalse();
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@ using ICSharpCode.SharpZipLib.GZip;
|
|||
using ICSharpCode.SharpZipLib.Tar;
|
||||
using ICSharpCode.SharpZipLib.Zip;
|
||||
using NLog;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
|
||||
namespace NzbDrone.Common
|
||||
{
|
||||
|
@ -32,7 +31,6 @@ namespace NzbDrone.Common
|
|||
{
|
||||
ExtractZip(compressedFile, destination);
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
ExtractTgz(compressedFile, destination);
|
||||
|
|
|
@ -96,4 +96,4 @@ namespace NzbDrone.Common.Composition
|
|||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,7 +6,6 @@ using NzbDrone.Common.EnvironmentInfo;
|
|||
using NzbDrone.Common.Messaging;
|
||||
using TinyIoC;
|
||||
|
||||
|
||||
namespace NzbDrone.Common.Composition
|
||||
{
|
||||
public abstract class ContainerBuilderBase
|
||||
|
|
|
@ -21,9 +21,17 @@ namespace NzbDrone.Common
|
|||
Console.WriteLine();
|
||||
Console.WriteLine(" Usage: {0} <command> ", Process.GetCurrentProcess().MainModule.ModuleName);
|
||||
Console.WriteLine(" Commands:");
|
||||
Console.WriteLine(" /{0} Install the application as a Windows Service ({1}).", StartupContext.INSTALL_SERVICE, ServiceProvider.SERVICE_NAME);
|
||||
Console.WriteLine(" /{0} Uninstall already installed Windows Service ({1}).", StartupContext.UNINSTALL_SERVICE, ServiceProvider.SERVICE_NAME);
|
||||
|
||||
if (OsInfo.IsWindows)
|
||||
{
|
||||
Console.WriteLine(" /{0} Install the application as a Windows Service ({1}).", StartupContext.INSTALL_SERVICE, ServiceProvider.SERVICE_NAME);
|
||||
Console.WriteLine(" /{0} Uninstall already installed Windows Service ({1}).", StartupContext.UNINSTALL_SERVICE, ServiceProvider.SERVICE_NAME);
|
||||
Console.WriteLine(" /{0} Register URL and open firewall port (allows access from other devices on your network).", StartupContext.REGISTER_URL);
|
||||
}
|
||||
|
||||
Console.WriteLine(" /{0} Don't open Radarr in a browser", StartupContext.NO_BROWSER);
|
||||
Console.WriteLine(" /{0} Start Radarr terminating any other instances", StartupContext.TERMINATE);
|
||||
Console.WriteLine(" /{0}=path Path to use as the AppData location (stores database, config, logs, etc)", StartupContext.APPDATA);
|
||||
Console.WriteLine(" <No Arguments> Run application in console mode.");
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace NzbDrone.Common.Disk
|
||||
{
|
||||
public class DestinationAlreadyExistsException : IOException
|
||||
{
|
||||
public DestinationAlreadyExistsException()
|
||||
{
|
||||
}
|
||||
|
||||
public DestinationAlreadyExistsException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DestinationAlreadyExistsException(string message, int hresult) : base(message, hresult)
|
||||
{
|
||||
}
|
||||
|
||||
public DestinationAlreadyExistsException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
protected DestinationAlreadyExistsException(SerializationInfo info, StreamingContext context) : base(info, context)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
|
@ -109,12 +109,12 @@ namespace NzbDrone.Common.Disk
|
|||
|
||||
switch (stringComparison)
|
||||
{
|
||||
case StringComparison.CurrentCulture:
|
||||
case StringComparison.InvariantCulture:
|
||||
case StringComparison.Ordinal:
|
||||
{
|
||||
return File.Exists(path) && path == path.GetActualCasing();
|
||||
}
|
||||
case StringComparison.CurrentCulture:
|
||||
case StringComparison.InvariantCulture:
|
||||
case StringComparison.Ordinal:
|
||||
{
|
||||
return File.Exists(path) && path == path.GetActualCasing();
|
||||
}
|
||||
default:
|
||||
{
|
||||
return File.Exists(path);
|
||||
|
|
|
@ -5,6 +5,7 @@ using System.Threading;
|
|||
using NLog;
|
||||
using NzbDrone.Common.EnsureThat;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Exceptions;
|
||||
using NzbDrone.Common.Extensions;
|
||||
|
||||
namespace NzbDrone.Common.Disk
|
||||
|
@ -55,6 +56,23 @@ namespace NzbDrone.Common.Disk
|
|||
Ensure.That(sourcePath, () => sourcePath).IsValidPath();
|
||||
Ensure.That(targetPath, () => targetPath).IsValidPath();
|
||||
|
||||
if (mode == TransferMode.Move && !_diskProvider.FolderExists(targetPath))
|
||||
{
|
||||
if (verificationMode == DiskTransferVerificationMode.TryTransactional || verificationMode == DiskTransferVerificationMode.VerifyOnly)
|
||||
{
|
||||
var sourceMount = _diskProvider.GetMount(sourcePath);
|
||||
var targetMount = _diskProvider.GetMount(targetPath);
|
||||
|
||||
// If we're on the same mount, do a simple folder move.
|
||||
if (sourceMount != null && targetMount != null && sourceMount.RootDirectory == targetMount.RootDirectory)
|
||||
{
|
||||
_logger.Debug("Move Directory [{0}] > [{1}]", sourcePath, targetPath);
|
||||
_diskProvider.MoveFolder(sourcePath, targetPath);
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!_diskProvider.FolderExists(targetPath))
|
||||
{
|
||||
_diskProvider.CreateFolder(targetPath);
|
||||
|
@ -64,11 +82,15 @@ namespace NzbDrone.Common.Disk
|
|||
|
||||
foreach (var subDir in _diskProvider.GetDirectoryInfos(sourcePath))
|
||||
{
|
||||
if (ShouldIgnore(subDir)) continue;
|
||||
|
||||
result &= TransferFolder(subDir.FullName, Path.Combine(targetPath, subDir.Name), mode, verificationMode);
|
||||
}
|
||||
|
||||
foreach (var sourceFile in _diskProvider.GetFileInfos(sourcePath))
|
||||
{
|
||||
if (ShouldIgnore(sourceFile)) continue;
|
||||
|
||||
var destFile = Path.Combine(targetPath, sourceFile.Name);
|
||||
|
||||
result &= TransferFile(sourceFile.FullName, destFile, mode, true, verificationMode);
|
||||
|
@ -101,11 +123,15 @@ namespace NzbDrone.Common.Disk
|
|||
|
||||
foreach (var subDir in targetFolders.Where(v => !sourceFolders.Any(d => d.Name == v.Name)))
|
||||
{
|
||||
if (ShouldIgnore(subDir)) continue;
|
||||
|
||||
_diskProvider.DeleteFolder(subDir.FullName, true);
|
||||
}
|
||||
|
||||
foreach (var subDir in sourceFolders)
|
||||
{
|
||||
if (ShouldIgnore(subDir)) continue;
|
||||
|
||||
filesCopied += MirrorFolder(subDir.FullName, Path.Combine(targetPath, subDir.Name));
|
||||
}
|
||||
|
||||
|
@ -114,11 +140,15 @@ namespace NzbDrone.Common.Disk
|
|||
|
||||
foreach (var targetFile in targetFiles.Where(v => !sourceFiles.Any(d => d.Name == v.Name)))
|
||||
{
|
||||
if (ShouldIgnore(targetFile)) continue;
|
||||
|
||||
_diskProvider.DeleteFile(targetFile.FullName);
|
||||
}
|
||||
|
||||
foreach (var sourceFile in sourceFiles)
|
||||
{
|
||||
if (ShouldIgnore(sourceFile)) continue;
|
||||
|
||||
var targetFile = Path.Combine(targetPath, sourceFile.Name);
|
||||
|
||||
if (CompareFiles(sourceFile.FullName, targetFile))
|
||||
|
@ -211,7 +241,7 @@ namespace NzbDrone.Common.Disk
|
|||
_diskProvider.MoveFile(sourcePath, tempPath, true);
|
||||
try
|
||||
{
|
||||
ClearTargetPath(targetPath, overwrite);
|
||||
ClearTargetPath(sourcePath, targetPath, overwrite);
|
||||
|
||||
_diskProvider.MoveFile(tempPath, targetPath);
|
||||
|
||||
|
@ -241,7 +271,7 @@ namespace NzbDrone.Common.Disk
|
|||
throw new IOException(string.Format("Destination cannot be a child of the source [{0}] => [{1}]", sourcePath, targetPath));
|
||||
}
|
||||
|
||||
ClearTargetPath(targetPath, overwrite);
|
||||
ClearTargetPath(sourcePath, targetPath, overwrite);
|
||||
|
||||
if (mode.HasFlag(TransferMode.HardLink))
|
||||
{
|
||||
|
@ -318,7 +348,7 @@ namespace NzbDrone.Common.Disk
|
|||
return TransferMode.None;
|
||||
}
|
||||
|
||||
private void ClearTargetPath(string targetPath, bool overwrite)
|
||||
private void ClearTargetPath(string sourcePath, string targetPath, bool overwrite)
|
||||
{
|
||||
if (_diskProvider.FileExists(targetPath))
|
||||
{
|
||||
|
@ -328,7 +358,7 @@ namespace NzbDrone.Common.Disk
|
|||
}
|
||||
else
|
||||
{
|
||||
throw new IOException(string.Format("Destination already exists [{0}]", targetPath));
|
||||
throw new DestinationAlreadyExistsException($"Destination {targetPath} already exists.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -352,7 +382,7 @@ namespace NzbDrone.Common.Disk
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, string.Format("Failed to properly rollback the file move [{0}] to [{1}], incomplete file may be left in target path.", sourcePath, targetPath));
|
||||
_logger.Error(ex, "Failed to properly rollback the file move [{0}] to [{1}], incomplete file may be left in target path.", sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -368,7 +398,7 @@ namespace NzbDrone.Common.Disk
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, string.Format("Failed to properly rollback the file move [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath));
|
||||
_logger.Error(ex, "Failed to properly rollback the file move [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -387,7 +417,7 @@ namespace NzbDrone.Common.Disk
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, string.Format("Failed to properly rollback the file copy [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath));
|
||||
_logger.Error(ex, "Failed to properly rollback the file copy [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -429,7 +459,7 @@ namespace NzbDrone.Common.Disk
|
|||
|
||||
if (i == RetryCount)
|
||||
{
|
||||
_logger.Error("Failed to completely transfer [{0}] to [{1}], aborting.", sourcePath, targetPath, i + 1, RetryCount);
|
||||
_logger.Error("Failed to completely transfer [{0}] to [{1}], aborting.", sourcePath, targetPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -564,5 +594,27 @@ namespace NzbDrone.Common.Disk
|
|||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldIgnore(DirectoryInfo folder)
|
||||
{
|
||||
if (folder.Name.StartsWith(".nfs"))
|
||||
{
|
||||
_logger.Trace("Ignoring folder {0}", folder.FullName);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool ShouldIgnore(FileInfo file)
|
||||
{
|
||||
if (file.Name.StartsWith(".nfs") || file.Name == "debug.log" || file.Name.EndsWith(".socket"))
|
||||
{
|
||||
_logger.Trace("Ignoring file {0}", file.FullName);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
|
||||
|
@ -98,15 +97,16 @@ namespace NzbDrone.Common.Disk
|
|||
{
|
||||
return d.DriveType != DriveType.Network;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.Select(d => new FileSystemModel
|
||||
{
|
||||
Type = FileSystemEntityType.Drive,
|
||||
Name = GetVolumeName(d),
|
||||
Path = d.RootDirectory,
|
||||
LastModified = null
|
||||
})
|
||||
{
|
||||
Type = FileSystemEntityType.Drive,
|
||||
Name = GetVolumeName(d),
|
||||
Path = d.RootDirectory,
|
||||
LastModified = null
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
@ -118,6 +118,7 @@ namespace NzbDrone.Common.Disk
|
|||
{
|
||||
result.Parent = GetParent(path);
|
||||
result.Directories = GetDirectories(path);
|
||||
|
||||
if (includeFiles)
|
||||
{
|
||||
result.Files = GetFiles(path);
|
||||
|
@ -149,12 +150,12 @@ namespace NzbDrone.Common.Disk
|
|||
var directories = _diskProvider.GetDirectoryInfos(path)
|
||||
.OrderBy(d => d.Name)
|
||||
.Select(d => new FileSystemModel
|
||||
{
|
||||
Name = d.Name,
|
||||
Path = GetDirectoryPath(d.FullName.GetActualCasing()),
|
||||
LastModified = d.LastWriteTimeUtc,
|
||||
Type = FileSystemEntityType.Folder
|
||||
})
|
||||
{
|
||||
Name = d.Name,
|
||||
Path = GetDirectoryPath(d.FullName.GetActualCasing()),
|
||||
LastModified = d.LastWriteTimeUtc,
|
||||
Type = FileSystemEntityType.Folder
|
||||
})
|
||||
.ToList();
|
||||
|
||||
directories.RemoveAll(d => _setToRemove.Contains(d.Name.ToLowerInvariant()));
|
||||
|
@ -167,14 +168,14 @@ namespace NzbDrone.Common.Disk
|
|||
return _diskProvider.GetFileInfos(path)
|
||||
.OrderBy(d => d.Name)
|
||||
.Select(d => new FileSystemModel
|
||||
{
|
||||
Name = d.Name,
|
||||
Path = d.FullName.GetActualCasing(),
|
||||
LastModified = d.LastWriteTimeUtc,
|
||||
Extension = d.Extension,
|
||||
Size = d.Length,
|
||||
Type = FileSystemEntityType.File
|
||||
})
|
||||
{
|
||||
Name = d.Name,
|
||||
Path = d.FullName.GetActualCasing(),
|
||||
LastModified = d.LastWriteTimeUtc,
|
||||
Extension = d.Extension,
|
||||
Size = d.Length,
|
||||
Type = FileSystemEntityType.File
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
@ -184,6 +185,7 @@ namespace NzbDrone.Common.Disk
|
|||
{
|
||||
return mountInfo.Name;
|
||||
}
|
||||
|
||||
return $"{mountInfo.Name} ({mountInfo.VolumeLabel})";
|
||||
}
|
||||
|
||||
|
|
15
src/NzbDrone.Common/Disk/LongPathSupport.cs
Normal file
15
src/NzbDrone.Common/Disk/LongPathSupport.cs
Normal file
|
@ -0,0 +1,15 @@
|
|||
using System;
|
||||
|
||||
namespace NzbDrone.Common.Disk
|
||||
{
|
||||
public static class LongPathSupport
|
||||
{
|
||||
public static void Enable()
|
||||
{
|
||||
// Mono has an issue with enabling long path support via app.config.
|
||||
// This works for both mono and .net on Windows.
|
||||
AppContext.SetSwitch("Switch.System.IO.UseLegacyPathHandling", false);
|
||||
AppContext.SetSwitch("Switch.System.IO.BlockLongPaths", false);
|
||||
}
|
||||
}
|
||||
}
|
15
src/NzbDrone.Common/Disk/NotParentException.cs
Normal file
15
src/NzbDrone.Common/Disk/NotParentException.cs
Normal file
|
@ -0,0 +1,15 @@
|
|||
using NzbDrone.Common.Exceptions;
|
||||
|
||||
namespace NzbDrone.Common.Disk
|
||||
{
|
||||
public class NotParentException : NzbDroneException
|
||||
{
|
||||
public NotParentException(string message, params object[] args) : base(message, args)
|
||||
{
|
||||
}
|
||||
|
||||
public NotParentException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
|
@ -105,7 +105,7 @@ namespace NzbDrone.Common.EnsureThat
|
|||
{
|
||||
throw ExceptionFactory.CreateForParamValidation(param.Name, string.Format("value [{0}] is not a valid Windows path. paths must be a full path eg. C:\\Windows", param.Value));
|
||||
}
|
||||
|
||||
|
||||
throw ExceptionFactory.CreateForParamValidation(param.Name, string.Format("value [{0}] is not a valid *nix path. paths must start with /", param.Value));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,9 +25,9 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
private readonly Logger _logger;
|
||||
|
||||
public AppFolderFactory(IAppFolderInfo appFolderInfo,
|
||||
IStartupContext startupContext,
|
||||
IDiskProvider diskProvider,
|
||||
IDiskTransferService diskTransferService)
|
||||
IStartupContext startupContext,
|
||||
IDiskProvider diskProvider,
|
||||
IDiskTransferService diskTransferService)
|
||||
{
|
||||
_appFolderInfo = appFolderInfo;
|
||||
_startupContext = startupContext;
|
||||
|
@ -43,9 +43,9 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
MigrateAppDataFolder();
|
||||
_diskProvider.EnsureFolder(_appFolderInfo.AppDataFolder);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
throw new RadarrStartupException(ex, "Cannot create AppFolder, Access to the path {0} is denied", _appFolderInfo.AppDataFolder);
|
||||
throw new RadarrStartupException("Cannot create AppFolder, Access to the path {0} is denied", _appFolderInfo.AppDataFolder);
|
||||
}
|
||||
|
||||
|
||||
|
@ -112,6 +112,7 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private void InitializeMonoApplicationData()
|
||||
{
|
||||
if (OsInfo.IsWindows) return;
|
||||
|
@ -149,6 +150,7 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
.ToList()
|
||||
.ForEach(_diskProvider.DeleteFile);
|
||||
}
|
||||
|
||||
private void RemovePidFile()
|
||||
{
|
||||
if (OsInfo.IsNotWindows)
|
||||
|
|
|
@ -17,7 +17,6 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
{
|
||||
private readonly Environment.SpecialFolder DATA_SPECIAL_FOLDER = Environment.SpecialFolder.CommonApplicationData;
|
||||
|
||||
|
||||
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(AppFolderInfo));
|
||||
|
||||
public AppFolderInfo(IStartupContext startupContext)
|
||||
|
|
|
@ -26,6 +26,7 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
|
||||
Release = $"{Version}-{Branch}";
|
||||
}
|
||||
|
||||
public static Version Version { get; }
|
||||
public static String Branch { get; }
|
||||
public static string Release { get; }
|
||||
|
@ -51,4 +52,4 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,4 +6,4 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
string Name { get; }
|
||||
string FullName { get; }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,4 +6,4 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
bool Enabled { get; }
|
||||
OsVersionModel Read();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -82,6 +82,9 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
Name = Os.ToString();
|
||||
FullName = Name;
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable("OS_NAME", Name);
|
||||
Environment.SetEnvironmentVariable("OS_VERSION", Version);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -98,4 +101,4 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
Linux,
|
||||
Osx
|
||||
}
|
||||
}
|
||||
}
|
|
@ -26,4 +26,4 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
public string FullName { get; }
|
||||
public string Version { get; }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -29,7 +29,7 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
if (entry != null)
|
||||
{
|
||||
ExecutingApplication = entry.Location;
|
||||
IsWindowsTray = entry.ManifestModule.Name == $"{ProcessProvider.RADARR_PROCESS_NAME}.exe";
|
||||
IsWindowsTray = OsInfo.IsWindows && entry.ManifestModule.Name == $"{ProcessProvider.RADARR_PROCESS_NAME}.exe";
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -129,8 +129,8 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
|
||||
try
|
||||
{
|
||||
var currentAssmeblyLocation = typeof(RuntimeInfo).Assembly.Location;
|
||||
if (currentAssmeblyLocation.ToLower().Contains("_output")) return false;
|
||||
var currentAssemblyLocation = typeof(RuntimeInfo).Assembly.Location;
|
||||
if (currentAssemblyLocation.ToLower().Contains("_output")) return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
@ -139,6 +139,7 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
|
||||
var lowerCurrentDir = Directory.GetCurrentDirectory().ToLower();
|
||||
if (lowerCurrentDir.Contains("teamcity")) return false;
|
||||
if (lowerCurrentDir.Contains("buildagent")) return false;
|
||||
if (lowerCurrentDir.Contains("_output")) return false;
|
||||
|
||||
return true;
|
||||
|
|
|
@ -6,8 +6,10 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
{
|
||||
HashSet<string> Flags { get; }
|
||||
Dictionary<string, string> Args { get; }
|
||||
bool Help { get; }
|
||||
bool InstallService { get; }
|
||||
bool UninstallService { get; }
|
||||
bool RegisterUrl { get; }
|
||||
|
||||
string PreservedArguments { get; }
|
||||
}
|
||||
|
@ -21,6 +23,7 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
public const string HELP = "?";
|
||||
public const string TERMINATE = "terminateexisting";
|
||||
public const string RESTART = "restart";
|
||||
public const string REGISTER_URL = "registerurl";
|
||||
|
||||
public StartupContext(params string[] args)
|
||||
{
|
||||
|
@ -47,9 +50,10 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
public HashSet<string> Flags { get; private set; }
|
||||
public Dictionary<string, string> Args { get; private set; }
|
||||
|
||||
public bool Help => Flags.Contains(HELP);
|
||||
public bool InstallService => Flags.Contains(INSTALL_SERVICE);
|
||||
|
||||
public bool UninstallService => Flags.Contains(UNINSTALL_SERVICE);
|
||||
public bool RegisterUrl => Flags.Contains(REGISTER_URL);
|
||||
|
||||
public string PreservedArguments
|
||||
{
|
||||
|
@ -71,4 +75,4 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
67
src/NzbDrone.Common/Extensions/ExceptionExtensions.cs
Normal file
67
src/NzbDrone.Common/Extensions/ExceptionExtensions.cs
Normal file
|
@ -0,0 +1,67 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace NzbDrone.Common.Extensions
|
||||
{
|
||||
public static class ExceptionExtensions
|
||||
{
|
||||
public static T WithData<T>(this T ex, string key, string value) where T : Exception
|
||||
{
|
||||
ex.AddData(key, value);
|
||||
|
||||
return ex;
|
||||
}
|
||||
public static T WithData<T>(this T ex, string key, int value) where T : Exception
|
||||
{
|
||||
ex.AddData(key, value.ToString());
|
||||
|
||||
return ex;
|
||||
}
|
||||
|
||||
public static T WithData<T>(this T ex, string key, Http.HttpUri value) where T : Exception
|
||||
{
|
||||
ex.AddData(key, value.ToString());
|
||||
|
||||
return ex;
|
||||
}
|
||||
|
||||
|
||||
public static T WithData<T>(this T ex, Http.HttpResponse response, int maxSampleLength = 512) where T : Exception
|
||||
{
|
||||
if (response == null || response.Content == null) return ex;
|
||||
|
||||
var contentSample = response.Content.Substring(0, Math.Min(response.Content.Length, maxSampleLength));
|
||||
|
||||
if (response.Request != null)
|
||||
{
|
||||
ex.AddData("RequestUri", response.Request.Url.ToString());
|
||||
|
||||
if (response.Request.ContentSummary != null)
|
||||
{
|
||||
ex.AddData("RequestSummary", response.Request.ContentSummary);
|
||||
}
|
||||
}
|
||||
|
||||
ex.AddData("StatusCode", response.StatusCode.ToString());
|
||||
|
||||
if (response.Headers != null)
|
||||
{
|
||||
ex.AddData("ContentType", response.Headers.ContentType ?? string.Empty);
|
||||
}
|
||||
ex.AddData("ContentLength", response.Content.Length.ToString());
|
||||
ex.AddData("ContentSample", contentSample);
|
||||
|
||||
return ex;
|
||||
}
|
||||
|
||||
|
||||
private static void AddData(this Exception ex, string key, string value)
|
||||
{
|
||||
if (value.IsNullOrWhiteSpace()) return;
|
||||
|
||||
ex.Data[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -51,6 +51,34 @@ namespace NzbDrone.Common.Extensions
|
|||
}
|
||||
}
|
||||
|
||||
public static Dictionary<TKey, TItem> ToDictionaryIgnoreDuplicates<TItem, TKey>(this IEnumerable<TItem> src, Func<TItem, TKey> keySelector)
|
||||
{
|
||||
var result = new Dictionary<TKey, TItem>();
|
||||
foreach (var item in src)
|
||||
{
|
||||
var key = keySelector(item);
|
||||
if (!result.ContainsKey(key))
|
||||
{
|
||||
result[key] = item;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Dictionary<TKey, TValue> ToDictionaryIgnoreDuplicates<TItem, TKey, TValue>(this IEnumerable<TItem> src, Func<TItem, TKey> keySelector, Func<TItem, TValue> valueSelector)
|
||||
{
|
||||
var result = new Dictionary<TKey, TValue>();
|
||||
foreach (var item in src)
|
||||
{
|
||||
var key = keySelector(item);
|
||||
if (!result.ContainsKey(key))
|
||||
{
|
||||
result[key] = valueSelector(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void AddIfNotNull<TSource>(this List<TSource> source, TSource item)
|
||||
{
|
||||
if (item == null)
|
||||
|
|
|
@ -25,6 +25,8 @@ namespace NzbDrone.Common.Extensions
|
|||
private static readonly string UPDATE_CLIENT_FOLDER_NAME = "Radarr.Update" + Path.DirectorySeparatorChar;
|
||||
private static readonly string UPDATE_LOG_FOLDER_NAME = "UpdateLogs" + Path.DirectorySeparatorChar;
|
||||
|
||||
private static readonly Regex PARENT_PATH_END_SLASH_REGEX = new Regex(@"(?<!:)\\$", RegexOptions.Compiled);
|
||||
|
||||
public static string CleanFilePath(this string path)
|
||||
{
|
||||
Ensure.That(path, () => path).IsNotNullOrWhiteSpace();
|
||||
|
@ -60,7 +62,7 @@ namespace NzbDrone.Common.Extensions
|
|||
{
|
||||
if (!parentPath.IsParentPath(childPath))
|
||||
{
|
||||
throw new Exceptions.NotParentException("{0} is not a child of {1}", childPath, parentPath);
|
||||
throw new NotParentException("{0} is not a child of {1}", childPath, parentPath);
|
||||
}
|
||||
|
||||
return childPath.Substring(parentPath.Length).Trim(Path.DirectorySeparatorChar);
|
||||
|
@ -68,24 +70,25 @@ namespace NzbDrone.Common.Extensions
|
|||
|
||||
public static string GetParentPath(this string childPath)
|
||||
{
|
||||
var parentPath = childPath.TrimEnd('\\', '/');
|
||||
var cleanPath = OsInfo.IsWindows
|
||||
? PARENT_PATH_END_SLASH_REGEX.Replace(childPath, "")
|
||||
: childPath.TrimEnd(Path.DirectorySeparatorChar);
|
||||
|
||||
var index = parentPath.LastIndexOfAny(new[] { '\\', '/' });
|
||||
|
||||
if (index != -1)
|
||||
if (cleanPath.IsNullOrWhiteSpace())
|
||||
{
|
||||
return parentPath.Substring(0, index);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
return Directory.GetParent(cleanPath)?.FullName;
|
||||
}
|
||||
|
||||
public static bool IsParentPath(this string parentPath, string childPath)
|
||||
{
|
||||
if (parentPath != "/")
|
||||
if (parentPath != "/" && !parentPath.EndsWith(":\\"))
|
||||
{
|
||||
parentPath = parentPath.TrimEnd(Path.DirectorySeparatorChar);
|
||||
}
|
||||
if (childPath != "/")
|
||||
if (childPath != "/" && !parentPath.EndsWith(":\\"))
|
||||
{
|
||||
childPath = childPath.TrimEnd(Path.DirectorySeparatorChar);
|
||||
}
|
||||
|
@ -192,6 +195,24 @@ namespace NzbDrone.Common.Extensions
|
|||
return directories;
|
||||
}
|
||||
|
||||
public static string GetAncestorPath(this string path, string ancestorName)
|
||||
{
|
||||
var parent = Path.GetDirectoryName(path);
|
||||
|
||||
while (parent != null)
|
||||
{
|
||||
var currentPath = parent;
|
||||
parent = Path.GetDirectoryName(parent);
|
||||
|
||||
if (Path.GetFileName(currentPath) == ancestorName)
|
||||
{
|
||||
return currentPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string GetAppDataPath(this IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
return appFolderInfo.AppDataFolder;
|
||||
|
|
13
src/NzbDrone.Common/Extensions/RegexExtensions.cs
Normal file
13
src/NzbDrone.Common/Extensions/RegexExtensions.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NzbDrone.Common.Extensions
|
||||
{
|
||||
public static class RegexExtensions
|
||||
{
|
||||
public static int EndIndex(this Capture regexMatch)
|
||||
{
|
||||
return regexMatch.Index + regexMatch.Length;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,9 +22,14 @@ namespace NzbDrone.Common.Extensions
|
|||
return "[NULL]";
|
||||
}
|
||||
|
||||
public static string FirstCharToLower(this string input)
|
||||
{
|
||||
return input.First().ToString().ToLower() + input.Substring(1);
|
||||
}
|
||||
|
||||
public static string FirstCharToUpper(this string input)
|
||||
{
|
||||
return input.First().ToString().ToUpper() + string.Join("", input.Skip(1));
|
||||
return input.First().ToString().ToUpper() + input.Substring(1);
|
||||
}
|
||||
|
||||
public static string Inject(this string format, params object[] formattingArgs)
|
||||
|
@ -65,6 +70,7 @@ namespace NzbDrone.Common.Extensions
|
|||
|
||||
return text;
|
||||
}
|
||||
|
||||
public static string Join(this IEnumerable<string> values, string separator)
|
||||
{
|
||||
return string.Join(separator, values);
|
||||
|
@ -144,5 +150,10 @@ namespace NzbDrone.Common.Extensions
|
|||
{
|
||||
return CamelCaseRegex.Replace(input, match => " " + match.Value);
|
||||
}
|
||||
|
||||
public static bool ContainsIgnoreCase(this IEnumerable<string> source, string value)
|
||||
{
|
||||
return source.Contains(value, StringComparer.InvariantCultureIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,11 @@ namespace NzbDrone.Common.Extensions
|
|||
return false;
|
||||
}
|
||||
|
||||
if (path.StartsWith(" ") || path.EndsWith(" "))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Uri uri;
|
||||
if (!Uri.TryCreate(path, UriKind.Absolute, out uri))
|
||||
{
|
||||
|
|
|
@ -24,7 +24,13 @@ namespace NzbDrone.Common
|
|||
}
|
||||
}
|
||||
}
|
||||
return string.Format("{0:x8}", mCrc);
|
||||
return $"{mCrc:x8}";
|
||||
}
|
||||
|
||||
public static string AnonymousToken()
|
||||
{
|
||||
var seed = $"{Environment.ProcessorCount}_{Environment.OSVersion.Platform}_{Environment.MachineName}_{Environment.UserName}";
|
||||
return HashUtil.CalculateCrc(seed);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -37,7 +37,7 @@ namespace NzbDrone.Common.Http
|
|||
}
|
||||
if (values.Length > 1)
|
||||
{
|
||||
throw new ApplicationException(string.Format("Expected {0} to occur only once.", key));
|
||||
throw new ApplicationException($"Expected {key} to occur only once, but was {values.Join("|")}.");
|
||||
}
|
||||
|
||||
return values[0];
|
||||
|
@ -54,7 +54,7 @@ namespace NzbDrone.Common.Http
|
|||
return converter(value);
|
||||
}
|
||||
protected void SetSingleValue(string key, string value)
|
||||
{
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
Remove(key);
|
||||
|
|
|
@ -3,11 +3,12 @@ namespace NzbDrone.Common.Http
|
|||
public enum HttpMethod
|
||||
{
|
||||
GET,
|
||||
PUT,
|
||||
POST,
|
||||
HEAD,
|
||||
PUT,
|
||||
DELETE,
|
||||
HEAD,
|
||||
OPTIONS,
|
||||
PATCH,
|
||||
OPTIONS
|
||||
MERGE
|
||||
}
|
||||
}
|
|
@ -58,6 +58,6 @@ namespace NzbDrone.Common.Http
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,8 @@ namespace NzbDrone.Common.Http
|
|||
StoreRequestCookie = true;
|
||||
IgnorePersistentCookies = false;
|
||||
Cookies = new Dictionary<string, string>();
|
||||
|
||||
|
||||
|
||||
if (!RuntimeInfo.IsProduction)
|
||||
{
|
||||
AllowAutoRedirect = false;
|
||||
|
|
|
@ -355,7 +355,7 @@ namespace NzbDrone.Common.Http
|
|||
FormData.Add(new HttpFormData
|
||||
{
|
||||
Name = key,
|
||||
ContentData = Encoding.UTF8.GetBytes(value.ToString())
|
||||
ContentData = Encoding.UTF8.GetBytes(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture))
|
||||
});
|
||||
|
||||
return this;
|
||||
|
|
|
@ -168,7 +168,7 @@ namespace NzbDrone.Common.Http
|
|||
{
|
||||
return basePath.Substring(0, baseSlashIndex) + "/" + relativePath;
|
||||
}
|
||||
|
||||
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
|
@ -263,7 +263,7 @@ namespace NzbDrone.Common.Http
|
|||
{
|
||||
return new HttpUri(baseUrl.Scheme, baseUrl.Host, baseUrl.Port, CombineRelativePath(baseUrl.Path, relativeUrl.Path), relativeUrl.Query, relativeUrl.Fragment);
|
||||
}
|
||||
|
||||
|
||||
return new HttpUri(baseUrl.Scheme, baseUrl.Host, baseUrl.Port, baseUrl.Path, relativeUrl.Query, relativeUrl.Fragment);
|
||||
}
|
||||
}
|
||||
|
|
47
src/NzbDrone.Common/Instrumentation/CleansingJsonVisitor.cs
Normal file
47
src/NzbDrone.Common/Instrumentation/CleansingJsonVisitor.cs
Normal file
|
@ -0,0 +1,47 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NzbDrone.Common.Serializer;
|
||||
|
||||
namespace NzbDrone.Common.Instrumentation
|
||||
{
|
||||
public class CleansingJsonVisitor : JsonVisitor
|
||||
{
|
||||
public override void Visit(JArray json)
|
||||
{
|
||||
for (var i = 0; i < json.Count; i++)
|
||||
{
|
||||
if (json[i].Type == JTokenType.String)
|
||||
{
|
||||
var text = json[i].Value<string>();
|
||||
json[i] = new JValue(CleanseLogMessage.Cleanse(text));
|
||||
}
|
||||
}
|
||||
foreach (JToken token in json)
|
||||
{
|
||||
Visit(token);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Visit(JProperty property)
|
||||
{
|
||||
if (property.Value.Type == JTokenType.String)
|
||||
{
|
||||
property.Value = CleanseValue(property.Value as JValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
base.Visit(property);
|
||||
}
|
||||
}
|
||||
|
||||
private JValue CleanseValue(JValue value)
|
||||
{
|
||||
var text = value.Value<string>();
|
||||
var cleansed = CleanseLogMessage.Cleanse(text);
|
||||
return new JValue(cleansed);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,7 +19,7 @@ namespace NzbDrone.Common.Instrumentation
|
|||
var exception = e.Exception;
|
||||
|
||||
Console.WriteLine("Task Error: {0}", exception);
|
||||
Logger.Error(exception, "Task Error: " + exception.Message);
|
||||
Logger.Error(exception, "Task Error");
|
||||
}
|
||||
|
||||
private static void HandleAppDomainException(object sender, UnhandledExceptionEventArgs e)
|
||||
|
@ -31,7 +31,7 @@ namespace NzbDrone.Common.Instrumentation
|
|||
if (exception is NullReferenceException &&
|
||||
exception.ToString().Contains("Microsoft.AspNet.SignalR.Transports.TransportHeartbeat.ProcessServerCommand"))
|
||||
{
|
||||
Logger.Warn("SignalR Heartbeat interupted");
|
||||
Logger.Warn("SignalR Heartbeat interrupted");
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -44,11 +44,9 @@ namespace NzbDrone.Common.Instrumentation
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine(exception.StackTrace);
|
||||
|
||||
Console.WriteLine("EPIC FAIL: {0}", exception);
|
||||
Logger.Fatal(exception, "EPIC FAIL: " + exception.Message);
|
||||
Logger.Fatal(exception, "EPIC FAIL.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,10 +88,13 @@
|
|||
<Compile Include="ConsoleService.cs" />
|
||||
<Compile Include="ConvertBase32.cs" />
|
||||
<Compile Include="Crypto\HashProvider.cs" />
|
||||
<Compile Include="Disk\DestinationAlreadyExistsException.cs" />
|
||||
<Compile Include="Disk\FileSystemLookupService.cs" />
|
||||
<Compile Include="Disk\DriveInfoMount.cs" />
|
||||
<Compile Include="Disk\IMount.cs" />
|
||||
<Compile Include="Disk\LongPathSupport.cs" />
|
||||
<Compile Include="Disk\MountOptions.cs" />
|
||||
<Compile Include="Disk\NotParentException.cs" />
|
||||
<Compile Include="Disk\RelativeFileSystemModel.cs" />
|
||||
<Compile Include="Disk\FileSystemModel.cs" />
|
||||
<Compile Include="Disk\FileSystemResult.cs" />
|
||||
|
@ -161,9 +164,11 @@
|
|||
<Compile Include="Extensions\Base64Extensions.cs" />
|
||||
<Compile Include="Extensions\DateTimeExtensions.cs" />
|
||||
<Compile Include="Crypto\HashConverter.cs" />
|
||||
<Compile Include="Extensions\ExceptionExtensions.cs" />
|
||||
<Compile Include="Extensions\Int64Extensions.cs" />
|
||||
<Compile Include="Extensions\IpAddressExtensions.cs" />
|
||||
<Compile Include="Extensions\ObjectExtensions.cs" />
|
||||
<Compile Include="Extensions\RegexExtensions.cs" />
|
||||
<Compile Include="Extensions\StreamExtensions.cs" />
|
||||
<Compile Include="Extensions\UrlExtensions.cs" />
|
||||
<Compile Include="Extensions\XmlExtensions.cs" />
|
||||
|
@ -203,6 +208,7 @@
|
|||
<Compile Include="Http\UnexpectedHtmlContentException.cs" />
|
||||
<Compile Include="Http\UserAgentBuilder.cs" />
|
||||
<Compile Include="Instrumentation\CleanseLogMessage.cs" />
|
||||
<Compile Include="Instrumentation\CleansingJsonVisitor.cs" />
|
||||
<Compile Include="Instrumentation\Extensions\LoggerProgressExtensions.cs" />
|
||||
<Compile Include="Instrumentation\GlobalExceptionHandlers.cs" />
|
||||
<Compile Include="Instrumentation\LogEventExtensions.cs" />
|
||||
|
@ -226,6 +232,7 @@
|
|||
<Compile Include="Serializer\HttpUriConverter.cs" />
|
||||
<Compile Include="Serializer\IntConverter.cs" />
|
||||
<Compile Include="Serializer\Json.cs" />
|
||||
<Compile Include="Serializer\JsonVisitor.cs" />
|
||||
<Compile Include="Serializer\UnderscoreStringEnumConverter.cs" />
|
||||
<Compile Include="ServiceFactory.cs" />
|
||||
<Compile Include="ServiceProvider.cs" />
|
||||
|
|
|
@ -60,6 +60,11 @@ namespace NzbDrone.Common.Reflection
|
|||
return (T)attribute;
|
||||
}
|
||||
|
||||
public static T[] GetAttributes<T>(this MemberInfo member) where T : Attribute
|
||||
{
|
||||
return member.GetCustomAttributes(typeof(T), false).OfType<T>().ToArray();
|
||||
}
|
||||
|
||||
public static Type FindTypeByName(this Assembly assembly, string name)
|
||||
{
|
||||
return assembly.GetTypes().SingleOrDefault(c => c.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue