mirror of
https://github.com/Radarr/Radarr.git
synced 2026-03-06 13:31:28 -05:00
Compare commits
144 Commits
v5.19.3.97
...
v5.22.4.98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
981a3c2db3 | ||
|
|
3f2ea56bf9 | ||
|
|
1679ed1327 | ||
|
|
69a1c1b21b | ||
|
|
5bd51832a0 | ||
|
|
52a69b662d | ||
|
|
7e34d89069 | ||
|
|
b0024b28a5 | ||
|
|
ae5450f75d | ||
|
|
1d1aca1a04 | ||
|
|
3a55316ada | ||
|
|
9ef7c2a0b4 | ||
|
|
e759f3fd0b | ||
|
|
03429db877 | ||
|
|
bb5f421e38 | ||
|
|
7dd3ed815a | ||
|
|
cc56482819 | ||
|
|
40f41847fd | ||
|
|
8485fc8c75 | ||
|
|
f3026df65d | ||
|
|
cfd25e974f | ||
|
|
c52f9c5ec4 | ||
|
|
b91517afd5 | ||
|
|
ee8aaadb29 | ||
|
|
0694f2fa76 | ||
|
|
2c81f3be0f | ||
|
|
8fb2f64e98 | ||
|
|
efd2b80e10 | ||
|
|
a9bbe06966 | ||
|
|
4c6f80b308 | ||
|
|
c8299f7e57 | ||
|
|
445babbca8 | ||
|
|
e5137d13e9 | ||
|
|
fb8f8f4dd3 | ||
|
|
2b8ca4746a | ||
|
|
9231a0e526 | ||
|
|
9fa75f0539 | ||
|
|
76b5568129 | ||
|
|
27efe506a7 | ||
|
|
d9be54575a | ||
|
|
a825b96518 | ||
|
|
221b7a4300 | ||
|
|
1ac784e323 | ||
|
|
aae34f4c43 | ||
|
|
7219648fea | ||
|
|
b7be80744c | ||
|
|
29ca18d3f3 | ||
|
|
d9704a999d | ||
|
|
a23983032a | ||
|
|
99d68cfd91 | ||
|
|
9c009a84f2 | ||
|
|
e8ca64fabc | ||
|
|
c821541a2f | ||
|
|
c10aadcc7b | ||
|
|
4a2202ed7f | ||
|
|
78c009d6fa | ||
|
|
e03289abe7 | ||
|
|
da2ce10c68 | ||
|
|
6d34f2afb1 | ||
|
|
49b0c9639c | ||
|
|
c281e68b9f | ||
|
|
740d3ce88c | ||
|
|
ad7b85f76d | ||
|
|
3aa93e7946 | ||
|
|
bc08b0b2e1 | ||
|
|
107f843303 | ||
|
|
16b6997b14 | ||
|
|
a5bcac5de9 | ||
|
|
1e10d569c8 | ||
|
|
74d2259f67 | ||
|
|
6e68a91922 | ||
|
|
a962de776b | ||
|
|
e8afde2e90 | ||
|
|
4633a834f3 | ||
|
|
cd021961f0 | ||
|
|
456ea3d57c | ||
|
|
d09fa6f880 | ||
|
|
bcd4fe1f08 | ||
|
|
8efce68922 | ||
|
|
4b3c29ed93 | ||
|
|
7ea9161779 | ||
|
|
f5c66c5093 | ||
|
|
a3515db9f7 | ||
|
|
d4bb318253 | ||
|
|
64e865f296 | ||
|
|
982f9062bd | ||
|
|
48075e33ac | ||
|
|
91f08a83cd | ||
|
|
886db23c58 | ||
|
|
b646386e77 | ||
|
|
4aa259a666 | ||
|
|
35f1a61bf8 | ||
|
|
1d855aed00 | ||
|
|
f7da5b0866 | ||
|
|
682cc70acf | ||
|
|
9d624b07ce | ||
|
|
2afb41498d | ||
|
|
a0679fcf11 | ||
|
|
df4a69ac02 | ||
|
|
2c8d8ff2d6 | ||
|
|
0593568065 | ||
|
|
25aa719ad6 | ||
|
|
3ab61a2fee | ||
|
|
954a040d6e | ||
|
|
905b23618a | ||
|
|
8decd5d8e1 | ||
|
|
8b5b177d16 | ||
|
|
e6c6fceff8 | ||
|
|
dcc8b28a07 | ||
|
|
02baf4d7a4 | ||
|
|
22ec1fe492 | ||
|
|
a7dbdadd21 | ||
|
|
93581e4a2f | ||
|
|
4c8da09df6 | ||
|
|
89666175a6 | ||
|
|
7a33e156a3 | ||
|
|
c7c07404b0 | ||
|
|
abeeee9363 | ||
|
|
23c30734d2 | ||
|
|
939e45e646 | ||
|
|
16ceba2392 | ||
|
|
94d620d878 | ||
|
|
ee0db93a0a | ||
|
|
f815b31c33 | ||
|
|
c078191b3d | ||
|
|
653b358fd3 | ||
|
|
6a7ed22b44 | ||
|
|
779292490a | ||
|
|
e4e96fc7f9 | ||
|
|
049bf7715e | ||
|
|
df4dfaac0b | ||
|
|
89c96b0a80 | ||
|
|
7db12b6e58 | ||
|
|
28dee7bc01 | ||
|
|
8ec60eb0a6 | ||
|
|
102849a697 | ||
|
|
95da7d7b47 | ||
|
|
22b5739967 | ||
|
|
cfba047d80 | ||
|
|
576d404e70 | ||
|
|
5959d4e51a | ||
|
|
2aca6c6e1d | ||
|
|
e8bbe0ee9f | ||
|
|
66332a110a |
@@ -6,7 +6,7 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "16",
|
||||
"version": "20",
|
||||
"nvmVersion": "latest"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '5.19.3'
|
||||
majorVersion: '5.22.4'
|
||||
minorVersion: $[counter('minorVersion', 2000)]
|
||||
radarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
|
||||
@@ -19,7 +19,7 @@ variables:
|
||||
nodeVersion: '20.X'
|
||||
innoVersion: '6.2.2'
|
||||
windowsImage: 'windows-2022'
|
||||
linuxImage: 'ubuntu-20.04'
|
||||
linuxImage: 'ubuntu-22.04'
|
||||
macImage: 'macOS-13'
|
||||
|
||||
trigger:
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'typings/History';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import formatAge from 'Utilities/Number/formatAge';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './HistoryDetails.css';
|
||||
@@ -50,6 +51,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
ageHours,
|
||||
ageMinutes,
|
||||
publishedDate,
|
||||
size,
|
||||
} = data as GrabbedHistoryData;
|
||||
|
||||
const downloadClientNameInfo = downloadClientName ?? downloadClient;
|
||||
@@ -160,12 +162,19 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('Size')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFailed') {
|
||||
const { message } = data as DownloadFailedHistory;
|
||||
const { message, indexer } = data as DownloadFailedHistory;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
@@ -179,6 +188,10 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
|
||||
) : null}
|
||||
|
||||
{indexer ? (
|
||||
<DescriptionListItem title={translate('Indexer')} data={indexer} />
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<DescriptionListItem title={translate('Message')} data={message} />
|
||||
) : null}
|
||||
@@ -187,7 +200,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFolderImported') {
|
||||
const { customFormatScore, droppedPath, importedPath } =
|
||||
const { customFormatScore, droppedPath, importedPath, size } =
|
||||
data as DownloadFolderImportedHistory;
|
||||
|
||||
return (
|
||||
@@ -220,12 +233,19 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('FileSize')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'movieFileDeleted') {
|
||||
const { reason, customFormatScore } = data as MovieFileDeletedHistory;
|
||||
const { reason, customFormatScore, size } = data as MovieFileDeletedHistory;
|
||||
|
||||
let reasonMessage = '';
|
||||
|
||||
@@ -255,6 +275,13 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('FileSize')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ interface HistoryDetailsModalProps {
|
||||
sourceTitle: string;
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
isMarkingAsFailed: boolean;
|
||||
isMarkingAsFailed?: boolean;
|
||||
onMarkAsFailedPress: () => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -82,8 +82,7 @@ class AddNewMovie extends Component {
|
||||
const {
|
||||
error,
|
||||
items,
|
||||
hasExistingMovies,
|
||||
colorImpairedMode
|
||||
hasExistingMovies
|
||||
} = this.props;
|
||||
|
||||
const term = this.state.term;
|
||||
@@ -150,7 +149,6 @@ class AddNewMovie extends Component {
|
||||
return (
|
||||
<AddNewMovieSearchResultConnector
|
||||
key={item.tmdbId}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
@@ -223,8 +221,7 @@ AddNewMovie.propTypes = {
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
hasExistingMovies: PropTypes.bool.isRequired,
|
||||
onMovieLookupChange: PropTypes.func.isRequired,
|
||||
onClearMovieLookup: PropTypes.func.isRequired,
|
||||
colorImpairedMode: PropTypes.bool.isRequired
|
||||
onClearMovieLookup: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddNewMovie;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { clearAddMovie, lookupMovie } from 'Store/Actions/addMovieActions';
|
||||
import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
|
||||
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import parseUrl from 'Utilities/String/parseUrl';
|
||||
@@ -17,15 +16,13 @@ function createMapStateToProps() {
|
||||
(state) => state.addMovie,
|
||||
(state) => state.movies.items.length,
|
||||
(state) => state.router.location,
|
||||
createUISettingsSelector(),
|
||||
(addMovie, existingMoviesCount, location, uiSettings) => {
|
||||
(addMovie, existingMoviesCount, location) => {
|
||||
const { params } = parseUrl(location.search);
|
||||
|
||||
return {
|
||||
...addMovie,
|
||||
term: params.term,
|
||||
hasExistingMovies: existingMoviesCount > 0,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
||||
hasExistingMovies: existingMoviesCount > 0
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -74,12 +74,9 @@ class AddNewMovieSearchResult extends Component {
|
||||
isExistingMovie,
|
||||
isExcluded,
|
||||
isSmallScreen,
|
||||
colorImpairedMode,
|
||||
id,
|
||||
monitored,
|
||||
isAvailable,
|
||||
movieFile,
|
||||
queueItem,
|
||||
runtime,
|
||||
movieRuntimeFormat,
|
||||
certification
|
||||
@@ -285,14 +282,12 @@ class AddNewMovieSearchResult extends Component {
|
||||
{
|
||||
isExistingMovie && isSmallScreen &&
|
||||
<MovieStatusLabel
|
||||
status={status}
|
||||
hasMovieFiles={hasMovieFile}
|
||||
movieId={existingMovieId}
|
||||
monitored={monitored}
|
||||
isAvailable={isAvailable}
|
||||
queueItem={queueItem}
|
||||
id={id}
|
||||
hasMovieFiles={hasMovieFile}
|
||||
status={status}
|
||||
useLabel={true}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@@ -337,12 +332,9 @@ AddNewMovieSearchResult.propTypes = {
|
||||
isExistingMovie: PropTypes.bool.isRequired,
|
||||
isExcluded: PropTypes.bool,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
id: PropTypes.number,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
isAvailable: PropTypes.bool.isRequired,
|
||||
movieFile: PropTypes.object,
|
||||
queueItem: PropTypes.object,
|
||||
colorImpairedMode: PropTypes.bool,
|
||||
runtime: PropTypes.number.isRequired,
|
||||
movieRuntimeFormat: PropTypes.string.isRequired,
|
||||
certification: PropTypes.string
|
||||
|
||||
@@ -8,19 +8,16 @@ function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createExistingMovieSelector(),
|
||||
createDimensionsSelector(),
|
||||
(state) => state.queue.details.items,
|
||||
(state) => state.movieFiles.items,
|
||||
(state, { internalId }) => internalId,
|
||||
(state) => state.settings.ui.item.movieRuntimeFormat,
|
||||
(isExistingMovie, dimensions, queueItems, movieFiles, internalId, movieRuntimeFormat) => {
|
||||
const queueItem = queueItems.find((item) => internalId > 0 && item.movieId === internalId);
|
||||
(isExistingMovie, dimensions, movieFiles, internalId, movieRuntimeFormat) => {
|
||||
const movieFile = movieFiles.find((item) => internalId > 0 && item.movieId === internalId);
|
||||
|
||||
return {
|
||||
existingMovieId: internalId,
|
||||
isExistingMovie,
|
||||
isSmallScreen: dimensions.isSmallScreen,
|
||||
queueItem,
|
||||
movieFile,
|
||||
movieRuntimeFormat
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import CollectionConnector from 'Collection/CollectionConnector';
|
||||
import NotFound from 'Components/NotFound';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector';
|
||||
import MovieDetailsPageConnector from 'Movie/Details/MovieDetailsPageConnector';
|
||||
import MovieDetailsPage from 'Movie/Details/MovieDetailsPage';
|
||||
import MovieIndex from 'Movie/Index/MovieIndex';
|
||||
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
|
||||
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
|
||||
@@ -67,7 +67,7 @@ function AppRoutes() {
|
||||
|
||||
<Route path="/add/discover" component={DiscoverMovieConnector} />
|
||||
|
||||
<Route path="/movie/:titleSlug" component={MovieDetailsPageConnector} />
|
||||
<Route path="/movie/:titleSlug" component={MovieDetailsPage} />
|
||||
|
||||
{/*
|
||||
Calendar
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import BlocklistAppState from './BlocklistAppState';
|
||||
import CalendarAppState from './CalendarAppState';
|
||||
import CaptchaAppState from './CaptchaAppState';
|
||||
import CommandAppState from './CommandAppState';
|
||||
import HistoryAppState from './HistoryAppState';
|
||||
import ExtraFilesAppState from './ExtraFilesAppState';
|
||||
import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState';
|
||||
import InteractiveImportAppState from './InteractiveImportAppState';
|
||||
import MovieBlocklistAppState from './MovieBlocklistAppState';
|
||||
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';
|
||||
import SettingsAppState from './SettingsAppState';
|
||||
import SystemAppState from './SystemAppState';
|
||||
@@ -50,6 +57,7 @@ export interface CustomFilter {
|
||||
export interface AppSectionState {
|
||||
isConnected: boolean;
|
||||
isReconnecting: boolean;
|
||||
isSidebarVisible: boolean;
|
||||
version: string;
|
||||
prevVersion?: string;
|
||||
dimensions: {
|
||||
@@ -63,17 +71,25 @@ interface AppState {
|
||||
app: AppSectionState;
|
||||
blocklist: BlocklistAppState;
|
||||
calendar: CalendarAppState;
|
||||
captcha: CaptchaAppState;
|
||||
commands: CommandAppState;
|
||||
extraFiles: ExtraFilesAppState;
|
||||
history: HistoryAppState;
|
||||
interactiveImport: InteractiveImportAppState;
|
||||
movieBlocklist: MovieBlocklistAppState;
|
||||
movieCollections: MovieCollectionAppState;
|
||||
movieCredits: MovieCreditAppState;
|
||||
movieFiles: MovieFilesAppState;
|
||||
movieHistory: MovieHistoryAppState;
|
||||
movieIndex: MovieIndexAppState;
|
||||
movies: MoviesAppState;
|
||||
oAuth: OAuthAppState;
|
||||
organizePreview: OrganizePreviewAppState;
|
||||
parse: ParseAppState;
|
||||
paths: PathsAppState;
|
||||
providerOptions: ProviderOptionsAppState;
|
||||
queue: QueueAppState;
|
||||
releases: ReleasesAppState;
|
||||
rootFolders: RootFolderAppState;
|
||||
settings: SettingsAppState;
|
||||
system: SystemAppState;
|
||||
|
||||
11
frontend/src/App/State/CaptchaAppState.ts
Normal file
11
frontend/src/App/State/CaptchaAppState.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
interface CaptchaAppState {
|
||||
refreshing: false;
|
||||
token: string;
|
||||
siteKey: unknown;
|
||||
secretToken: unknown;
|
||||
ray: unknown;
|
||||
stoken: unknown;
|
||||
responseUrl: unknown;
|
||||
}
|
||||
|
||||
export default CaptchaAppState;
|
||||
6
frontend/src/App/State/ExtraFilesAppState.ts
Normal file
6
frontend/src/App/State/ExtraFilesAppState.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import { ExtraFile } from 'MovieFile/ExtraFile';
|
||||
|
||||
type ExtraFilesAppState = AppSectionState<ExtraFile>;
|
||||
|
||||
export default ExtraFilesAppState;
|
||||
@@ -5,6 +5,8 @@ import AppSectionState, {
|
||||
} from 'App/State/AppSectionState';
|
||||
import History from 'typings/History';
|
||||
|
||||
export type MovieHistoryAppState = AppSectionState<History>;
|
||||
|
||||
interface HistoryAppState
|
||||
extends AppSectionState<History>,
|
||||
AppSectionFilterState<History>,
|
||||
|
||||
6
frontend/src/App/State/MovieBlocklistAppState.ts
Normal file
6
frontend/src/App/State/MovieBlocklistAppState.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import Blocklist from 'typings/Blocklist';
|
||||
|
||||
type MovieBlocklistAppState = AppSectionState<Blocklist>;
|
||||
|
||||
export default MovieBlocklistAppState;
|
||||
@@ -1,8 +1,20 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
AppSectionSaveState,
|
||||
Error,
|
||||
} from 'App/State/AppSectionState';
|
||||
import MovieCollection from 'typings/MovieCollection';
|
||||
|
||||
interface MovieCollectionAppState extends AppSectionState<MovieCollection> {
|
||||
interface MovieCollectionAppState
|
||||
extends AppSectionState<MovieCollection>,
|
||||
AppSectionFilterState<MovieCollection>,
|
||||
AppSectionSaveState {
|
||||
itemMap: Record<number, number>;
|
||||
|
||||
isAdding: boolean;
|
||||
addError: Error;
|
||||
|
||||
pendingChanges: Partial<MovieCollection>;
|
||||
}
|
||||
|
||||
export default MovieCollectionAppState;
|
||||
|
||||
@@ -64,6 +64,8 @@ interface MoviesAppState
|
||||
deleteOptions: {
|
||||
addImportExclusion: boolean;
|
||||
};
|
||||
|
||||
pendingChanges: Partial<Movie>;
|
||||
}
|
||||
|
||||
export default MoviesAppState;
|
||||
|
||||
9
frontend/src/App/State/OAuthAppState.ts
Normal file
9
frontend/src/App/State/OAuthAppState.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Error } from './AppSectionState';
|
||||
|
||||
interface OAuthAppState {
|
||||
authorizing: boolean;
|
||||
result: Record<string, unknown> | null;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export default OAuthAppState;
|
||||
13
frontend/src/App/State/OrganizePreviewAppState.ts
Normal file
13
frontend/src/App/State/OrganizePreviewAppState.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
|
||||
export interface OrganizePreviewModel extends ModelBase {
|
||||
movieId: number;
|
||||
movieFileId: number;
|
||||
existingPath: string;
|
||||
newPath: string;
|
||||
}
|
||||
|
||||
type OrganizePreviewAppState = AppSectionState<OrganizePreviewModel>;
|
||||
|
||||
export default OrganizePreviewAppState;
|
||||
22
frontend/src/App/State/ProviderOptionsAppState.ts
Normal file
22
frontend/src/App/State/ProviderOptionsAppState.ts
Normal 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;
|
||||
10
frontend/src/App/State/ReleasesAppState.ts
Normal file
10
frontend/src/App/State/ReleasesAppState.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Release from 'typings/Release';
|
||||
|
||||
interface ReleasesAppState
|
||||
extends AppSectionState<Release>,
|
||||
AppSectionFilterState<Release> {}
|
||||
|
||||
export default ReleasesAppState;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
45
frontend/src/Collection/AddNewMovieCollectionMovieModal.tsx
Normal file
45
frontend/src/Collection/AddNewMovieCollectionMovieModal.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
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 wasOpen = usePrevious(isOpen);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
dispatch(clearPendingChanges({ section: 'movieCollections' }));
|
||||
onModalClose();
|
||||
}, [dispatch, onModalClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasOpen && !isOpen) {
|
||||
dispatch(clearPendingChanges({ section: 'movieCollections' }));
|
||||
}
|
||||
}, [wasOpen, isOpen, dispatch]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={handleModalClose}>
|
||||
<AddNewMovieCollectionMovieModalContent
|
||||
{...otherProps}
|
||||
onModalClose={handleModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddNewMovieCollectionMovieModal;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
317
frontend/src/Collection/CollectionFooter.tsx
Normal file
317
frontend/src/Collection/CollectionFooter.tsx
Normal 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;
|
||||
@@ -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;
|
||||
32
frontend/src/Collection/CollectionFooterLabel.tsx
Normal file
32
frontend/src/Collection/CollectionFooterLabel.tsx
Normal 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;
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
36
frontend/src/Collection/Edit/EditMovieCollectionModal.tsx
Normal file
36
frontend/src/Collection/Edit/EditMovieCollectionModal.tsx
Normal 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;
|
||||
214
frontend/src/Collection/Edit/EditMovieCollectionModalContent.tsx
Normal file
214
frontend/src/Collection/Edit/EditMovieCollectionModalContent.tsx
Normal 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;
|
||||
@@ -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;
|
||||
34
frontend/src/Collection/Menus/MovieCollectionFilterMenu.tsx
Normal file
34
frontend/src/Collection/Menus/MovieCollectionFilterMenu.tsx
Normal 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;
|
||||
@@ -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;
|
||||
39
frontend/src/Collection/MovieCollectionFilterModal.tsx
Normal file
39
frontend/src/Collection/MovieCollectionFilterModal.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
|
||||
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}
|
||||
@@ -172,7 +172,7 @@ class CollectionMovie extends Component {
|
||||
collectionId={collectionId}
|
||||
/>
|
||||
|
||||
<EditMovieModalConnector
|
||||
<EditMovieModal
|
||||
isOpen={isEditMovieModalOpen}
|
||||
movieId={id}
|
||||
onModalClose={this.onEditMovieModalClose}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
@@ -11,7 +11,7 @@ import IconButton from 'Components/Link/IconButton';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import { icons, sizes } from 'Helpers/Props';
|
||||
import MovieGenres from 'Movie/MovieGenres';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
|
||||
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import fonts from 'Styles/Variables/fonts';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -212,7 +212,7 @@ class CollectionOverview extends Component {
|
||||
/>
|
||||
<span className={styles.qualityProfileName}>
|
||||
{
|
||||
<QualityProfileNameConnector
|
||||
<QualityProfileName
|
||||
qualityProfileId={qualityProfileId}
|
||||
/>
|
||||
}
|
||||
@@ -311,7 +311,7 @@ class CollectionOverview extends Component {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditCollectionModalConnector
|
||||
<EditMovieCollectionModal
|
||||
isOpen={isEditCollectionModalOpen}
|
||||
collectionId={id}
|
||||
onModalClose={this.onEditCollectionModalClose}
|
||||
|
||||
21
frontend/src/Collection/useMovieCollection.ts
Normal file
21
frontend/src/Collection/useMovieCollection.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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;
|
||||
@@ -18,7 +19,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
|
||||
className = styles.container,
|
||||
messageClassName = styles.message,
|
||||
detailsClassName = styles.details,
|
||||
message = 'There was an error loading this content',
|
||||
message = translate('ErrorLoadingContent'),
|
||||
error,
|
||||
info,
|
||||
} = props;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -3,7 +3,10 @@ import translate from 'Utilities/String/translate';
|
||||
import FilterBuilderRowValue from './FilterBuilderRowValue';
|
||||
|
||||
const statusTagList = [
|
||||
{ id: 'tba', name: 'TBA' },
|
||||
{ id: 'tba',
|
||||
get name() {
|
||||
return translate('Tba');
|
||||
} },
|
||||
{
|
||||
id: 'announced',
|
||||
get name() {
|
||||
|
||||
@@ -53,7 +53,7 @@ function CustomFiltersModalContent(props) {
|
||||
|
||||
<div className={styles.addButtonContainer}>
|
||||
<Button onPress={onAddCustomFilter}>
|
||||
Add Custom Filter
|
||||
{translate('AddCustomFilter')}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@@ -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;
|
||||
82
frontend/src/Components/Form/AutoCompleteInput.tsx
Normal file
82
frontend/src/Components/Form/AutoCompleteInput.tsx
Normal 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;
|
||||
@@ -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;
|
||||
259
frontend/src/Components/Form/AutoSuggestInput.tsx
Normal file
259
frontend/src/Components/Form/AutoSuggestInput.tsx
Normal 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;
|
||||
@@ -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;
|
||||
118
frontend/src/Components/Form/CaptchaInput.tsx
Normal file
118
frontend/src/Components/Form/CaptchaInput.tsx
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
140
frontend/src/Components/Form/CheckInput.tsx
Normal file
140
frontend/src/Components/Form/CheckInput.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -1,616 +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';
|
||||
|
||||
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 {
|
||||
top,
|
||||
bottom
|
||||
} = data.offsets.reference;
|
||||
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if ((/^botton/).test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom;
|
||||
} else {
|
||||
data.styles.maxHeight = top;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
onWindowClick = (event) => {
|
||||
const button = 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
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ 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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
45
frontend/src/Components/Form/Form.tsx
Normal file
45
frontend/src/Components/Form/Form.tsx
Normal 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;
|
||||
@@ -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;
|
||||
43
frontend/src/Components/Form/FormGroup.tsx
Normal file
43
frontend/src/Components/Form/FormGroup.tsx
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
330
frontend/src/Components/Form/FormInputGroup.tsx
Normal file
330
frontend/src/Components/Form/FormInputGroup.tsx
Normal 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;
|
||||
@@ -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;
|
||||
55
frontend/src/Components/Form/FormInputHelpText.tsx
Normal file
55
frontend/src/Components/Form/FormInputHelpText.tsx
Normal 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;
|
||||
@@ -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;
|
||||
42
frontend/src/Components/Form/FormLabel.tsx
Normal file
42
frontend/src/Components/Form/FormLabel.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,93 +0,0 @@
|
||||
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 EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.indexers,
|
||||
(state, { includeAny }) => includeAny,
|
||||
(indexers, includeAny) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items
|
||||
} = indexers;
|
||||
|
||||
const values = items.sort(sortByProp('name')).map((indexer) => ({
|
||||
key: indexer.id,
|
||||
value: indexer.name
|
||||
}));
|
||||
|
||||
if (includeAny) {
|
||||
values.unshift({
|
||||
key: 0,
|
||||
value: '(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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user