1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-04-14 20:55:01 -04:00

Compare commits

..

28 Commits

Author SHA1 Message Date
Bogdan
52a69b662d Convert Add Movie from collection to TypeScript 2025-04-19 16:36:53 +03:00
Bogdan
7e34d89069 Convert Movie Collection Menus to TypeScript 2025-04-19 13:52:57 +03:00
Bogdan
b0024b28a5 Movie file is optional on movie resources 2025-04-18 23:35:43 +03:00
Bogdan
ae5450f75d Convert Edit Movie Collection modal to TypeScript 2025-04-18 23:15:28 +03:00
Bogdan
1d1aca1a04 Convert Collection Footer to TypeScript 2025-04-18 16:02:31 +03:00
Bogdan
3a55316ada Improve typings for select options 2025-04-18 12:11:55 +03:00
Bogdan
9ef7c2a0b4 Fixed: Autotagging using tag specification 2025-04-18 10:14:38 +03:00
Weblate
e759f3fd0b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2025-04-17 12:09:12 +03:00
Bogdan
03429db877 Fixed: Prevent new imports without deleting old movie files 2025-04-16 21:09:27 +03:00
Bogdan
bb5f421e38 Log when expected movie file is missing from disk on upgrade 2025-04-16 18:18:58 +03:00
Mark McDowall
7dd3ed815a Convert Modal components to TypeScript
(cherry picked from commit 9d0acba00065d78dab2fcd73cad6883bb6539fc7)
2025-04-16 12:56:29 +03:00
Bogdan
cc56482819 Translate settings for Radarr import list 2025-04-16 12:27:47 +03:00
Bogdan
40f41847fd Fixed: Selected value for empty root folder inputs 2025-04-16 12:19:04 +03:00
Bogdan
8485fc8c75 Fix various typos 2025-04-15 23:48:07 +03:00
Servarr
f3026df65d Automated API Docs update 2025-04-15 23:06:49 +03:00
Bogdan
cfd25e974f Fixed: Free space and missing for selected root folder value 2025-04-15 22:30:42 +03:00
Mark McDowall
c52f9c5ec4 New: Ability to change root folder when editing movie
(cherry picked from commit 417af2b91542e709e4b99aa5ca55b0501ba426ad)
2025-04-15 22:30:42 +03:00
Mark McDowall
b91517afd5 Add React Query
(cherry picked from commit 4491df3ae7530f2167beebc3548dd01fd2cc1a12)
2025-04-15 22:30:42 +03:00
Bogdan
ee8aaadb29 Fix editing import lists 2025-04-15 22:30:42 +03:00
Mark McDowall
0694f2fa76 Convert ProviderFieldFormGroup to TypeScript
(cherry picked from commit 2935d148a8ee2717421609eb26e2005eda963110)
2025-04-15 22:30:42 +03:00
Mark McDowall
2c81f3be0f Improve typings in FormInputGroup
(cherry picked from commit 6838f068bcd04b770cd9c53873f160be97ea745f)
2025-04-15 22:30:42 +03:00
Mark McDowall
8fb2f64e98 Fixed: Tooltips for detailed error messages
(cherry picked from commit c589c4f85e49d9d30c48e778e9ea08e692544730)
2025-04-15 22:30:42 +03:00
Mark McDowall
efd2b80e10 Fixed: Truncate long text in tag values
(cherry picked from commit 93c3f6d1d6b50a7ae06aca083aba5297d8d8b6e8)
2025-04-15 22:30:42 +03:00
Stevie Robinson
a9bbe06966 Fix: Adding a new root folder from add movie modal
(cherry picked from commit 983b079c8257790b52d7f63b2aa8051e88c7ceb2)
2025-04-15 22:30:42 +03:00
Mark McDowall
4c6f80b308 Fixed: Closing on click outside select input and styling on Library Import
(cherry picked from commit 3e99917e9d2ba486355e80ccba9e199f73a4f5af)
2025-04-15 22:30:42 +03:00
Bogdan
c8299f7e57 Convert Form Components to TypeScript
Co-authored-by: Mark McDowall <mark@mcdowall.ca>

Remove defaultProps from TypeScript components

(cherry picked from commit a90c13e86f798841cb6db038bb6b6d1408a00585)

Fix multi-select checkboxes not appearing

(cherry picked from commit e199710c15fbfa643a9f71c7a20f70b1722d0df6)
2025-04-15 22:30:42 +03:00
Bogdan
445babbca8 Fixed: Parse JAP instead of JPN as Japanese 2025-04-13 13:06:32 +03:00
Bogdan
e5137d13e9 Bump version to 5.22.3 2025-04-13 09:47:07 +03:00
289 changed files with 7398 additions and 7588 deletions

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '5.22.2'
majorVersion: '5.22.3'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'

View File

@@ -6,7 +6,7 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
import { CheckInputChanged } from 'typings/inputs';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
function QueueOptions() {
@@ -16,7 +16,7 @@ function QueueOptions() {
);
const handleOptionChange = useCallback(
({ name, value }: CheckInputChanged) => {
({ name, value }: InputChanged<boolean>) => {
dispatch(
setQueueOption({
[name]: value,

View File

@@ -79,9 +79,9 @@ class AddNewMovieModalContent extends Component {
}
<div className={styles.info}>
<div className={styles.overview}>
{overview}
</div>
{overview ? (
<div className={styles.overview}>{overview}</div>
) : null}
<Form>
<FormGroup>
@@ -98,7 +98,9 @@ class AddNewMovieModalContent extends Component {
movieFolder: folder,
isWindows
}}
helpText={translate('SubfolderWillBeCreatedAutomaticallyInterp', [folder])}
helpText={translate('AddNewMovieRootFolderHelpText', {
folder
})}
onChange={onInputChange}
{...rootFolderPath}
/>
@@ -110,7 +112,7 @@ class AddNewMovieModalContent extends Component {
</FormLabel>
<FormInputGroup
type={inputTypes.MOVIE_MONITORED_SELECT}
type={inputTypes.MONITOR_MOVIES_SELECT}
name="monitor"
onChange={onInputChange}
{...monitor}

View File

@@ -1,18 +1,10 @@
.inputContainer {
margin-right: 20px;
min-width: 150px;
div {
margin-top: 10px;
&:first-child {
margin-top: 0;
}
}
}
.label {
margin-bottom: 3px;
margin-bottom: 10px;
font-weight: bold;
}

View File

@@ -117,7 +117,7 @@ class ImportMovieFooter extends Component {
</div>
<FormInputGroup
type={inputTypes.MOVIE_MONITORED_SELECT}
type={inputTypes.MONITOR_MOVIES_SELECT}
name="monitor"
value={monitor}
isDisabled={!selectedCount}

View File

@@ -44,7 +44,7 @@ function ImportMovieRow(props) {
<VirtualTableRowCell className={styles.monitor}>
<FormInputGroup
type={inputTypes.MOVIE_MONITORED_SELECT}
type={inputTypes.MONITOR_MOVIES_SELECT}
name="monitor"
value={monitor}
onChange={onInputChange}

View File

@@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
import React from 'react';
import DocumentTitle from 'react-document-title';
@@ -12,17 +13,21 @@ interface AppProps {
history: ConnectedRouterProps['history'];
}
const queryClient = new QueryClient();
function App({ store, history }: AppProps) {
return (
<DocumentTitle title={window.Radarr.instanceName}>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme />
<PageConnector>
<AppRoutes />
</PageConnector>
</ConnectedRouter>
</Provider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme />
<PageConnector>
<AppRoutes />
</PageConnector>
</ConnectedRouter>
</Provider>
</QueryClientProvider>
</DocumentTitle>
);
}

View File

@@ -1,5 +1,6 @@
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CaptchaAppState from './CaptchaAppState';
import CommandAppState from './CommandAppState';
import ExtraFilesAppState from './ExtraFilesAppState';
import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState';
@@ -9,9 +10,11 @@ import MovieCollectionAppState from './MovieCollectionAppState';
import MovieCreditAppState from './MovieCreditAppState';
import MovieFilesAppState from './MovieFilesAppState';
import MoviesAppState, { MovieIndexAppState } from './MoviesAppState';
import OAuthAppState from './OAuthAppState';
import OrganizePreviewAppState from './OrganizePreviewAppState';
import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
@@ -68,6 +71,7 @@ interface AppState {
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;
captcha: CaptchaAppState;
commands: CommandAppState;
extraFiles: ExtraFilesAppState;
history: HistoryAppState;
@@ -79,9 +83,11 @@ interface AppState {
movieHistory: MovieHistoryAppState;
movieIndex: MovieIndexAppState;
movies: MoviesAppState;
oAuth: OAuthAppState;
organizePreview: OrganizePreviewAppState;
parse: ParseAppState;
paths: PathsAppState;
providerOptions: ProviderOptionsAppState;
queue: QueueAppState;
releases: ReleasesAppState;
rootFolders: RootFolderAppState;

View File

@@ -0,0 +1,11 @@
interface CaptchaAppState {
refreshing: false;
token: string;
siteKey: unknown;
secretToken: unknown;
ray: unknown;
stoken: unknown;
responseUrl: unknown;
}
export default CaptchaAppState;

View File

@@ -1,12 +1,20 @@
import AppSectionState, {
AppSectionFilterState,
AppSectionSaveState,
Error,
} from 'App/State/AppSectionState';
import MovieCollection from 'typings/MovieCollection';
interface MovieCollectionAppState
extends AppSectionState<MovieCollection>,
AppSectionFilterState<MovieCollection>,
AppSectionSaveState {
itemMap: Record<number, number>;
isAdding: boolean;
addError: Error;
pendingChanges: Partial<MovieCollection>;
}
export default MovieCollectionAppState;

View File

@@ -0,0 +1,9 @@
import { Error } from './AppSectionState';
interface OAuthAppState {
authorizing: boolean;
result: Record<string, unknown> | null;
error: Error;
}
export default OAuthAppState;

View File

@@ -0,0 +1,22 @@
import AppSectionState from 'App/State/AppSectionState';
import Field, { FieldSelectOption } from 'typings/Field';
export interface ProviderOptions {
fields?: Field[];
}
interface ProviderOptionsDevice {
id: string;
name: string;
}
interface ProviderOptionsAppState {
devices: AppSectionState<ProviderOptionsDevice>;
servers: AppSectionState<FieldSelectOption<unknown>>;
newznabCategories: AppSectionState<FieldSelectOption<unknown>>;
getProfiles: AppSectionState<FieldSelectOption<unknown>>;
getTags: AppSectionState<FieldSelectOption<unknown>>;
getRootFolders: AppSectionState<FieldSelectOption<unknown>>;
}
export default ProviderOptionsAppState;

View File

@@ -1,31 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddNewCollectionMovieModalContentConnector from './AddNewCollectionMovieModalContentConnector';
function AddNewCollectionMovieModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddNewCollectionMovieModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddNewCollectionMovieModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddNewCollectionMovieModal;

View File

@@ -1,204 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import MoviePoster from 'Movie/MoviePoster';
import translate from 'Utilities/String/translate';
import styles from './AddNewCollectionMovieModalContent.css';
class AddNewCollectionMovieModalContent extends Component {
//
// Listeners
onQualityProfileIdChange = ({ value }) => {
this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
};
onAddMoviePress = () => {
this.props.onAddMoviePress();
};
//
// Render
render() {
const {
title,
year,
overview,
images,
isAdding,
folder,
tags,
isSmallScreen,
isWindows,
onModalClose,
onInputChange,
rootFolderPath,
monitor,
qualityProfileId,
minimumAvailability,
searchForMovie
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{title}
{
!title.contains(year) && !!year &&
<span className={styles.year}>({year})</span>
}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
!isSmallScreen &&
<div className={styles.poster}>
<MoviePoster
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
<div className={styles.overview}>
{overview}
</div>
<Form>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
valueOptions={{
movieFolder: folder,
isWindows
}}
selectedValueOptions={{
movieFolder: folder,
isWindows
}}
helpText={translate('SubfolderWillBeCreatedAutomaticallyInterp', [folder])}
onChange={onInputChange}
{...rootFolderPath}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('Monitor')}
</FormLabel>
<FormInputGroup
type={inputTypes.MOVIE_MONITORED_SELECT}
name="monitor"
onChange={onInputChange}
{...monitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('MinimumAvailability')}</FormLabel>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}
name="minimumAvailability"
onChange={onInputChange}
{...minimumAvailability}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
onChange={this.onQualityProfileIdChange}
{...qualityProfileId}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<label className={styles.searchForMissingMovieLabelContainer}>
<span className={styles.searchForMissingMovieLabel}>
{translate('StartSearchForMissingMovie')}
</span>
<CheckInput
containerClassName={styles.searchForMissingMovieContainer}
className={styles.searchForMissingMovieInput}
name="searchForMovie"
onChange={onInputChange}
{...searchForMovie}
/>
</label>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={this.onAddMoviePress}
>
{translate('AddMovie')}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
AddNewCollectionMovieModalContent.propTypes = {
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
overview: PropTypes.string,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
minimumAvailability: PropTypes.object.isRequired,
searchForMovie: PropTypes.object.isRequired,
folder: PropTypes.string.isRequired,
tags: PropTypes.object.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
isWindows: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired,
onAddMoviePress: PropTypes.func.isRequired
};
export default AddNewCollectionMovieModalContent;

View File

@@ -1,121 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { addMovie, setMovieCollectionValue } from 'Store/Actions/movieCollectionActions';
import createCollectionSelector from 'Store/Selectors/createCollectionSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import AddNewMovieModalContent from './AddNewCollectionMovieModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.movieCollections,
createCollectionSelector(),
createDimensionsSelector(),
createSystemStatusSelector(),
(discoverMovieState, collection, dimensions, systemStatus) => {
const {
isAdding,
addError,
pendingChanges
} = discoverMovieState;
const collectionDefaults = {
rootFolderPath: collection.rootFolderPath,
monitor: 'movieOnly',
qualityProfileId: collection.qualityProfileId,
minimumAvailability: collection.minimumAvailability,
searchForMovie: collection.searchOnAdd,
tags: collection.tags || []
};
const {
settings,
validationErrors,
validationWarnings
} = selectSettings(collectionDefaults, pendingChanges, addError);
return {
isAdding,
addError,
isSmallScreen: dimensions.isSmallScreen,
validationErrors,
validationWarnings,
isWindows: systemStatus.isWindows,
...settings
};
}
);
}
const mapDispatchToProps = {
addMovie,
setMovieCollectionValue
};
class AddNewCollectionMovieModalContentConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setMovieCollectionValue({ name, value });
};
onAddMoviePress = () => {
const {
tmdbId,
title,
rootFolderPath,
monitor,
qualityProfileId,
minimumAvailability,
searchForMovie,
tags
} = this.props;
this.props.addMovie({
tmdbId,
title,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
minimumAvailability: minimumAvailability.value,
searchForMovie: searchForMovie.value,
tags: tags.value
});
this.props.onModalClose(true);
};
//
// Render
render() {
return (
<AddNewMovieModalContent
{...this.props}
onInputChange={this.onInputChange}
onAddMoviePress={this.onAddMoviePress}
/>
);
}
}
AddNewCollectionMovieModalContentConnector.propTypes = {
tmdbId: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
minimumAvailability: PropTypes.object.isRequired,
searchForMovie: PropTypes.object.isRequired,
tags: PropTypes.object.isRequired,
onModalClose: PropTypes.func.isRequired,
addMovie: PropTypes.func.isRequired,
setMovieCollectionValue: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewCollectionMovieModalContentConnector);

View File

@@ -0,0 +1,36 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import AddNewMovieCollectionMovieModalContent, {
AddNewMovieCollectionMovieModalContentProps,
} from './AddNewMovieCollectionMovieModalContent';
interface AddNewCollectionMovieModalProps
extends AddNewMovieCollectionMovieModalContentProps {
isOpen: boolean;
}
function AddNewMovieCollectionMovieModal({
isOpen,
onModalClose,
...otherProps
}: AddNewCollectionMovieModalProps) {
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'movieCollections' }));
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal isOpen={isOpen} onModalClose={handleModalClose}>
<AddNewMovieCollectionMovieModalContent
{...otherProps}
onModalClose={handleModalClose}
/>
</Modal>
);
}
export default AddNewMovieCollectionMovieModal;

View File

@@ -0,0 +1,269 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import MovieMinimumAvailabilityPopoverContent from 'AddMovie/MovieMinimumAvailabilityPopoverContent';
import AppState from 'App/State/AppState';
import useMovieCollection from 'Collection/useMovieCollection';
import CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import { Image } from 'Movie/Movie';
import MoviePoster from 'Movie/MoviePoster';
import {
addMovie,
setMovieCollectionValue,
} from 'Store/Actions/movieCollectionActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import useIsWindows from 'System/useIsWindows';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './AddNewMovieCollectionMovieModalContent.css';
export interface AddNewMovieCollectionMovieModalContentProps {
tmdbId: number;
title: string;
year: number;
overview?: string;
images: Image[];
collectionId: number;
folder: string;
onModalClose: () => void;
}
function AddNewMovieCollectionMovieModalContent({
tmdbId,
title,
year,
overview,
images,
collectionId,
folder,
onModalClose,
}: AddNewMovieCollectionMovieModalContentProps) {
const dispatch = useDispatch();
const collection = useMovieCollection(collectionId)!;
const { isSmallScreen } = useSelector(createDimensionsSelector());
const isWindows = useIsWindows();
const { isAdding, addError, pendingChanges } = useSelector(
(state: AppState) => state.movieCollections
);
const wasAdding = usePrevious(isAdding);
const { settings, validationErrors, validationWarnings } = useMemo(() => {
const options = {
rootFolderPath: collection.rootFolderPath,
monitor: collection.monitored ? 'movieOnly' : 'none',
qualityProfileId: collection.qualityProfileId,
minimumAvailability: collection.minimumAvailability,
searchForMovie: collection.searchOnAdd,
tags: collection.tags || [],
};
return selectSettings(options, pendingChanges, addError);
}, [collection, pendingChanges, addError]);
const {
monitor,
qualityProfileId,
minimumAvailability,
rootFolderPath,
searchForMovie,
tags,
} = settings;
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error actions aren't typed
dispatch(setMovieCollectionValue({ name, value }));
},
[dispatch]
);
const handleAddMoviePress = useCallback(() => {
dispatch(
addMovie({
tmdbId,
title,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
minimumAvailability: minimumAvailability.value,
searchForMovie: searchForMovie.value,
tags: tags.value,
})
);
}, [
tmdbId,
title,
rootFolderPath,
monitor,
qualityProfileId,
minimumAvailability,
searchForMovie,
tags,
dispatch,
]);
useEffect(() => {
if (!isAdding && wasAdding && !addError) {
onModalClose();
}
}, [isAdding, wasAdding, addError, onModalClose]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{title}
{!title.includes(String(year)) && year ? (
<span className={styles.year}>({year})</span>
) : null}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{isSmallScreen ? null : (
<div className={styles.poster}>
<MoviePoster
className={styles.poster}
images={images}
size={250}
/>
</div>
)}
<div className={styles.info}>
{overview ? (
<div className={styles.overview}>{overview}</div>
) : null}
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
valueOptions={{
movieFolder: folder,
isWindows,
}}
selectedValueOptions={{
movieFolder: folder,
isWindows,
}}
helpText={translate('AddNewMovieRootFolderHelpText', {
folder,
})}
{...rootFolderPath}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Monitor')}</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_MOVIES_SELECT}
name="monitor"
{...monitor}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('MinimumAvailability')}
<Popover
anchor={
<Icon className={styles.labelIcon} name={icons.INFO} />
}
title={translate('MinimumAvailability')}
body={<MovieMinimumAvailabilityPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}
name="minimumAvailability"
{...minimumAvailability}
helpLink="https://wiki.servarr.com/radarr/faq#what-is-minimum-availability"
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
{...qualityProfileId}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
{...tags}
onChange={handleInputChange}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<label className={styles.searchForMissingMovieLabelContainer}>
<span className={styles.searchForMissingMovieLabel}>
{translate('StartSearchForMissingMovie')}
</span>
<CheckInput
containerClassName={styles.searchForMissingMovieContainer}
className={styles.searchForMissingMovieInput}
name="searchForMovie"
{...searchForMovie}
onChange={handleInputChange}
/>
</label>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={handleAddMoviePress}
>
{translate('AddMovie')}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
export default AddNewMovieCollectionMovieModalContent;

View File

@@ -18,9 +18,9 @@ import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import CollectionFooter from './CollectionFooter';
import CollectionFilterMenu from './Menus/CollectionFilterMenu';
import CollectionSortMenu from './Menus/CollectionSortMenu';
import NoCollection from './NoCollection';
import MovieCollectionFilterMenu from './Menus/MovieCollectionFilterMenu';
import MovieCollectionSortMenu from './Menus/MovieCollectionSortMenu';
import NoMovieCollections from './NoMovieCollections';
import CollectionOverviewsConnector from './Overview/CollectionOverviewsConnector';
import CollectionOverviewOptionsModal from './Overview/Options/CollectionOverviewOptionsModal';
@@ -284,14 +284,14 @@ class Collection extends Component {
<PageToolbarSeparator />
}
<CollectionSortMenu
<MovieCollectionSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoCollection}
onSortSelect={onSortSelect}
/>
<CollectionFilterMenu
<MovieCollectionFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
@@ -341,7 +341,7 @@ class Collection extends Component {
{
!error && isPopulated && !items.length &&
<NoCollection totalItems={totalItems} />
<NoMovieCollections totalItems={totalItems} />
}
</PageContentBody>

View File

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

View File

@@ -1,296 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AvailabilitySelectInput from 'Components/Form/AvailabilitySelectInput';
import QualityProfileSelectInputConnector from 'Components/Form/QualityProfileSelectInputConnector';
import RootFolderSelectInputConnector from 'Components/Form/RootFolderSelectInputConnector';
import SelectInput from 'Components/Form/SelectInput';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import CollectionFooterLabel from './CollectionFooterLabel';
import styles from './CollectionFooter.css';
const NO_CHANGE = 'noChange';
const monitoredOptions = [
{
key: NO_CHANGE,
get value() {
return translate('NoChange');
},
isDisabled: true
},
{
key: 'monitored',
get value() {
return translate('Monitored');
}
},
{
key: 'unmonitored',
get value() {
return translate('Unmonitored');
}
}
];
const searchOnAddOptions = [
{
key: NO_CHANGE,
get value() {
return translate('NoChange');
},
isDisabled: true
},
{
key: 'yes',
get value() {
return translate('Yes');
}
},
{
key: 'no',
get value() {
return translate('No');
}
}
];
class CollectionFooter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
monitored: NO_CHANGE,
monitor: NO_CHANGE,
qualityProfileId: NO_CHANGE,
minimumAvailability: NO_CHANGE,
rootFolderPath: NO_CHANGE,
searchOnAdd: NO_CHANGE
};
}
componentDidUpdate(prevProps) {
const {
isSaving,
saveError
} = this.props;
const newState = {};
if (prevProps.isSaving && !isSaving && !saveError) {
this.setState({
monitored: NO_CHANGE,
monitor: NO_CHANGE,
qualityProfileId: NO_CHANGE,
minimumAvailability: NO_CHANGE,
rootFolderPath: NO_CHANGE,
searchOnAdd: NO_CHANGE
});
}
if (!_.isEmpty(newState)) {
this.setState(newState);
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
};
onUpdateSelectedPress = () => {
const {
monitored,
monitor,
qualityProfileId,
minimumAvailability,
rootFolderPath,
searchOnAdd
} = this.state;
const changes = {};
if (monitored !== NO_CHANGE) {
changes.monitored = monitored === 'monitored';
}
if (monitor !== NO_CHANGE) {
changes.monitor = monitor;
}
if (qualityProfileId !== NO_CHANGE) {
changes.qualityProfileId = qualityProfileId;
}
if (minimumAvailability !== NO_CHANGE) {
changes.minimumAvailability = minimumAvailability;
}
if (rootFolderPath !== NO_CHANGE) {
changes.rootFolderPath = rootFolderPath;
}
if (searchOnAdd !== NO_CHANGE) {
changes.searchOnAdd = searchOnAdd === 'yes';
}
this.props.onUpdateSelectedPress(changes);
};
//
// Render
render() {
const {
selectedIds,
isSaving
} = this.props;
const {
monitored,
monitor,
qualityProfileId,
minimumAvailability,
rootFolderPath,
searchOnAdd
} = this.state;
const selectedCount = selectedIds.length;
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('MonitorCollection')}
isSaving={isSaving && monitored !== NO_CHANGE}
/>
<SelectInput
name="monitored"
value={monitored}
values={monitoredOptions}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('MonitorMovies')}
isSaving={isSaving && monitor !== NO_CHANGE}
/>
<SelectInput
name="monitor"
value={monitor}
values={monitoredOptions}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('QualityProfile')}
isSaving={isSaving && qualityProfileId !== NO_CHANGE}
/>
<QualityProfileSelectInputConnector
name="qualityProfileId"
value={qualityProfileId}
includeNoChange={true}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('MinimumAvailability')}
isSaving={isSaving && minimumAvailability !== NO_CHANGE}
/>
<AvailabilitySelectInput
name="minimumAvailability"
value={minimumAvailability}
includeNoChange={true}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('RootFolder')}
isSaving={isSaving && rootFolderPath !== NO_CHANGE}
/>
<RootFolderSelectInputConnector
name="rootFolderPath"
value={rootFolderPath}
includeNoChange={true}
isDisabled={!selectedCount}
selectedValueOptions={{ includeFreeSpace: false }}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('SearchMoviesOnAdd')}
isSaving={isSaving && searchOnAdd !== NO_CHANGE}
/>
<SelectInput
name="searchOnAdd"
value={searchOnAdd}
values={searchOnAddOptions}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.buttonContainer}>
<div className={styles.buttonContainerContent}>
<CollectionFooterLabel
label={translate('CountCollectionsSelected', { count: selectedCount })}
isSaving={false}
/>
<div className={styles.buttons}>
<div>
<SpinnerButton
className={styles.addSelectedButton}
kind={kinds.PRIMARY}
isSpinning={isSaving}
isDisabled={!selectedCount || isSaving}
onPress={this.onUpdateSelectedPress}
>
{translate('UpdateSelected')}
</SpinnerButton>
</div>
</div>
</div>
</div>
</PageContentFooter>
);
}
}
CollectionFooter.propTypes = {
selectedIds: PropTypes.arrayOf(PropTypes.number).isRequired,
isAdding: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
onUpdateSelectedPress: PropTypes.func.isRequired
};
export default CollectionFooter;

View File

@@ -0,0 +1,317 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Error } from 'App/State/AppSectionState';
import FormInputGroup from 'Components/Form/FormInputGroup';
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes, kinds } from 'Helpers/Props';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import CollectionFooterLabel from './CollectionFooterLabel';
import styles from './CollectionFooter.css';
interface SavePayload {
monitored?: boolean;
monitor?: string;
qualityProfileId?: number;
minimumAvailability?: string;
rootFolderPath?: string;
searchOnAdd?: boolean;
}
interface CollectionFooterProps {
selectedIds: number[];
isAdding: boolean;
isSaving: boolean;
saveError: Error;
onUpdateSelectedPress(payload: object): void;
}
const NO_CHANGE = 'noChange';
const monitoredOptions: EnhancedSelectInputValue<string>[] = [
{
key: NO_CHANGE,
get value() {
return translate('NoChange');
},
},
{
key: 'monitored',
get value() {
return translate('Monitored');
},
},
{
key: 'unmonitored',
get value() {
return translate('Unmonitored');
},
},
];
const searchOnAddOptions: EnhancedSelectInputValue<string>[] = [
{
key: NO_CHANGE,
get value() {
return translate('NoChange');
},
},
{
key: 'yes',
get value() {
return translate('Yes');
},
},
{
key: 'no',
get value() {
return translate('No');
},
},
];
function CollectionFooter({
selectedIds,
isSaving,
saveError,
onUpdateSelectedPress,
}: CollectionFooterProps) {
const [monitored, setMonitored] = useState(NO_CHANGE);
const [monitor, setMonitor] = useState(NO_CHANGE);
const [qualityProfileId, setQualityProfileId] = useState<string | number>(
NO_CHANGE
);
const [minimumAvailability, setMinimumAvailability] = useState(NO_CHANGE);
const [rootFolderPath, setRootFolderPath] = useState(NO_CHANGE);
const [searchOnAdd, setSearchOnAdd] = useState(NO_CHANGE);
const wasSaving = usePrevious(isSaving);
const handleSavePress = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (monitored !== NO_CHANGE) {
hasChanges = true;
payload.monitored = monitored === 'monitored';
}
if (monitor !== NO_CHANGE) {
hasChanges = true;
payload.monitor = monitor;
}
if (qualityProfileId !== NO_CHANGE) {
hasChanges = true;
payload.qualityProfileId = qualityProfileId as number;
}
if (minimumAvailability !== NO_CHANGE) {
hasChanges = true;
payload.minimumAvailability = minimumAvailability as string;
}
if (rootFolderPath !== NO_CHANGE) {
hasChanges = true;
payload.rootFolderPath = rootFolderPath;
}
if (searchOnAdd !== NO_CHANGE) {
hasChanges = true;
payload.searchOnAdd = searchOnAdd === 'yes';
}
if (hasChanges) {
onUpdateSelectedPress(payload);
}
}, [
monitor,
monitored,
qualityProfileId,
minimumAvailability,
rootFolderPath,
searchOnAdd,
onUpdateSelectedPress,
]);
const handleInputChange = useCallback(({ name, value }: InputChanged) => {
switch (name) {
case 'monitored':
setMonitored(value as string);
break;
case 'monitor':
setMonitor(value as string);
break;
case 'qualityProfileId':
setQualityProfileId(value as string);
break;
case 'minimumAvailability':
setMinimumAvailability(value as string);
break;
case 'rootFolderPath':
setRootFolderPath(value as string);
break;
case 'searchOnAdd':
setSearchOnAdd(value as string);
break;
default:
console.warn(`CollectionFooter Unknown Input: '${name}'`);
}
}, []);
useEffect(() => {
if (!isSaving && wasSaving && !saveError) {
setMonitored(NO_CHANGE);
setMonitor(NO_CHANGE);
setQualityProfileId(NO_CHANGE);
setMinimumAvailability(NO_CHANGE);
setRootFolderPath(NO_CHANGE);
setSearchOnAdd(NO_CHANGE);
}
}, [
isSaving,
wasSaving,
saveError,
setMonitored,
setMonitor,
setQualityProfileId,
setMinimumAvailability,
setRootFolderPath,
setSearchOnAdd,
]);
const selectedCount = selectedIds.length;
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('MonitorCollection')}
isSaving={isSaving && monitored !== NO_CHANGE}
/>
<FormInputGroup
type={inputTypes.SELECT}
name="monitored"
value={monitored}
values={monitoredOptions}
isDisabled={!selectedCount}
onChange={handleInputChange}
/>
</div>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('MonitorMovies')}
isSaving={isSaving && monitor !== NO_CHANGE}
/>
<FormInputGroup
type={inputTypes.SELECT}
name="monitor"
value={monitor}
values={monitoredOptions}
isDisabled={!selectedCount}
onChange={handleInputChange}
/>
</div>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('QualityProfile')}
isSaving={isSaving && qualityProfileId !== NO_CHANGE}
/>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
includeNoChange={true}
includeNoChangeDisabled={false}
isDisabled={!selectedCount}
onChange={handleInputChange}
/>
</div>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('MinimumAvailability')}
isSaving={isSaving && minimumAvailability !== NO_CHANGE}
/>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}
name="minimumAvailability"
value={minimumAvailability}
includeNoChange={true}
includeNoChangeDisabled={false}
isDisabled={!selectedCount}
onChange={handleInputChange}
/>
</div>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('RootFolder')}
isSaving={isSaving && rootFolderPath !== NO_CHANGE}
/>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
value={rootFolderPath}
includeNoChange={true}
includeNoChangeDisabled={false}
selectedValueOptions={{ includeFreeSpace: false }}
isDisabled={!selectedCount}
onChange={handleInputChange}
/>
</div>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('SearchMoviesOnAdd')}
isSaving={isSaving && searchOnAdd !== NO_CHANGE}
/>
<FormInputGroup
type={inputTypes.SELECT}
name="searchOnAdd"
value={searchOnAdd}
values={searchOnAddOptions}
isDisabled={!selectedCount}
onChange={handleInputChange}
/>
</div>
<div className={styles.buttonContainer}>
<div className={styles.buttonContainerContent}>
<CollectionFooterLabel
label={translate('CountCollectionsSelected', {
count: selectedCount,
})}
isSaving={false}
/>
<div className={styles.buttons}>
<div>
<SpinnerButton
className={styles.addSelectedButton}
kind={kinds.PRIMARY}
isSpinning={isSaving}
isDisabled={!selectedCount || isSaving}
onPress={handleSavePress}
>
{translate('UpdateSelected')}
</SpinnerButton>
</div>
</div>
</div>
</div>
</PageContentFooter>
);
}
export default CollectionFooter;

View File

@@ -1,40 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import SpinnerIcon from 'Components/SpinnerIcon';
import { icons } from 'Helpers/Props';
import styles from './CollectionFooterLabel.css';
function CollectionFooterLabel(props) {
const {
className,
label,
isSaving
} = props;
return (
<div className={className}>
{label}
{
isSaving &&
<SpinnerIcon
className={styles.savingIcon}
name={icons.SPINNER}
isSpinning={true}
/>
}
</div>
);
}
CollectionFooterLabel.propTypes = {
className: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
isSaving: PropTypes.bool.isRequired
};
CollectionFooterLabel.defaultProps = {
className: styles.label
};
export default CollectionFooterLabel;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import SpinnerIcon from 'Components/SpinnerIcon';
import { icons } from 'Helpers/Props';
import styles from './CollectionFooterLabel.css';
interface CollectionFooterLabelProps {
className?: string;
label: string;
isSaving: boolean;
}
function CollectionFooterLabel({
className = styles.label,
label,
isSaving,
}: CollectionFooterLabelProps) {
return (
<div className={className}>
{label}
{isSaving ? (
<SpinnerIcon
className={styles.savingIcon}
name={icons.SPINNER}
isSpinning={true}
/>
) : null}
</div>
);
}
export default CollectionFooterLabel;

View File

@@ -9,7 +9,7 @@ function createMapStateToProps() {
createCollectionSelector(),
(collection) => {
// If a movie is deleted this selector may fire before the parent
// selecors, which will result in an undefined movie, if that happens
// selectors, which will result in an undefined movie, if that happens
// we want to return early here and again in the render function to avoid
// trying to show a movie that has no information available.

View File

@@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import EditCollectionModalContentConnector from './EditCollectionModalContentConnector';
function EditCollectionModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditCollectionModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditCollectionModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditCollectionModal;

View File

@@ -1,39 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditCollectionModal from './EditCollectionModal';
const mapDispatchToProps = {
clearPendingChanges
};
class EditCollectionModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'movieCollections' });
this.props.onModalClose();
};
//
// Render
render() {
return (
<EditCollectionModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditCollectionModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(undefined, mapDispatchToProps)(EditCollectionModalConnector);

View File

@@ -1,190 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import MoviePoster from 'Movie/MoviePoster';
import translate from 'Utilities/String/translate';
import styles from './EditCollectionModalContent.css';
class EditCollectionModalContent extends Component {
//
// Listeners
onSavePress = () => {
const {
onSavePress
} = this.props;
onSavePress(false);
};
//
// Render
render() {
const {
title,
images,
overview,
item,
isSaving,
onInputChange,
onModalClose,
isSmallScreen,
...otherProps
} = this.props;
const {
monitored,
qualityProfileId,
minimumAvailability,
// Id,
rootFolderPath,
tags,
searchOnAdd
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('Edit')} - {title}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
!isSmallScreen &&
<div className={styles.poster}>
<MoviePoster
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
<div className={styles.overview}>
{overview}
</div>
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>{translate('Monitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="monitored"
helpText={translate('MonitoredCollectionHelpText')}
{...monitored}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('MinimumAvailability')}</FormLabel>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}
name="minimumAvailability"
{...minimumAvailability}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
{...qualityProfileId}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
{...rootFolderPath}
includeMissingValue={true}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SearchOnAdd')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="searchOnAdd"
helpText={translate('SearchOnAddCollectionHelpText')}
{...searchOnAdd}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerButton
isSpinning={isSaving}
onPress={this.onSavePress}
>
{translate('Save')}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
EditCollectionModalContent.propTypes = {
collectionId: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
overview: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
item: PropTypes.object.isRequired,
isSaving: PropTypes.bool.isRequired,
isPathChanging: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditCollectionModalContent;

View File

@@ -1,120 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveMovieCollection, setMovieCollectionValue } from 'Store/Actions/movieCollectionActions';
import createCollectionSelector from 'Store/Selectors/createCollectionSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import EditCollectionModalContent from './EditCollectionModalContent';
function createIsPathChangingSelector() {
return createSelector(
(state) => state.movieCollections.pendingChanges,
createCollectionSelector(),
(pendingChanges, collection) => {
const rootFolderPath = pendingChanges.rootFolderPath;
if (rootFolderPath == null) {
return false;
}
return collection.rootFolderPath !== rootFolderPath;
}
);
}
function createMapStateToProps() {
return createSelector(
(state) => state.movieCollections,
createCollectionSelector(),
createIsPathChangingSelector(),
createDimensionsSelector(),
(moviesState, collection, isPathChanging, dimensions) => {
const {
isSaving,
saveError,
pendingChanges
} = moviesState;
const movieSettings = {
monitored: collection.monitored,
qualityProfileId: collection.qualityProfileId,
minimumAvailability: collection.minimumAvailability,
rootFolderPath: collection.rootFolderPath,
tags: collection.tags,
searchOnAdd: collection.searchOnAdd
};
const settings = selectSettings(movieSettings, pendingChanges, saveError);
return {
title: collection.title,
images: collection.images,
overview: collection.overview,
isSaving,
saveError,
isPathChanging,
originalPath: collection.path,
item: settings.settings,
isSmallScreen: dimensions.isSmallScreen,
...settings
};
}
);
}
const mapDispatchToProps = {
dispatchSetMovieCollectionValue: setMovieCollectionValue,
dispatchSaveMovieCollection: saveMovieCollection
};
class EditCollectionModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.dispatchSetMovieCollectionValue({ name, value });
};
onSavePress = () => {
this.props.dispatchSaveMovieCollection({
id: this.props.collectionId
});
};
//
// Render
render() {
return (
<EditCollectionModalContent
{...this.props}
onInputChange={this.onInputChange}
onSavePress={this.onSavePress}
onMoveMoviePress={this.onMoveMoviePress}
/>
);
}
}
EditCollectionModalContentConnector.propTypes = {
collectionId: PropTypes.number,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
dispatchSetMovieCollectionValue: PropTypes.func.isRequired,
dispatchSaveMovieCollection: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditCollectionModalContentConnector);

View File

@@ -0,0 +1,36 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditMovieCollectionModalContent, {
EditMovieCollectionModalContentProps,
} from './EditMovieCollectionModalContent';
interface EditMovieCollectionModalProps
extends EditMovieCollectionModalContentProps {
isOpen: boolean;
}
function EditMovieCollectionModal({
isOpen,
onModalClose,
...otherProps
}: EditMovieCollectionModalProps) {
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'movieCollections' }));
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal isOpen={isOpen} onModalClose={handleModalClose}>
<EditMovieCollectionModalContent
{...otherProps}
onModalClose={handleModalClose}
/>
</Modal>
);
}
export default EditMovieCollectionModal;

View File

@@ -0,0 +1,214 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import useMovieCollection from 'Collection/useMovieCollection';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes } from 'Helpers/Props';
import MoviePoster from 'Movie/MoviePoster';
import {
saveMovieCollection,
setMovieCollectionValue,
} from 'Store/Actions/movieCollectionActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './EditMovieCollectionModalContent.css';
export interface EditMovieCollectionModalContentProps {
collectionId: number;
onModalClose: () => void;
}
function EditMovieCollectionModalContent({
collectionId,
onModalClose,
}: EditMovieCollectionModalContentProps) {
const dispatch = useDispatch();
const {
title,
overview,
monitored,
qualityProfileId,
minimumAvailability,
rootFolderPath,
searchOnAdd,
images,
tags,
} = useMovieCollection(collectionId)!;
const { isSaving, saveError, pendingChanges } = useSelector(
(state: AppState) => state.movieCollections
);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const wasSaving = usePrevious(isSaving);
const { settings, ...otherSettings } = useMemo(() => {
return selectSettings(
{
monitored,
minimumAvailability,
qualityProfileId,
rootFolderPath,
searchOnAdd,
tags,
},
pendingChanges,
saveError
);
}, [
monitored,
minimumAvailability,
qualityProfileId,
rootFolderPath,
searchOnAdd,
tags,
pendingChanges,
saveError,
]);
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error actions aren't typed
dispatch(setMovieCollectionValue({ name, value }));
},
[dispatch]
);
const handleSavePress = useCallback(() => {
dispatch(
saveMovieCollection({
id: collectionId,
})
);
}, [collectionId, dispatch]);
useEffect(() => {
if (!isSaving && wasSaving && !saveError) {
onModalClose();
}
}, [isSaving, wasSaving, saveError, onModalClose]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('EditMovieCollectionModalHeader', { title })}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{isSmallScreen ? null : (
<div className={styles.poster}>
<MoviePoster
className={styles.poster}
images={images}
size={250}
/>
</div>
)}
<div className={styles.info}>
<div className={styles.overview}>{overview}</div>
<Form {...otherSettings}>
<FormGroup>
<FormLabel>{translate('Monitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="monitored"
helpText={translate('MonitoredCollectionHelpText')}
{...settings.monitored}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('MinimumAvailability')}</FormLabel>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}
name="minimumAvailability"
{...settings.minimumAvailability}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
{...settings.qualityProfileId}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
{...settings.rootFolderPath}
includeMissingValue={true}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
{...settings.tags}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SearchOnAdd')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="searchOnAdd"
helpText={translate('SearchOnAddCollectionHelpText')}
{...settings.searchOnAdd}
onChange={handleInputChange}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
error={saveError}
isSpinning={isSaving}
onPress={handleSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
export default EditMovieCollectionModalContent;

View File

@@ -1,41 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import CollectionFilterModalConnector from 'Collection/CollectionFilterModalConnector';
import FilterMenu from 'Components/Menu/FilterMenu';
import { align } from 'Helpers/Props';
function CollectionFilterMenu(props) {
const {
selectedFilterKey,
filters,
customFilters,
isDisabled,
onFilterSelect
} = props;
return (
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={isDisabled}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={CollectionFilterModalConnector}
onFilterSelect={onFilterSelect}
/>
);
}
CollectionFilterMenu.propTypes = {
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired
};
CollectionFilterMenu.defaultProps = {
showCustomFilters: false
};
export default CollectionFilterMenu;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { CustomFilter, Filter } from 'App/State/AppState';
import MovieCollectionFilterModal from 'Collection/MovieCollectionFilterModal';
import FilterMenu from 'Components/Menu/FilterMenu';
interface MovieCollectionFilterMenuProps {
selectedFilterKey: string | number;
filters: Filter[];
customFilters: CustomFilter[];
isDisabled: boolean;
onFilterSelect: (filter: number | string) => void;
}
function MovieCollectionFilterMenu({
selectedFilterKey,
filters,
customFilters,
isDisabled,
onFilterSelect,
}: MovieCollectionFilterMenuProps) {
return (
<FilterMenu
alignMenu="right"
isDisabled={isDisabled}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={MovieCollectionFilterModal}
onFilterSelect={onFilterSelect}
/>
);
}
export default MovieCollectionFilterMenu;

View File

@@ -1,24 +1,26 @@
import PropTypes from 'prop-types';
import React from 'react';
import MenuContent from 'Components/Menu/MenuContent';
import SortMenu from 'Components/Menu/SortMenu';
import SortMenuItem from 'Components/Menu/SortMenuItem';
import { align, sortDirections } from 'Helpers/Props';
import { align } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import translate from 'Utilities/String/translate';
function CollectionSortMenu(props) {
const {
sortKey,
sortDirection,
isDisabled,
onSortSelect
} = props;
interface MovieCollectionSortMenuProps {
sortKey?: string;
sortDirection?: SortDirection;
isDisabled: boolean;
onSortSelect(sortKey: string): void;
}
function MovieCollectionSortMenu({
sortKey,
sortDirection,
isDisabled,
onSortSelect,
}: MovieCollectionSortMenuProps) {
return (
<SortMenu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<SortMenu isDisabled={isDisabled} alignMenu={align.RIGHT}>
<MenuContent>
<SortMenuItem
name="sortTitle"
@@ -28,6 +30,7 @@ function CollectionSortMenu(props) {
>
{translate('Title')}
</SortMenuItem>
<SortMenuItem
name="missingMovies"
sortKey={sortKey}
@@ -41,11 +44,4 @@ function CollectionSortMenu(props) {
);
}
CollectionSortMenu.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
isDisabled: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired
};
export default CollectionSortMenu;
export default MovieCollectionSortMenu;

View File

@@ -0,0 +1,39 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FilterModal from 'Components/Filter/FilterModal';
import { setMovieCollectionsFilter } from 'Store/Actions/movieCollectionActions';
interface MovieCollectionFilterModalProps {
isOpen: boolean;
}
export default function MovieCollectionFilterModal(
props: MovieCollectionFilterModalProps
) {
const sectionItems = useSelector(
(state: AppState) => state.movieCollections.items
);
const filterBuilderProps = useSelector(
(state: AppState) => state.movieCollections.filterBuilderProps
);
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload: { selectedFilterKey: string | number }) => {
dispatch(setMovieCollectionsFilter(payload));
},
[dispatch]
);
return (
<FilterModal
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType="movieCollections"
dispatchSetFilter={dispatchSetFilter}
/>
);
}

View File

@@ -1,13 +1,14 @@
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './NoCollection.css';
import styles from './NoMovieCollections.css';
function NoCollection(props) {
const { totalItems } = props;
interface NoMovieCollectionsProps {
totalItems: number;
}
function NoMovieCollections({ totalItems }: NoMovieCollectionsProps) {
if (totalItems > 0) {
return (
<div>
@@ -20,24 +21,16 @@ function NoCollection(props) {
return (
<div>
<div className={styles.message}>
{translate('NoCollections')}
</div>
<div className={styles.message}>{translate('NoCollections')}</div>
<div className={styles.buttonContainer}>
<Button
to="/add/import"
kind={kinds.PRIMARY}
>
<Button to="/add/import" kind={kinds.PRIMARY}>
{translate('ImportExistingMovies')}
</Button>
</div>
<div className={styles.buttonContainer}>
<Button
to="/add/new"
kind={kinds.PRIMARY}
>
<Button to="/add/new" kind={kinds.PRIMARY}>
{translate('AddNewMovie')}
</Button>
</div>
@@ -45,8 +38,4 @@ function NoCollection(props) {
);
}
NoCollection.propTypes = {
totalItems: PropTypes.number.isRequired
};
export default NoCollection;
export default NoMovieCollections;

View File

@@ -1,12 +1,12 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AddNewMovieCollectionMovieModal from 'Collection/AddNewMovieCollectionMovieModal';
import Link from 'Components/Link/Link';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import EditMovieModal from 'Movie/Edit/EditMovieModal';
import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
import MoviePoster from 'Movie/MoviePoster';
import translate from 'Utilities/String/translate';
import AddNewCollectionMovieModal from './../AddNewCollectionMovieModal';
import styles from './CollectionMovie.css';
class CollectionMovie extends Component {
@@ -160,7 +160,7 @@ class CollectionMovie extends Component {
</Link>
</div>
<AddNewCollectionMovieModal
<AddNewMovieCollectionMovieModal
isOpen={isNewAddMovieModalOpen && !isExistingMovie}
tmdbId={tmdbId}
title={title}
@@ -188,7 +188,7 @@ CollectionMovie.propTypes = {
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
status: PropTypes.string.isRequired,
overview: PropTypes.string.isRequired,
overview: PropTypes.string,
monitored: PropTypes.bool,
collectionId: PropTypes.number.isRequired,
hasFile: PropTypes.bool,

View File

@@ -3,7 +3,7 @@ import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import { Navigation } from 'swiper';
import { Swiper, SwiperSlide } from 'swiper/react';
import EditCollectionModalConnector from 'Collection/Edit/EditCollectionModalConnector';
import EditMovieCollectionModal from 'Collection/Edit/EditMovieCollectionModal';
import CheckInput from 'Components/Form/CheckInput';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
@@ -311,7 +311,7 @@ class CollectionOverview extends Component {
</div>
</div>
<EditCollectionModalConnector
<EditMovieCollectionModal
isOpen={isEditCollectionModalOpen}
collectionId={id}
onModalClose={this.onEditCollectionModalClose}

View File

@@ -0,0 +1,21 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
export function createMovieCollectionSelector(collectionId?: number) {
return createSelector(
(state: AppState) => state.movieCollections.itemMap,
(state: AppState) => state.movieCollections.items,
(itemMap, allMovieCollections) => {
return collectionId
? allMovieCollections[itemMap[collectionId]]
: undefined;
}
);
}
function useMovieCollection(collectionId: number | undefined) {
return useSelector(createMovieCollectionSelector(collectionId));
}
export default useMovieCollection;

View File

@@ -4,6 +4,7 @@ import React, { Component, ErrorInfo } from 'react';
interface ErrorBoundaryProps {
children: React.ReactNode;
errorComponent: React.ElementType;
onModalClose?: () => void;
}
interface ErrorBoundaryState {
@@ -32,11 +33,17 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
}
render() {
const { children, errorComponent: ErrorComponent } = this.props;
const {
children,
errorComponent: ErrorComponent,
onModalClose,
} = this.props;
const { error, info } = this.state;
if (error) {
return <ErrorComponent error={error} info={info} />;
return (
<ErrorComponent error={error} info={info} onModalClose={onModalClose} />
);
}
return children;

View File

@@ -3,7 +3,7 @@ import StackTrace from 'stacktrace-js';
import translate from 'Utilities/String/translate';
import styles from './ErrorBoundaryError.css';
interface ErrorBoundaryErrorProps {
export interface ErrorBoundaryErrorProps {
className: string;
messageClassName: string;
detailsClassName: string;

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import PathInput from 'Components/Form/PathInput';
import { PathInputInternal } from 'Components/Form/PathInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
@@ -151,7 +151,7 @@ function FileBrowserModalContent(props: FileBrowserModalContentProps) {
</Alert>
) : null}
<PathInput
<PathInputInternal
className={styles.pathInput}
placeholder={translate('FileBrowserPlaceholderText')}
hasFileBrowser={false}

View File

@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TagInput from 'Components/Form/TagInput';
import TagInput from 'Components/Form/Tag/TagInput';
import { filterBuilderTypes, filterBuilderValueTypes, kinds } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import convertToBytes from 'Utilities/Number/convertToBytes';

View File

@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import TagInputTag from 'Components/Form/TagInputTag';
import TagInputTag from 'Components/Form/Tag/TagInputTag';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './FilterBuilderRowValueTag.css';

View File

@@ -1,98 +0,0 @@
import jdu from 'jdu';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AutoSuggestInput from './AutoSuggestInput';
class AutoCompleteInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
suggestions: []
};
}
//
// Control
getSuggestionValue(item) {
return item;
}
renderSuggestion(item) {
return item;
}
//
// Listeners
onInputChange = (event, { newValue }) => {
this.props.onChange({
name: this.props.name,
value: newValue
});
};
onInputBlur = () => {
this.setState({ suggestions: [] });
};
onSuggestionsFetchRequested = ({ value }) => {
const { values } = this.props;
const lowerCaseValue = jdu.replace(value).toLowerCase();
const filteredValues = values.filter((v) => {
return jdu.replace(v).toLowerCase().contains(lowerCaseValue);
});
this.setState({ suggestions: filteredValues });
};
onSuggestionsClearRequested = () => {
this.setState({ suggestions: [] });
};
//
// Render
render() {
const {
name,
value,
...otherProps
} = this.props;
const { suggestions } = this.state;
return (
<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 = {
name: PropTypes.string.isRequired,
value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired
};
AutoCompleteInput.defaultProps = {
value: ''
};
export default AutoCompleteInput;

View File

@@ -0,0 +1,82 @@
import jdu from 'jdu';
import React, { SyntheticEvent, useCallback, useState } from 'react';
import {
ChangeEvent,
SuggestionsFetchRequestedParams,
} from 'react-autosuggest';
import { InputChanged } from 'typings/inputs';
import AutoSuggestInput from './AutoSuggestInput';
export interface AutoCompleteInputProps {
name: string;
readOnly?: boolean;
value?: string;
values: string[];
onChange: (change: InputChanged<string>) => unknown;
}
function AutoCompleteInput({
name,
value = '',
values,
onChange,
...otherProps
}: AutoCompleteInputProps) {
const [suggestions, setSuggestions] = useState<string[]>([]);
const getSuggestionValue = useCallback((item: string) => {
return item;
}, []);
const renderSuggestion = useCallback((item: string) => {
return item;
}, []);
const handleInputChange = useCallback(
(_event: SyntheticEvent, { newValue }: ChangeEvent) => {
onChange({
name,
value: newValue,
});
},
[name, onChange]
);
const handleInputBlur = useCallback(() => {
setSuggestions([]);
}, [setSuggestions]);
const handleSuggestionsFetchRequested = useCallback(
({ value: newValue }: SuggestionsFetchRequestedParams) => {
const lowerCaseValue = jdu.replace(newValue).toLowerCase();
const filteredValues = values.filter((v) => {
return jdu.replace(v).toLowerCase().includes(lowerCaseValue);
});
setSuggestions(filteredValues);
},
[values, setSuggestions]
);
const handleSuggestionsClearRequested = useCallback(() => {
setSuggestions([]);
}, [setSuggestions]);
return (
<AutoSuggestInput
{...otherProps}
name={name}
value={value}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
onInputChange={handleInputChange}
onInputBlur={handleInputBlur}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
/>
);
}
export default AutoCompleteInput;

View File

@@ -1,257 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
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.elementType,
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;

View File

@@ -0,0 +1,259 @@
import classNames from 'classnames';
import React, {
FocusEvent,
FormEvent,
KeyboardEvent,
KeyboardEventHandler,
MutableRefObject,
ReactNode,
Ref,
SyntheticEvent,
useCallback,
useEffect,
useRef,
} from 'react';
import Autosuggest, {
AutosuggestPropsBase,
BlurEvent,
ChangeEvent,
RenderInputComponentProps,
RenderSuggestionsContainerParams,
} from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { InputChanged } from 'typings/inputs';
import styles from './AutoSuggestInput.css';
interface AutoSuggestInputProps<T>
extends Omit<AutosuggestPropsBase<T>, 'renderInputComponent' | 'inputProps'> {
forwardedRef?: MutableRefObject<Autosuggest<T> | null>;
className?: string;
inputContainerClassName?: string;
name: string;
value?: string;
placeholder?: string;
suggestions: T[];
hasError?: boolean;
hasWarning?: boolean;
enforceMaxHeight?: boolean;
minHeight?: number;
maxHeight?: number;
renderInputComponent?: (
inputProps: RenderInputComponentProps,
ref: Ref<HTMLDivElement>
) => ReactNode;
onInputChange: (
event: FormEvent<HTMLElement>,
params: ChangeEvent
) => unknown;
onInputKeyDown?: KeyboardEventHandler<HTMLElement>;
onInputFocus?: (event: SyntheticEvent) => unknown;
onInputBlur: (
event: FocusEvent<HTMLElement>,
params?: BlurEvent<T>
) => unknown;
onChange?: (change: InputChanged<T>) => unknown;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
const {
// TODO: forwaredRef should be replaces with React.forwardRef
forwardedRef,
className = styles.input,
inputContainerClassName = styles.inputContainer,
name,
value = '',
placeholder,
suggestions,
enforceMaxHeight = true,
hasError,
hasWarning,
minHeight = 50,
maxHeight = 200,
getSuggestionValue,
renderSuggestion,
renderInputComponent,
onInputChange,
onInputKeyDown,
onInputFocus,
onInputBlur,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
onSuggestionSelected,
onChange,
...otherProps
} = props;
const updater = useRef<(() => void) | null>(null);
const previousSuggestions = usePrevious(suggestions);
const handleComputeMaxHeight = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data: any) => {
const { top, bottom, width } = data.offsets.reference;
if (enforceMaxHeight) {
data.styles.maxHeight = maxHeight;
} else {
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;
},
[enforceMaxHeight, maxHeight]
);
const createRenderInputComponent = useCallback(
(inputProps: RenderInputComponentProps) => {
return (
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
return (
<div ref={ref}>
<input {...inputProps} />
</div>
);
}}
</Reference>
);
},
[renderInputComponent]
);
const renderSuggestionsContainer = useCallback(
({ containerProps, children }: RenderSuggestionsContainerParams) => {
return (
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: handleComputeMaxHeight,
},
flip: {
padding: minHeight,
},
}}
>
{({ ref: popperRef, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={
children ? styles.suggestionsContainerOpen : undefined
}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight,
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
);
},
[minHeight, handleComputeMaxHeight]
);
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== value
) {
event.preventDefault();
if (value) {
onSuggestionSelected?.(event, {
suggestion: suggestions[0],
suggestionValue: value,
suggestionIndex: 0,
sectionIndex: null,
method: 'enter',
});
}
}
},
[value, suggestions, onSuggestionSelected]
);
const inputProps = {
className: classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: onInputChange,
onKeyDown: onInputKeyDown || handleInputKeyDown,
onFocus: onInputFocus,
onBlur: onInputBlur,
};
const theme = {
container: inputContainerClassName,
containerOpen: styles.suggestionsContainerOpen,
suggestionsContainer: styles.suggestionsContainer,
suggestionsList: styles.suggestionsList,
suggestion: styles.suggestion,
suggestionHighlighted: styles.suggestionHighlighted,
};
useEffect(() => {
if (updater.current && suggestions !== previousSuggestions) {
updater.current();
}
}, [suggestions, previousSuggestions]);
return (
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={createRenderInputComponent}
renderSuggestionsContainer={renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
);
}
export default AutoSuggestInput;

View File

@@ -1,84 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import FormInputButton from './FormInputButton';
import TextInput from './TextInput';
import styles from './CaptchaInput.css';
function CaptchaInput(props) {
const {
className,
name,
value,
hasError,
hasWarning,
refreshing,
siteKey,
secretToken,
onChange,
onRefreshPress,
onCaptchaChange
} = props;
return (
<div>
<div className={styles.captchaInputWrapper}>
<TextInput
className={classNames(
className,
styles.hasButton,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
name={name}
value={value}
onChange={onChange}
/>
<FormInputButton
onPress={onRefreshPress}
>
<Icon
name={icons.REFRESH}
isSpinning={refreshing}
/>
</FormInputButton>
</div>
{
!!siteKey && !!secretToken &&
<div className={styles.recaptchaWrapper}>
<ReCAPTCHA
sitekey={siteKey}
stoken={secretToken}
onChange={onCaptchaChange}
/>
</div>
}
</div>
);
}
CaptchaInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
refreshing: PropTypes.bool.isRequired,
siteKey: PropTypes.string,
secretToken: PropTypes.string,
onChange: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onCaptchaChange: PropTypes.func.isRequired
};
CaptchaInput.defaultProps = {
className: styles.input,
value: ''
};
export default CaptchaInput;

View File

@@ -0,0 +1,118 @@
import classNames from 'classnames';
import React, { useCallback, useEffect } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Icon from 'Components/Icon';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons } from 'Helpers/Props';
import {
getCaptchaCookie,
refreshCaptcha,
resetCaptcha,
} from 'Store/Actions/captchaActions';
import { InputChanged } from 'typings/inputs';
import FormInputButton from './FormInputButton';
import TextInput from './TextInput';
import styles from './CaptchaInput.css';
export interface CaptchaInputProps {
className?: string;
name: string;
value?: string;
provider: string;
providerData: object;
hasError?: boolean;
hasWarning?: boolean;
refreshing: boolean;
siteKey?: string;
secretToken?: string;
onChange: (change: InputChanged<string>) => unknown;
}
function CaptchaInput({
className = styles.input,
name,
value = '',
provider,
providerData,
hasError,
hasWarning,
refreshing,
siteKey,
secretToken,
onChange,
}: CaptchaInputProps) {
const { token } = useSelector((state: AppState) => state.captcha);
const dispatch = useDispatch();
const previousToken = usePrevious(token);
const handleCaptchaChange = useCallback(
(token: string | null) => {
// If the captcha has expired `captchaResponse` will be null.
// In the event it's null don't try to get the captchaCookie.
// TODO: Should we clear the cookie? or reset the captcha?
if (!token) {
return;
}
dispatch(
getCaptchaCookie({
provider,
providerData,
captchaResponse: token,
})
);
},
[provider, providerData, dispatch]
);
const handleRefreshPress = useCallback(() => {
dispatch(refreshCaptcha({ provider, providerData }));
}, [provider, providerData, dispatch]);
useEffect(() => {
if (token && token !== previousToken) {
onChange({ name, value: token });
}
}, [name, token, previousToken, onChange]);
useEffect(() => {
dispatch(resetCaptcha());
}, [dispatch]);
return (
<div>
<div className={styles.captchaInputWrapper}>
<TextInput
className={classNames(
className,
styles.hasButton,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
name={name}
value={value}
onChange={onChange}
/>
<FormInputButton onPress={handleRefreshPress}>
<Icon name={icons.REFRESH} isSpinning={refreshing} />
</FormInputButton>
</div>
{siteKey && secretToken ? (
<div className={styles.recaptchaWrapper}>
<ReCAPTCHA
sitekey={siteKey}
stoken={secretToken}
onChange={handleCaptchaChange}
/>
</div>
) : null}
</div>
);
}
export default CaptchaInput;

View File

@@ -1,98 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { getCaptchaCookie, refreshCaptcha, resetCaptcha } from 'Store/Actions/captchaActions';
import CaptchaInput from './CaptchaInput';
function createMapStateToProps() {
return createSelector(
(state) => state.captcha,
(captcha) => {
return captcha;
}
);
}
const mapDispatchToProps = {
refreshCaptcha,
getCaptchaCookie,
resetCaptcha
};
class CaptchaInputConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
const {
name,
token,
onChange
} = this.props;
if (token && token !== prevProps.token) {
onChange({ name, value: token });
}
}
componentWillUnmount = () => {
this.props.resetCaptcha();
};
//
// Listeners
onRefreshPress = () => {
const {
provider,
providerData
} = this.props;
this.props.refreshCaptcha({ provider, providerData });
};
onCaptchaChange = (captchaResponse) => {
// If the captcha has expired `captchaResponse` will be null.
// In the event it's null don't try to get the captchaCookie.
// TODO: Should we clear the cookie? or reset the captcha?
if (!captchaResponse) {
return;
}
const {
provider,
providerData
} = this.props;
this.props.getCaptchaCookie({ provider, providerData, captchaResponse });
};
//
// Render
render() {
return (
<CaptchaInput
{...this.props}
onRefreshPress={this.onRefreshPress}
onCaptchaChange={this.onCaptchaChange}
/>
);
}
}
CaptchaInputConnector.propTypes = {
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
token: PropTypes.string,
onChange: PropTypes.func.isRequired,
refreshCaptcha: PropTypes.func.isRequired,
getCaptchaCookie: PropTypes.func.isRequired,
resetCaptcha: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector);

View File

@@ -41,10 +41,11 @@
.checkbox:focus + .input {
outline: 0;
border-color: var(--inputFocusBorderColor);
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), 0 0 8px var(--inputFocusBoxShadowColor);
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor),
0 0 8px var(--inputFocusBoxShadowColor);
}
.dangerIsChecked {
.danger {
border-color: var(--dangerColor);
background-color: var(--dangerColor);
@@ -53,7 +54,7 @@
}
}
.primaryIsChecked {
.primary {
border-color: var(--primaryColor);
background-color: var(--primaryColor);
@@ -62,7 +63,7 @@
}
}
.successIsChecked {
.success {
border-color: var(--successColor);
background-color: var(--successColor);
@@ -71,7 +72,7 @@
}
}
.warningIsChecked {
.warning {
border-color: var(--warningColor);
background-color: var(--warningColor);

View File

@@ -3,16 +3,16 @@
interface CssExports {
'checkbox': string;
'container': string;
'dangerIsChecked': string;
'danger': string;
'helpText': string;
'input': string;
'isDisabled': string;
'isIndeterminate': string;
'isNotChecked': string;
'label': string;
'primaryIsChecked': string;
'successIsChecked': string;
'warningIsChecked': string;
'primary': string;
'success': string;
'warning': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,191 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import { icons, kinds } from 'Helpers/Props';
import FormInputHelpText from './FormInputHelpText';
import styles from './CheckInput.css';
class CheckInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._checkbox = null;
}
componentDidMount() {
this.setIndeterminate();
}
componentDidUpdate() {
this.setIndeterminate();
}
//
// Control
setIndeterminate() {
if (!this._checkbox) {
return;
}
const {
value,
uncheckedValue,
checkedValue
} = this.props;
this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue;
}
toggleChecked = (checked, shiftKey) => {
const {
name,
value,
checkedValue,
uncheckedValue
} = this.props;
const newValue = checked ? checkedValue : uncheckedValue;
if (value !== newValue) {
this.props.onChange({
name,
value: newValue,
shiftKey
});
}
};
//
// Listeners
setRef = (ref) => {
this._checkbox = ref;
};
onClick = (event) => {
if (this.props.isDisabled) {
return;
}
const shiftKey = event.nativeEvent.shiftKey;
const checked = !this._checkbox.checked;
event.preventDefault();
this.toggleChecked(checked, shiftKey);
};
onChange = (event) => {
const checked = event.target.checked;
const shiftKey = event.nativeEvent.shiftKey;
this.toggleChecked(checked, shiftKey);
};
//
// Render
render() {
const {
className,
containerClassName,
name,
value,
checkedValue,
uncheckedValue,
helpText,
helpTextWarning,
isDisabled,
kind
} = this.props;
const isChecked = value === checkedValue;
const isUnchecked = value === uncheckedValue;
const isIndeterminate = !isChecked && !isUnchecked;
const isCheckClass = `${kind}IsChecked`;
return (
<div className={containerClassName}>
<label
className={styles.label}
onClick={this.onClick}
>
<input
ref={this.setRef}
className={styles.checkbox}
type="checkbox"
name={name}
checked={isChecked}
disabled={isDisabled}
onChange={this.onChange}
/>
<div
className={classNames(
className,
isChecked ? styles[isCheckClass] : styles.isNotChecked,
isIndeterminate && styles.isIndeterminate,
isDisabled && styles.isDisabled
)}
>
{
isChecked &&
<Icon name={icons.CHECK} />
}
{
isIndeterminate &&
<Icon name={icons.CHECK_INDETERMINATE} />
}
</div>
{
helpText &&
<FormInputHelpText
className={styles.helpText}
text={helpText}
/>
}
{
!helpText && helpTextWarning &&
<FormInputHelpText
className={styles.helpText}
text={helpTextWarning}
isWarning={true}
/>
}
</label>
</div>
);
}
}
CheckInput.propTypes = {
className: PropTypes.string.isRequired,
containerClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
checkedValue: PropTypes.bool,
uncheckedValue: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
helpText: PropTypes.string,
helpTextWarning: PropTypes.string,
isDisabled: PropTypes.bool,
kind: PropTypes.oneOf(kinds.all).isRequired,
onChange: PropTypes.func.isRequired
};
CheckInput.defaultProps = {
className: styles.input,
containerClassName: styles.container,
checkedValue: true,
uncheckedValue: false,
kind: kinds.PRIMARY
};
export default CheckInput;

View File

@@ -0,0 +1,140 @@
import classNames from 'classnames';
import React, { SyntheticEvent, useCallback, useEffect, useRef } from 'react';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { CheckInputChanged } from 'typings/inputs';
import FormInputHelpText from './FormInputHelpText';
import styles from './CheckInput.css';
interface ChangeEvent<T = Element> extends SyntheticEvent<T, MouseEvent> {
target: EventTarget & T;
}
export interface CheckInputProps {
className?: string;
containerClassName?: string;
name: string;
checkedValue?: boolean;
uncheckedValue?: boolean;
value?: string | boolean | null;
helpText?: string;
helpTextWarning?: string;
isDisabled?: boolean;
kind?: Extract<Kind, keyof typeof styles>;
onChange: (changes: CheckInputChanged) => void;
}
function CheckInput(props: CheckInputProps) {
const {
className = styles.input,
containerClassName = styles.container,
name,
value,
checkedValue = true,
uncheckedValue = false,
helpText,
helpTextWarning,
isDisabled,
kind = 'primary',
onChange,
} = props;
const inputRef = useRef<HTMLInputElement>(null);
const isChecked = value === checkedValue;
const isUnchecked = value === uncheckedValue;
const isIndeterminate = !isChecked && !isUnchecked;
const toggleChecked = useCallback(
(checked: boolean, shiftKey: boolean) => {
const newValue = checked ? checkedValue : uncheckedValue;
if (value !== newValue) {
onChange({
name,
value: newValue,
shiftKey,
});
}
},
[name, value, checkedValue, uncheckedValue, onChange]
);
const handleClick = useCallback(
(event: SyntheticEvent<HTMLElement, MouseEvent>) => {
if (isDisabled) {
return;
}
const shiftKey = event.nativeEvent.shiftKey;
const checked = !(inputRef.current?.checked ?? false);
event.preventDefault();
toggleChecked(checked, shiftKey);
},
[isDisabled, toggleChecked]
);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked;
const shiftKey = event.nativeEvent.shiftKey;
toggleChecked(checked, shiftKey);
},
[toggleChecked]
);
useEffect(() => {
if (!inputRef.current) {
return;
}
inputRef.current.indeterminate =
value !== uncheckedValue && value !== checkedValue;
}, [value, uncheckedValue, checkedValue]);
return (
<div className={containerClassName}>
<label className={styles.label} onClick={handleClick}>
<input
ref={inputRef}
className={styles.checkbox}
type="checkbox"
name={name}
checked={isChecked}
disabled={isDisabled}
onChange={handleChange}
/>
<div
className={classNames(
className,
isChecked ? styles[kind] : styles.isNotChecked,
isIndeterminate && styles.isIndeterminate,
isDisabled && styles.isDisabled
)}
>
{isChecked ? <Icon name={icons.CHECK} /> : null}
{isIndeterminate ? <Icon name={icons.CHECK_INDETERMINATE} /> : null}
</div>
{helpText ? (
<FormInputHelpText className={styles.helpText} text={helpText} />
) : null}
{!helpText && helpTextWarning ? (
<FormInputHelpText
className={styles.helpText}
text={helpTextWarning}
isWarning={true}
/>
) : null}
</label>
</div>
);
}
export default CheckInput;

View File

@@ -1,106 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import FormInputButton from './FormInputButton';
import TagInput from './TagInput';
import styles from './DeviceInput.css';
class DeviceInput extends Component {
onTagAdd = (device) => {
const {
name,
value,
onChange
} = this.props;
// New tags won't have an ID, only a name.
const deviceId = device.id || device.name;
onChange({
name,
value: [...value, deviceId]
});
};
onTagDelete = ({ index }) => {
const {
name,
value,
onChange
} = this.props;
const newValue = value.slice();
newValue.splice(index, 1);
onChange({
name,
value: newValue
});
};
//
// Render
render() {
const {
className,
name,
items,
selectedDevices,
hasError,
hasWarning,
isFetching,
onRefreshPress
} = this.props;
return (
<div className={className}>
<TagInput
inputContainerClassName={styles.input}
name={name}
tags={selectedDevices}
tagList={items}
allowNew={true}
minQueryLength={0}
hasError={hasError}
hasWarning={hasWarning}
onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete}
/>
<FormInputButton
onPress={onRefreshPress}
>
<Icon
name={icons.REFRESH}
isSpinning={isFetching}
/>
</FormInputButton>
</div>
);
}
}
DeviceInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired
};
DeviceInput.defaultProps = {
className: styles.deviceInputWrapper,
inputClassName: styles.input
};
export default DeviceInput;

View File

@@ -1,104 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
import DeviceInput from './DeviceInput';
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
(state) => state.providerOptions.devices || defaultState,
(value, devices) => {
return {
...devices,
selectedDevices: value.map((valueDevice) => {
// Disable equality ESLint rule so we don't need to worry about
// a type mismatch between the value items and the device ID.
// eslint-disable-next-line eqeqeq
const device = devices.items.find((d) => d.id == valueDevice);
if (device) {
return {
id: device.id,
name: `${device.name} (${device.id})`
};
}
return {
id: valueDevice,
name: `Unknown (${valueDevice})`
};
})
};
}
);
}
const mapDispatchToProps = {
dispatchFetchOptions: fetchOptions,
dispatchClearOptions: clearOptions
};
class DeviceInputConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
this._populate();
};
componentWillUnmount = () => {
this.props.dispatchClearOptions({ section: 'devices' });
};
//
// Control
_populate() {
const {
provider,
providerData,
dispatchFetchOptions
} = this.props;
dispatchFetchOptions({
section: 'devices',
action: 'getDevices',
provider,
providerData
});
}
//
// Listeners
onRefreshPress = () => {
this._populate();
};
//
// Render
render() {
return (
<DeviceInput
{...this.props}
onRefreshPress={this.onRefreshPress}
/>
);
}
}
DeviceInputConnector.propTypes = {
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
dispatchFetchOptions: PropTypes.func.isRequired,
dispatchClearOptions: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector);

View File

@@ -1,102 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.downloadClients,
(state, { includeAny }) => includeAny,
(state, { protocol }) => protocol,
(downloadClients, includeAny, protocolFilter) => {
const {
isFetching,
isPopulated,
error,
items
} = downloadClients;
const filteredItems = items.filter((item) => item.protocol === protocolFilter);
const values = _.map(filteredItems.sort(sortByProp('name')), (downloadClient) => {
return {
key: downloadClient.id,
value: downloadClient.name,
hint: `(${downloadClient.id})`
};
});
if (includeAny) {
values.unshift({
key: 0,
value: `(${translate('Any')})`
});
}
return {
isFetching,
isPopulated,
error,
values
};
}
);
}
const mapDispatchToProps = {
dispatchFetchDownloadClients: fetchDownloadClients
};
class DownloadClientSelectInputConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.isPopulated) {
this.props.dispatchFetchDownloadClients();
}
}
//
// Listeners
onChange = ({ name, value }) => {
this.props.onChange({ name, value: parseInt(value) });
};
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
DownloadClientSelectInputConnector.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
includeAny: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
dispatchFetchDownloadClients: PropTypes.func.isRequired
};
DownloadClientSelectInputConnector.defaultProps = {
includeAny: false,
protocol: 'torrent'
};
export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector);

View File

@@ -1,613 +0,0 @@
import classNames from 'classnames';
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import Portal from 'Components/Portal';
import Scroller from 'Components/Scroller/Scroller';
import { icons, scrollDirections, sizes } from 'Helpers/Props';
import { isMobile as isMobileUtil } from 'Utilities/browser';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import HintedSelectInputOption from './HintedSelectInputOption';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import TextInput from './TextInput';
import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 10;
function isArrowKey(keyCode) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
function getSelectedOption(selectedIndex, values) {
return values[selectedIndex];
}
function findIndex(startingIndex, direction, values) {
let indexToTest = startingIndex + direction;
while (indexToTest !== startingIndex) {
if (indexToTest < 0) {
indexToTest = values.length - 1;
} else if (indexToTest >= values.length) {
indexToTest = 0;
}
if (getSelectedOption(indexToTest, values).isDisabled) {
indexToTest = indexToTest + direction;
} else {
return indexToTest;
}
}
}
function previousIndex(selectedIndex, values) {
return findIndex(selectedIndex, -1, values);
}
function nextIndex(selectedIndex, values) {
return findIndex(selectedIndex, 1, values);
}
function getSelectedIndex(props) {
const {
value,
values
} = props;
if (Array.isArray(value)) {
return values.findIndex((v) => {
return value.size && v.key === value[0];
});
}
return values.findIndex((v) => {
return v.key === value;
});
}
function isSelectedItem(index, props) {
const {
value,
values
} = props;
if (Array.isArray(value)) {
return value.includes(values[index].key);
}
return values[index].key === value;
}
function getKey(selectedIndex, values) {
return values[selectedIndex].key;
}
class EnhancedSelectInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scheduleUpdate = null;
this._buttonId = getUniqueElememtId();
this._optionsId = getUniqueElememtId();
this.state = {
isOpen: false,
selectedIndex: getSelectedIndex(props),
width: 0,
isMobile: isMobileUtil()
};
}
componentDidUpdate(prevProps) {
if (this._scheduleUpdate) {
this._scheduleUpdate();
}
if (!Array.isArray(this.props.value)) {
if (prevProps.value !== this.props.value || prevProps.values !== this.props.values) {
this.setState({
selectedIndex: getSelectedIndex(this.props)
});
}
}
}
//
// Control
_addListener() {
window.addEventListener('click', this.onWindowClick);
}
_removeListener() {
window.removeEventListener('click', this.onWindowClick);
}
//
// Listeners
onComputeMaxHeight = (data) => {
const windowHeight = window.innerHeight;
data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
return data;
};
onWindowClick = (event) => {
const button = document.getElementById(this._buttonId);
const options = document.getElementById(this._optionsId);
if (!button || !event.target.isConnected || this.state.isMobile) {
return;
}
if (
!button.contains(event.target) &&
options &&
!options.contains(event.target) &&
this.state.isOpen
) {
this.setState({ isOpen: false });
this._removeListener();
}
};
onFocus = () => {
if (this.state.isOpen) {
this._removeListener();
this.setState({ isOpen: false });
}
};
onBlur = () => {
if (!this.props.isEditable) {
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
const origIndex = getSelectedIndex(this.props);
if (origIndex !== this.state.selectedIndex) {
this.setState({ selectedIndex: origIndex });
}
}
};
onKeyDown = (event) => {
const {
values
} = this.props;
const {
isOpen,
selectedIndex
} = this.state;
const keyCode = event.keyCode;
const newState = {};
if (!isOpen) {
if (isArrowKey(keyCode)) {
event.preventDefault();
newState.isOpen = true;
}
if (
selectedIndex == null || selectedIndex === -1 ||
getSelectedOption(selectedIndex, values).isDisabled
) {
if (keyCode === keyCodes.UP_ARROW) {
newState.selectedIndex = previousIndex(0, values);
} else if (keyCode === keyCodes.DOWN_ARROW) {
newState.selectedIndex = nextIndex(values.length - 1, values);
}
}
this.setState(newState);
return;
}
if (keyCode === keyCodes.UP_ARROW) {
event.preventDefault();
newState.selectedIndex = previousIndex(selectedIndex, values);
}
if (keyCode === keyCodes.DOWN_ARROW) {
event.preventDefault();
newState.selectedIndex = nextIndex(selectedIndex, values);
}
if (keyCode === keyCodes.ENTER) {
event.preventDefault();
newState.isOpen = false;
this.onSelect(getKey(selectedIndex, values));
}
if (keyCode === keyCodes.TAB) {
newState.isOpen = false;
this.onSelect(getKey(selectedIndex, values));
}
if (keyCode === keyCodes.ESCAPE) {
event.preventDefault();
event.stopPropagation();
newState.isOpen = false;
newState.selectedIndex = getSelectedIndex(this.props);
}
if (!_.isEmpty(newState)) {
this.setState(newState);
}
};
onPress = () => {
if (this.state.isOpen) {
this._removeListener();
} else {
this._addListener();
}
if (!this.state.isOpen && this.props.onOpen) {
this.props.onOpen();
}
this.setState({ isOpen: !this.state.isOpen });
};
onSelect = (newValue) => {
const { name, value, values, onChange } = this.props;
const additionalProperties = values.find((v) => v.key === newValue)?.additionalProperties;
if (Array.isArray(value)) {
let arrayValue = null;
const index = value.indexOf(newValue);
if (index === -1) {
arrayValue = values.map((v) => v.key).filter((v) => (v === newValue) || value.includes(v));
} else {
arrayValue = [...value];
arrayValue.splice(index, 1);
}
onChange({
name,
value: arrayValue,
additionalProperties
});
} else {
this.setState({ isOpen: false });
onChange({
name,
value: newValue,
additionalProperties
});
}
};
onMeasure = ({ width }) => {
this.setState({ width });
};
onOptionsModalClose = () => {
this.setState({ isOpen: false });
};
//
// Render
render() {
const {
className,
disabledClassName,
name,
value,
values,
isDisabled,
isEditable,
isFetching,
hasError,
hasWarning,
valueOptions,
selectedValueOptions,
selectedValueComponent: SelectedValueComponent,
optionComponent: OptionComponent,
onChange
} = this.props;
const {
selectedIndex,
width,
isOpen,
isMobile
} = this.state;
const isMultiSelect = Array.isArray(value);
const selectedOption = getSelectedOption(selectedIndex, values);
let selectedValue = value;
if (!values.length) {
selectedValue = isMultiSelect ? [] : '';
}
return (
<div>
<Manager>
<Reference>
{({ ref }) => (
<div
ref={ref}
id={this._buttonId}
>
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
{
isEditable ?
<div
className={styles.editableContainer}
>
<TextInput
className={className}
name={name}
value={value}
readOnly={isDisabled}
hasError={hasError}
hasWarning={hasWarning}
onFocus={this.onFocus}
onBlur={this.onBlur}
onChange={onChange}
/>
<Link
className={classNames(
styles.dropdownArrowContainerEditable,
isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer)
}
onPress={this.onPress}
>
{
isFetching ?
<LoadingIndicator
className={styles.loading}
size={20}
/> :
null
}
{
isFetching ?
null :
<Icon
name={icons.CARET_DOWN}
/>
}
</Link>
</div> :
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
>
<SelectedValueComponent
value={selectedValue}
values={values}
{...selectedValueOptions}
{...selectedOption}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : null}
</SelectedValueComponent>
<div
className={isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer
}
>
{
isFetching ?
<LoadingIndicator
className={styles.loading}
size={20}
/> :
null
}
{
isFetching ?
null :
<Icon
name={icons.CARET_DOWN}
/>
}
</div>
</Link>
}
</Measure>
</div>
)}
</Reference>
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
},
preventOverflow: {
enabled: true,
boundariesElement: 'viewport'
}
}}
>
{({ ref, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={ref}
id={this._optionsId}
className={styles.optionsContainer}
style={{
...style,
minWidth: width
}}
>
{
isOpen && !isMobile ?
<Scroller
className={styles.options}
style={{
maxHeight: style.maxHeight
}}
>
{
values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected = hasParent && Array.isArray(value) && value.includes(v.parentKey);
return (
<OptionComponent
key={v.key}
id={v.key}
dividerAfter={v.dividerAfter}
depth={depth}
isSelected={isSelectedItem(index, this.props)}
isDisabled={parentSelected}
isMultiSelect={isMultiSelect}
{...valueOptions}
{...v}
isMobile={false}
onSelect={this.onSelect}
>
{v.value}
</OptionComponent>
);
})
}
</Scroller> :
null
}
</div>
);
}
}
</Popper>
</Portal>
</Manager>
{
isMobile ?
<Modal
className={styles.optionsModal}
size={sizes.EXTRA_SMALL}
isOpen={isOpen}
onModalClose={this.onOptionsModalClose}
>
<ModalBody
className={styles.optionsModalBody}
innerClassName={styles.optionsInnerModalBody}
scrollDirection={scrollDirections.NONE}
>
<Scroller className={styles.optionsModalScroller}>
<div className={styles.mobileCloseButtonContainer}>
<Link
className={styles.mobileCloseButton}
onPress={this.onOptionsModalClose}
>
<Icon
name={icons.CLOSE}
size={18}
/>
</Link>
</div>
{
values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
const parentSelected = hasParent && value.includes(v.parentKey);
return (
<OptionComponent
key={v.key}
id={v.key}
dividerAfter={v.dividerAfter}
depth={depth}
isSelected={isSelectedItem(index, this.props)}
isMultiSelect={isMultiSelect}
isDisabled={parentSelected}
{...valueOptions}
{...v}
isMobile={true}
onSelect={this.onSelect}
>
{v.value}
</OptionComponent>
);
})
}
</Scroller>
</ModalBody>
</Modal> :
null
}
</div>
);
}
}
EnhancedSelectInput.propTypes = {
className: PropTypes.string,
disabledClassName: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isEditable: PropTypes.bool.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
valueOptions: PropTypes.object.isRequired,
selectedValueOptions: PropTypes.object.isRequired,
selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
optionComponent: PropTypes.elementType,
onOpen: PropTypes.func,
onChange: PropTypes.func.isRequired
};
EnhancedSelectInput.defaultProps = {
className: styles.enhancedSelect,
disabledClassName: styles.isDisabled,
isDisabled: false,
isFetching: false,
isEditable: false,
valueOptions: {},
selectedValueOptions: {},
selectedValueComponent: HintedSelectInputSelectedValue,
optionComponent: HintedSelectInputOption
};
export default EnhancedSelectInput;

View File

@@ -1,162 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
import EnhancedSelectInput from './EnhancedSelectInput';
const importantFieldNames = [
'baseUrl',
'apiPath',
'apiKey',
'authToken'
];
function getProviderDataKey(providerData) {
if (!providerData || !providerData.fields) {
return null;
}
const fields = providerData.fields
.filter((f) => importantFieldNames.includes(f.name))
.map((f) => f.value);
return fields;
}
function getSelectOptions(items) {
if (!items) {
return [];
}
return items.map((option) => {
return {
key: option.value,
value: option.name,
hint: option.hint,
parentKey: option.parentValue,
isDisabled: option.isDisabled,
additionalProperties: option.additionalProperties
};
});
}
function createMapStateToProps() {
return createSelector(
(state, { selectOptionsProviderAction }) => state.providerOptions[selectOptionsProviderAction] || defaultState,
(options) => {
if (options) {
return {
isFetching: options.isFetching,
values: getSelectOptions(options.items)
};
}
}
);
}
const mapDispatchToProps = {
dispatchFetchOptions: fetchOptions,
dispatchClearOptions: clearOptions
};
class EnhancedSelectInputConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
refetchRequired: false
};
}
componentDidMount = () => {
this._populate();
};
componentDidUpdate = (prevProps) => {
const prevKey = getProviderDataKey(prevProps.providerData);
const nextKey = getProviderDataKey(this.props.providerData);
if (!_.isEqual(prevKey, nextKey)) {
this.setState({ refetchRequired: true });
}
};
componentWillUnmount = () => {
this._cleanup();
};
//
// Listeners
onOpen = () => {
if (this.state.refetchRequired) {
this._populate();
}
};
//
// Control
_populate() {
const {
provider,
providerData,
selectOptionsProviderAction,
dispatchFetchOptions
} = this.props;
if (selectOptionsProviderAction) {
this.setState({ refetchRequired: false });
dispatchFetchOptions({
section: selectOptionsProviderAction,
action: selectOptionsProviderAction,
provider,
providerData
});
}
}
_cleanup() {
const {
selectOptionsProviderAction,
dispatchClearOptions
} = this.props;
if (selectOptionsProviderAction) {
dispatchClearOptions({ section: selectOptionsProviderAction });
}
}
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onOpen={this.onOpen}
/>
);
}
}
EnhancedSelectInputConnector.propTypes = {
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
selectOptionsProviderAction: PropTypes.string,
onChange: PropTypes.func.isRequired,
isFetching: PropTypes.bool.isRequired,
dispatchFetchOptions: PropTypes.func.isRequired,
dispatchClearOptions: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EnhancedSelectInputConnector);

View File

@@ -1,113 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import CheckInput from './CheckInput';
import styles from './EnhancedSelectInputOption.css';
class EnhancedSelectInputOption extends Component {
//
// Listeners
onPress = (e) => {
e.preventDefault();
const {
id,
onSelect
} = this.props;
onSelect(id);
};
onCheckPress = () => {
// CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation.
};
//
// Render
render() {
const {
className,
id,
depth,
isSelected,
isDisabled,
isHidden,
isMultiSelect,
isMobile,
children
} = this.props;
return (
<Link
className={classNames(
className,
isSelected && !isMultiSelect && styles.isSelected,
isDisabled && !isMultiSelect && styles.isDisabled,
isHidden && styles.isHidden,
isMobile && styles.isMobile
)}
component="div"
isDisabled={isDisabled}
onPress={this.onPress}
>
{
depth !== 0 &&
<div style={{ width: `${depth * 20}px` }} />
}
{
isMultiSelect &&
<CheckInput
className={styles.optionCheckInput}
containerClassName={styles.optionCheck}
name={`select-${id}`}
value={isSelected}
isDisabled={isDisabled}
onChange={this.onCheckPress}
/>
}
{children}
{
isMobile &&
<div className={styles.iconContainer}>
<Icon
name={isSelected ? icons.CHECK_CIRCLE : icons.CIRCLE_OUTLINE}
/>
</div>
}
</Link>
);
}
}
EnhancedSelectInputOption.propTypes = {
className: PropTypes.string.isRequired,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
depth: PropTypes.number.isRequired,
isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired,
isHidden: PropTypes.bool.isRequired,
isMultiSelect: PropTypes.bool.isRequired,
isMobile: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
onSelect: PropTypes.func.isRequired
};
EnhancedSelectInputOption.defaultProps = {
className: styles.option,
depth: 0,
isDisabled: false,
isHidden: false,
isMultiSelect: false
};
export default EnhancedSelectInputOption;

View File

@@ -1,35 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import styles from './EnhancedSelectInputSelectedValue.css';
function EnhancedSelectInputSelectedValue(props) {
const {
className,
children,
isDisabled
} = props;
return (
<div className={classNames(
className,
isDisabled && styles.isDisabled
)}
>
{children}
</div>
);
}
EnhancedSelectInputSelectedValue.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.node,
isDisabled: PropTypes.bool.isRequired
};
EnhancedSelectInputSelectedValue.defaultProps = {
className: styles.selectedValue,
isDisabled: false
};
export default EnhancedSelectInputSelectedValue;

View File

@@ -1,66 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import { kinds } from 'Helpers/Props';
import styles from './Form.css';
function Form(props) {
const {
children,
validationErrors,
validationWarnings,
// eslint-disable-next-line no-unused-vars
...otherProps
} = props;
return (
<div>
{
validationErrors.length || validationWarnings.length ?
<div className={styles.validationFailures}>
{
validationErrors.map((error, index) => {
return (
<Alert
key={index}
kind={kinds.DANGER}
>
{error.errorMessage}
</Alert>
);
})
}
{
validationWarnings.map((warning, index) => {
return (
<Alert
key={index}
kind={kinds.WARNING}
>
{warning.errorMessage}
</Alert>
);
})
}
</div> :
null
}
{children}
</div>
);
}
Form.propTypes = {
children: PropTypes.node.isRequired,
validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired,
validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired
};
Form.defaultProps = {
validationErrors: [],
validationWarnings: []
};
export default Form;

View File

@@ -0,0 +1,45 @@
import React, { ReactNode } from 'react';
import Alert from 'Components/Alert';
import { kinds } from 'Helpers/Props';
import { ValidationError, ValidationWarning } from 'typings/pending';
import styles from './Form.css';
export interface FormProps {
children: ReactNode;
validationErrors?: ValidationError[];
validationWarnings?: ValidationWarning[];
}
function Form({
children,
validationErrors = [],
validationWarnings = [],
}: FormProps) {
return (
<div>
{validationErrors.length || validationWarnings.length ? (
<div className={styles.validationFailures}>
{validationErrors.map((error, index) => {
return (
<Alert key={index} kind={kinds.DANGER}>
{error.errorMessage}
</Alert>
);
})}
{validationWarnings.map((warning, index) => {
return (
<Alert key={index} kind={kinds.WARNING}>
{warning.errorMessage}
</Alert>
);
})}
</div>
) : null}
{children}
</div>
);
}
export default Form;

View File

@@ -1,56 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { map } from 'Helpers/elementChildren';
import { sizes } from 'Helpers/Props';
import styles from './FormGroup.css';
function FormGroup(props) {
const {
className,
children,
size,
advancedSettings,
isAdvanced,
...otherProps
} = props;
if (!advancedSettings && isAdvanced) {
return null;
}
const childProps = isAdvanced ? { isAdvanced } : {};
return (
<div
className={classNames(
className,
styles[size]
)}
{...otherProps}
>
{
map(children, (child) => {
return React.cloneElement(child, childProps);
})
}
</div>
);
}
FormGroup.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
size: PropTypes.oneOf(sizes.all).isRequired,
advancedSettings: PropTypes.bool.isRequired,
isAdvanced: PropTypes.bool.isRequired
};
FormGroup.defaultProps = {
className: styles.group,
size: sizes.SMALL,
advancedSettings: false,
isAdvanced: false
};
export default FormGroup;

View File

@@ -0,0 +1,43 @@
import classNames from 'classnames';
import React, { Children, ComponentPropsWithoutRef, ReactNode } from 'react';
import { Size } from 'Helpers/Props/sizes';
import styles from './FormGroup.css';
interface FormGroupProps extends ComponentPropsWithoutRef<'div'> {
className?: string;
children: ReactNode;
size?: Extract<Size, keyof typeof styles>;
advancedSettings?: boolean;
isAdvanced?: boolean;
}
function FormGroup(props: FormGroupProps) {
const {
className = styles.group,
children,
size = 'small',
advancedSettings = false,
isAdvanced = false,
...otherProps
} = props;
if (!advancedSettings && isAdvanced) {
return null;
}
const childProps = isAdvanced ? { isAdvanced } : {};
return (
<div className={classNames(className, styles[size])} {...otherProps}>
{Children.map(children, (child) => {
if (!React.isValidElement(child)) {
return child;
}
return React.cloneElement(child, childProps);
})}
</div>
);
}
export default FormGroup;

View File

@@ -14,13 +14,14 @@ function FormInputButton({
className = styles.button,
canSpin = false,
isLastButton = true,
kind = kinds.PRIMARY,
...otherProps
}: FormInputButtonProps) {
if (canSpin) {
return (
<SpinnerButton
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
kind={kind}
{...otherProps}
/>
);
@@ -29,7 +30,7 @@ function FormInputButton({
return (
<Button
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
kind={kind}
{...otherProps}
/>
);

View File

@@ -1,311 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Link from 'Components/Link/Link';
import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AutoCompleteInput from './AutoCompleteInput';
import AvailabilitySelectInput from './AvailabilitySelectInput';
import CaptchaInputConnector from './CaptchaInputConnector';
import CheckInput from './CheckInput';
import DeviceInputConnector from './DeviceInputConnector';
import DownloadClientSelectInputConnector from './DownloadClientSelectInputConnector';
import EnhancedSelectInput from './EnhancedSelectInput';
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
import FormInputHelpText from './FormInputHelpText';
import IndexerFlagsSelectInput from './IndexerFlagsSelectInput';
import IndexerSelectInputConnector from './IndexerSelectInputConnector';
import KeyValueListInput from './KeyValueListInput';
import LanguageSelectInputConnector from './LanguageSelectInputConnector';
import MovieMonitoredSelectInput from './MovieMonitoredSelectInput';
import MovieTagInput from './MovieTagInput';
import NumberInput from './NumberInput';
import OAuthInputConnector from './OAuthInputConnector';
import PasswordInput from './PasswordInput';
import PathInputConnector from './PathInputConnector';
import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
import TagInputConnector from './TagInputConnector';
import TagSelectInputConnector from './TagSelectInputConnector';
import TextArea from './TextArea';
import TextInput from './TextInput';
import TextTagInputConnector from './TextTagInputConnector';
import UMaskInput from './UMaskInput';
import styles from './FormInputGroup.css';
function getComponent(type) {
switch (type) {
case inputTypes.AUTO_COMPLETE:
return AutoCompleteInput;
case inputTypes.AVAILABILITY_SELECT:
return AvailabilitySelectInput;
case inputTypes.CAPTCHA:
return CaptchaInputConnector;
case inputTypes.CHECK:
return CheckInput;
case inputTypes.DEVICE:
return DeviceInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.NUMBER:
return NumberInput;
case inputTypes.OAUTH:
return OAuthInputConnector;
case inputTypes.PASSWORD:
return PasswordInput;
case inputTypes.PATH:
return PathInputConnector;
case inputTypes.QUALITY_PROFILE_SELECT:
return QualityProfileSelectInputConnector;
case inputTypes.INDEXER_SELECT:
return IndexerSelectInputConnector;
case inputTypes.MOVIE_MONITORED_SELECT:
return MovieMonitoredSelectInput;
case inputTypes.ROOT_FOLDER_SELECT:
return RootFolderSelectInputConnector;
case inputTypes.INDEXER_FLAGS_SELECT:
return IndexerFlagsSelectInput;
case inputTypes.DOWNLOAD_CLIENT_SELECT:
return DownloadClientSelectInputConnector;
case inputTypes.LANGUAGE_SELECT:
return LanguageSelectInputConnector;
case inputTypes.SELECT:
return EnhancedSelectInput;
case inputTypes.DYNAMIC_SELECT:
return EnhancedSelectInputConnector;
case inputTypes.MOVIE_TAG:
return MovieTagInput;
case inputTypes.TAG:
return TagInputConnector;
case inputTypes.TEXT_AREA:
return TextArea;
case inputTypes.TEXT_TAG:
return TextTagInputConnector;
case inputTypes.TAG_SELECT:
return TagSelectInputConnector;
case inputTypes.UMASK:
return UMaskInput;
default:
return TextInput;
}
}
function FormInputGroup(props) {
const {
className,
containerClassName,
inputClassName,
type,
unit,
buttons,
helpText,
helpTexts,
helpTextWarning,
helpLink,
pending,
errors,
warnings,
...otherProps
} = props;
const InputComponent = getComponent(type);
const checkInput = type === inputTypes.CHECK;
const hasError = !!errors.length;
const hasWarning = !hasError && !!warnings.length;
const buttonsArray = React.Children.toArray(buttons);
const lastButtonIndex = buttonsArray.length - 1;
const hasButton = !!buttonsArray.length;
return (
<div className={containerClassName}>
<div className={className}>
<div className={styles.inputContainer}>
<InputComponent
className={inputClassName}
helpText={helpText}
helpTextWarning={helpTextWarning}
hasError={hasError}
hasWarning={hasWarning}
hasButton={hasButton}
{...otherProps}
/>
{
unit &&
<div
className={
type === inputTypes.NUMBER ?
styles.inputUnitNumber :
styles.inputUnit
}
>
{unit}
</div>
}
</div>
{
buttonsArray.map((button, index) => {
return React.cloneElement(
button,
{
isLastButton: index === lastButtonIndex
}
);
})
}
{/* <div className={styles.pendingChangesContainer}>
{
pending &&
<Icon
name={icons.UNSAVED_SETTING}
className={styles.pendingChangesIcon}
title={translate('ChangeHasNotBeenSavedYet')}
/>
}
</div> */}
</div>
{
!checkInput && helpText &&
<FormInputHelpText
text={helpText}
/>
}
{
!checkInput && helpTexts &&
<div>
{
helpTexts.map((text, index) => {
return (
<FormInputHelpText
key={index}
text={text}
isCheckInput={checkInput}
/>
);
})
}
</div>
}
{
(!checkInput || helpText) && helpTextWarning &&
<FormInputHelpText
text={helpTextWarning}
isWarning={true}
/>
}
{
helpLink &&
<Link
to={helpLink}
>
{translate('MoreInfo')}
</Link>
}
{
errors.map((error, index) => {
return (
<FormInputHelpText
key={index}
text={error.message}
link={error.link}
tooltip={error.detailedMessage}
isError={true}
isCheckInput={checkInput}
/>
);
})
}
{
warnings.map((warning, index) => {
return (
<FormInputHelpText
key={index}
text={warning.message}
link={warning.link}
tooltip={warning.detailedMessage}
isWarning={true}
isCheckInput={checkInput}
/>
);
})
}
</div>
);
}
FormInputGroup.propTypes = {
className: PropTypes.string.isRequired,
containerClassName: PropTypes.string.isRequired,
inputClassName: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.any,
values: PropTypes.arrayOf(PropTypes.any),
placeholder: PropTypes.string,
delimiters: PropTypes.arrayOf(PropTypes.string),
isDisabled: PropTypes.bool,
type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all),
min: PropTypes.number,
max: PropTypes.number,
unit: PropTypes.string,
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
helpText: PropTypes.string,
helpTexts: PropTypes.arrayOf(PropTypes.string),
helpTextWarning: PropTypes.string,
helpLink: PropTypes.string,
autoFocus: PropTypes.bool,
canEdit: PropTypes.bool,
includeNoChange: PropTypes.bool,
includeNoChangeDisabled: PropTypes.bool,
includeAny: PropTypes.bool,
selectedValueOptions: PropTypes.object,
indexerFlags: PropTypes.number,
pending: PropTypes.bool,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object),
onChange: PropTypes.func.isRequired
};
FormInputGroup.defaultProps = {
className: styles.inputGroup,
containerClassName: styles.inputGroupContainer,
type: inputTypes.TEXT,
buttons: [],
helpTexts: [],
errors: [],
warnings: []
};
export default FormInputGroup;

View File

@@ -0,0 +1,330 @@
import React, { ElementType, ReactNode } from 'react';
import Link from 'Components/Link/Link';
import { inputTypes } from 'Helpers/Props';
import { InputType } from 'Helpers/Props/inputTypes';
import { ValidationError, ValidationWarning } from 'typings/pending';
import translate from 'Utilities/String/translate';
import AutoCompleteInput, { AutoCompleteInputProps } from './AutoCompleteInput';
import CaptchaInput, { CaptchaInputProps } from './CaptchaInput';
import CheckInput, { CheckInputProps } from './CheckInput';
import { FormInputButtonProps } from './FormInputButton';
import FormInputHelpText from './FormInputHelpText';
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
import NumberInput, { NumberInputProps } from './NumberInput';
import OAuthInput, { OAuthInputProps } from './OAuthInput';
import PasswordInput from './PasswordInput';
import PathInput, { PathInputProps } from './PathInput';
import AvailabilitySelectInput, {
AvailabilitySelectInputProps,
} from './Select/AvailabilitySelectInput';
import DownloadClientSelectInput, {
DownloadClientSelectInputProps,
} from './Select/DownloadClientSelectInput';
import EnhancedSelectInput, {
EnhancedSelectInputProps,
} from './Select/EnhancedSelectInput';
import IndexerFlagsSelectInput, {
IndexerFlagsSelectInputProps,
} from './Select/IndexerFlagsSelectInput';
import IndexerSelectInput, {
IndexerSelectInputProps,
} from './Select/IndexerSelectInput';
import LanguageSelectInput, {
LanguageSelectInputProps,
} from './Select/LanguageSelectInput';
import MonitorMoviesSelectInput, {
MonitorMoviesSelectInputProps,
} from './Select/MonitorMoviesSelectInput';
import ProviderDataSelectInput, {
ProviderOptionSelectInputProps,
} from './Select/ProviderOptionSelectInput';
import QualityProfileSelectInput, {
QualityProfileSelectInputProps,
} from './Select/QualityProfileSelectInput';
import RootFolderSelectInput, {
RootFolderSelectInputProps,
} from './Select/RootFolderSelectInput';
import UMaskInput, { UMaskInputProps } from './Select/UMaskInput';
import DeviceInput, { DeviceInputProps } from './Tag/DeviceInput';
import MovieTagInput, { MovieTagInputProps } from './Tag/MovieTagInput';
import TagSelectInput, { TagSelectInputProps } from './Tag/TagSelectInput';
import TextTagInput, { TextTagInputProps } from './Tag/TextTagInput';
import TextArea, { TextAreaProps } from './TextArea';
import TextInput, { TextInputProps } from './TextInput';
import styles from './FormInputGroup.css';
const componentMap: Record<InputType, ElementType> = {
autoComplete: AutoCompleteInput,
availabilitySelect: AvailabilitySelectInput,
captcha: CaptchaInput,
check: CheckInput,
date: TextInput,
device: DeviceInput,
downloadClientSelect: DownloadClientSelectInput,
dynamicSelect: ProviderDataSelectInput,
file: TextInput,
float: NumberInput,
indexerFlagsSelect: IndexerFlagsSelectInput,
indexerSelect: IndexerSelectInput,
keyValueList: KeyValueListInput,
languageSelect: LanguageSelectInput,
monitorMoviesSelect: MonitorMoviesSelectInput,
movieTag: MovieTagInput,
number: NumberInput,
oauth: OAuthInput,
password: PasswordInput,
path: PathInput,
qualityProfileSelect: QualityProfileSelectInput,
rootFolderSelect: RootFolderSelectInput,
select: EnhancedSelectInput,
tag: MovieTagInput,
tagSelect: TagSelectInput,
text: TextInput,
textArea: TextArea,
textTag: TextTagInput,
umask: UMaskInput,
} as const;
// type Components = typeof componentMap;
type PickProps<V, C extends InputType> = C extends 'text'
? TextInputProps
: C extends 'autoComplete'
? AutoCompleteInputProps
: C extends 'availabilitySelect'
? AvailabilitySelectInputProps
: C extends 'captcha'
? CaptchaInputProps
: C extends 'check'
? CheckInputProps
: C extends 'date'
? TextInputProps
: C extends 'device'
? DeviceInputProps
: C extends 'downloadClientSelect'
? DownloadClientSelectInputProps
: C extends 'dynamicSelect'
? ProviderOptionSelectInputProps
: C extends 'file'
? TextInputProps
: C extends 'float'
? TextInputProps
: C extends 'indexerFlagsSelect'
? IndexerFlagsSelectInputProps
: C extends 'indexerSelect'
? IndexerSelectInputProps
: C extends 'keyValueList'
? KeyValueListInputProps
: C extends 'languageSelect'
? LanguageSelectInputProps
: C extends 'monitorMoviesSelect'
? MonitorMoviesSelectInputProps
: C extends 'movieTag'
? MovieTagInputProps<V>
: C extends 'number'
? NumberInputProps
: C extends 'oauth'
? OAuthInputProps
: C extends 'password'
? TextInputProps
: C extends 'path'
? PathInputProps
: C extends 'qualityProfileSelect'
? QualityProfileSelectInputProps
: C extends 'rootFolderSelect'
? RootFolderSelectInputProps
: C extends 'select'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
EnhancedSelectInputProps<any, V>
: C extends 'tag'
? MovieTagInputProps<V>
: C extends 'tagSelect'
? TagSelectInputProps
: C extends 'text'
? TextInputProps
: C extends 'textArea'
? TextAreaProps
: C extends 'textTag'
? TextTagInputProps
: C extends 'umask'
? UMaskInputProps
: never;
export interface FormInputGroupValues<T> {
key: T;
value: string;
hint?: string;
}
// TODO: Remove once all parent components are updated to TSX and we can refactor to a consistent type
export interface ValidationMessage {
message: string;
}
export type FormInputGroupProps<V, C extends InputType> = Omit<
PickProps<V, C>,
'className'
> & {
type: C;
className?: string;
containerClassName?: string;
inputClassName?: string;
autoFocus?: boolean;
autocomplete?: string;
name: string;
buttons?: ReactNode | ReactNode[];
helpText?: string;
helpTexts?: string[];
helpTextWarning?: string;
helpLink?: string;
pending?: boolean;
placeholder?: string;
unit?: string;
errors?: (ValidationMessage | ValidationError)[];
warnings?: (ValidationMessage | ValidationWarning)[];
};
function FormInputGroup<T, C extends InputType>(
props: FormInputGroupProps<T, C>
) {
const {
className = styles.inputGroup,
containerClassName = styles.inputGroupContainer,
inputClassName,
type,
unit,
buttons = [],
helpText,
helpTexts = [],
helpTextWarning,
helpLink,
pending,
errors = [],
warnings = [],
...otherProps
} = props;
const InputComponent = componentMap[type];
const checkInput = type === inputTypes.CHECK;
const hasError = !!errors.length;
const hasWarning = !hasError && !!warnings.length;
const buttonsArray = React.Children.toArray(buttons);
const lastButtonIndex = buttonsArray.length - 1;
const hasButton = !!buttonsArray.length;
return (
<div className={containerClassName}>
<div className={className}>
<div className={styles.inputContainer}>
{/* @ts-expect-error - types are validated already */}
<InputComponent
className={inputClassName}
helpText={helpText}
helpTextWarning={helpTextWarning}
hasError={hasError}
hasWarning={hasWarning}
hasButton={hasButton}
{...otherProps}
/>
{unit && (
<div
className={
type === inputTypes.NUMBER
? styles.inputUnitNumber
: styles.inputUnit
}
>
{unit}
</div>
)}
</div>
{buttonsArray.map((button, index) => {
if (!React.isValidElement<FormInputButtonProps>(button)) {
return button;
}
return React.cloneElement(button, {
isLastButton: index === lastButtonIndex,
});
})}
{/* <div className={styles.pendingChangesContainer}>
{
pending &&
<Icon
name={icons.UNSAVED_SETTING}
className={styles.pendingChangesIcon}
title="Change has not been saved yet"
/>
}
</div> */}
</div>
{!checkInput && helpText ? <FormInputHelpText text={helpText} /> : null}
{!checkInput && helpTexts ? (
<div>
{helpTexts.map((text, index) => {
return (
<FormInputHelpText
key={index}
text={text}
isCheckInput={checkInput}
/>
);
})}
</div>
) : null}
{(!checkInput || helpText) && helpTextWarning ? (
<FormInputHelpText text={helpTextWarning} isWarning={true} />
) : null}
{helpLink ? <Link to={helpLink}>{translate('MoreInfo')}</Link> : null}
{errors.map((error, index) => {
return 'errorMessage' in error ? (
<FormInputHelpText
key={index}
text={error.errorMessage}
link={error.infoLink}
tooltip={error.detailedDescription}
isError={true}
isCheckInput={checkInput}
/>
) : (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={checkInput}
/>
);
})}
{warnings.map((warning, index) => {
return 'errorMessage' in warning ? (
<FormInputHelpText
key={index}
text={warning.errorMessage}
link={warning.infoLink}
tooltip={warning.detailedDescription}
isWarning={true}
isCheckInput={checkInput}
/>
) : (
<FormInputHelpText
key={index}
text={warning.message}
isWarning={true}
isCheckInput={checkInput}
/>
);
})}
</div>
);
}
export default FormInputGroup;

View File

@@ -1,74 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import styles from './FormInputHelpText.css';
function FormInputHelpText(props) {
const {
className,
text,
link,
tooltip,
isError,
isWarning,
isCheckInput
} = props;
return (
<div className={classNames(
className,
isError && styles.isError,
isWarning && styles.isWarning,
isCheckInput && styles.isCheckInput
)}
>
{text}
{
link ?
<Link
className={styles.link}
to={link}
title={tooltip}
>
<Icon
name={icons.EXTERNAL_LINK}
/>
</Link> :
null
}
{
!link && tooltip ?
<Icon
containerClassName={styles.details}
name={icons.INFO}
title={tooltip}
/> :
null
}
</div>
);
}
FormInputHelpText.propTypes = {
className: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
link: PropTypes.string,
tooltip: PropTypes.string,
isError: PropTypes.bool,
isWarning: PropTypes.bool,
isCheckInput: PropTypes.bool
};
FormInputHelpText.defaultProps = {
className: styles.helpText,
isError: false,
isWarning: false,
isCheckInput: false
};
export default FormInputHelpText;

View File

@@ -0,0 +1,55 @@
import classNames from 'classnames';
import React from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import styles from './FormInputHelpText.css';
interface FormInputHelpTextProps {
className?: string;
text: string;
link?: string;
tooltip?: string;
isError?: boolean;
isWarning?: boolean;
isCheckInput?: boolean;
}
function FormInputHelpText({
className = styles.helpText,
text,
link,
tooltip,
isError = false,
isWarning = false,
isCheckInput = false,
}: FormInputHelpTextProps) {
return (
<div
className={classNames(
className,
isError && styles.isError,
isWarning && styles.isWarning,
isCheckInput && styles.isCheckInput
)}
>
{text}
{link ? (
<Link className={styles.link} to={link} title={tooltip}>
<Icon name={icons.EXTERNAL_LINK} />
</Link>
) : null}
{!link && tooltip ? (
<Icon
containerClassName={styles.details}
name={icons.INFO}
title={tooltip}
/>
) : null}
</div>
);
}
export default FormInputHelpText;

View File

@@ -1,52 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { sizes } from 'Helpers/Props';
import styles from './FormLabel.css';
function FormLabel(props) {
const {
children,
className,
errorClassName,
size,
name,
hasError,
isAdvanced,
...otherProps
} = props;
return (
<label
{...otherProps}
className={classNames(
className,
styles[size],
hasError && errorClassName,
isAdvanced && styles.isAdvanced
)}
htmlFor={name}
>
{children}
</label>
);
}
FormLabel.propTypes = {
children: PropTypes.oneOfType([PropTypes.node, PropTypes.string]).isRequired,
className: PropTypes.string,
errorClassName: PropTypes.string,
size: PropTypes.oneOf(sizes.all),
name: PropTypes.string,
hasError: PropTypes.bool,
isAdvanced: PropTypes.bool
};
FormLabel.defaultProps = {
className: styles.label,
errorClassName: styles.hasError,
isAdvanced: false,
size: sizes.LARGE
};
export default FormLabel;

View File

@@ -0,0 +1,42 @@
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import { Size } from 'Helpers/Props/sizes';
import styles from './FormLabel.css';
interface FormLabelProps {
children: ReactNode;
className?: string;
errorClassName?: string;
size?: Extract<Size, keyof typeof styles>;
name?: string;
hasError?: boolean;
isAdvanced?: boolean;
}
function FormLabel(props: FormLabelProps) {
const {
children,
className = styles.label,
errorClassName = styles.hasError,
size = 'large',
name,
hasError,
isAdvanced = false,
} = props;
return (
<label
className={classNames(
className,
styles[size],
hasError && errorClassName,
isAdvanced && styles.isAdvanced
)}
htmlFor={name}
>
{children}
</label>
);
}
export default FormLabel;

View File

@@ -1,78 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
import styles from './HintedSelectInputOption.css';
function HintedSelectInputOption(props) {
const {
id,
value,
hint,
depth,
isSelected,
isDisabled,
dividerAfter,
isMultiSelect,
isMobile,
...otherProps
} = props;
return (
<div>
<EnhancedSelectInputOption
id={id}
depth={depth}
isSelected={isSelected}
isDisabled={isDisabled}
isHidden={isDisabled}
isMultiSelect={isMultiSelect}
isMobile={isMobile}
{...otherProps}
>
<div className={classNames(
styles.optionText,
isMobile && styles.isMobile
)}
>
<div>{value}</div>
{
hint != null &&
<div className={styles.hintText}>
{hint}
</div>
}
</div>
</EnhancedSelectInputOption>
{
dividerAfter ?
<div className={styles.divider} /> :
null
}
</div>
);
}
HintedSelectInputOption.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
value: PropTypes.string.isRequired,
hint: PropTypes.node,
name: PropTypes.string,
depth: PropTypes.number,
isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired,
dividerAfter: PropTypes.bool.isRequired,
isMultiSelect: PropTypes.bool.isRequired,
isMobile: PropTypes.bool.isRequired
};
HintedSelectInputOption.defaultProps = {
isDisabled: false,
dividerAfter: false,
isHidden: false,
isMultiSelect: false
};
export default HintedSelectInputOption;

View File

@@ -1,68 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
import styles from './HintedSelectInputSelectedValue.css';
function HintedSelectInputSelectedValue(props) {
const {
value,
values,
hint,
isMultiSelect,
includeHint,
...otherProps
} = props;
const valuesMap = isMultiSelect && _.keyBy(values, 'key');
return (
<EnhancedSelectInputSelectedValue
className={styles.selectedValue}
{...otherProps}
>
<div className={styles.valueText}>
{
isMultiSelect ?
value.map((key, index) => {
const v = valuesMap[key];
return (
<Label key={key}>
{v ? v.value : key}
</Label>
);
}) :
null
}
{
isMultiSelect ? null : value
}
</div>
{
hint != null && includeHint ?
<div className={styles.hintText}>
{hint}
</div> :
null
}
</EnhancedSelectInputSelectedValue>
);
}
HintedSelectInputSelectedValue.propTypes = {
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number]))]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
hint: PropTypes.string,
isMultiSelect: PropTypes.bool.isRequired,
includeHint: PropTypes.bool.isRequired
};
HintedSelectInputSelectedValue.defaultProps = {
isMultiSelect: false,
includeHint: true
};
export default HintedSelectInputSelectedValue;

View File

@@ -1,97 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchIndexers } from 'Store/Actions/settingsActions';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.indexers,
(state, { includeAny }) => includeAny,
(indexers, includeAny) => {
const {
isFetching,
isPopulated,
error,
items
} = indexers;
const values = _.map(items.sort(sortByProp('name')), (indexer) => {
return {
key: indexer.id,
value: indexer.name
};
});
if (includeAny) {
values.unshift({
key: 0,
value: `(${translate('Any')})`
});
}
return {
isFetching,
isPopulated,
error,
values
};
}
);
}
const mapDispatchToProps = {
dispatchFetchIndexers: fetchIndexers
};
class IndexerSelectInputConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.isPopulated) {
this.props.dispatchFetchIndexers();
}
}
//
// Listeners
onChange = ({ name, value }) => {
this.props.onChange({ name, value: parseInt(value) });
};
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
IndexerSelectInputConnector.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
includeAny: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
dispatchFetchIndexers: PropTypes.func.isRequired
};
IndexerSelectInputConnector.defaultProps = {
includeAny: false
};
export default connect(createMapStateToProps, mapDispatchToProps)(IndexerSelectInputConnector);

View File

@@ -1,52 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
return createSelector(
(state, { values }) => values,
( languages ) => {
const minId = languages.reduce((min, v) => (v.key < 1 ? v.key : min), languages[0].key);
const values = languages.map(({ key, value }) => {
return {
key,
value,
dividerAfter: minId < 1 ? key === minId : false
};
});
return {
values
};
}
);
}
class LanguageSelectInputConnector extends Component {
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onChange={this.props.onChange}
/>
);
}
}
LanguageSelectInputConnector.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
onChange: PropTypes.func.isRequired
};
export default connect(createMapStateToProps)(LanguageSelectInputConnector);

View File

@@ -1,54 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import monitorOptions from 'Utilities/Movie/monitorOptions';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
function MovieMonitoredSelectInput(props) {
const {
includeNoChange,
includeMixed,
...otherProps
} = props;
const values = [...monitorOptions];
if (includeNoChange) {
values.unshift({
key: 'noChange',
get value() {
return translate('NoChange');
},
isDisabled: true
});
}
if (includeMixed) {
values.unshift({
key: 'mixed',
get value() {
return `(${translate('Mixed')})`;
},
isDisabled: true
});
}
return (
<EnhancedSelectInput
{...otherProps}
values={values}
/>
);
}
MovieMonitoredSelectInput.propTypes = {
includeNoChange: PropTypes.bool.isRequired,
includeMixed: PropTypes.bool.isRequired
};
MovieMonitoredSelectInput.defaultProps = {
includeNoChange: false,
includeMixed: false
};
export default MovieMonitoredSelectInput;

View File

@@ -1,53 +0,0 @@
import React, { useCallback } from 'react';
import TagInputConnector from './TagInputConnector';
interface MovieTagInputProps {
name: string;
value: number | number[];
onChange: ({
name,
value,
}: {
name: string;
value: number | number[];
}) => void;
}
export default function MovieTagInput(props: MovieTagInputProps) {
const { value, onChange, ...otherProps } = props;
const isArray = Array.isArray(value);
const handleChange = useCallback(
({ name, value: newValue }: { name: string; value: number[] }) => {
if (isArray) {
onChange({ name, value: newValue });
} else {
onChange({
name,
value: newValue.length ? newValue[newValue.length - 1] : 0,
});
}
},
[isArray, onChange]
);
let finalValue: number[] = [];
if (isArray) {
finalValue = value;
} else if (value === 0) {
finalValue = [];
} else {
finalValue = [value];
}
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore 2786 'TagInputConnector' isn't typed yet
<TagInputConnector
{...otherProps}
value={finalValue}
onChange={handleChange}
/>
);
}

View File

@@ -1,126 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextInput from './TextInput';
function parseValue(props, value) {
const {
isFloat,
min,
max
} = props;
if (value == null || value === '') {
return null;
}
let newValue = isFloat ? parseFloat(value) : parseInt(value);
if (min != null && newValue != null && newValue < min) {
newValue = min;
} else if (max != null && newValue != null && newValue > max) {
newValue = max;
}
return newValue;
}
class NumberInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
value: props.value == null ? '' : props.value.toString(),
isFocused: false
};
}
componentDidUpdate(prevProps, prevState) {
const { value } = this.props;
if (!isNaN(value) && value !== prevProps.value && !this.state.isFocused) {
this.setState({
value: value == null ? '' : value.toString()
});
}
}
//
// Listeners
onChange = ({ name, value }) => {
this.setState({ value });
this.props.onChange({
name,
value: parseValue(this.props, value)
});
};
onFocus = () => {
this.setState({ isFocused: true });
};
onBlur = () => {
const {
name,
onChange
} = this.props;
const { value } = this.state;
const parsedValue = parseValue(this.props, value);
const stringValue = parsedValue == null ? '' : parsedValue.toString();
if (stringValue === value) {
this.setState({ isFocused: false });
} else {
this.setState({
value: stringValue,
isFocused: false
});
}
onChange({
name,
value: parsedValue
});
};
//
// Render
render() {
const value = this.state.value;
return (
<TextInput
{...this.props}
type="number"
value={value == null ? '' : value}
onChange={this.onChange}
onBlur={this.onBlur}
onFocus={this.onFocus}
/>
);
}
}
NumberInput.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.number,
min: PropTypes.number,
max: PropTypes.number,
isFloat: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired
};
NumberInput.defaultProps = {
value: null,
isFloat: false
};
export default NumberInput;

View File

@@ -0,0 +1,109 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { InputChanged } from 'typings/inputs';
import TextInput, { TextInputProps } from './TextInput';
function parseValue(
value: string | null | undefined,
isFloat: boolean,
min: number | undefined,
max: number | undefined
) {
if (value == null || value === '') {
return null;
}
let newValue = isFloat ? parseFloat(value) : parseInt(value);
if (min != null && newValue != null && newValue < min) {
newValue = min;
} else if (max != null && newValue != null && newValue > max) {
newValue = max;
}
return newValue;
}
export interface NumberInputProps
extends Omit<TextInputProps, 'value' | 'onChange'> {
value?: number | null;
min?: number;
max?: number;
isFloat?: boolean;
onChange: (input: InputChanged<number | null>) => void;
}
function NumberInput({
name,
value: inputValue = null,
isFloat = false,
min,
max,
onChange,
...otherProps
}: NumberInputProps) {
const [value, setValue] = useState(
inputValue == null ? '' : inputValue.toString()
);
const isFocused = useRef(false);
const previousValue = usePrevious(inputValue);
const handleChange = useCallback(
({ name, value: newValue }: InputChanged<string>) => {
setValue(newValue);
onChange({
name,
value: parseValue(newValue, isFloat, min, max),
});
},
[isFloat, min, max, onChange, setValue]
);
const handleFocus = useCallback(() => {
isFocused.current = true;
}, []);
const handleBlur = useCallback(() => {
const parsedValue = parseValue(value, isFloat, min, max);
const stringValue = parsedValue == null ? '' : parsedValue.toString();
if (stringValue !== value) {
setValue(stringValue);
}
onChange({
name,
value: parsedValue,
});
isFocused.current = false;
}, [name, value, isFloat, min, max, onChange]);
useEffect(() => {
if (
// @ts-expect-error inputValue may be null
!isNaN(inputValue) &&
inputValue !== previousValue &&
!isFocused.current
) {
setValue(inputValue == null ? '' : inputValue.toString());
}
}, [inputValue, previousValue, setValue]);
return (
<TextInput
{...otherProps}
name={name}
type="number"
value={value == null ? '' : value}
min={min}
max={max}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
/>
);
}
export default NumberInput;

View File

@@ -1,39 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import { kinds } from 'Helpers/Props';
function OAuthInput(props) {
const {
label,
authorizing,
error,
onPress
} = props;
return (
<div>
<SpinnerErrorButton
kind={kinds.PRIMARY}
isSpinning={authorizing}
error={error}
onPress={onPress}
>
{label}
</SpinnerErrorButton>
</div>
);
}
OAuthInput.propTypes = {
label: PropTypes.string.isRequired,
authorizing: PropTypes.bool.isRequired,
error: PropTypes.object,
onPress: PropTypes.func.isRequired
};
OAuthInput.defaultProps = {
label: 'Start OAuth'
};
export default OAuthInput;

View File

@@ -0,0 +1,72 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import { kinds } from 'Helpers/Props';
import { resetOAuth, startOAuth } from 'Store/Actions/oAuthActions';
import { InputOnChange } from 'typings/inputs';
export interface OAuthInputProps {
label?: string;
name: string;
provider: string;
providerData: object;
section: string;
onChange: InputOnChange<unknown>;
}
function OAuthInput({
label = 'Start OAuth',
name,
provider,
providerData,
section,
onChange,
}: OAuthInputProps) {
const dispatch = useDispatch();
const { authorizing, error, result } = useSelector(
(state: AppState) => state.oAuth
);
const handlePress = useCallback(() => {
dispatch(
startOAuth({
name,
provider,
providerData,
section,
})
);
}, [name, provider, providerData, section, dispatch]);
useEffect(() => {
if (!result) {
return;
}
Object.keys(result).forEach((key) => {
onChange({ name: key, value: result[key] });
});
}, [result, onChange]);
useEffect(() => {
return () => {
dispatch(resetOAuth());
};
}, [dispatch]);
return (
<div>
<SpinnerErrorButton
kind={kinds.PRIMARY}
isSpinning={authorizing}
error={error}
onPress={handlePress}
>
{label}
</SpinnerErrorButton>
</div>
);
}
export default OAuthInput;

View File

@@ -1,89 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { resetOAuth, startOAuth } from 'Store/Actions/oAuthActions';
import OAuthInput from './OAuthInput';
function createMapStateToProps() {
return createSelector(
(state) => state.oAuth,
(oAuth) => {
return oAuth;
}
);
}
const mapDispatchToProps = {
startOAuth,
resetOAuth
};
class OAuthInputConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
const {
result,
onChange
} = this.props;
if (!result || result === prevProps.result) {
return;
}
Object.keys(result).forEach((key) => {
onChange({ name: key, value: result[key] });
});
}
componentWillUnmount = () => {
this.props.resetOAuth();
};
//
// Listeners
onPress = () => {
const {
name,
provider,
providerData,
section
} = this.props;
this.props.startOAuth({
name,
provider,
providerData,
section
});
};
//
// Render
render() {
return (
<OAuthInput
{...this.props}
onPress={this.onPress}
/>
);
}
}
OAuthInputConnector.propTypes = {
name: PropTypes.string.isRequired,
result: PropTypes.object,
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
section: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
startOAuth: PropTypes.func.isRequired,
resetOAuth: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(OAuthInputConnector);

View File

@@ -1,24 +0,0 @@
import React from 'react';
import TextInput from './TextInput';
// Prevent a user from copying (or cutting) the password from the input
function onCopy(e) {
e.preventDefault();
e.nativeEvent.stopImmediatePropagation();
}
function PasswordInput(props) {
return (
<TextInput
{...props}
type="password"
onCopy={onCopy}
/>
);
}
PasswordInput.propTypes = {
...TextInput.props
};
export default PasswordInput;

View File

@@ -0,0 +1,14 @@
import React, { SyntheticEvent } from 'react';
import TextInput, { TextInputProps } from './TextInput';
// Prevent a user from copying (or cutting) the password from the input
function onCopy(e: SyntheticEvent) {
e.preventDefault();
e.nativeEvent.stopImmediatePropagation();
}
function PasswordInput(props: TextInputProps) {
return <TextInput {...props} type="password" onCopy={onCopy} />;
}
export default PasswordInput;

View File

@@ -16,3 +16,7 @@
height: 35px;
}
.fileBrowserMiddleButton {
composes: middleButton from '~./FormInputButton.css';
}

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'fileBrowserButton': string;
'fileBrowserMiddleButton': string;
'hasFileBrowser': string;
'inputWrapper': string;
'pathMatch': string;

View File

@@ -1,195 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import AutoSuggestInput from './AutoSuggestInput';
import FormInputButton from './FormInputButton';
import styles from './PathInput.css';
class PathInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._node = document.getElementById('portal-root');
this.state = {
value: props.value,
isFileBrowserModalOpen: false
};
}
componentDidUpdate(prevProps) {
const { value } = this.props;
if (prevProps.value !== value) {
this.setState({ value });
}
}
//
// Control
getSuggestionValue({ path }) {
return path;
}
renderSuggestion({ path }, { query }) {
const lastSeparatorIndex = query.lastIndexOf('\\') || query.lastIndexOf('/');
if (lastSeparatorIndex === -1) {
return (
<span>{path}</span>
);
}
return (
<span>
<span className={styles.pathMatch}>
{path.substr(0, lastSeparatorIndex)}
</span>
{path.substr(lastSeparatorIndex)}
</span>
);
}
//
// Listeners
onInputChange = ({ value }) => {
this.setState({ value });
};
onInputKeyDown = (event) => {
if (event.key === 'Tab') {
event.preventDefault();
const path = this.props.paths[0];
if (path) {
this.props.onChange({
name: this.props.name,
value: path.path
});
if (path.type !== 'file') {
this.props.onFetchPaths(path.path);
}
}
}
};
onInputBlur = () => {
this.props.onChange({
name: this.props.name,
value: this.state.value
});
this.props.onClearPaths();
};
onSuggestionsFetchRequested = ({ value }) => {
this.props.onFetchPaths(value);
};
onSuggestionsClearRequested = () => {
// Required because props aren't always rendered, but no-op
// because we don't want to reset the paths after a path is selected.
};
onSuggestionSelected = (event, { suggestionValue }) => {
this.props.onFetchPaths(suggestionValue);
};
onFileBrowserOpenPress = () => {
this.setState({ isFileBrowserModalOpen: true });
};
onFileBrowserModalClose = () => {
this.setState({ isFileBrowserModalOpen: false });
};
//
// Render
render() {
const {
className,
name,
paths,
includeFiles,
hasFileBrowser,
onChange,
...otherProps
} = this.props;
const {
value,
isFileBrowserModalOpen
} = this.state;
return (
<div className={className}>
<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={this.onInputChange}
/>
{
hasFileBrowser &&
<div>
<FormInputButton
className={styles.fileBrowserButton}
onPress={this.onFileBrowserOpenPress}
>
<Icon name={icons.FOLDER_OPEN} />
</FormInputButton>
<FileBrowserModal
isOpen={isFileBrowserModalOpen}
name={name}
value={value}
includeFiles={includeFiles}
onChange={onChange}
onModalClose={this.onFileBrowserModalClose}
/>
</div>
}
</div>
);
}
}
PathInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string,
paths: PropTypes.array.isRequired,
includeFiles: PropTypes.bool.isRequired,
hasFileBrowser: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onFetchPaths: PropTypes.func.isRequired,
onClearPaths: PropTypes.func.isRequired
};
PathInput.defaultProps = {
className: styles.inputWrapper,
value: '',
hasFileBrowser: true
};
export default PathInput;

View File

@@ -0,0 +1,258 @@
import classNames from 'classnames';
import React, {
KeyboardEvent,
SyntheticEvent,
useCallback,
useEffect,
useState,
} from 'react';
import {
ChangeEvent,
SuggestionsFetchRequestedParams,
} from 'react-autosuggest';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import { Path } from 'App/State/PathsAppState';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import Icon from 'Components/Icon';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons } from 'Helpers/Props';
import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
import { InputChanged } from 'typings/inputs';
import AutoSuggestInput from './AutoSuggestInput';
import FormInputButton from './FormInputButton';
import styles from './PathInput.css';
export interface PathInputProps {
className?: string;
name: string;
value?: string;
placeholder?: string;
includeFiles: boolean;
hasButton?: boolean;
hasFileBrowser?: boolean;
onChange: (change: InputChanged<string>) => void;
}
interface PathInputInternalProps extends PathInputProps {
paths: Path[];
onFetchPaths: (path: string) => void;
onClearPaths: () => void;
}
function handleSuggestionsClearRequested() {
// Required because props aren't always rendered, but no-op
// because we don't want to reset the paths after a path is selected.
}
function createPathsSelector() {
return createSelector(
(state: AppState) => state.paths,
(paths) => {
const { currentPath, directories, files } = paths;
const filteredPaths = [...directories, ...files].filter(({ path }) => {
return path.toLowerCase().startsWith(currentPath.toLowerCase());
});
return filteredPaths;
}
);
}
function PathInput(props: PathInputProps) {
const { includeFiles } = props;
const dispatch = useDispatch();
const paths = useSelector(createPathsSelector());
const handleFetchPaths = useCallback(
(path: string) => {
dispatch(fetchPaths({ path, includeFiles }));
},
[includeFiles, dispatch]
);
const handleClearPaths = useCallback(() => {
dispatch(clearPaths);
}, [dispatch]);
return (
<PathInputInternal
{...props}
paths={paths}
onFetchPaths={handleFetchPaths}
onClearPaths={handleClearPaths}
/>
);
}
export default PathInput;
export function PathInputInternal(props: PathInputInternalProps) {
const {
className = styles.inputWrapper,
name,
value: inputValue = '',
paths,
includeFiles,
hasButton,
hasFileBrowser = true,
onChange,
onFetchPaths,
onClearPaths,
...otherProps
} = props;
const [value, setValue] = useState(inputValue);
const [isFileBrowserModalOpen, setIsFileBrowserModalOpen] = useState(false);
const previousInputValue = usePrevious(inputValue);
const dispatch = useDispatch();
const handleFetchPaths = useCallback(
(path: string) => {
dispatch(fetchPaths({ path, includeFiles }));
},
[includeFiles, dispatch]
);
const handleInputChange = useCallback(
(_event: SyntheticEvent, { newValue }: ChangeEvent) => {
setValue(newValue);
},
[setValue]
);
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (event.key === 'Tab') {
event.preventDefault();
const path = paths[0];
if (path) {
onChange({
name,
value: path.path,
});
if (path.type !== 'file') {
handleFetchPaths(path.path);
}
}
}
},
[name, paths, handleFetchPaths, onChange]
);
const handleInputBlur = useCallback(() => {
onChange({
name,
value,
});
onClearPaths();
}, [name, value, onClearPaths, onChange]);
const handleSuggestionSelected = useCallback(
(_event: SyntheticEvent, { suggestion }: { suggestion: Path }) => {
handleFetchPaths(suggestion.path);
},
[handleFetchPaths]
);
const handleSuggestionsFetchRequested = useCallback(
({ value: newValue }: SuggestionsFetchRequestedParams) => {
handleFetchPaths(newValue);
},
[handleFetchPaths]
);
const handleFileBrowserOpenPress = useCallback(() => {
setIsFileBrowserModalOpen(true);
}, [setIsFileBrowserModalOpen]);
const handleFileBrowserModalClose = useCallback(() => {
setIsFileBrowserModalOpen(false);
}, [setIsFileBrowserModalOpen]);
const handleChange = useCallback(
(change: InputChanged<Path>) => {
onChange({ name, value: change.value.path });
},
[name, onChange]
);
const getSuggestionValue = useCallback(({ path }: Path) => path, []);
const renderSuggestion = useCallback(
({ path }: Path, { query }: { query: string }) => {
const lastSeparatorIndex =
query.lastIndexOf('\\') || query.lastIndexOf('/');
if (lastSeparatorIndex === -1) {
return <span>{path}</span>;
}
return (
<span>
<span className={styles.pathMatch}>
{path.substring(0, lastSeparatorIndex)}
</span>
{path.substring(lastSeparatorIndex)}
</span>
);
},
[]
);
useEffect(() => {
if (inputValue !== previousInputValue) {
setValue(inputValue);
}
}, [inputValue, previousInputValue, setValue]);
return (
<div className={className}>
<AutoSuggestInput
{...otherProps}
className={hasFileBrowser ? styles.hasFileBrowser : undefined}
name={name}
value={value}
suggestions={paths}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
onInputKeyDown={handleInputKeyDown}
onInputChange={handleInputChange}
onInputBlur={handleInputBlur}
onSuggestionSelected={handleSuggestionSelected}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
onChange={handleChange}
/>
{hasFileBrowser ? (
<>
<FormInputButton
className={classNames(
styles.fileBrowserButton,
hasButton && styles.fileBrowserMiddleButton
)}
onPress={handleFileBrowserOpenPress}
>
<Icon name={icons.FOLDER_OPEN} />
</FormInputButton>
<FileBrowserModal
isOpen={isFileBrowserModalOpen}
name={name}
value={value}
includeFiles={includeFiles}
onChange={onChange}
onModalClose={handleFileBrowserModalClose}
/>
</>
) : null}
</div>
);
}

View File

@@ -1,81 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
import PathInput from './PathInput';
function createMapStateToProps() {
return createSelector(
(state) => state.paths,
(paths) => {
const {
currentPath,
directories,
files
} = paths;
const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
return path.toLowerCase().startsWith(currentPath.toLowerCase());
});
return {
paths: filteredPaths
};
}
);
}
const mapDispatchToProps = {
dispatchFetchPaths: fetchPaths,
dispatchClearPaths: clearPaths
};
class PathInputConnector extends Component {
//
// Listeners
onFetchPaths = (path) => {
const {
includeFiles,
dispatchFetchPaths
} = this.props;
dispatchFetchPaths({
path,
includeFiles
});
};
onClearPaths = () => {
this.props.dispatchClearPaths();
};
//
// Render
render() {
return (
<PathInput
onFetchPaths={this.onFetchPaths}
onClearPaths={this.onClearPaths}
{...this.props}
/>
);
}
}
PathInputConnector.propTypes = {
...PathInput.props,
includeFiles: PropTypes.bool.isRequired,
dispatchFetchPaths: PropTypes.func.isRequired,
dispatchClearPaths: PropTypes.func.isRequired
};
PathInputConnector.defaultProps = {
includeFiles: false
};
export default connect(createMapStateToProps, mapDispatchToProps)(PathInputConnector);

View File

@@ -1,156 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
function getType({ type, selectOptionsProviderAction }) {
switch (type) {
case 'captcha':
return inputTypes.CAPTCHA;
case 'checkbox':
return inputTypes.CHECK;
case 'device':
return inputTypes.DEVICE;
case 'keyValueList':
return inputTypes.KEY_VALUE_LIST;
case 'password':
return inputTypes.PASSWORD;
case 'number':
return inputTypes.NUMBER;
case 'path':
return inputTypes.PATH;
case 'filePath':
return inputTypes.PATH;
case 'select':
if (selectOptionsProviderAction) {
return inputTypes.DYNAMIC_SELECT;
}
return inputTypes.SELECT;
case 'movieTag':
return inputTypes.MOVIE_TAG;
case 'tag':
return inputTypes.TEXT_TAG;
case 'tagSelect':
return inputTypes.TAG_SELECT;
case 'textbox':
return inputTypes.TEXT;
case 'oAuth':
return inputTypes.OAUTH;
case 'rootFolder':
return inputTypes.ROOT_FOLDER_SELECT;
case 'qualityProfile':
return inputTypes.QUALITY_PROFILE_SELECT;
default:
return inputTypes.TEXT;
}
}
function getSelectValues(selectOptions) {
if (!selectOptions) {
return;
}
return _.reduce(selectOptions, (result, option) => {
result.push({
key: option.value,
value: option.name,
dividerAfter: option.dividerAfter,
hint: option.hint
});
return result;
}, []);
}
function ProviderFieldFormGroup(props) {
const {
advancedSettings,
name,
label,
helpText,
helpTextWarning,
helpLink,
placeholder,
value,
type,
advanced,
hidden,
pending,
errors,
warnings,
selectOptions,
onChange,
...otherProps
} = props;
if (
hidden === 'hidden' ||
(hidden === 'hiddenIfNotSet' && !value)
) {
return null;
}
return (
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={advanced}
>
<FormLabel>{label}</FormLabel>
<FormInputGroup
type={getType(props)}
name={name}
label={label}
helpText={helpText}
helpTextWarning={helpTextWarning}
helpLink={helpLink}
placeholder={placeholder}
value={value}
values={getSelectValues(selectOptions)}
errors={errors}
warnings={warnings}
pending={pending}
includeFiles={type === 'filePath' ? true : undefined}
onChange={onChange}
{...otherProps}
/>
</FormGroup>
);
}
const selectOptionsShape = {
name: PropTypes.string.isRequired,
value: PropTypes.number.isRequired,
hint: PropTypes.string
};
ProviderFieldFormGroup.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
helpText: PropTypes.string,
helpTextWarning: PropTypes.string,
helpLink: PropTypes.string,
placeholder: PropTypes.string,
value: PropTypes.any,
type: PropTypes.string.isRequired,
advanced: PropTypes.bool.isRequired,
hidden: PropTypes.string,
isDisabled: PropTypes.bool,
provider: PropTypes.string,
pending: PropTypes.bool.isRequired,
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)),
selectOptionsProviderAction: PropTypes.string,
onChange: PropTypes.func.isRequired
};
ProviderFieldFormGroup.defaultProps = {
advancedSettings: false
};
export default ProviderFieldFormGroup;

View File

@@ -0,0 +1,138 @@
import React, { useMemo } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { FieldSelectOption } from 'typings/Field';
import { InputChanged } from 'typings/inputs';
import { Failure } from 'typings/pending';
interface ProviderFieldFormGroupProps<T> {
advancedSettings: boolean;
name: string;
label: string;
helpText?: string;
helpTextWarning?: string;
helpLink?: string;
placeholder?: string;
value?: T;
type: string;
advanced: boolean;
hidden?: string;
isDisabled?: boolean;
provider?: string;
providerData?: object;
pending: boolean;
errors: Failure[];
warnings: Failure[];
selectOptions?: FieldSelectOption<T>[];
selectOptionsProviderAction?: string;
onChange: (change: InputChanged<T>) => void;
}
function ProviderFieldFormGroup<T>({
advancedSettings = false,
name,
label,
helpText,
helpTextWarning,
helpLink,
placeholder,
value,
type: providerType,
advanced,
hidden,
pending,
errors,
warnings,
selectOptions,
selectOptionsProviderAction,
onChange,
...otherProps
}: ProviderFieldFormGroupProps<T>) {
const type = useMemo(() => {
switch (providerType) {
case 'captcha':
return 'captcha';
case 'checkbox':
return 'check';
case 'device':
return 'device';
case 'keyValueList':
return 'keyValueList';
case 'password':
return 'password';
case 'number':
return 'number';
case 'path':
return 'path';
case 'filePath':
return 'path';
case 'select':
if (selectOptionsProviderAction) {
return 'dynamicSelect';
}
return 'select';
case 'movieTag':
return 'movieTag';
case 'tag':
return 'textTag';
case 'tagSelect':
return 'tagSelect';
case 'textbox':
return 'text';
case 'oAuth':
return 'oauth';
case 'rootFolder':
return 'rootFolderSelect';
case 'qualityProfile':
return 'qualityProfileSelect';
default:
return 'text';
}
}, [providerType, selectOptionsProviderAction]);
const selectValues = useMemo(() => {
if (!selectOptions) {
return;
}
return selectOptions.map((option) => {
return {
key: option.value,
value: option.name,
hint: option.hint,
};
});
}, [selectOptions]);
if (hidden === 'hidden' || (hidden === 'hiddenIfNotSet' && !value)) {
return null;
}
return (
<FormGroup advancedSettings={advancedSettings} isAdvanced={advanced}>
<FormLabel>{label}</FormLabel>
<FormInputGroup
type={type}
name={name}
helpText={helpText}
helpTextWarning={helpTextWarning}
helpLink={helpLink}
placeholder={placeholder}
// @ts-expect-error - this isn't available on all types
selectOptionsProviderAction={selectOptionsProviderAction}
value={value}
values={selectValues}
errors={errors}
warnings={warnings}
pending={pending}
includeFiles={providerType === 'filePath' ? true : undefined}
onChange={onChange}
{...otherProps}
/>
</FormGroup>
);
}
export default ProviderFieldFormGroup;

View File

@@ -1,101 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')),
(state, { includeNoChange }) => includeNoChange,
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(state, { includeMixed }) => includeMixed,
(qualityProfiles, includeNoChange, includeNoChangeDisabled = true, includeMixed) => {
const values = _.map(qualityProfiles.items, (qualityProfile) => {
return {
key: qualityProfile.id,
value: qualityProfile.name
};
});
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: translate('NoChange'),
isDisabled: includeNoChangeDisabled
});
}
if (includeMixed) {
values.unshift({
key: 'mixed',
value: `(${translate('Mixed')})`,
isDisabled: true
});
}
return {
values
};
}
);
}
class QualityProfileSelectInputConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
name,
value,
values
} = this.props;
if (!value || !values.some((option) => option.key === value || parseInt(option.key) === value)) {
const firstValue = values.find((option) => !isNaN(parseInt(option.key)));
if (firstValue) {
this.onChange({ name, value: firstValue.key });
}
}
}
//
// Listeners
onChange = ({ name, value }) => {
this.props.onChange({ name, value: value === 'noChange' ? value : parseInt(value) });
};
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
QualityProfileSelectInputConnector.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
values: PropTypes.arrayOf(PropTypes.object).isRequired,
includeNoChange: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired
};
QualityProfileSelectInputConnector.defaultProps = {
includeNoChange: false
};
export default connect(createMapStateToProps)(QualityProfileSelectInputConnector);

View File

@@ -1,109 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import EnhancedSelectInput from './EnhancedSelectInput';
import RootFolderSelectInputOption from './RootFolderSelectInputOption';
import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
class RootFolderSelectInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddNewRootFolderModalOpen: false,
newRootFolderPath: ''
};
}
componentDidUpdate(prevProps) {
const {
name,
isSaving,
saveError,
onChange
} = this.props;
const newRootFolderPath = this.state.newRootFolderPath;
if (
prevProps.isSaving &&
!isSaving &&
!saveError &&
newRootFolderPath
) {
onChange({ name, value: newRootFolderPath });
this.setState({ newRootFolderPath: '' });
}
}
//
// Listeners
onChange = ({ name, value }) => {
if (value === 'addNew') {
this.setState({ isAddNewRootFolderModalOpen: true });
} else {
this.props.onChange({ name, value });
}
};
onNewRootFolderSelect = ({ value }) => {
this.setState({ newRootFolderPath: value }, () => {
this.props.onNewRootFolderSelect(value);
});
};
onAddRootFolderModalClose = () => {
this.setState({ isAddNewRootFolderModalOpen: false });
};
//
// Render
render() {
const {
includeNoChange,
onNewRootFolderSelect,
...otherProps
} = this.props;
return (
<div>
<EnhancedSelectInput
{...otherProps}
selectedValueComponent={RootFolderSelectInputSelectedValue}
optionComponent={RootFolderSelectInputOption}
onChange={this.onChange}
/>
<FileBrowserModal
isOpen={this.state.isAddNewRootFolderModalOpen}
name="rootFolderPath"
value=""
onChange={this.onNewRootFolderSelect}
onModalClose={this.onAddRootFolderModalClose}
/>
</div>
);
}
}
RootFolderSelectInput.propTypes = {
name: PropTypes.string.isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
includeNoChange: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onNewRootFolderSelect: PropTypes.func.isRequired
};
RootFolderSelectInput.defaultProps = {
includeNoChange: false
};
export default RootFolderSelectInput;

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