- Import series you already have
+ {translate('LibraryImportSeriesHeader')}
- Some tips to ensure the import goes smoothly:
+ {translate('LibraryImportTips')}
- Make sure that your files include the quality in their filenames. eg. episode.s02e15.bluray.mkv
+
- Point Sonarr to the folder containing all of your tv shows, not a specific one. eg. "{isWindows ? 'C:\\tv shows' : '/tv shows'}" and not "{isWindows ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons'}" Additionally, each series must be in its own folder within the root/library folder.
+
- Do not use for importing downloads from your download client, this is only for existing organized libraries, not unsorted files.
+ {translate('LibraryImportTipsDontUseDownloadsFolder')}
@@ -96,7 +100,7 @@ class ImportSeriesSelectFolder extends Component {
{
hasRootFolders ?
-
+
- Unable to add root folder
+ {translate('AddRootFolderError')}
{
@@ -149,8 +153,8 @@ class ImportSeriesSelectFolder extends Component {
/>
{
hasRootFolders ?
- 'Choose another folder' :
- 'Start Import'
+ translate('ChooseAnotherFolder') :
+ translate('StartImport')
}
diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js
index 76bb2f34044..1df231f4e74 100644
--- a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js
+++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js
@@ -5,12 +5,13 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { addRootFolder, fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import ImportSeriesSelectFolder from './ImportSeriesSelectFolder';
function createMapStateToProps() {
return createSelector(
- (state) => state.rootFolders,
+ createRootFoldersSelector(),
createSystemStatusSelector(),
(rootFolders, systemStatus) => {
return {
diff --git a/frontend/src/AddSeries/SeriesMonitorNewItemsOptionsPopoverContent.js b/frontend/src/AddSeries/SeriesMonitorNewItemsOptionsPopoverContent.js
new file mode 100644
index 00000000000..c70ec0decf2
--- /dev/null
+++ b/frontend/src/AddSeries/SeriesMonitorNewItemsOptionsPopoverContent.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import translate from 'Utilities/String/translate';
+
+function SeriesMonitorNewItemsOptionsPopoverContent() {
+ return (
+
+
+
+
+
+ );
+}
+
+export default SeriesMonitorNewItemsOptionsPopoverContent;
diff --git a/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js
index e889fbb095d..21289fcb807 100644
--- a/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js
+++ b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js
@@ -1,43 +1,64 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import translate from 'Utilities/String/translate';
function SeriesMonitoringOptionsPopoverContent() {
return (
+
+
+
+
+
+
+
+
);
diff --git a/frontend/src/AddSeries/SeriesTypePopoverContent.js b/frontend/src/AddSeries/SeriesTypePopoverContent.js
index e57d49a9e5e..9771bd8dba0 100644
--- a/frontend/src/AddSeries/SeriesTypePopoverContent.js
+++ b/frontend/src/AddSeries/SeriesTypePopoverContent.js
@@ -1,23 +1,24 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import translate from 'Utilities/String/translate';
function SeriesTypePopoverContent() {
return (
);
diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js
deleted file mode 100644
index 781b2ca1003..00000000000
--- a/frontend/src/App/App.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { ConnectedRouter } from 'connected-react-router';
-import PropTypes from 'prop-types';
-import React from 'react';
-import DocumentTitle from 'react-document-title';
-import { Provider } from 'react-redux';
-import PageConnector from 'Components/Page/PageConnector';
-import ApplyTheme from './ApplyTheme';
-import AppRoutes from './AppRoutes';
-
-function App({ store, history }) {
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-App.propTypes = {
- store: PropTypes.object.isRequired,
- history: PropTypes.object.isRequired
-};
-
-export default App;
diff --git a/frontend/src/App/App.tsx b/frontend/src/App/App.tsx
new file mode 100644
index 00000000000..b71199bb378
--- /dev/null
+++ b/frontend/src/App/App.tsx
@@ -0,0 +1,35 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
+import React from 'react';
+import DocumentTitle from 'react-document-title';
+import { Provider } from 'react-redux';
+import { Store } from 'redux';
+import PageConnector from 'Components/Page/PageConnector';
+import ApplyTheme from './ApplyTheme';
+import AppRoutes from './AppRoutes';
+
+interface AppProps {
+ store: Store;
+ history: ConnectedRouterProps['history'];
+}
+
+const queryClient = new QueryClient();
+
+function App({ store, history }: AppProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js
deleted file mode 100644
index dd1bca729c4..00000000000
--- a/frontend/src/App/AppRoutes.js
+++ /dev/null
@@ -1,266 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Redirect, Route } from 'react-router-dom';
-import BlocklistConnector from 'Activity/Blocklist/BlocklistConnector';
-import HistoryConnector from 'Activity/History/HistoryConnector';
-import QueueConnector from 'Activity/Queue/QueueConnector';
-import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
-import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
-import CalendarPageConnector from 'Calendar/CalendarPageConnector';
-import NotFound from 'Components/NotFound';
-import Switch from 'Components/Router/Switch';
-import SeasonPassConnector from 'SeasonPass/SeasonPassConnector';
-import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
-import SeriesEditorConnector from 'Series/Editor/SeriesEditorConnector';
-import SeriesIndexConnector from 'Series/Index/SeriesIndexConnector';
-import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector';
-import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
-import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
-import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
-import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
-import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
-import MetadataSettings from 'Settings/Metadata/MetadataSettings';
-import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings';
-import NotificationSettings from 'Settings/Notifications/NotificationSettings';
-import Profiles from 'Settings/Profiles/Profiles';
-import QualityConnector from 'Settings/Quality/QualityConnector';
-import Settings from 'Settings/Settings';
-import TagSettings from 'Settings/Tags/TagSettings';
-import UISettingsConnector from 'Settings/UI/UISettingsConnector';
-import BackupsConnector from 'System/Backup/BackupsConnector';
-import LogsTableConnector from 'System/Events/LogsTableConnector';
-import Logs from 'System/Logs/Logs';
-import Status from 'System/Status/Status';
-import Tasks from 'System/Tasks/Tasks';
-import UpdatesConnector from 'System/Updates/UpdatesConnector';
-import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
-import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
-import MissingConnector from 'Wanted/Missing/MissingConnector';
-
-function AppRoutes(props) {
- const {
- app
- } = props;
-
- return (
-
- {/*
- Series
- */}
-
-
-
- {
- window.Sonarr.urlBase &&
- {
- return (
-
- );
- }}
- />
- }
-
-
-
-
-
-
-
-
-
-
-
- {/*
- Calendar
- */}
-
-
-
- {/*
- Activity
- */}
-
-
-
-
-
-
-
- {/*
- Wanted
- */}
-
-
-
-
-
- {/*
- Settings
- */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/*
- System
- */}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/*
- Not Found
- */}
-
-
-
- );
-}
-
-AppRoutes.propTypes = {
- app: PropTypes.func.isRequired
-};
-
-export default AppRoutes;
diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx
new file mode 100644
index 00000000000..fbe4a15bb5e
--- /dev/null
+++ b/frontend/src/App/AppRoutes.tsx
@@ -0,0 +1,167 @@
+import React from 'react';
+import { Redirect, Route } from 'react-router-dom';
+import Blocklist from 'Activity/Blocklist/Blocklist';
+import History from 'Activity/History/History';
+import Queue from 'Activity/Queue/Queue';
+import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
+import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
+import CalendarPage from 'Calendar/CalendarPage';
+import NotFound from 'Components/NotFound';
+import Switch from 'Components/Router/Switch';
+import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
+import SeriesIndex from 'Series/Index/SeriesIndex';
+import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
+import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
+import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
+import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
+import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
+import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
+import MetadataSettings from 'Settings/Metadata/MetadataSettings';
+import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings';
+import NotificationSettings from 'Settings/Notifications/NotificationSettings';
+import Profiles from 'Settings/Profiles/Profiles';
+import QualityConnector from 'Settings/Quality/QualityConnector';
+import Settings from 'Settings/Settings';
+import TagSettings from 'Settings/Tags/TagSettings';
+import UISettingsConnector from 'Settings/UI/UISettingsConnector';
+import BackupsConnector from 'System/Backup/BackupsConnector';
+import LogsTableConnector from 'System/Events/LogsTableConnector';
+import Logs from 'System/Logs/Logs';
+import Status from 'System/Status/Status';
+import Tasks from 'System/Tasks/Tasks';
+import Updates from 'System/Updates/Updates';
+import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
+import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
+import MissingConnector from 'Wanted/Missing/MissingConnector';
+
+function RedirectWithUrlBase() {
+ return
;
+}
+
+function AppRoutes() {
+ return (
+
+ {/*
+ Series
+ */}
+
+
+
+ {window.Sonarr.urlBase && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Calendar
+ */}
+
+
+
+ {/*
+ Activity
+ */}
+
+
+
+
+
+
+
+ {/*
+ Wanted
+ */}
+
+
+
+
+
+ {/*
+ Settings
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ System
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Not Found
+ */}
+
+
+
+ );
+}
+
+export default AppRoutes;
diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js
deleted file mode 100644
index abc7f8832fd..00000000000
--- a/frontend/src/App/AppUpdatedModal.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Modal from 'Components/Modal/Modal';
-import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector';
-
-function AppUpdatedModal(props) {
- const {
- isOpen,
- onModalClose
- } = props;
-
- return (
-
-
-
- );
-}
-
-AppUpdatedModal.propTypes = {
- isOpen: PropTypes.bool.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default AppUpdatedModal;
diff --git a/frontend/src/App/AppUpdatedModal.tsx b/frontend/src/App/AppUpdatedModal.tsx
new file mode 100644
index 00000000000..696d36fb244
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModal.tsx
@@ -0,0 +1,28 @@
+import React, { useCallback } from 'react';
+import Modal from 'Components/Modal/Modal';
+import AppUpdatedModalContent from './AppUpdatedModalContent';
+
+interface AppUpdatedModalProps {
+ isOpen: boolean;
+ onModalClose: (...args: unknown[]) => unknown;
+}
+
+function AppUpdatedModal(props: AppUpdatedModalProps) {
+ const { isOpen, onModalClose } = props;
+
+ const handleModalClose = useCallback(() => {
+ location.reload();
+ }, []);
+
+ return (
+
+
+
+ );
+}
+
+export default AppUpdatedModal;
diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js
deleted file mode 100644
index a21afbc5aa9..00000000000
--- a/frontend/src/App/AppUpdatedModalConnector.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { connect } from 'react-redux';
-import AppUpdatedModal from './AppUpdatedModal';
-
-function createMapDispatchToProps(dispatch, props) {
- return {
- onModalClose() {
- location.reload();
- }
- };
-}
-
-export default connect(null, createMapDispatchToProps)(AppUpdatedModal);
diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css
index 37b89c9becf..0df4183a662 100644
--- a/frontend/src/App/AppUpdatedModalContent.css
+++ b/frontend/src/App/AppUpdatedModalContent.css
@@ -1,6 +1,7 @@
.version {
margin: 0 3px;
font-weight: bold;
+ font-family: var(--defaultFontFamily);
}
.maintenance {
diff --git a/frontend/src/App/AppUpdatedModalContent.css.d.ts b/frontend/src/App/AppUpdatedModalContent.css.d.ts
new file mode 100644
index 00000000000..70ddbf6a1a2
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'changes': string;
+ 'maintenance': string;
+ 'version': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js
deleted file mode 100644
index 6957f98300f..00000000000
--- a/frontend/src/App/AppUpdatedModalContent.js
+++ /dev/null
@@ -1,137 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Button from 'Components/Link/Button';
-import LoadingIndicator from 'Components/Loading/LoadingIndicator';
-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 { kinds } from 'Helpers/Props';
-import UpdateChanges from 'System/Updates/UpdateChanges';
-import styles from './AppUpdatedModalContent.css';
-
-function mergeUpdates(items, version, prevVersion) {
- let installedIndex = items.findIndex((u) => u.version === version);
- let installedPreviouslyIndex = items.findIndex((u) => u.version === prevVersion);
-
- if (installedIndex === -1) {
- installedIndex = 0;
- }
-
- if (installedPreviouslyIndex === -1) {
- installedPreviouslyIndex = items.length;
- } else if (installedPreviouslyIndex === installedIndex && items.length) {
- installedPreviouslyIndex += 1;
- }
-
- const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex);
-
- if (!appliedUpdates.length) {
- return null;
- }
-
- const appliedChanges = { new: [], fixed: [] };
- appliedUpdates.forEach((u) => {
- if (u.changes) {
- appliedChanges.new.push(... u.changes.new);
- appliedChanges.fixed.push(... u.changes.fixed);
- }
- });
-
- const mergedUpdate = Object.assign({}, appliedUpdates[0], { changes: appliedChanges });
-
- if (!appliedChanges.new.length && !appliedChanges.fixed.length) {
- mergedUpdate.changes = null;
- }
-
- return mergedUpdate;
-}
-
-function AppUpdatedModalContent(props) {
- const {
- version,
- prevVersion,
- isPopulated,
- error,
- items,
- onSeeChangesPress,
- onModalClose
- } = props;
-
- const update = mergeUpdates(items, version, prevVersion);
-
- return (
-
-
- Sonarr Updated
-
-
-
-
- Sonarr has been updated to version {version} , in order to get the latest changes you'll need to reload Sonarr.
-
-
- {
- isPopulated && !error && !!update &&
-
- {
- !update.changes &&
-
Maintenance release
- }
-
- {
- !!update.changes &&
-
-
- What's new?
-
-
-
-
-
-
- }
-
- }
-
- {
- !isPopulated && !error &&
-
- }
-
-
-
-
- Recent Changes
-
-
-
- Reload
-
-
-
- );
-}
-
-AppUpdatedModalContent.propTypes = {
- version: PropTypes.string.isRequired,
- prevVersion: PropTypes.string,
- isPopulated: PropTypes.bool.isRequired,
- error: PropTypes.object,
- items: PropTypes.arrayOf(PropTypes.object).isRequired,
- onSeeChangesPress: PropTypes.func.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default AppUpdatedModalContent;
diff --git a/frontend/src/App/AppUpdatedModalContent.tsx b/frontend/src/App/AppUpdatedModalContent.tsx
new file mode 100644
index 00000000000..6553d6270c3
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.tsx
@@ -0,0 +1,145 @@
+import React, { useCallback, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+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 { kinds } from 'Helpers/Props';
+import { fetchUpdates } from 'Store/Actions/systemActions';
+import UpdateChanges from 'System/Updates/UpdateChanges';
+import Update from 'typings/Update';
+import translate from 'Utilities/String/translate';
+import AppState from './State/AppState';
+import styles from './AppUpdatedModalContent.css';
+
+function mergeUpdates(items: Update[], version: string, prevVersion?: string) {
+ let installedIndex = items.findIndex((u) => u.version === version);
+ let installedPreviouslyIndex = items.findIndex(
+ (u) => u.version === prevVersion
+ );
+
+ if (installedIndex === -1) {
+ installedIndex = 0;
+ }
+
+ if (installedPreviouslyIndex === -1) {
+ installedPreviouslyIndex = items.length;
+ } else if (installedPreviouslyIndex === installedIndex && items.length) {
+ installedPreviouslyIndex += 1;
+ }
+
+ const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex);
+
+ if (!appliedUpdates.length) {
+ return null;
+ }
+
+ const appliedChanges: Update['changes'] = { new: [], fixed: [] };
+
+ appliedUpdates.forEach((u: Update) => {
+ if (u.changes) {
+ appliedChanges.new.push(...u.changes.new);
+ appliedChanges.fixed.push(...u.changes.fixed);
+ }
+ });
+
+ const mergedUpdate: Update = Object.assign({}, appliedUpdates[0], {
+ changes: appliedChanges,
+ });
+
+ if (!appliedChanges.new.length && !appliedChanges.fixed.length) {
+ mergedUpdate.changes = null;
+ }
+
+ return mergedUpdate;
+}
+
+interface AppUpdatedModalContentProps {
+ onModalClose: () => void;
+}
+
+function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
+ const dispatch = useDispatch();
+ const { version, prevVersion } = useSelector((state: AppState) => state.app);
+ const { isPopulated, error, items } = useSelector(
+ (state: AppState) => state.system.updates
+ );
+ const previousVersion = usePrevious(version);
+
+ const { onModalClose } = props;
+
+ const update = mergeUpdates(items, version, prevVersion);
+
+ const handleSeeChangesPress = useCallback(() => {
+ window.location.href = `${window.Sonarr.urlBase}/system/updates`;
+ }, []);
+
+ useEffect(() => {
+ dispatch(fetchUpdates());
+ }, [dispatch]);
+
+ useEffect(() => {
+ if (version !== previousVersion) {
+ dispatch(fetchUpdates());
+ }
+ }, [version, previousVersion, dispatch]);
+
+ return (
+
+ {translate('AppUpdated')}
+
+
+
+
+
+
+ {isPopulated && !error && !!update ? (
+
+ {update.changes ? (
+
+ {translate('MaintenanceRelease')}
+
+ ) : null}
+
+ {update.changes ? (
+
+
{translate('WhatsNew')}
+
+
+
+
+
+ ) : null}
+
+ ) : null}
+
+ {!isPopulated && !error ? : null}
+
+
+
+
+ {translate('RecentChanges')}
+
+
+
+ {translate('Reload')}
+
+
+
+ );
+}
+
+export default AppUpdatedModalContent;
diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js
deleted file mode 100644
index 4100ee67452..00000000000
--- a/frontend/src/App/AppUpdatedModalContentConnector.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { fetchUpdates } from 'Store/Actions/systemActions';
-import AppUpdatedModalContent from './AppUpdatedModalContent';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.app.version,
- (state) => state.app.prevVersion,
- (state) => state.system.updates,
- (version, prevVersion, updates) => {
- const {
- isPopulated,
- error,
- items
- } = updates;
-
- return {
- version,
- prevVersion,
- isPopulated,
- error,
- items
- };
- }
- );
-}
-
-function createMapDispatchToProps(dispatch, props) {
- return {
- dispatchFetchUpdates() {
- dispatch(fetchUpdates());
- },
-
- onSeeChangesPress() {
- window.location = `${window.Sonarr.urlBase}/system/updates`;
- }
- };
-}
-
-class AppUpdatedModalContentConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidMount() {
- this.props.dispatchFetchUpdates();
- }
-
- componentDidUpdate(prevProps) {
- if (prevProps.version !== this.props.version) {
- this.props.dispatchFetchUpdates();
- }
- }
-
- //
- // Render
-
- render() {
- const {
- dispatchFetchUpdates,
- ...otherProps
- } = this.props;
-
- return (
-
- );
- }
-}
-
-AppUpdatedModalContentConnector.propTypes = {
- version: PropTypes.string.isRequired,
- dispatchFetchUpdates: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector);
diff --git a/frontend/src/App/ApplyTheme.js b/frontend/src/App/ApplyTheme.js
deleted file mode 100644
index ef177749f97..00000000000
--- a/frontend/src/App/ApplyTheme.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Fragment, useCallback, useEffect } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import themes from 'Styles/Themes';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.settings.ui.item.theme || window.Sonarr.theme,
- (
- theme
- ) => {
- return {
- theme
- };
- }
- );
-}
-
-function ApplyTheme({ theme, children }) {
- // Update the CSS Variables
-
- const updateCSSVariables = useCallback(() => {
- const arrayOfVariableKeys = Object.keys(themes[theme]);
- const arrayOfVariableValues = Object.values(themes[theme]);
-
- // Loop through each array key and set the CSS Variables
- arrayOfVariableKeys.forEach((cssVariableKey, index) => {
- // Based on our snippet from MDN
- document.documentElement.style.setProperty(
- `--${cssVariableKey}`,
- arrayOfVariableValues[index]
- );
- });
- }, [theme]);
-
- // On Component Mount and Component Update
- useEffect(() => {
- updateCSSVariables(theme);
- }, [updateCSSVariables, theme]);
-
- return
{children} ;
-}
-
-ApplyTheme.propTypes = {
- theme: PropTypes.string.isRequired,
- children: PropTypes.object.isRequired
-};
-
-export default connect(createMapStateToProps)(ApplyTheme);
diff --git a/frontend/src/App/ApplyTheme.tsx b/frontend/src/App/ApplyTheme.tsx
new file mode 100644
index 00000000000..ce598f2dc41
--- /dev/null
+++ b/frontend/src/App/ApplyTheme.tsx
@@ -0,0 +1,33 @@
+import { useCallback, useEffect } from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import themes from 'Styles/Themes';
+import AppState from './State/AppState';
+
+function createThemeSelector() {
+ return createSelector(
+ (state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
+ (theme) => {
+ return theme;
+ }
+ );
+}
+
+function ApplyTheme() {
+ const theme = useSelector(createThemeSelector());
+
+ const updateCSSVariables = useCallback(() => {
+ Object.entries(themes[theme]).forEach(([key, value]) => {
+ document.documentElement.style.setProperty(`--${key}`, value);
+ });
+ }, [theme]);
+
+ // On Component Mount and Component Update
+ useEffect(() => {
+ updateCSSVariables();
+ }, [updateCSSVariables, theme]);
+
+ return null;
+}
+
+export default ApplyTheme;
diff --git a/frontend/src/App/ColorImpairedContext.js b/frontend/src/App/ColorImpairedContext.ts
similarity index 100%
rename from frontend/src/App/ColorImpairedContext.js
rename to frontend/src/App/ColorImpairedContext.ts
diff --git a/frontend/src/App/ConnectionLostModal.css.d.ts b/frontend/src/App/ConnectionLostModal.css.d.ts
new file mode 100644
index 00000000000..027f2a9a3ec
--- /dev/null
+++ b/frontend/src/App/ConnectionLostModal.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'automatic': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.js
deleted file mode 100644
index aa886b7722b..00000000000
--- a/frontend/src/App/ConnectionLostModal.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Button from 'Components/Link/Button';
-import Modal from 'Components/Modal/Modal';
-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 { kinds } from 'Helpers/Props';
-import styles from './ConnectionLostModal.css';
-
-function ConnectionLostModal(props) {
- const {
- isOpen,
- onModalClose
- } = props;
-
- return (
-
-
-
- Connection Lost
-
-
-
-
- Sonarr has lost its connection to the backend and will need to be reloaded to restore functionality.
-
-
-
- Sonarr will try to connect automatically, or you can click reload below.
-
-
-
-
- Reload
-
-
-
-
- );
-}
-
-ConnectionLostModal.propTypes = {
- isOpen: PropTypes.bool.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default ConnectionLostModal;
diff --git a/frontend/src/App/ConnectionLostModal.tsx b/frontend/src/App/ConnectionLostModal.tsx
new file mode 100644
index 00000000000..f08f2c0e205
--- /dev/null
+++ b/frontend/src/App/ConnectionLostModal.tsx
@@ -0,0 +1,45 @@
+import React, { useCallback } from 'react';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+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 { kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './ConnectionLostModal.css';
+
+interface ConnectionLostModalProps {
+ isOpen: boolean;
+}
+
+function ConnectionLostModal(props: ConnectionLostModalProps) {
+ const { isOpen } = props;
+
+ const handleModalClose = useCallback(() => {
+ location.reload();
+ }, []);
+
+ return (
+
+
+ {translate('ConnectionLost')}
+
+
+ {translate('ConnectionLostToBackend')}
+
+
+ {translate('ConnectionLostReconnect')}
+
+
+
+
+ {translate('Reload')}
+
+
+
+
+ );
+}
+
+export default ConnectionLostModal;
diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js
deleted file mode 100644
index 8ab8e3cd07c..00000000000
--- a/frontend/src/App/ConnectionLostModalConnector.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { connect } from 'react-redux';
-import ConnectionLostModal from './ConnectionLostModal';
-
-function createMapDispatchToProps(dispatch, props) {
- return {
- onModalClose() {
- location.reload();
- }
- };
-}
-
-export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal);
diff --git a/frontend/src/App/ModelBase.ts b/frontend/src/App/ModelBase.ts
new file mode 100644
index 00000000000..187b12fb239
--- /dev/null
+++ b/frontend/src/App/ModelBase.ts
@@ -0,0 +1,5 @@
+interface ModelBase {
+ id: number;
+}
+
+export default ModelBase;
diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx
new file mode 100644
index 00000000000..66be388ce0a
--- /dev/null
+++ b/frontend/src/App/SelectContext.tsx
@@ -0,0 +1,83 @@
+import { cloneDeep } from 'lodash';
+import React, { useCallback, useEffect } from 'react';
+import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState';
+import ModelBase from './ModelBase';
+
+export type SelectContextAction =
+ | { type: 'reset' }
+ | { type: 'selectAll' }
+ | { type: 'unselectAll' }
+ | {
+ type: 'toggleSelected';
+ id: number;
+ isSelected: boolean;
+ shiftKey: boolean;
+ }
+ | {
+ type: 'removeItem';
+ id: number;
+ }
+ | {
+ type: 'updateItems';
+ items: ModelBase[];
+ };
+
+export type SelectDispatch = (action: SelectContextAction) => void;
+
+interface SelectProviderOptions
{
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ children: any;
+ items: Array;
+}
+
+const SelectContext = React.createContext<
+ [SelectState, SelectDispatch] | undefined
+>(cloneDeep(undefined));
+
+export function SelectProvider(
+ props: SelectProviderOptions
+) {
+ const { items } = props;
+ const [state, dispatch] = useSelectState();
+
+ const dispatchWrapper = useCallback(
+ (action: SelectContextAction) => {
+ switch (action.type) {
+ case 'reset':
+ case 'removeItem':
+ dispatch(action);
+ break;
+
+ default:
+ dispatch({
+ ...action,
+ items,
+ });
+ break;
+ }
+ },
+ [items, dispatch]
+ );
+
+ const value: [SelectState, SelectDispatch] = [state, dispatchWrapper];
+
+ useEffect(() => {
+ dispatch({ type: 'updateItems', items });
+ }, [items, dispatch]);
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function useSelect() {
+ const context = React.useContext(SelectContext);
+
+ if (context === undefined) {
+ throw new Error('useSelect must be used within a SelectProvider');
+ }
+
+ return context;
+}
diff --git a/frontend/src/App/State/AppSectionState.ts b/frontend/src/App/State/AppSectionState.ts
new file mode 100644
index 00000000000..4e9dbe7a099
--- /dev/null
+++ b/frontend/src/App/State/AppSectionState.ts
@@ -0,0 +1,85 @@
+import Column from 'Components/Table/Column';
+import { SortDirection } from 'Helpers/Props/sortDirections';
+import { ValidationFailure } from 'typings/pending';
+import { FilterBuilderProp, PropertyFilter } from './AppState';
+
+export interface Error {
+ status?: number;
+ responseJSON:
+ | {
+ message: string | undefined;
+ }
+ | ValidationFailure[]
+ | undefined;
+}
+
+export interface AppSectionDeleteState {
+ isDeleting: boolean;
+ deleteError: Error;
+}
+
+export interface AppSectionSaveState {
+ isSaving: boolean;
+ saveError: Error;
+}
+
+export interface PagedAppSectionState {
+ page: number;
+ pageSize: number;
+ totalPages: number;
+ totalRecords?: number;
+}
+export interface TableAppSectionState {
+ columns: Column[];
+}
+
+export interface AppSectionFilterState {
+ selectedFilterKey: string;
+ filters: PropertyFilter[];
+ filterBuilderProps: FilterBuilderProp[];
+}
+
+export interface AppSectionSchemaState {
+ isSchemaFetching: boolean;
+ isSchemaPopulated: boolean;
+ schemaError: Error;
+ schema: {
+ items: T[];
+ };
+}
+
+export interface AppSectionItemSchemaState {
+ isSchemaFetching: boolean;
+ isSchemaPopulated: boolean;
+ schemaError: Error;
+ schema: T;
+}
+
+export interface AppSectionItemState {
+ isFetching: boolean;
+ isPopulated: boolean;
+ error: Error;
+ pendingChanges: Partial;
+ item: T;
+}
+
+export interface AppSectionProviderState
+ extends AppSectionDeleteState,
+ AppSectionSaveState {
+ isFetching: boolean;
+ isPopulated: boolean;
+ error: Error;
+ items: T[];
+ pendingChanges: Partial;
+}
+
+interface AppSectionState {
+ isFetching: boolean;
+ isPopulated: boolean;
+ error: Error;
+ items: T[];
+ sortKey: string;
+ sortDirection: SortDirection;
+}
+
+export default AppSectionState;
diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts
new file mode 100644
index 00000000000..84bd5d0b48a
--- /dev/null
+++ b/frontend/src/App/State/AppState.ts
@@ -0,0 +1,95 @@
+import BlocklistAppState from './BlocklistAppState';
+import CalendarAppState from './CalendarAppState';
+import CaptchaAppState from './CaptchaAppState';
+import CommandAppState from './CommandAppState';
+import EpisodeFilesAppState from './EpisodeFilesAppState';
+import EpisodesAppState from './EpisodesAppState';
+import HistoryAppState from './HistoryAppState';
+import InteractiveImportAppState from './InteractiveImportAppState';
+import OAuthAppState from './OAuthAppState';
+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 SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
+import SettingsAppState from './SettingsAppState';
+import SystemAppState from './SystemAppState';
+import TagsAppState from './TagsAppState';
+import WantedAppState from './WantedAppState';
+
+interface FilterBuilderPropOption {
+ id: string;
+ name: string;
+}
+
+export interface FilterBuilderProp {
+ name: string;
+ label: string;
+ type: string;
+ valueType?: string;
+ optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
+}
+
+export interface PropertyFilter {
+ key: string;
+ value: boolean | string | number | string[] | number[];
+ type: string;
+}
+
+export interface Filter {
+ key: string;
+ label: string;
+ filters: PropertyFilter[];
+}
+
+export interface CustomFilter {
+ id: number;
+ type: string;
+ label: string;
+ filters: PropertyFilter[];
+}
+
+export interface AppSectionState {
+ isConnected: boolean;
+ isReconnecting: boolean;
+ isSidebarVisible: boolean;
+ version: string;
+ prevVersion?: string;
+ dimensions: {
+ isSmallScreen: boolean;
+ isLargeScreen: boolean;
+ width: number;
+ height: number;
+ };
+}
+
+interface AppState {
+ app: AppSectionState;
+ blocklist: BlocklistAppState;
+ calendar: CalendarAppState;
+ captcha: CaptchaAppState;
+ commands: CommandAppState;
+ episodeFiles: EpisodeFilesAppState;
+ episodeHistory: HistoryAppState;
+ episodes: EpisodesAppState;
+ episodesSelection: EpisodesAppState;
+ history: HistoryAppState;
+ interactiveImport: InteractiveImportAppState;
+ oAuth: OAuthAppState;
+ parse: ParseAppState;
+ paths: PathsAppState;
+ providerOptions: ProviderOptionsAppState;
+ queue: QueueAppState;
+ releases: ReleasesAppState;
+ rootFolders: RootFolderAppState;
+ series: SeriesAppState;
+ seriesIndex: SeriesIndexAppState;
+ settings: SettingsAppState;
+ system: SystemAppState;
+ tags: TagsAppState;
+ wanted: WantedAppState;
+}
+
+export default AppState;
diff --git a/frontend/src/App/State/BlocklistAppState.ts b/frontend/src/App/State/BlocklistAppState.ts
new file mode 100644
index 00000000000..004a30732ed
--- /dev/null
+++ b/frontend/src/App/State/BlocklistAppState.ts
@@ -0,0 +1,16 @@
+import Blocklist from 'typings/Blocklist';
+import AppSectionState, {
+ AppSectionFilterState,
+ PagedAppSectionState,
+ TableAppSectionState,
+} from './AppSectionState';
+
+interface BlocklistAppState
+ extends AppSectionState,
+ AppSectionFilterState,
+ PagedAppSectionState,
+ TableAppSectionState {
+ isRemoving: boolean;
+}
+
+export default BlocklistAppState;
diff --git a/frontend/src/App/State/CalendarAppState.ts b/frontend/src/App/State/CalendarAppState.ts
new file mode 100644
index 00000000000..75c8b5e50bf
--- /dev/null
+++ b/frontend/src/App/State/CalendarAppState.ts
@@ -0,0 +1,29 @@
+import moment from 'moment';
+import AppSectionState, {
+ AppSectionFilterState,
+} from 'App/State/AppSectionState';
+import { CalendarView } from 'Calendar/calendarViews';
+import { CalendarItem } from 'typings/Calendar';
+
+interface CalendarOptions {
+ showEpisodeInformation: boolean;
+ showFinaleIcon: boolean;
+ showSpecialIcon: boolean;
+ showCutoffUnmetIcon: boolean;
+ collapseMultipleEpisodes: boolean;
+ fullColorEvents: boolean;
+}
+
+interface CalendarAppState
+ extends AppSectionState,
+ AppSectionFilterState {
+ searchMissingCommandId: number | null;
+ start: moment.Moment;
+ end: moment.Moment;
+ dates: string[];
+ time: string;
+ view: CalendarView;
+ options: CalendarOptions;
+}
+
+export default CalendarAppState;
diff --git a/frontend/src/App/State/CaptchaAppState.ts b/frontend/src/App/State/CaptchaAppState.ts
new file mode 100644
index 00000000000..7252937eb53
--- /dev/null
+++ b/frontend/src/App/State/CaptchaAppState.ts
@@ -0,0 +1,11 @@
+interface CaptchaAppState {
+ refreshing: false;
+ token: string;
+ siteKey: unknown;
+ secretToken: unknown;
+ ray: unknown;
+ stoken: unknown;
+ responseUrl: unknown;
+}
+
+export default CaptchaAppState;
diff --git a/frontend/src/App/State/ClientSideCollectionAppState.ts b/frontend/src/App/State/ClientSideCollectionAppState.ts
new file mode 100644
index 00000000000..f4110ef73bb
--- /dev/null
+++ b/frontend/src/App/State/ClientSideCollectionAppState.ts
@@ -0,0 +1,8 @@
+import { CustomFilter } from './AppState';
+
+interface ClientSideCollectionAppState {
+ totalItems: number;
+ customFilters: CustomFilter[];
+}
+
+export default ClientSideCollectionAppState;
diff --git a/frontend/src/App/State/CommandAppState.ts b/frontend/src/App/State/CommandAppState.ts
new file mode 100644
index 00000000000..1bde3737156
--- /dev/null
+++ b/frontend/src/App/State/CommandAppState.ts
@@ -0,0 +1,6 @@
+import AppSectionState from 'App/State/AppSectionState';
+import Command from 'Commands/Command';
+
+export type CommandAppState = AppSectionState;
+
+export default CommandAppState;
diff --git a/frontend/src/App/State/CustomFiltersAppState.ts b/frontend/src/App/State/CustomFiltersAppState.ts
new file mode 100644
index 00000000000..6ac4820c74c
--- /dev/null
+++ b/frontend/src/App/State/CustomFiltersAppState.ts
@@ -0,0 +1,10 @@
+import AppSectionState, {
+ AppSectionDeleteState,
+} from 'App/State/AppSectionState';
+import { CustomFilter } from './AppState';
+
+interface CustomFiltersAppState
+ extends AppSectionState,
+ AppSectionDeleteState {}
+
+export default CustomFiltersAppState;
diff --git a/frontend/src/App/State/EpisodeFilesAppState.ts b/frontend/src/App/State/EpisodeFilesAppState.ts
new file mode 100644
index 00000000000..5e6e94a06a3
--- /dev/null
+++ b/frontend/src/App/State/EpisodeFilesAppState.ts
@@ -0,0 +1,10 @@
+import AppSectionState, {
+ AppSectionDeleteState,
+} from 'App/State/AppSectionState';
+import { EpisodeFile } from 'EpisodeFile/EpisodeFile';
+
+interface EpisodeFilesAppState
+ extends AppSectionState,
+ AppSectionDeleteState {}
+
+export default EpisodeFilesAppState;
diff --git a/frontend/src/App/State/EpisodesAppState.ts b/frontend/src/App/State/EpisodesAppState.ts
new file mode 100644
index 00000000000..4234c0bcbd5
--- /dev/null
+++ b/frontend/src/App/State/EpisodesAppState.ts
@@ -0,0 +1,6 @@
+import AppSectionState from 'App/State/AppSectionState';
+import Episode from 'Episode/Episode';
+
+type EpisodesAppState = AppSectionState;
+
+export default EpisodesAppState;
diff --git a/frontend/src/App/State/HistoryAppState.ts b/frontend/src/App/State/HistoryAppState.ts
new file mode 100644
index 00000000000..632b8217932
--- /dev/null
+++ b/frontend/src/App/State/HistoryAppState.ts
@@ -0,0 +1,14 @@
+import AppSectionState, {
+ AppSectionFilterState,
+ PagedAppSectionState,
+ TableAppSectionState,
+} from 'App/State/AppSectionState';
+import History from 'typings/History';
+
+interface HistoryAppState
+ extends AppSectionState,
+ AppSectionFilterState,
+ PagedAppSectionState,
+ TableAppSectionState {}
+
+export default HistoryAppState;
diff --git a/frontend/src/App/State/InteractiveImportAppState.ts b/frontend/src/App/State/InteractiveImportAppState.ts
new file mode 100644
index 00000000000..84fd9f4c14c
--- /dev/null
+++ b/frontend/src/App/State/InteractiveImportAppState.ts
@@ -0,0 +1,21 @@
+import AppSectionState from 'App/State/AppSectionState';
+import ImportMode from 'InteractiveImport/ImportMode';
+import InteractiveImport from 'InteractiveImport/InteractiveImport';
+
+interface FavoriteFolder {
+ folder: string;
+}
+
+interface RecentFolder {
+ folder: string;
+ lastUsed: string;
+}
+
+interface InteractiveImportAppState extends AppSectionState {
+ originalItems: InteractiveImport[];
+ importMode: ImportMode;
+ favoriteFolders: FavoriteFolder[];
+ recentFolders: RecentFolder[];
+}
+
+export default InteractiveImportAppState;
diff --git a/frontend/src/App/State/MetadataAppState.ts b/frontend/src/App/State/MetadataAppState.ts
new file mode 100644
index 00000000000..495f109d8b4
--- /dev/null
+++ b/frontend/src/App/State/MetadataAppState.ts
@@ -0,0 +1,6 @@
+import { AppSectionProviderState } from 'App/State/AppSectionState';
+import Metadata from 'typings/Metadata';
+
+type MetadataAppState = AppSectionProviderState;
+
+export default MetadataAppState;
diff --git a/frontend/src/App/State/OAuthAppState.ts b/frontend/src/App/State/OAuthAppState.ts
new file mode 100644
index 00000000000..619767929c6
--- /dev/null
+++ b/frontend/src/App/State/OAuthAppState.ts
@@ -0,0 +1,9 @@
+import { Error } from './AppSectionState';
+
+interface OAuthAppState {
+ authorizing: boolean;
+ result: Record | null;
+ error: Error;
+}
+
+export default OAuthAppState;
diff --git a/frontend/src/App/State/ParseAppState.ts b/frontend/src/App/State/ParseAppState.ts
new file mode 100644
index 00000000000..67fb4cc630e
--- /dev/null
+++ b/frontend/src/App/State/ParseAppState.ts
@@ -0,0 +1,54 @@
+import ModelBase from 'App/ModelBase';
+import { AppSectionItemState } from 'App/State/AppSectionState';
+import Episode from 'Episode/Episode';
+import Language from 'Language/Language';
+import { QualityModel } from 'Quality/Quality';
+import Series from 'Series/Series';
+import CustomFormat from 'typings/CustomFormat';
+
+export interface SeriesTitleInfo {
+ title: string;
+ titleWithoutYear: string;
+ year: number;
+ allTitles: string[];
+}
+
+export interface ParsedEpisodeInfo {
+ releaseTitle: string;
+ seriesTitle: string;
+ seriesTitleInfo: SeriesTitleInfo;
+ quality: QualityModel;
+ seasonNumber: number;
+ episodeNumbers: number[];
+ absoluteEpisodeNumbers: number[];
+ specialAbsoluteEpisodeNumbers: number[];
+ languages: Language[];
+ fullSeason: boolean;
+ isPartialSeason: boolean;
+ isMultiSeason: boolean;
+ isSeasonExtra: boolean;
+ special: boolean;
+ releaseHash: string;
+ seasonPart: number;
+ releaseGroup?: string;
+ releaseTokens: string;
+ airDate?: string;
+ isDaily: boolean;
+ isAbsoluteNumbering: boolean;
+ isPossibleSpecialEpisode: boolean;
+ isPossibleSceneSeasonSpecial: boolean;
+}
+
+export interface ParseModel extends ModelBase {
+ title: string;
+ parsedEpisodeInfo: ParsedEpisodeInfo;
+ series?: Series;
+ episodes: Episode[];
+ languages?: Language[];
+ customFormats?: CustomFormat[];
+ customFormatScore?: number;
+}
+
+type ParseAppState = AppSectionItemState;
+
+export default ParseAppState;
diff --git a/frontend/src/App/State/PathsAppState.ts b/frontend/src/App/State/PathsAppState.ts
new file mode 100644
index 00000000000..068a48dc098
--- /dev/null
+++ b/frontend/src/App/State/PathsAppState.ts
@@ -0,0 +1,29 @@
+interface BasePath {
+ name: string;
+ path: string;
+ size: number;
+ lastModified: string;
+}
+
+interface File extends BasePath {
+ type: 'file';
+}
+
+interface Folder extends BasePath {
+ type: 'folder';
+}
+
+export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent';
+export type Path = File | Folder;
+
+interface PathsAppState {
+ currentPath: string;
+ isFetching: boolean;
+ isPopulated: boolean;
+ error: Error;
+ directories: Folder[];
+ files: File[];
+ parent: string | null;
+}
+
+export default PathsAppState;
diff --git a/frontend/src/App/State/ProviderOptionsAppState.ts b/frontend/src/App/State/ProviderOptionsAppState.ts
new file mode 100644
index 00000000000..7fb5df02b86
--- /dev/null
+++ b/frontend/src/App/State/ProviderOptionsAppState.ts
@@ -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;
+ servers: AppSectionState>;
+ newznabCategories: AppSectionState>;
+ getProfiles: AppSectionState>;
+ getTags: AppSectionState>;
+ getRootFolders: AppSectionState>;
+}
+
+export default ProviderOptionsAppState;
diff --git a/frontend/src/App/State/QueueAppState.ts b/frontend/src/App/State/QueueAppState.ts
new file mode 100644
index 00000000000..954d649a2ff
--- /dev/null
+++ b/frontend/src/App/State/QueueAppState.ts
@@ -0,0 +1,44 @@
+import Queue from 'typings/Queue';
+import AppSectionState, {
+ AppSectionFilterState,
+ AppSectionItemState,
+ Error,
+ PagedAppSectionState,
+ TableAppSectionState,
+} from './AppSectionState';
+
+export interface QueueStatus {
+ totalCount: number;
+ count: number;
+ unknownCount: number;
+ errors: boolean;
+ warnings: boolean;
+ unknownErrors: boolean;
+ unknownWarnings: boolean;
+}
+
+export interface QueueDetailsAppState extends AppSectionState {
+ params: unknown;
+}
+
+export interface QueuePagedAppState
+ extends AppSectionState,
+ AppSectionFilterState,
+ PagedAppSectionState,
+ TableAppSectionState {
+ isGrabbing: boolean;
+ grabError: Error;
+ isRemoving: boolean;
+ removeError: Error;
+}
+
+interface QueueAppState {
+ status: AppSectionItemState;
+ details: QueueDetailsAppState;
+ paged: QueuePagedAppState;
+ options: {
+ includeUnknownSeriesItems: boolean;
+ };
+}
+
+export default QueueAppState;
diff --git a/frontend/src/App/State/ReleasesAppState.ts b/frontend/src/App/State/ReleasesAppState.ts
new file mode 100644
index 00000000000..350f6eac8ea
--- /dev/null
+++ b/frontend/src/App/State/ReleasesAppState.ts
@@ -0,0 +1,10 @@
+import AppSectionState, {
+ AppSectionFilterState,
+} from 'App/State/AppSectionState';
+import Release from 'typings/Release';
+
+interface ReleasesAppState
+ extends AppSectionState,
+ AppSectionFilterState {}
+
+export default ReleasesAppState;
diff --git a/frontend/src/App/State/RootFolderAppState.ts b/frontend/src/App/State/RootFolderAppState.ts
new file mode 100644
index 00000000000..9e636c95f47
--- /dev/null
+++ b/frontend/src/App/State/RootFolderAppState.ts
@@ -0,0 +1,12 @@
+import AppSectionState, {
+ AppSectionDeleteState,
+ AppSectionSaveState,
+} from 'App/State/AppSectionState';
+import RootFolder from 'typings/RootFolder';
+
+interface RootFolderAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {}
+
+export default RootFolderAppState;
diff --git a/frontend/src/App/State/SeriesAppState.ts b/frontend/src/App/State/SeriesAppState.ts
new file mode 100644
index 00000000000..5da5987dd75
--- /dev/null
+++ b/frontend/src/App/State/SeriesAppState.ts
@@ -0,0 +1,66 @@
+import AppSectionState, {
+ AppSectionDeleteState,
+ AppSectionSaveState,
+} from 'App/State/AppSectionState';
+import Column from 'Components/Table/Column';
+import { SortDirection } from 'Helpers/Props/sortDirections';
+import Series from 'Series/Series';
+import { Filter, FilterBuilderProp } from './AppState';
+
+export interface SeriesIndexAppState {
+ sortKey: string;
+ sortDirection: SortDirection;
+ secondarySortKey: string;
+ secondarySortDirection: SortDirection;
+ view: string;
+
+ posterOptions: {
+ detailedProgressBar: boolean;
+ size: string;
+ showTitle: boolean;
+ showMonitored: boolean;
+ showQualityProfile: boolean;
+ showTags: boolean;
+ showSearchAction: boolean;
+ };
+
+ overviewOptions: {
+ detailedProgressBar: boolean;
+ size: string;
+ showMonitored: boolean;
+ showNetwork: boolean;
+ showQualityProfile: boolean;
+ showPreviousAiring: boolean;
+ showAdded: boolean;
+ showSeasonCount: boolean;
+ showPath: boolean;
+ showSizeOnDisk: boolean;
+ showTags: boolean;
+ showSearchAction: boolean;
+ };
+
+ tableOptions: {
+ showBanners: boolean;
+ showSearchAction: boolean;
+ };
+
+ selectedFilterKey: string;
+ filterBuilderProps: FilterBuilderProp[];
+ filters: Filter[];
+ columns: Column[];
+}
+
+interface SeriesAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {
+ itemMap: Record;
+
+ deleteOptions: {
+ addImportListExclusion: boolean;
+ };
+
+ pendingChanges: Partial;
+}
+
+export default SeriesAppState;
diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts
new file mode 100644
index 00000000000..b8e6f495416
--- /dev/null
+++ b/frontend/src/App/State/SettingsAppState.ts
@@ -0,0 +1,109 @@
+import AppSectionState, {
+ AppSectionDeleteState,
+ AppSectionItemSchemaState,
+ AppSectionItemState,
+ AppSectionSaveState,
+ PagedAppSectionState,
+} from 'App/State/AppSectionState';
+import Language from 'Language/Language';
+import CustomFormat from 'typings/CustomFormat';
+import DownloadClient from 'typings/DownloadClient';
+import ImportList from 'typings/ImportList';
+import ImportListExclusion from 'typings/ImportListExclusion';
+import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
+import Indexer from 'typings/Indexer';
+import IndexerFlag from 'typings/IndexerFlag';
+import Notification from 'typings/Notification';
+import QualityProfile from 'typings/QualityProfile';
+import General from 'typings/Settings/General';
+import NamingConfig from 'typings/Settings/NamingConfig';
+import NamingExample from 'typings/Settings/NamingExample';
+import ReleaseProfile from 'typings/Settings/ReleaseProfile';
+import UiSettings from 'typings/Settings/UiSettings';
+import MetadataAppState from './MetadataAppState';
+
+export interface DownloadClientAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {
+ isTestingAll: boolean;
+}
+
+export interface GeneralAppState
+ extends AppSectionItemState,
+ AppSectionSaveState {}
+
+export interface NamingAppState
+ extends AppSectionItemState,
+ AppSectionSaveState {}
+
+export type NamingExamplesAppState = AppSectionItemState;
+
+export interface ImportListAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {}
+
+export interface IndexerAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {
+ isTestingAll: boolean;
+}
+
+export interface NotificationAppState
+ extends AppSectionState,
+ AppSectionDeleteState {}
+
+export interface QualityProfilesAppState
+ extends AppSectionState,
+ AppSectionItemSchemaState {}
+
+export interface ReleaseProfilesAppState
+ extends AppSectionState,
+ AppSectionSaveState {
+ pendingChanges: Partial;
+}
+
+export interface CustomFormatAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {}
+
+export interface ImportListOptionsSettingsAppState
+ extends AppSectionItemState,
+ AppSectionSaveState {}
+
+export interface ImportListExclusionsSettingsAppState
+ extends AppSectionState,
+ AppSectionSaveState,
+ PagedAppSectionState,
+ AppSectionDeleteState {
+ pendingChanges: Partial;
+}
+
+export type IndexerFlagSettingsAppState = AppSectionState;
+export type LanguageSettingsAppState = AppSectionState;
+export type UiSettingsAppState = AppSectionItemState;
+
+interface SettingsAppState {
+ advancedSettings: boolean;
+ customFormats: CustomFormatAppState;
+ downloadClients: DownloadClientAppState;
+ general: GeneralAppState;
+ importListExclusions: ImportListExclusionsSettingsAppState;
+ importListOptions: ImportListOptionsSettingsAppState;
+ importLists: ImportListAppState;
+ indexerFlags: IndexerFlagSettingsAppState;
+ indexers: IndexerAppState;
+ languages: LanguageSettingsAppState;
+ metadata: MetadataAppState;
+ naming: NamingAppState;
+ namingExamples: NamingExamplesAppState;
+ notifications: NotificationAppState;
+ qualityProfiles: QualityProfilesAppState;
+ releaseProfiles: ReleaseProfilesAppState;
+ ui: UiSettingsAppState;
+}
+
+export default SettingsAppState;
diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts
new file mode 100644
index 00000000000..1161f0e1eed
--- /dev/null
+++ b/frontend/src/App/State/SystemAppState.ts
@@ -0,0 +1,22 @@
+import DiskSpace from 'typings/DiskSpace';
+import Health from 'typings/Health';
+import SystemStatus from 'typings/SystemStatus';
+import Task from 'typings/Task';
+import Update from 'typings/Update';
+import AppSectionState, { AppSectionItemState } from './AppSectionState';
+
+export type DiskSpaceAppState = AppSectionState;
+export type HealthAppState = AppSectionState;
+export type SystemStatusAppState = AppSectionItemState;
+export type TaskAppState = AppSectionState;
+export type UpdateAppState = AppSectionState;
+
+interface SystemAppState {
+ diskSpace: DiskSpaceAppState;
+ health: HealthAppState;
+ status: SystemStatusAppState;
+ tasks: TaskAppState;
+ updates: UpdateAppState;
+}
+
+export default SystemAppState;
diff --git a/frontend/src/App/State/TagsAppState.ts b/frontend/src/App/State/TagsAppState.ts
new file mode 100644
index 00000000000..914df904425
--- /dev/null
+++ b/frontend/src/App/State/TagsAppState.ts
@@ -0,0 +1,32 @@
+import ModelBase from 'App/ModelBase';
+import AppSectionState, {
+ AppSectionDeleteState,
+ AppSectionSaveState,
+} from 'App/State/AppSectionState';
+
+export interface Tag extends ModelBase {
+ label: string;
+}
+
+export interface TagDetail extends ModelBase {
+ label: string;
+ autoTagIds: number[];
+ delayProfileIds: number[];
+ downloadClientIds: [];
+ importListIds: number[];
+ indexerIds: number[];
+ notificationIds: number[];
+ restrictionIds: number[];
+ seriesIds: number[];
+}
+
+export interface TagDetailAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {}
+
+interface TagsAppState extends AppSectionState, AppSectionDeleteState {
+ details: TagDetailAppState;
+}
+
+export default TagsAppState;
diff --git a/frontend/src/App/State/WantedAppState.ts b/frontend/src/App/State/WantedAppState.ts
new file mode 100644
index 00000000000..b543d387906
--- /dev/null
+++ b/frontend/src/App/State/WantedAppState.ts
@@ -0,0 +1,13 @@
+import AppSectionState from 'App/State/AppSectionState';
+import Episode from 'Episode/Episode';
+
+type WantedCutoffUnmetAppState = AppSectionState;
+
+type WantedMissingAppState = AppSectionState;
+
+interface WantedAppState {
+ cutoffUnmet: WantedCutoffUnmetAppState;
+ missing: WantedMissingAppState;
+}
+
+export default WantedAppState;
diff --git a/frontend/src/Calendar/Agenda/Agenda.css.d.ts b/frontend/src/Calendar/Agenda/Agenda.css.d.ts
new file mode 100644
index 00000000000..44421cc994e
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/Agenda.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'agenda': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js
deleted file mode 100644
index 89472301d1c..00000000000
--- a/frontend/src/Calendar/Agenda/Agenda.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React from 'react';
-import AgendaEventConnector from './AgendaEventConnector';
-import styles from './Agenda.css';
-
-function Agenda(props) {
- const {
- items
- } = props;
-
- return (
-
- {
- items.map((item, index) => {
- const momentDate = moment(item.airDateUtc);
- const showDate = index === 0 ||
- !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
-
- return (
-
- );
- })
- }
-
- );
-}
-
-Agenda.propTypes = {
- items: PropTypes.arrayOf(PropTypes.object).isRequired
-};
-
-export default Agenda;
diff --git a/frontend/src/Calendar/Agenda/Agenda.tsx b/frontend/src/Calendar/Agenda/Agenda.tsx
new file mode 100644
index 00000000000..fdef4046600
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/Agenda.tsx
@@ -0,0 +1,25 @@
+import moment from 'moment';
+import React from 'react';
+import { useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import AgendaEvent from './AgendaEvent';
+import styles from './Agenda.css';
+
+function Agenda() {
+ const { items } = useSelector((state: AppState) => state.calendar);
+
+ return (
+
+ {items.map((item, index) => {
+ const momentDate = moment(item.airDateUtc);
+ const showDate =
+ index === 0 ||
+ !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
+
+ return
;
+ })}
+
+ );
+}
+
+export default Agenda;
diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js
deleted file mode 100644
index b6f2388736b..00000000000
--- a/frontend/src/Calendar/Agenda/AgendaConnector.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import Agenda from './Agenda';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar,
- (calendar) => {
- return calendar;
- }
- );
-}
-
-export default connect(createMapStateToProps)(Agenda);
diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css
index 27b91b857bf..7ad9ccf6a4f 100644
--- a/frontend/src/Calendar/Agenda/AgendaEvent.css
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.css
@@ -103,8 +103,12 @@
composes: premiere from '~Calendar/Events/CalendarEvent.css';
}
+.unaired {
+ composes: unaired from '~Calendar/Events/CalendarEvent.css';
+}
+
@media only screen and (max-width: $breakpointSmall) {
- .event {
+ .overlay {
flex-direction: column;
}
diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css.d.ts b/frontend/src/Calendar/Agenda/AgendaEvent.css.d.ts
new file mode 100644
index 00000000000..288e11824c2
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.css.d.ts
@@ -0,0 +1,25 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'absoluteEpisodeNumber': string;
+ 'date': string;
+ 'downloaded': string;
+ 'downloading': string;
+ 'episodeSeparator': string;
+ 'episodeTitle': string;
+ 'event': string;
+ 'eventWrapper': string;
+ 'missing': string;
+ 'onAir': string;
+ 'overlay': string;
+ 'premiere': string;
+ 'seasonEpisodeNumber': string;
+ 'seriesTitle': string;
+ 'statusIcon': string;
+ 'time': string;
+ 'unaired': string;
+ 'underlay': string;
+ 'unmonitored': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js
deleted file mode 100644
index 155be19f154..00000000000
--- a/frontend/src/Calendar/Agenda/AgendaEvent.js
+++ /dev/null
@@ -1,253 +0,0 @@
-import classNames from 'classnames';
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
-import getStatusStyle from 'Calendar/getStatusStyle';
-import Icon from 'Components/Icon';
-import Link from 'Components/Link/Link';
-import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
-import episodeEntities from 'Episode/episodeEntities';
-import { icons, kinds } from 'Helpers/Props';
-import formatTime from 'Utilities/Date/formatTime';
-import padNumber from 'Utilities/Number/padNumber';
-import styles from './AgendaEvent.css';
-
-class AgendaEvent extends Component {
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isDetailsModalOpen: false
- };
- }
-
- //
- // Listeners
-
- onPress = () => {
- this.setState({ isDetailsModalOpen: true });
- };
-
- onDetailsModalClose = () => {
- this.setState({ isDetailsModalOpen: false });
- };
-
- //
- // Render
-
- render() {
- const {
- id,
- series,
- episodeFile,
- title,
- seasonNumber,
- episodeNumber,
- absoluteEpisodeNumber,
- airDateUtc,
- monitored,
- unverifiedSceneNumbering,
- hasFile,
- grabbed,
- queueItem,
- showDate,
- showEpisodeInformation,
- showFinaleIcon,
- showSpecialIcon,
- showCutoffUnmetIcon,
- timeFormat,
- longDateFormat,
- colorImpairedMode
- } = this.props;
-
- const startTime = moment(airDateUtc);
- const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
- const downloading = !!(queueItem || grabbed);
- const isMonitored = series.monitored && monitored;
- const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored);
- const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
- const season = series.seasons.find((s) => s.seasonNumber === seasonNumber);
- const seasonStatistics = season?.statistics || {};
-
- return (
-
-
-
-
-
- {
- showDate &&
- startTime.format(longDateFormat)
- }
-
-
-
-
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
-
-
-
- {series.title}
-
-
- {
- showEpisodeInformation &&
-
- {seasonNumber}x{padNumber(episodeNumber, 2)}
-
- {
- series.seriesType === 'anime' && absoluteEpisodeNumber &&
-
({absoluteEpisodeNumber})
- }
-
-
-
-
- }
-
-
- {
- showEpisodeInformation &&
- title
- }
-
-
- {
- missingAbsoluteNumber &&
-
- }
-
- {
- unverifiedSceneNumbering && !missingAbsoluteNumber ?
-
:
- null
- }
-
- {
- !!queueItem &&
-
-
-
- }
-
- {
- !queueItem && grabbed &&
-
- }
-
- {
- showCutoffUnmetIcon &&
- !!episodeFile &&
- episodeFile.qualityCutoffNotMet &&
-
- }
-
- {
- episodeNumber === 1 && seasonNumber > 0 &&
-
- }
-
- {
- showFinaleIcon &&
- episodeNumber !== 1 &&
- seasonNumber > 0 &&
- episodeNumber === seasonStatistics.totalEpisodeCount &&
-
- }
-
- {
- showSpecialIcon &&
- (episodeNumber === 0 || seasonNumber === 0) &&
-
- }
-
-
-
-
-
- );
- }
-}
-
-AgendaEvent.propTypes = {
- id: PropTypes.number.isRequired,
- series: PropTypes.object.isRequired,
- episodeFile: PropTypes.object,
- title: PropTypes.string.isRequired,
- seasonNumber: PropTypes.number.isRequired,
- episodeNumber: PropTypes.number.isRequired,
- absoluteEpisodeNumber: PropTypes.number,
- airDateUtc: PropTypes.string.isRequired,
- monitored: PropTypes.bool.isRequired,
- unverifiedSceneNumbering: PropTypes.bool,
- hasFile: PropTypes.bool.isRequired,
- grabbed: PropTypes.bool,
- queueItem: PropTypes.object,
- showDate: PropTypes.bool.isRequired,
- showEpisodeInformation: PropTypes.bool.isRequired,
- showFinaleIcon: PropTypes.bool.isRequired,
- showSpecialIcon: PropTypes.bool.isRequired,
- showCutoffUnmetIcon: PropTypes.bool.isRequired,
- timeFormat: PropTypes.string.isRequired,
- longDateFormat: PropTypes.string.isRequired,
- colorImpairedMode: PropTypes.bool.isRequired
-};
-
-export default AgendaEvent;
diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.tsx b/frontend/src/Calendar/Agenda/AgendaEvent.tsx
new file mode 100644
index 00000000000..2fd2d7c54ba
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.tsx
@@ -0,0 +1,227 @@
+import classNames from 'classnames';
+import moment from 'moment';
+import React, { useCallback, useState } from 'react';
+import { useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
+import episodeEntities from 'Episode/episodeEntities';
+import getFinaleTypeName from 'Episode/getFinaleTypeName';
+import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
+import { icons, kinds } from 'Helpers/Props';
+import useSeries from 'Series/useSeries';
+import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import formatTime from 'Utilities/Date/formatTime';
+import padNumber from 'Utilities/Number/padNumber';
+import translate from 'Utilities/String/translate';
+import styles from './AgendaEvent.css';
+
+interface AgendaEventProps {
+ id: number;
+ seriesId: number;
+ episodeFileId: number;
+ title: string;
+ seasonNumber: number;
+ episodeNumber: number;
+ absoluteEpisodeNumber?: number;
+ airDateUtc: string;
+ monitored: boolean;
+ unverifiedSceneNumbering?: boolean;
+ finaleType?: string;
+ hasFile: boolean;
+ grabbed?: boolean;
+ showDate: boolean;
+}
+
+function AgendaEvent(props: AgendaEventProps) {
+ const {
+ id,
+ seriesId,
+ episodeFileId,
+ title,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ airDateUtc,
+ monitored,
+ unverifiedSceneNumbering,
+ finaleType,
+ hasFile,
+ grabbed,
+ showDate,
+ } = props;
+
+ const series = useSeries(seriesId)!;
+ const episodeFile = useEpisodeFile(episodeFileId);
+ const queueItem = useSelector(createQueueItemSelectorForHook(id));
+ const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
+ createUISettingsSelector()
+ );
+
+ const {
+ showEpisodeInformation,
+ showFinaleIcon,
+ showSpecialIcon,
+ showCutoffUnmetIcon,
+ } = useSelector((state: AppState) => state.calendar.options);
+
+ const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
+
+ const startTime = moment(airDateUtc);
+ const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
+ const downloading = !!(queueItem || grabbed);
+ const isMonitored = series.monitored && monitored;
+ const statusStyle = getStatusStyle(
+ hasFile,
+ downloading,
+ startTime,
+ endTime,
+ isMonitored
+ );
+ const missingAbsoluteNumber =
+ series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
+
+ const handlePress = useCallback(() => {
+ setIsDetailsModalOpen(true);
+ }, []);
+
+ const handleDetailsModalClose = useCallback(() => {
+ setIsDetailsModalOpen(false);
+ }, []);
+
+ return (
+
+
+
+
+
+ {showDate && startTime.format(longDateFormat)}
+
+
+
+
+ {formatTime(airDateUtc, timeFormat)} -{' '}
+ {formatTime(endTime.toISOString(), timeFormat, {
+ includeMinuteZero: true,
+ })}
+
+
+
{series.title}
+
+ {showEpisodeInformation ? (
+
+ {seasonNumber}x{padNumber(episodeNumber, 2)}
+ {series.seriesType === 'anime' && absoluteEpisodeNumber && (
+
+ ({absoluteEpisodeNumber})
+
+ )}
+
-
+
+ ) : null}
+
+
+ {showEpisodeInformation ? title : null}
+
+
+ {missingAbsoluteNumber ? (
+
+ ) : null}
+
+ {unverifiedSceneNumbering && !missingAbsoluteNumber ? (
+
+ ) : null}
+
+ {queueItem ? (
+
+
+
+ ) : null}
+
+ {!queueItem && grabbed ? (
+
+ ) : null}
+
+ {showCutoffUnmetIcon &&
+ episodeFile &&
+ episodeFile.qualityCutoffNotMet ? (
+
+ ) : null}
+
+ {episodeNumber === 1 && seasonNumber > 0 && (
+
+ )}
+
+ {showFinaleIcon && finaleType ? (
+
+ ) : null}
+
+ {showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? (
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+export default AgendaEvent;
diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js
deleted file mode 100644
index d476acf8006..00000000000
--- a/frontend/src/Calendar/Agenda/AgendaEventConnector.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
-import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
-import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
-import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
-import AgendaEvent from './AgendaEvent';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar.options,
- createSeriesSelector(),
- createEpisodeFileSelector(),
- createQueueItemSelector(),
- createUISettingsSelector(),
- (calendarOptions, series, episodeFile, queueItem, uiSettings) => {
- return {
- series,
- episodeFile,
- queueItem,
- ...calendarOptions,
- timeFormat: uiSettings.timeFormat,
- longDateFormat: uiSettings.longDateFormat,
- colorImpairedMode: uiSettings.enableColorImpairedMode
- };
- }
- );
-}
-
-export default connect(createMapStateToProps)(AgendaEvent);
diff --git a/frontend/src/Calendar/Calendar.css.d.ts b/frontend/src/Calendar/Calendar.css.d.ts
new file mode 100644
index 00000000000..503034402eb
--- /dev/null
+++ b/frontend/src/Calendar/Calendar.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'calendar': string;
+ 'calendarContent': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js
deleted file mode 100644
index 734de312154..00000000000
--- a/frontend/src/Calendar/Calendar.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import LoadingIndicator from 'Components/Loading/LoadingIndicator';
-import AgendaConnector from './Agenda/AgendaConnector';
-import * as calendarViews from './calendarViews';
-import CalendarDaysConnector from './Day/CalendarDaysConnector';
-import DaysOfWeekConnector from './Day/DaysOfWeekConnector';
-import CalendarHeaderConnector from './Header/CalendarHeaderConnector';
-import styles from './Calendar.css';
-
-class Calendar extends Component {
-
- //
- // Render
-
- render() {
- const {
- isFetching,
- isPopulated,
- error,
- view
- } = this.props;
-
- return (
-
- {
- isFetching && !isPopulated &&
-
- }
-
- {
- !isFetching && !!error &&
-
Unable to load the calendar
- }
-
- {
- !error && isPopulated && view === calendarViews.AGENDA &&
-
- }
-
- {
- !error && isPopulated && view !== calendarViews.AGENDA &&
-
-
-
-
-
- }
-
- );
- }
-}
-
-Calendar.propTypes = {
- isFetching: PropTypes.bool.isRequired,
- isPopulated: PropTypes.bool.isRequired,
- error: PropTypes.object,
- view: PropTypes.string.isRequired
-};
-
-export default Calendar;
diff --git a/frontend/src/Calendar/Calendar.tsx b/frontend/src/Calendar/Calendar.tsx
new file mode 100644
index 00000000000..caa337cf00c
--- /dev/null
+++ b/frontend/src/Calendar/Calendar.tsx
@@ -0,0 +1,170 @@
+import React, { useCallback, useEffect, useRef } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import * as commandNames from 'Commands/commandNames';
+import Alert from 'Components/Alert';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Episode from 'Episode/Episode';
+import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import { kinds } from 'Helpers/Props';
+import {
+ clearCalendar,
+ fetchCalendar,
+ gotoCalendarToday,
+} from 'Store/Actions/calendarActions';
+import {
+ clearEpisodeFiles,
+ fetchEpisodeFiles,
+} from 'Store/Actions/episodeFileActions';
+import {
+ clearQueueDetails,
+ fetchQueueDetails,
+} from 'Store/Actions/queueActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import {
+ registerPagePopulator,
+ unregisterPagePopulator,
+} from 'Utilities/pagePopulator';
+import translate from 'Utilities/String/translate';
+import Agenda from './Agenda/Agenda';
+import CalendarDays from './Day/CalendarDays';
+import DaysOfWeek from './Day/DaysOfWeek';
+import CalendarHeader from './Header/CalendarHeader';
+import styles from './Calendar.css';
+
+const UPDATE_DELAY = 3600000; // 1 hour
+
+function Calendar() {
+ const dispatch = useDispatch();
+ const requestCurrentPage = useCurrentPage();
+ const updateTimeout = useRef>();
+
+ const { isFetching, isPopulated, error, items, time, view } = useSelector(
+ (state: AppState) => state.calendar
+ );
+
+ const isRefreshingSeries = useSelector(
+ createCommandExecutingSelector(commandNames.REFRESH_SERIES)
+ );
+
+ const firstDayOfWeek = useSelector(
+ (state: AppState) => state.settings.ui.item.firstDayOfWeek
+ );
+
+ const wasRefreshingSeries = usePrevious(isRefreshingSeries);
+ const previousFirstDayOfWeek = usePrevious(firstDayOfWeek);
+ const previousItems = usePrevious(items);
+
+ const handleScheduleUpdate = useCallback(() => {
+ clearTimeout(updateTimeout.current);
+
+ function updateCalendar() {
+ dispatch(gotoCalendarToday());
+ updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
+ }
+
+ updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
+ }, [dispatch]);
+
+ useEffect(() => {
+ handleScheduleUpdate();
+
+ return () => {
+ dispatch(clearCalendar());
+ dispatch(clearQueueDetails());
+ dispatch(clearEpisodeFiles());
+ clearTimeout(updateTimeout.current);
+ };
+ }, [dispatch, handleScheduleUpdate]);
+
+ useEffect(() => {
+ if (requestCurrentPage) {
+ dispatch(fetchCalendar());
+ } else {
+ dispatch(gotoCalendarToday());
+ }
+ }, [requestCurrentPage, dispatch]);
+
+ useEffect(() => {
+ const repopulate = () => {
+ dispatch(fetchQueueDetails({ time, view }));
+ dispatch(fetchCalendar({ time, view }));
+ };
+
+ registerPagePopulator(repopulate, [
+ 'episodeFileUpdated',
+ 'episodeFileDeleted',
+ ]);
+
+ return () => {
+ unregisterPagePopulator(repopulate);
+ };
+ }, [time, view, dispatch]);
+
+ useEffect(() => {
+ handleScheduleUpdate();
+ }, [time, handleScheduleUpdate]);
+
+ useEffect(() => {
+ if (
+ previousFirstDayOfWeek != null &&
+ firstDayOfWeek !== previousFirstDayOfWeek
+ ) {
+ dispatch(fetchCalendar({ time, view }));
+ }
+ }, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]);
+
+ useEffect(() => {
+ if (wasRefreshingSeries && !isRefreshingSeries) {
+ dispatch(fetchCalendar({ time, view }));
+ }
+ }, [time, view, isRefreshingSeries, wasRefreshingSeries, dispatch]);
+
+ useEffect(() => {
+ if (!previousItems || hasDifferentItems(items, previousItems)) {
+ const episodeIds = selectUniqueIds(items, 'id');
+ const episodeFileIds = selectUniqueIds(
+ items,
+ 'episodeFileId'
+ );
+
+ if (items.length) {
+ dispatch(fetchQueueDetails({ episodeIds }));
+ }
+
+ if (episodeFileIds.length) {
+ dispatch(fetchEpisodeFiles({ episodeFileIds }));
+ }
+ }
+ }, [items, previousItems, dispatch]);
+
+ return (
+
+ {isFetching && !isPopulated ?
: null}
+
+ {!isFetching && error ? (
+
{translate('CalendarLoadError')}
+ ) : null}
+
+ {!error && isPopulated && view === 'agenda' ? (
+
+ ) : null}
+
+ {!error && isPopulated && view !== 'agenda' ? (
+
+
+
+
+
+ ) : null}
+
+ );
+}
+
+export default Calendar;
diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js
deleted file mode 100644
index 1c5932219c9..00000000000
--- a/frontend/src/Calendar/CalendarConnector.js
+++ /dev/null
@@ -1,196 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import * as commandNames from 'Commands/commandNames';
-import * as calendarActions from 'Store/Actions/calendarActions';
-import { clearEpisodeFiles, fetchEpisodeFiles } from 'Store/Actions/episodeFileActions';
-import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
-import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
-import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
-import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
-import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
-import Calendar from './Calendar';
-
-const UPDATE_DELAY = 3600000; // 1 hour
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar,
- (state) => state.settings.ui.item.firstDayOfWeek,
- createCommandExecutingSelector(commandNames.REFRESH_SERIES),
- (calendar, firstDayOfWeek, isRefreshingSeries) => {
- return {
- ...calendar,
- isRefreshingSeries,
- firstDayOfWeek
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- ...calendarActions,
- fetchEpisodeFiles,
- clearEpisodeFiles,
- fetchQueueDetails,
- clearQueueDetails
-};
-
-class CalendarConnector extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.updateTimeoutId = null;
- }
-
- componentDidMount() {
- const {
- useCurrentPage,
- fetchCalendar,
- gotoCalendarToday
- } = this.props;
-
- registerPagePopulator(this.repopulate);
-
- if (useCurrentPage) {
- fetchCalendar();
- } else {
- gotoCalendarToday();
- }
-
- this.scheduleUpdate();
- }
-
- componentDidUpdate(prevProps) {
- const {
- items,
- time,
- view,
- isRefreshingSeries,
- firstDayOfWeek
- } = this.props;
-
- if (hasDifferentItems(prevProps.items, items)) {
- const episodeIds = selectUniqueIds(items, 'id');
- const episodeFileIds = selectUniqueIds(items, 'episodeFileId');
-
- if (items.length) {
- this.props.fetchQueueDetails({ episodeIds });
- }
-
- if (episodeFileIds.length) {
- this.props.fetchEpisodeFiles({ episodeFileIds });
- }
- }
-
- if (prevProps.time !== time) {
- this.scheduleUpdate();
- }
-
- if (prevProps.firstDayOfWeek !== firstDayOfWeek) {
- this.props.fetchCalendar({ time, view });
- }
-
- if (prevProps.isRefreshingSeries && !isRefreshingSeries) {
- this.props.fetchCalendar({ time, view });
- }
- }
-
- componentWillUnmount() {
- unregisterPagePopulator(this.repopulate);
- this.props.clearCalendar();
- this.props.clearQueueDetails();
- this.props.clearEpisodeFiles();
- this.clearUpdateTimeout();
- }
-
- //
- // Control
-
- repopulate = () => {
- const {
- time,
- view
- } = this.props;
-
- this.props.fetchQueueDetails({ time, view });
- this.props.fetchCalendar({ time, view });
- };
-
- scheduleUpdate = () => {
- this.clearUpdateTimeout();
-
- this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY);
- };
-
- clearUpdateTimeout = () => {
- if (this.updateTimeoutId) {
- clearTimeout(this.updateTimeoutId);
- }
- };
-
- updateCalendar = () => {
- this.props.gotoCalendarToday();
- this.scheduleUpdate();
- };
-
- //
- // Listeners
-
- onCalendarViewChange = (view) => {
- this.props.setCalendarView({ view });
- };
-
- onTodayPress = () => {
- this.props.gotoCalendarToday();
- };
-
- onPreviousPress = () => {
- this.props.gotoCalendarPreviousRange();
- };
-
- onNextPress = () => {
- this.props.gotoCalendarNextRange();
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-CalendarConnector.propTypes = {
- useCurrentPage: PropTypes.bool.isRequired,
- time: PropTypes.string,
- view: PropTypes.string.isRequired,
- firstDayOfWeek: PropTypes.number.isRequired,
- items: PropTypes.arrayOf(PropTypes.object).isRequired,
- isRefreshingSeries: PropTypes.bool.isRequired,
- setCalendarView: PropTypes.func.isRequired,
- gotoCalendarToday: PropTypes.func.isRequired,
- gotoCalendarPreviousRange: PropTypes.func.isRequired,
- gotoCalendarNextRange: PropTypes.func.isRequired,
- clearCalendar: PropTypes.func.isRequired,
- fetchCalendar: PropTypes.func.isRequired,
- fetchEpisodeFiles: PropTypes.func.isRequired,
- clearEpisodeFiles: PropTypes.func.isRequired,
- fetchQueueDetails: PropTypes.func.isRequired,
- clearQueueDetails: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector);
diff --git a/frontend/src/Calendar/CalendarFilterModal.tsx b/frontend/src/Calendar/CalendarFilterModal.tsx
new file mode 100644
index 00000000000..e26b2928bb3
--- /dev/null
+++ b/frontend/src/Calendar/CalendarFilterModal.tsx
@@ -0,0 +1,54 @@
+import React, { useCallback } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import FilterModal from 'Components/Filter/FilterModal';
+import { setCalendarFilter } from 'Store/Actions/calendarActions';
+
+function createCalendarSelector() {
+ return createSelector(
+ (state: AppState) => state.calendar.items,
+ (calendar) => {
+ return calendar;
+ }
+ );
+}
+
+function createFilterBuilderPropsSelector() {
+ return createSelector(
+ (state: AppState) => state.calendar.filterBuilderProps,
+ (filterBuilderProps) => {
+ return filterBuilderProps;
+ }
+ );
+}
+
+interface CalendarFilterModalProps {
+ isOpen: boolean;
+}
+
+export default function CalendarFilterModal(props: CalendarFilterModalProps) {
+ const sectionItems = useSelector(createCalendarSelector());
+ const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
+ const customFilterType = 'calendar';
+
+ const dispatch = useDispatch();
+
+ const dispatchSetFilter = useCallback(
+ (payload: unknown) => {
+ dispatch(setCalendarFilter(payload));
+ },
+ [dispatch]
+ );
+
+ return (
+
+ );
+}
diff --git a/frontend/src/Calendar/CalendarPage.css.d.ts b/frontend/src/Calendar/CalendarPage.css.d.ts
new file mode 100644
index 00000000000..30befba5530
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPage.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'calendarInnerPageBody': string;
+ 'calendarPageBody': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js
deleted file mode 100644
index 7b5a987a79b..00000000000
--- a/frontend/src/Calendar/CalendarPage.js
+++ /dev/null
@@ -1,192 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import Measure from 'Components/Measure';
-import FilterMenu from 'Components/Menu/FilterMenu';
-import PageContent from 'Components/Page/PageContent';
-import PageContentBody from 'Components/Page/PageContentBody';
-import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
-import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
-import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
-import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
-import { align, icons } from 'Helpers/Props';
-import NoSeries from 'Series/NoSeries';
-import CalendarConnector from './CalendarConnector';
-import CalendarLinkModal from './iCal/CalendarLinkModal';
-import LegendConnector from './Legend/LegendConnector';
-import CalendarOptionsModal from './Options/CalendarOptionsModal';
-import styles from './CalendarPage.css';
-
-const MINIMUM_DAY_WIDTH = 120;
-
-class CalendarPage extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isCalendarLinkModalOpen: false,
- isOptionsModalOpen: false,
- width: 0
- };
- }
-
- //
- // Listeners
-
- onMeasure = ({ width }) => {
- this.setState({ width });
- const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
-
- this.props.onDaysCountChange(days);
- };
-
- onGetCalendarLinkPress = () => {
- this.setState({ isCalendarLinkModalOpen: true });
- };
-
- onGetCalendarLinkModalClose = () => {
- this.setState({ isCalendarLinkModalOpen: false });
- };
-
- onOptionsPress = () => {
- this.setState({ isOptionsModalOpen: true });
- };
-
- onOptionsModalClose = () => {
- this.setState({ isOptionsModalOpen: false });
- };
-
- onSearchMissingPress = () => {
- const {
- missingEpisodeIds,
- onSearchMissingPress
- } = this.props;
-
- onSearchMissingPress(missingEpisodeIds);
- };
-
- //
- // Render
-
- render() {
- const {
- selectedFilterKey,
- filters,
- hasSeries,
- missingEpisodeIds,
- isRssSyncExecuting,
- isSearchingForMissing,
- useCurrentPage,
- onRssSyncPress,
- onFilterSelect
- } = this.props;
-
- const {
- isCalendarLinkModalOpen,
- isOptionsModalOpen
- } = this.state;
-
- const isMeasured = this.state.width > 0;
- const PageComponent = hasSeries ? CalendarConnector : NoSeries;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- isMeasured ?
- :
-
- }
-
-
- {
- hasSeries &&
-
- }
-
-
-
-
-
-
- );
- }
-}
-
-CalendarPage.propTypes = {
- selectedFilterKey: PropTypes.string.isRequired,
- filters: PropTypes.arrayOf(PropTypes.object).isRequired,
- hasSeries: PropTypes.bool.isRequired,
- missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired,
- isRssSyncExecuting: PropTypes.bool.isRequired,
- isSearchingForMissing: PropTypes.bool.isRequired,
- useCurrentPage: PropTypes.bool.isRequired,
- onSearchMissingPress: PropTypes.func.isRequired,
- onDaysCountChange: PropTypes.func.isRequired,
- onRssSyncPress: PropTypes.func.isRequired,
- onFilterSelect: PropTypes.func.isRequired
-};
-
-export default CalendarPage;
diff --git a/frontend/src/Calendar/CalendarPage.tsx b/frontend/src/Calendar/CalendarPage.tsx
new file mode 100644
index 00000000000..f408b6a60c0
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPage.tsx
@@ -0,0 +1,226 @@
+import moment from 'moment';
+import React, { useCallback, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import * as commandNames from 'Commands/commandNames';
+import Measure from 'Components/Measure';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import { align, icons } from 'Helpers/Props';
+import NoSeries from 'Series/NoSeries';
+import {
+ searchMissing,
+ setCalendarDaysCount,
+ setCalendarFilter,
+} from 'Store/Actions/calendarActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
+import { isCommandExecuting } from 'Utilities/Command';
+import isBefore from 'Utilities/Date/isBefore';
+import translate from 'Utilities/String/translate';
+import Calendar from './Calendar';
+import CalendarFilterModal from './CalendarFilterModal';
+import CalendarLinkModal from './iCal/CalendarLinkModal';
+import Legend from './Legend/Legend';
+import CalendarOptionsModal from './Options/CalendarOptionsModal';
+import styles from './CalendarPage.css';
+
+const MINIMUM_DAY_WIDTH = 120;
+
+function createMissingEpisodeIdsSelector() {
+ return createSelector(
+ (state: AppState) => state.calendar.start,
+ (state: AppState) => state.calendar.end,
+ (state: AppState) => state.calendar.items,
+ (state: AppState) => state.queue.details.items,
+ (start, end, episodes, queueDetails) => {
+ return episodes.reduce((acc, episode) => {
+ const airDateUtc = episode.airDateUtc;
+
+ if (
+ !episode.episodeFileId &&
+ moment(airDateUtc).isAfter(start) &&
+ moment(airDateUtc).isBefore(end) &&
+ isBefore(episode.airDateUtc) &&
+ !queueDetails.some(
+ (details) => !!details.episode && details.episode.id === episode.id
+ )
+ ) {
+ acc.push(episode.id);
+ }
+
+ return acc;
+ }, []);
+ }
+ );
+}
+
+function createIsSearchingSelector() {
+ return createSelector(
+ (state: AppState) => state.calendar.searchMissingCommandId,
+ createCommandsSelector(),
+ (searchMissingCommandId, commands) => {
+ if (searchMissingCommandId == null) {
+ return false;
+ }
+
+ return isCommandExecuting(
+ commands.find((command) => {
+ return command.id === searchMissingCommandId;
+ })
+ );
+ }
+ );
+}
+
+function CalendarPage() {
+ const dispatch = useDispatch();
+
+ const { selectedFilterKey, filters } = useSelector(
+ (state: AppState) => state.calendar
+ );
+ const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
+ const isSearchingForMissing = useSelector(createIsSearchingSelector());
+ const isRssSyncExecuting = useSelector(
+ createCommandExecutingSelector(commandNames.RSS_SYNC)
+ );
+ const customFilters = useSelector(createCustomFiltersSelector('calendar'));
+ const hasSeries = !!useSelector(createSeriesCountSelector());
+
+ const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false);
+ const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
+ const [width, setWidth] = useState(0);
+
+ const isMeasured = width > 0;
+ const PageComponent = hasSeries ? Calendar : NoSeries;
+
+ const handleMeasure = useCallback(
+ ({ width: newWidth }: { width: number }) => {
+ setWidth(newWidth);
+
+ const dayCount = Math.max(
+ 3,
+ Math.min(7, Math.floor(newWidth / MINIMUM_DAY_WIDTH))
+ );
+
+ dispatch(setCalendarDaysCount({ dayCount }));
+ },
+ [dispatch]
+ );
+
+ const handleGetCalendarLinkPress = useCallback(() => {
+ setIsCalendarLinkModalOpen(true);
+ }, []);
+
+ const handleGetCalendarLinkModalClose = useCallback(() => {
+ setIsCalendarLinkModalOpen(false);
+ }, []);
+
+ const handleOptionsPress = useCallback(() => {
+ setIsOptionsModalOpen(true);
+ }, []);
+
+ const handleOptionsModalClose = useCallback(() => {
+ setIsOptionsModalOpen(false);
+ }, []);
+
+ const handleRssSyncPress = useCallback(() => {
+ dispatch(
+ executeCommand({
+ name: commandNames.RSS_SYNC,
+ })
+ );
+ }, [dispatch]);
+
+ const handleSearchMissingPress = useCallback(() => {
+ dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
+ }, [missingEpisodeIds, dispatch]);
+
+ const handleFilterSelect = useCallback(
+ (key: string) => {
+ dispatch(setCalendarFilter({ selectedFilterKey: key }));
+ },
+ [dispatch]
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isMeasured ? :
}
+
+
+ {hasSeries && }
+
+
+
+
+
+
+ );
+}
+
+export default CalendarPage;
diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js
deleted file mode 100644
index 350377c5605..00000000000
--- a/frontend/src/Calendar/CalendarPageConnector.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import moment from 'moment';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import * as commandNames from 'Commands/commandNames';
-import withCurrentPage from 'Components/withCurrentPage';
-import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
-import { executeCommand } from 'Store/Actions/commandActions';
-import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
-import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
-import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
-import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
-import { isCommandExecuting } from 'Utilities/Command';
-import isBefore from 'Utilities/Date/isBefore';
-import CalendarPage from './CalendarPage';
-
-function createMissingEpisodeIdsSelector() {
- return createSelector(
- (state) => state.calendar.start,
- (state) => state.calendar.end,
- (state) => state.calendar.items,
- (state) => state.queue.details.items,
- (start, end, episodes, queueDetails) => {
- return episodes.reduce((acc, episode) => {
- const airDateUtc = episode.airDateUtc;
-
- if (
- !episode.episodeFileId &&
- moment(airDateUtc).isAfter(start) &&
- moment(airDateUtc).isBefore(end) &&
- isBefore(episode.airDateUtc) &&
- !queueDetails.some((details) => !!details.episode && details.episode.id === episode.id)
- ) {
- acc.push(episode.id);
- }
-
- return acc;
- }, []);
- }
- );
-}
-
-function createIsSearchingSelector() {
- return createSelector(
- (state) => state.calendar.searchMissingCommandId,
- createCommandsSelector(),
- (searchMissingCommandId, commands) => {
- if (searchMissingCommandId == null) {
- return false;
- }
-
- return isCommandExecuting(commands.find((command) => {
- return command.id === searchMissingCommandId;
- }));
- }
- );
-}
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar.selectedFilterKey,
- (state) => state.calendar.filters,
- createSeriesCountSelector(),
- createUISettingsSelector(),
- createMissingEpisodeIdsSelector(),
- createCommandExecutingSelector(commandNames.RSS_SYNC),
- createIsSearchingSelector(),
- (
- selectedFilterKey,
- filters,
- seriesCount,
- uiSettings,
- missingEpisodeIds,
- isRssSyncExecuting,
- isSearchingForMissing
- ) => {
- return {
- selectedFilterKey,
- filters,
- colorImpairedMode: uiSettings.enableColorImpairedMode,
- hasSeries: !!seriesCount,
- missingEpisodeIds,
- isRssSyncExecuting,
- isSearchingForMissing
- };
- }
- );
-}
-
-function createMapDispatchToProps(dispatch, props) {
- return {
- onRssSyncPress() {
- dispatch(executeCommand({
- name: commandNames.RSS_SYNC
- }));
- },
-
- onSearchMissingPress(episodeIds) {
- dispatch(searchMissing({ episodeIds }));
- },
-
- onDaysCountChange(dayCount) {
- dispatch(setCalendarDaysCount({ dayCount }));
- },
-
- onFilterSelect(selectedFilterKey) {
- dispatch(setCalendarFilter({ selectedFilterKey }));
- }
- };
-}
-
-export default withCurrentPage(
- connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage)
-);
diff --git a/frontend/src/Calendar/Day/CalendarDay.css.d.ts b/frontend/src/Calendar/Day/CalendarDay.css.d.ts
new file mode 100644
index 00000000000..f32def3dd07
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDay.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'day': string;
+ 'dayOfMonth': string;
+ 'isDifferentMonth': string;
+ 'isSingleDay': string;
+ 'isToday': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Day/CalendarDay.js b/frontend/src/Calendar/Day/CalendarDay.js
deleted file mode 100644
index b3da227dd4f..00000000000
--- a/frontend/src/Calendar/Day/CalendarDay.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import classNames from 'classnames';
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React from 'react';
-import * as calendarViews from 'Calendar/calendarViews';
-import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
-import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector';
-import styles from './CalendarDay.css';
-
-function CalendarDay(props) {
- const {
- date,
- time,
- isTodaysDate,
- events,
- view,
- onEventModalOpenToggle
- } = props;
-
- return (
-
- {
- view === calendarViews.MONTH &&
-
- {moment(date).date()}
-
- }
-
- {
- events.map((event) => {
- if (event.isGroup) {
- return (
-
- );
- }
-
- return (
-
- );
- })
- }
-
-
- );
-}
-
-CalendarDay.propTypes = {
- date: PropTypes.string.isRequired,
- time: PropTypes.string.isRequired,
- isTodaysDate: PropTypes.bool.isRequired,
- events: PropTypes.arrayOf(PropTypes.object).isRequired,
- view: PropTypes.string.isRequired,
- onEventModalOpenToggle: PropTypes.func.isRequired
-};
-
-export default CalendarDay;
diff --git a/frontend/src/Calendar/Day/CalendarDay.tsx b/frontend/src/Calendar/Day/CalendarDay.tsx
new file mode 100644
index 00000000000..a619109ca01
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDay.tsx
@@ -0,0 +1,159 @@
+import classNames from 'classnames';
+import moment from 'moment';
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import * as calendarViews from 'Calendar/calendarViews';
+import CalendarEvent from 'Calendar/Events/CalendarEvent';
+import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup';
+import {
+ CalendarEvent as CalendarEventModel,
+ CalendarEventGroup as CalendarEventGroupModel,
+ CalendarItem,
+} from 'typings/Calendar';
+import styles from './CalendarDay.css';
+
+function sort(items: (CalendarEventModel | CalendarEventGroupModel)[]) {
+ return items.sort((a, b) => {
+ const aDate = a.isGroup
+ ? moment(a.events[0].airDateUtc).unix()
+ : moment(a.airDateUtc).unix();
+
+ const bDate = b.isGroup
+ ? moment(b.events[0].airDateUtc).unix()
+ : moment(b.airDateUtc).unix();
+
+ return aDate - bDate;
+ });
+}
+
+function createCalendarEventsConnector(date: string) {
+ return createSelector(
+ (state: AppState) => state.calendar.items,
+ (state: AppState) => state.calendar.options.collapseMultipleEpisodes,
+ (items, collapseMultipleEpisodes) => {
+ const momentDate = moment(date);
+
+ const filtered = items.filter((item) => {
+ return momentDate.isSame(moment(item.airDateUtc), 'day');
+ });
+
+ if (!collapseMultipleEpisodes) {
+ return sort(
+ filtered.map((item) => ({
+ isGroup: false,
+ ...item,
+ }))
+ );
+ }
+
+ const groupedObject = Object.groupBy(
+ filtered,
+ (item: CalendarItem) => `${item.seriesId}-${item.seasonNumber}`
+ );
+
+ const grouped = Object.entries(groupedObject).reduce<
+ (CalendarEventModel | CalendarEventGroupModel)[]
+ >((acc, [, events]) => {
+ if (!events) {
+ return acc;
+ }
+
+ if (events.length === 1) {
+ acc.push({
+ isGroup: false,
+ ...events[0],
+ });
+ } else {
+ acc.push({
+ isGroup: true,
+ seriesId: events[0].seriesId,
+ seasonNumber: events[0].seasonNumber,
+ episodeIds: events.map((event) => event.id),
+ events: events.sort(
+ (a, b) =>
+ moment(a.airDateUtc).unix() - moment(b.airDateUtc).unix()
+ ),
+ });
+ }
+
+ return acc;
+ }, []);
+
+ return sort(grouped);
+ }
+ );
+}
+
+interface CalendarDayProps {
+ date: string;
+ isTodaysDate: boolean;
+ onEventModalOpenToggle(isOpen: boolean): unknown;
+}
+
+function CalendarDay({
+ date,
+ isTodaysDate,
+ onEventModalOpenToggle,
+}: CalendarDayProps) {
+ const { time, view } = useSelector((state: AppState) => state.calendar);
+ const events = useSelector(createCalendarEventsConnector(date));
+
+ const ref = React.useRef(null);
+
+ React.useEffect(() => {
+ if (isTodaysDate && view === calendarViews.MONTH && ref.current) {
+ ref.current.scrollIntoView();
+ }
+ }, [time, isTodaysDate, view]);
+
+ return (
+
+ {view === calendarViews.MONTH && (
+
+ {moment(date).date()}
+
+ )}
+
+ {events.map((event) => {
+ if (event.isGroup) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+export default CalendarDay;
diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js
deleted file mode 100644
index 8fd6cc5a147..00000000000
--- a/frontend/src/Calendar/Day/CalendarDayConnector.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import _ from 'lodash';
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import CalendarDay from './CalendarDay';
-
-function sort(items) {
- return _.sortBy(items, (item) => {
- if (item.isGroup) {
- return moment(item.events[0].airDateUtc).unix();
- }
-
- return moment(item.airDateUtc).unix();
- });
-}
-
-function createCalendarEventsConnector() {
- return createSelector(
- (state, { date }) => date,
- (state) => state.calendar.items,
- (state) => state.calendar.options.collapseMultipleEpisodes,
- (date, items, collapseMultipleEpisodes) => {
- const filtered = _.filter(items, (item) => {
- return moment(date).isSame(moment(item.airDateUtc), 'day');
- });
-
- if (!collapseMultipleEpisodes) {
- return sort(filtered);
- }
-
- const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`);
- const grouped = [];
-
- Object.keys(groupedObject).forEach((key) => {
- const events = groupedObject[key];
-
- if (events.length === 1) {
- grouped.push(events[0]);
- } else {
- grouped.push({
- isGroup: true,
- seriesId: events[0].seriesId,
- seasonNumber: events[0].seasonNumber,
- episodeIds: events.map((event) => event.id),
- events: _.sortBy(events, (item) => moment(item.airDateUtc).unix())
- });
- }
- });
-
- const sorted = sort(grouped);
-
- return sorted;
- }
- );
-}
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar,
- createCalendarEventsConnector(),
- (calendar, events) => {
- return {
- time: calendar.time,
- view: calendar.view,
- events
- };
- }
- );
-}
-
-class CalendarDayConnector extends Component {
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-CalendarDayConnector.propTypes = {
- date: PropTypes.string.isRequired
-};
-
-export default connect(createMapStateToProps)(CalendarDayConnector);
diff --git a/frontend/src/Calendar/Day/CalendarDays.css.d.ts b/frontend/src/Calendar/Day/CalendarDays.css.d.ts
new file mode 100644
index 00000000000..ae3e7aebc0b
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDays.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'day': string;
+ 'days': string;
+ 'forecast': string;
+ 'month': string;
+ 'week': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js
deleted file mode 100644
index f2bb4c8d456..00000000000
--- a/frontend/src/Calendar/Day/CalendarDays.js
+++ /dev/null
@@ -1,164 +0,0 @@
-import classNames from 'classnames';
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import * as calendarViews from 'Calendar/calendarViews';
-import isToday from 'Utilities/Date/isToday';
-import CalendarDayConnector from './CalendarDayConnector';
-import styles from './CalendarDays.css';
-
-class CalendarDays extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._touchStart = null;
-
- this.state = {
- todaysDate: moment().startOf('day').toISOString(),
- isEventModalOpen: false
- };
-
- this.updateTimeoutId = null;
- }
-
- // Lifecycle
-
- componentDidMount() {
- const view = this.props.view;
-
- if (view === calendarViews.MONTH) {
- this.scheduleUpdate();
- }
-
- window.addEventListener('touchstart', this.onTouchStart);
- window.addEventListener('touchend', this.onTouchEnd);
- window.addEventListener('touchcancel', this.onTouchCancel);
- window.addEventListener('touchmove', this.onTouchMove);
- }
-
- componentWillUnmount() {
- this.clearUpdateTimeout();
-
- window.removeEventListener('touchstart', this.onTouchStart);
- window.removeEventListener('touchend', this.onTouchEnd);
- window.removeEventListener('touchcancel', this.onTouchCancel);
- window.removeEventListener('touchmove', this.onTouchMove);
- }
-
- //
- // Control
-
- scheduleUpdate = () => {
- this.clearUpdateTimeout();
- const todaysDate = moment().startOf('day');
- const diff = moment().diff(todaysDate.clone().add(1, 'day'));
-
- this.setState({ todaysDate: todaysDate.toISOString() });
-
- this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
- };
-
- clearUpdateTimeout = () => {
- if (this.updateTimeoutId) {
- clearTimeout(this.updateTimeoutId);
- }
- };
-
- //
- // Listeners
-
- onEventModalOpenToggle = (isEventModalOpen) => {
- this.setState({ isEventModalOpen });
- };
-
- onTouchStart = (event) => {
- const touches = event.touches;
- const touchStart = touches[0].pageX;
-
- if (touches.length !== 1) {
- return;
- }
-
- if (
- touchStart < 50 ||
- this.props.isSidebarVisible ||
- this.state.isEventModalOpen
- ) {
- return;
- }
-
- this._touchStart = touchStart;
- };
-
- onTouchEnd = (event) => {
- const touches = event.changedTouches;
- const currentTouch = touches[0].pageX;
-
- if (!this._touchStart) {
- return;
- }
-
- if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
- this.props.onNavigatePrevious();
- } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
- this.props.onNavigateNext();
- }
-
- this._touchStart = null;
- };
-
- onTouchCancel = (event) => {
- this._touchStart = null;
- };
-
- onTouchMove = (event) => {
- if (!this._touchStart) {
- return;
- }
- };
-
- //
- // Render
-
- render() {
- const {
- dates,
- view
- } = this.props;
-
- return (
-
- {
- dates.map((date) => {
- return (
-
- );
- })
- }
-
- );
- }
-}
-
-CalendarDays.propTypes = {
- dates: PropTypes.arrayOf(PropTypes.string).isRequired,
- view: PropTypes.string.isRequired,
- isSidebarVisible: PropTypes.bool.isRequired,
- onNavigatePrevious: PropTypes.func.isRequired,
- onNavigateNext: PropTypes.func.isRequired
-};
-
-export default CalendarDays;
diff --git a/frontend/src/Calendar/Day/CalendarDays.tsx b/frontend/src/Calendar/Day/CalendarDays.tsx
new file mode 100644
index 00000000000..149dc145557
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDays.tsx
@@ -0,0 +1,135 @@
+import classNames from 'classnames';
+import moment from 'moment';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import * as calendarViews from 'Calendar/calendarViews';
+import {
+ gotoCalendarNextRange,
+ gotoCalendarPreviousRange,
+} from 'Store/Actions/calendarActions';
+import CalendarDay from './CalendarDay';
+import styles from './CalendarDays.css';
+
+function CalendarDays() {
+ const dispatch = useDispatch();
+ const { dates, view } = useSelector((state: AppState) => state.calendar);
+ const isSidebarVisible = useSelector(
+ (state: AppState) => state.app.isSidebarVisible
+ );
+
+ const updateTimeout = useRef>();
+ const touchStart = useRef(null);
+ const isEventModalOpen = useRef(false);
+ const [todaysDate, setTodaysDate] = useState(
+ moment().startOf('day').toISOString()
+ );
+
+ const handleEventModalOpenToggle = useCallback((isOpen: boolean) => {
+ isEventModalOpen.current = isOpen;
+ }, []);
+
+ const scheduleUpdate = useCallback(() => {
+ clearTimeout(updateTimeout.current);
+
+ const todaysDate = moment().startOf('day');
+ const diff = moment().diff(todaysDate.clone().add(1, 'day'));
+
+ setTodaysDate(todaysDate.toISOString());
+
+ updateTimeout.current = setTimeout(scheduleUpdate, diff);
+ }, []);
+
+ const handleTouchStart = useCallback(
+ (event: TouchEvent) => {
+ const touches = event.touches;
+ const currentTouch = touches[0].pageX;
+
+ if (touches.length !== 1) {
+ return;
+ }
+
+ if (currentTouch < 50 || isSidebarVisible || isEventModalOpen.current) {
+ return;
+ }
+
+ touchStart.current = currentTouch;
+ },
+ [isSidebarVisible]
+ );
+
+ const handleTouchEnd = useCallback(
+ (event: TouchEvent) => {
+ const touches = event.changedTouches;
+ const currentTouch = touches[0].pageX;
+
+ if (!touchStart.current) {
+ return;
+ }
+
+ if (
+ currentTouch > touchStart.current &&
+ currentTouch - touchStart.current > 100
+ ) {
+ dispatch(gotoCalendarPreviousRange());
+ } else if (
+ currentTouch < touchStart.current &&
+ touchStart.current - currentTouch > 100
+ ) {
+ dispatch(gotoCalendarNextRange());
+ }
+
+ touchStart.current = null;
+ },
+ [dispatch]
+ );
+
+ const handleTouchCancel = useCallback(() => {
+ touchStart.current = null;
+ }, []);
+
+ const handleTouchMove = useCallback(() => {
+ if (!touchStart.current) {
+ return;
+ }
+ }, []);
+
+ useEffect(() => {
+ if (view === calendarViews.MONTH) {
+ scheduleUpdate();
+ }
+ }, [view, scheduleUpdate]);
+
+ useEffect(() => {
+ window.addEventListener('touchstart', handleTouchStart);
+ window.addEventListener('touchend', handleTouchEnd);
+ window.addEventListener('touchcancel', handleTouchCancel);
+ window.addEventListener('touchmove', handleTouchMove);
+
+ return () => {
+ window.removeEventListener('touchstart', handleTouchStart);
+ window.removeEventListener('touchend', handleTouchEnd);
+ window.removeEventListener('touchcancel', handleTouchCancel);
+ window.removeEventListener('touchmove', handleTouchMove);
+ };
+ }, [handleTouchStart, handleTouchEnd, handleTouchCancel, handleTouchMove]);
+
+ return (
+
+ {dates.map((date) => {
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export default CalendarDays;
diff --git a/frontend/src/Calendar/Day/CalendarDaysConnector.js b/frontend/src/Calendar/Day/CalendarDaysConnector.js
deleted file mode 100644
index 0acce70b909..00000000000
--- a/frontend/src/Calendar/Day/CalendarDaysConnector.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { gotoCalendarNextRange, gotoCalendarPreviousRange } from 'Store/Actions/calendarActions';
-import CalendarDays from './CalendarDays';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar,
- (state) => state.app.isSidebarVisible,
- (calendar, isSidebarVisible) => {
- return {
- dates: calendar.dates,
- view: calendar.view,
- isSidebarVisible
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- onNavigatePrevious: gotoCalendarPreviousRange,
- onNavigateNext: gotoCalendarNextRange
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays);
diff --git a/frontend/src/Calendar/Day/DayOfWeek.css b/frontend/src/Calendar/Day/DayOfWeek.css
index bb73fa3ba87..2d31a30be0c 100644
--- a/frontend/src/Calendar/Day/DayOfWeek.css
+++ b/frontend/src/Calendar/Day/DayOfWeek.css
@@ -1,6 +1,6 @@
.dayOfWeek {
flex: 1 0 14.28%;
- background-color: var(--calendarBackgroudColor);
+ background-color: var(--calendarBackgroundColor);
text-align: center;
}
diff --git a/frontend/src/Calendar/Day/DayOfWeek.css.d.ts b/frontend/src/Calendar/Day/DayOfWeek.css.d.ts
new file mode 100644
index 00000000000..a377e4a8e19
--- /dev/null
+++ b/frontend/src/Calendar/Day/DayOfWeek.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'dayOfWeek': string;
+ 'isSingleDay': string;
+ 'isToday': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js
deleted file mode 100644
index 39e40fce85f..00000000000
--- a/frontend/src/Calendar/Day/DayOfWeek.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import classNames from 'classnames';
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import * as calendarViews from 'Calendar/calendarViews';
-import getRelativeDate from 'Utilities/Date/getRelativeDate';
-import styles from './DayOfWeek.css';
-
-class DayOfWeek extends Component {
-
- //
- // Render
-
- render() {
- const {
- date,
- view,
- isTodaysDate,
- calendarWeekColumnHeader,
- shortDateFormat,
- showRelativeDates
- } = this.props;
-
- const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
- const momentDate = moment(date);
- let formatedDate = momentDate.format('dddd');
-
- if (view === calendarViews.WEEK) {
- formatedDate = momentDate.format(calendarWeekColumnHeader);
- } else if (view === calendarViews.FORECAST) {
- formatedDate = getRelativeDate(date, shortDateFormat, showRelativeDates);
- }
-
- return (
-
- {formatedDate}
-
- );
- }
-}
-
-DayOfWeek.propTypes = {
- date: PropTypes.string.isRequired,
- view: PropTypes.string.isRequired,
- isTodaysDate: PropTypes.bool.isRequired,
- calendarWeekColumnHeader: PropTypes.string.isRequired,
- shortDateFormat: PropTypes.string.isRequired,
- showRelativeDates: PropTypes.bool.isRequired
-};
-
-export default DayOfWeek;
diff --git a/frontend/src/Calendar/Day/DayOfWeek.tsx b/frontend/src/Calendar/Day/DayOfWeek.tsx
new file mode 100644
index 00000000000..c8b493b7c9a
--- /dev/null
+++ b/frontend/src/Calendar/Day/DayOfWeek.tsx
@@ -0,0 +1,54 @@
+import classNames from 'classnames';
+import moment from 'moment';
+import React from 'react';
+import * as calendarViews from 'Calendar/calendarViews';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import styles from './DayOfWeek.css';
+
+interface DayOfWeekProps {
+ date: string;
+ view: string;
+ isTodaysDate: boolean;
+ calendarWeekColumnHeader: string;
+ shortDateFormat: string;
+ showRelativeDates: boolean;
+}
+
+function DayOfWeek(props: DayOfWeekProps) {
+ const {
+ date,
+ view,
+ isTodaysDate,
+ calendarWeekColumnHeader,
+ shortDateFormat,
+ showRelativeDates,
+ } = props;
+
+ const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
+ const momentDate = moment(date);
+ let formatedDate = momentDate.format('dddd');
+
+ if (view === calendarViews.WEEK) {
+ formatedDate = momentDate.format(calendarWeekColumnHeader);
+ } else if (view === calendarViews.FORECAST) {
+ formatedDate = getRelativeDate({
+ date,
+ shortDateFormat,
+ showRelativeDates,
+ });
+ }
+
+ return (
+
+ {formatedDate}
+
+ );
+}
+
+export default DayOfWeek;
diff --git a/frontend/src/Calendar/Day/DaysOfWeek.css.d.ts b/frontend/src/Calendar/Day/DaysOfWeek.css.d.ts
new file mode 100644
index 00000000000..5bc224b68e9
--- /dev/null
+++ b/frontend/src/Calendar/Day/DaysOfWeek.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'daysOfWeek': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js
deleted file mode 100644
index 9f94b1079d1..00000000000
--- a/frontend/src/Calendar/Day/DaysOfWeek.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import * as calendarViews from 'Calendar/calendarViews';
-import DayOfWeek from './DayOfWeek';
-import styles from './DaysOfWeek.css';
-
-class DaysOfWeek extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- todaysDate: moment().startOf('day').toISOString()
- };
-
- this.updateTimeoutId = null;
- }
-
- // Lifecycle
-
- componentDidMount() {
- const view = this.props.view;
-
- if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) {
- this.scheduleUpdate();
- }
- }
-
- componentWillUnmount() {
- this.clearUpdateTimeout();
- }
-
- //
- // Control
-
- scheduleUpdate = () => {
- this.clearUpdateTimeout();
- const todaysDate = moment().startOf('day');
- const diff = todaysDate.clone().add(1, 'day').diff(moment());
-
- this.setState({
- todaysDate: todaysDate.toISOString()
- });
-
- this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
- };
-
- clearUpdateTimeout = () => {
- if (this.updateTimeoutId) {
- clearTimeout(this.updateTimeoutId);
- }
- };
-
- //
- // Render
-
- render() {
- const {
- dates,
- view,
- ...otherProps
- } = this.props;
-
- if (view === calendarViews.AGENDA) {
- return null;
- }
-
- return (
-
- {
- dates.map((date) => {
- return (
-
- );
- })
- }
-
- );
- }
-}
-
-DaysOfWeek.propTypes = {
- dates: PropTypes.arrayOf(PropTypes.string),
- view: PropTypes.string.isRequired
-};
-
-export default DaysOfWeek;
diff --git a/frontend/src/Calendar/Day/DaysOfWeek.tsx b/frontend/src/Calendar/Day/DaysOfWeek.tsx
new file mode 100644
index 00000000000..64bc886ccd3
--- /dev/null
+++ b/frontend/src/Calendar/Day/DaysOfWeek.tsx
@@ -0,0 +1,60 @@
+import moment from 'moment';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import * as calendarViews from 'Calendar/calendarViews';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import DayOfWeek from './DayOfWeek';
+import styles from './DaysOfWeek.css';
+
+function DaysOfWeek() {
+ const { dates, view } = useSelector((state: AppState) => state.calendar);
+ const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } =
+ useSelector(createUISettingsSelector());
+
+ const updateTimeout = useRef>();
+ const [todaysDate, setTodaysDate] = useState(
+ moment().startOf('day').toISOString()
+ );
+
+ const scheduleUpdate = useCallback(() => {
+ clearTimeout(updateTimeout.current);
+
+ const todaysDate = moment().startOf('day');
+ const diff = moment().diff(todaysDate.clone().add(1, 'day'));
+
+ setTodaysDate(todaysDate.toISOString());
+
+ updateTimeout.current = setTimeout(scheduleUpdate, diff);
+ }, []);
+
+ useEffect(() => {
+ if (view !== calendarViews.AGENDA && view !== calendarViews.MONTH) {
+ scheduleUpdate();
+ }
+ }, [view, scheduleUpdate]);
+
+ if (view === calendarViews.AGENDA) {
+ return null;
+ }
+
+ return (
+
+ {dates.map((date) => {
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export default DaysOfWeek;
diff --git a/frontend/src/Calendar/Day/DaysOfWeekConnector.js b/frontend/src/Calendar/Day/DaysOfWeekConnector.js
deleted file mode 100644
index 7f5cdef1989..00000000000
--- a/frontend/src/Calendar/Day/DaysOfWeekConnector.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
-import DaysOfWeek from './DaysOfWeek';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar,
- createUISettingsSelector(),
- (calendar, UiSettings) => {
- return {
- dates: calendar.dates.slice(0, 7),
- view: calendar.view,
- calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader,
- shortDateFormat: UiSettings.shortDateFormat,
- showRelativeDates: UiSettings.showRelativeDates
- };
- }
- );
-}
-
-export default connect(createMapStateToProps)(DaysOfWeek);
diff --git a/frontend/src/Calendar/Events/CalendarEvent.css b/frontend/src/Calendar/Events/CalendarEvent.css
index 0cf3900bf31..679b4cc515b 100644
--- a/frontend/src/Calendar/Events/CalendarEvent.css
+++ b/frontend/src/Calendar/Events/CalendarEvent.css
@@ -52,6 +52,10 @@ $fullColorGradient: rgba(244, 245, 246, 0.2);
.statusContainer {
display: flex;
align-items: center;
+
+ &:global(.fullColor) {
+ filter: var(--calendarFullColorFilter)
+ }
}
.statusIcon {
diff --git a/frontend/src/Calendar/Events/CalendarEvent.css.d.ts b/frontend/src/Calendar/Events/CalendarEvent.css.d.ts
new file mode 100644
index 00000000000..f099df21174
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.css.d.ts
@@ -0,0 +1,23 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'absoluteEpisodeNumber': string;
+ 'airTime': string;
+ 'downloaded': string;
+ 'downloading': string;
+ 'episodeInfo': string;
+ 'episodeTitle': string;
+ 'event': string;
+ 'info': string;
+ 'missing': string;
+ 'onAir': string;
+ 'overlay': string;
+ 'seriesTitle': string;
+ 'statusContainer': string;
+ 'statusIcon': string;
+ 'unaired': string;
+ 'underlay': string;
+ 'unmonitored': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js
deleted file mode 100644
index 81d2beaf421..00000000000
--- a/frontend/src/Calendar/Events/CalendarEvent.js
+++ /dev/null
@@ -1,261 +0,0 @@
-import classNames from 'classnames';
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import getStatusStyle from 'Calendar/getStatusStyle';
-import Icon from 'Components/Icon';
-import Link from 'Components/Link/Link';
-import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
-import episodeEntities from 'Episode/episodeEntities';
-import { icons, kinds } from 'Helpers/Props';
-import formatTime from 'Utilities/Date/formatTime';
-import padNumber from 'Utilities/Number/padNumber';
-import CalendarEventQueueDetails from './CalendarEventQueueDetails';
-import styles from './CalendarEvent.css';
-
-class CalendarEvent extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isDetailsModalOpen: false
- };
- }
-
- //
- // Listeners
-
- onPress = () => {
- this.setState({ isDetailsModalOpen: true }, () => {
- this.props.onEventModalOpenToggle(true);
- });
- };
-
- onDetailsModalClose = () => {
- this.setState({ isDetailsModalOpen: false }, () => {
- this.props.onEventModalOpenToggle(false);
- });
- };
-
- //
- // Render
-
- render() {
- const {
- id,
- series,
- episodeFile,
- title,
- seasonNumber,
- episodeNumber,
- absoluteEpisodeNumber,
- airDateUtc,
- monitored,
- unverifiedSceneNumbering,
- hasFile,
- grabbed,
- queueItem,
- showEpisodeInformation,
- showFinaleIcon,
- showSpecialIcon,
- showCutoffUnmetIcon,
- fullColorEvents,
- timeFormat,
- colorImpairedMode
- } = this.props;
-
- if (!series) {
- return null;
- }
-
- const startTime = moment(airDateUtc);
- const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
- const isDownloading = !!(queueItem || grabbed);
- const isMonitored = series.monitored && monitored;
- const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored);
- const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
- const season = series.seasons.find((s) => s.seasonNumber === seasonNumber);
- const seasonStatistics = season?.statistics || {};
-
- return (
-
-
-
-
-
-
- {series.title}
-
-
-
- {
- missingAbsoluteNumber ?
- :
- null
- }
-
- {
- unverifiedSceneNumbering && !missingAbsoluteNumber ?
- :
- null
- }
-
- {
- queueItem ?
-
-
- :
- null
- }
-
- {
- !queueItem && grabbed ?
- :
- null
- }
-
- {
- showCutoffUnmetIcon &&
- !!episodeFile &&
- episodeFile.qualityCutoffNotMet ?
- :
- null
- }
-
- {
- episodeNumber === 1 && seasonNumber > 0 ?
- :
- null
- }
-
- {
- showFinaleIcon &&
- episodeNumber !== 1 &&
- seasonNumber > 0 &&
- episodeNumber === seasonStatistics.totalEpisodeCount ?
- :
- null
- }
-
- {
- showSpecialIcon &&
- (episodeNumber === 0 || seasonNumber === 0) ?
- :
- null
- }
-
-
-
- {
- showEpisodeInformation ?
-
-
- {title}
-
-
-
- {seasonNumber}x{padNumber(episodeNumber, 2)}
-
- {
- series.seriesType === 'anime' && absoluteEpisodeNumber ?
- ({absoluteEpisodeNumber}) : null
- }
-
-
:
- null
- }
-
-
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
-
-
-
-
-
- );
- }
-}
-
-CalendarEvent.propTypes = {
- id: PropTypes.number.isRequired,
- series: PropTypes.object.isRequired,
- episodeFile: PropTypes.object,
- title: PropTypes.string.isRequired,
- seasonNumber: PropTypes.number.isRequired,
- episodeNumber: PropTypes.number.isRequired,
- absoluteEpisodeNumber: PropTypes.number,
- airDateUtc: PropTypes.string.isRequired,
- monitored: PropTypes.bool.isRequired,
- unverifiedSceneNumbering: PropTypes.bool,
- hasFile: PropTypes.bool.isRequired,
- grabbed: PropTypes.bool,
- queueItem: PropTypes.object,
- showEpisodeInformation: PropTypes.bool.isRequired,
- showFinaleIcon: PropTypes.bool.isRequired,
- showSpecialIcon: PropTypes.bool.isRequired,
- showCutoffUnmetIcon: PropTypes.bool.isRequired,
- fullColorEvents: PropTypes.bool.isRequired,
- timeFormat: PropTypes.string.isRequired,
- colorImpairedMode: PropTypes.bool.isRequired,
- onEventModalOpenToggle: PropTypes.func.isRequired
-};
-
-export default CalendarEvent;
diff --git a/frontend/src/Calendar/Events/CalendarEvent.tsx b/frontend/src/Calendar/Events/CalendarEvent.tsx
new file mode 100644
index 00000000000..079256a0e83
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.tsx
@@ -0,0 +1,240 @@
+import classNames from 'classnames';
+import moment from 'moment';
+import React, { useCallback, useState } from 'react';
+import { useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
+import episodeEntities from 'Episode/episodeEntities';
+import getFinaleTypeName from 'Episode/getFinaleTypeName';
+import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
+import { icons, kinds } from 'Helpers/Props';
+import useSeries from 'Series/useSeries';
+import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import formatTime from 'Utilities/Date/formatTime';
+import padNumber from 'Utilities/Number/padNumber';
+import translate from 'Utilities/String/translate';
+import CalendarEventQueueDetails from './CalendarEventQueueDetails';
+import styles from './CalendarEvent.css';
+
+interface CalendarEventProps {
+ id: number;
+ episodeId: number;
+ seriesId: number;
+ episodeFileId?: number;
+ title: string;
+ seasonNumber: number;
+ episodeNumber: number;
+ absoluteEpisodeNumber?: number;
+ airDateUtc: string;
+ monitored: boolean;
+ unverifiedSceneNumbering?: boolean;
+ finaleType?: string;
+ hasFile: boolean;
+ grabbed?: boolean;
+ onEventModalOpenToggle: (isOpen: boolean) => void;
+}
+
+function CalendarEvent(props: CalendarEventProps) {
+ const {
+ id,
+ seriesId,
+ episodeFileId,
+ title,
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ airDateUtc,
+ monitored,
+ unverifiedSceneNumbering,
+ finaleType,
+ hasFile,
+ grabbed,
+ onEventModalOpenToggle,
+ } = props;
+
+ const series = useSeries(seriesId);
+ const episodeFile = useEpisodeFile(episodeFileId);
+ const queueItem = useSelector(createQueueItemSelectorForHook(id));
+
+ const { timeFormat, enableColorImpairedMode } = useSelector(
+ createUISettingsSelector()
+ );
+
+ const {
+ showEpisodeInformation,
+ showFinaleIcon,
+ showSpecialIcon,
+ showCutoffUnmetIcon,
+ fullColorEvents,
+ } = useSelector((state: AppState) => state.calendar.options);
+
+ const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
+
+ const handlePress = useCallback(() => {
+ setIsDetailsModalOpen(true);
+ onEventModalOpenToggle(true);
+ }, [onEventModalOpenToggle]);
+
+ const handleDetailsModalClose = useCallback(() => {
+ setIsDetailsModalOpen(false);
+ onEventModalOpenToggle(false);
+ }, [onEventModalOpenToggle]);
+
+ if (!series) {
+ return null;
+ }
+
+ const startTime = moment(airDateUtc);
+ const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
+ const isDownloading = !!(queueItem || grabbed);
+ const isMonitored = series.monitored && monitored;
+ const statusStyle = getStatusStyle(
+ hasFile,
+ isDownloading,
+ startTime,
+ endTime,
+ isMonitored
+ );
+ const missingAbsoluteNumber =
+ series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
+
+ return (
+
+
+
+
+
+
{series.title}
+
+
+ {missingAbsoluteNumber ? (
+
+ ) : null}
+
+ {unverifiedSceneNumbering && !missingAbsoluteNumber ? (
+
+ ) : null}
+
+ {queueItem ? (
+
+
+
+ ) : null}
+
+ {!queueItem && grabbed ? (
+
+ ) : null}
+
+ {showCutoffUnmetIcon &&
+ !!episodeFile &&
+ episodeFile.qualityCutoffNotMet ? (
+
+ ) : null}
+
+ {episodeNumber === 1 && seasonNumber > 0 ? (
+
+ ) : null}
+
+ {showFinaleIcon && finaleType ? (
+
+ ) : null}
+
+ {showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? (
+
+ ) : null}
+
+
+
+ {showEpisodeInformation ? (
+
+
{title}
+
+
+ {seasonNumber}x{padNumber(episodeNumber, 2)}
+ {series.seriesType === 'anime' && absoluteEpisodeNumber ? (
+
+ ({absoluteEpisodeNumber})
+
+ ) : null}
+
+
+ ) : null}
+
+
+ {formatTime(airDateUtc, timeFormat)} -{' '}
+ {formatTime(endTime.toISOString(), timeFormat, {
+ includeMinuteZero: true,
+ })}
+
+
+
+
+
+ );
+}
+
+export default CalendarEvent;
diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js
deleted file mode 100644
index e1ac2096d01..00000000000
--- a/frontend/src/Calendar/Events/CalendarEventConnector.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
-import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
-import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
-import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
-import CalendarEvent from './CalendarEvent';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar.options,
- createSeriesSelector(),
- createEpisodeFileSelector(),
- createQueueItemSelector(),
- createUISettingsSelector(),
- (calendarOptions, series, episodeFile, queueItem, uiSettings) => {
- return {
- series,
- episodeFile,
- queueItem,
- ...calendarOptions,
- timeFormat: uiSettings.timeFormat,
- colorImpairedMode: uiSettings.enableColorImpairedMode
- };
- }
- );
-}
-
-export default connect(createMapStateToProps)(CalendarEvent);
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.css b/frontend/src/Calendar/Events/CalendarEventGroup.css
index 68a12851d96..990d994ec92 100644
--- a/frontend/src/Calendar/Events/CalendarEventGroup.css
+++ b/frontend/src/Calendar/Events/CalendarEventGroup.css
@@ -43,6 +43,7 @@
.expandContainer,
.collapseContainer {
display: flex;
+ align-items: center;
justify-content: center;
}
@@ -50,6 +51,15 @@
margin-bottom: 5px;
}
+.statusContainer {
+ display: flex;
+ align-items: center;
+
+ &:global(.fullColor) {
+ filter: var(--calendarFullColorFilter)
+ }
+}
+
.statusIcon {
margin-left: 3px;
}
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.css.d.ts b/frontend/src/Calendar/Events/CalendarEventGroup.css.d.ts
new file mode 100644
index 00000000000..c527feff1f8
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventGroup.css.d.ts
@@ -0,0 +1,25 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'absoluteEpisodeNumber': string;
+ 'airTime': string;
+ 'airingInfo': string;
+ 'collapseContainer': string;
+ 'downloaded': string;
+ 'downloading': string;
+ 'episodeInfo': string;
+ 'eventGroup': string;
+ 'expandContainer': string;
+ 'expandContainerInline': string;
+ 'info': string;
+ 'missing': string;
+ 'onAir': string;
+ 'premiere': string;
+ 'seriesTitle': string;
+ 'statusContainer': string;
+ 'statusIcon': string;
+ 'unaired': string;
+ 'unmonitored': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.js b/frontend/src/Calendar/Events/CalendarEventGroup.js
deleted file mode 100644
index e62232e2069..00000000000
--- a/frontend/src/Calendar/Events/CalendarEventGroup.js
+++ /dev/null
@@ -1,249 +0,0 @@
-import classNames from 'classnames';
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
-import getStatusStyle from 'Calendar/getStatusStyle';
-import Icon from 'Components/Icon';
-import Link from 'Components/Link/Link';
-import { icons, kinds } from 'Helpers/Props';
-import formatTime from 'Utilities/Date/formatTime';
-import padNumber from 'Utilities/Number/padNumber';
-import styles from './CalendarEventGroup.css';
-
-function getEventsInfo(series, events) {
- let files = 0;
- let queued = 0;
- let monitored = 0;
- let absoluteEpisodeNumbers = 0;
-
- events.forEach((event) => {
- if (event.episodeFileId) {
- files++;
- }
-
- if (event.queued) {
- queued++;
- }
-
- if (series.monitored && event.monitored) {
- monitored++;
- }
-
- if (event.absoluteEpisodeNumber) {
- absoluteEpisodeNumbers++;
- }
- });
-
- return {
- allDownloaded: files === events.length,
- anyQueued: queued > 0,
- anyMonitored: monitored > 0,
- allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length
- };
-}
-
-class CalendarEventGroup extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isExpanded: false
- };
- }
-
- //
- // Listeners
-
- onExpandPress = () => {
- this.setState({ isExpanded: !this.state.isExpanded });
- };
-
- //
- // Render
-
- render() {
- const {
- series,
- events,
- isDownloading,
- showEpisodeInformation,
- showFinaleIcon,
- timeFormat,
- fullColorEvents,
- colorImpairedMode,
- onEventModalOpenToggle
- } = this.props;
-
- const { isExpanded } = this.state;
- const {
- allDownloaded,
- anyQueued,
- anyMonitored,
- allAbsoluteEpisodeNumbers
- } = getEventsInfo(series, events);
- const anyDownloading = isDownloading || anyQueued;
- const firstEpisode = events[0];
- const lastEpisode = events[events.length -1];
- const airDateUtc = firstEpisode.airDateUtc;
- const startTime = moment(airDateUtc);
- const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
- const seasonNumber = firstEpisode.seasonNumber;
- const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored);
- const isMissingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !allAbsoluteEpisodeNumbers;
-
- if (isExpanded) {
- return (
-
- {
- events.map((event) => {
- if (event.isGroup) {
- return null;
- }
-
- return (
-
- );
- })
- }
-
-
-
-
-
- );
- }
-
- return (
-
-
-
- {series.title}
-
-
- {
- isMissingAbsoluteNumber &&
-
- }
-
- {
- anyDownloading &&
-
- }
-
- {
- firstEpisode.episodeNumber === 1 && seasonNumber > 0 &&
-
- }
-
- {
- showFinaleIcon &&
- lastEpisode.episodeNumber !== 1 &&
- seasonNumber > 0 &&
- lastEpisode.episodeNumber === series.seasons.find((season) => season.seasonNumber === seasonNumber).statistics.totalEpisodeCount &&
-
- }
-
-
-
-
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
-
-
- {
- showEpisodeInformation ?
-
- {seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-{padNumber(lastEpisode.episodeNumber, 2)}
-
- {
- series.seriesType === 'anime' &&
- firstEpisode.absoluteEpisodeNumber &&
- lastEpisode.absoluteEpisodeNumber &&
-
- ({firstEpisode.absoluteEpisodeNumber}-{lastEpisode.absoluteEpisodeNumber})
-
- }
-
:
-
-
-
- }
-
-
- {
- showEpisodeInformation &&
-
-
-
- }
-
- );
- }
-}
-
-CalendarEventGroup.propTypes = {
- series: PropTypes.object.isRequired,
- events: PropTypes.arrayOf(PropTypes.object).isRequired,
- isDownloading: PropTypes.bool.isRequired,
- showEpisodeInformation: PropTypes.bool.isRequired,
- showFinaleIcon: PropTypes.bool.isRequired,
- fullColorEvents: PropTypes.bool.isRequired,
- timeFormat: PropTypes.string.isRequired,
- colorImpairedMode: PropTypes.bool.isRequired,
- onEventModalOpenToggle: PropTypes.func.isRequired
-};
-
-export default CalendarEventGroup;
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.tsx b/frontend/src/Calendar/Events/CalendarEventGroup.tsx
new file mode 100644
index 00000000000..1ee981cfdff
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventGroup.tsx
@@ -0,0 +1,253 @@
+import classNames from 'classnames';
+import moment from 'moment';
+import React, { useCallback, useMemo, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import getFinaleTypeName from 'Episode/getFinaleTypeName';
+import { icons, kinds } from 'Helpers/Props';
+import useSeries from 'Series/useSeries';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import { CalendarItem } from 'typings/Calendar';
+import formatTime from 'Utilities/Date/formatTime';
+import padNumber from 'Utilities/Number/padNumber';
+import translate from 'Utilities/String/translate';
+import CalendarEvent from './CalendarEvent';
+import styles from './CalendarEventGroup.css';
+
+function createIsDownloadingSelector(episodeIds: number[]) {
+ return createSelector(
+ (state: AppState) => state.queue.details,
+ (details) => {
+ return details.items.some((item) => {
+ return !!(item.episodeId && episodeIds.includes(item.episodeId));
+ });
+ }
+ );
+}
+
+interface CalendarEventGroupProps {
+ episodeIds: number[];
+ seriesId: number;
+ events: CalendarItem[];
+ onEventModalOpenToggle: (isOpen: boolean) => void;
+}
+
+function CalendarEventGroup({
+ episodeIds,
+ seriesId,
+ events,
+ onEventModalOpenToggle,
+}: CalendarEventGroupProps) {
+ const isDownloading = useSelector(createIsDownloadingSelector(episodeIds));
+ const series = useSeries(seriesId)!;
+
+ const { timeFormat, enableColorImpairedMode } = useSelector(
+ createUISettingsSelector()
+ );
+
+ const { showEpisodeInformation, showFinaleIcon, fullColorEvents } =
+ useSelector((state: AppState) => state.calendar.options);
+
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const firstEpisode = events[0];
+ const lastEpisode = events[events.length - 1];
+ const airDateUtc = firstEpisode.airDateUtc;
+ const startTime = moment(airDateUtc);
+ const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
+ const seasonNumber = firstEpisode.seasonNumber;
+
+ const { allDownloaded, anyQueued, anyMonitored, allAbsoluteEpisodeNumbers } =
+ useMemo(() => {
+ let files = 0;
+ let queued = 0;
+ let monitored = 0;
+ let absoluteEpisodeNumbers = 0;
+
+ events.forEach((event) => {
+ if (event.episodeFileId) {
+ files++;
+ }
+
+ if (event.queued) {
+ queued++;
+ }
+
+ if (series.monitored && event.monitored) {
+ monitored++;
+ }
+
+ if (event.absoluteEpisodeNumber) {
+ absoluteEpisodeNumbers++;
+ }
+ });
+
+ return {
+ allDownloaded: files === events.length,
+ anyQueued: queued > 0,
+ anyMonitored: monitored > 0,
+ allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length,
+ };
+ }, [series, events]);
+
+ const anyDownloading = isDownloading || anyQueued;
+
+ const statusStyle = getStatusStyle(
+ allDownloaded,
+ anyDownloading,
+ startTime,
+ endTime,
+ anyMonitored
+ );
+ const isMissingAbsoluteNumber =
+ series.seriesType === 'anime' &&
+ seasonNumber > 0 &&
+ !allAbsoluteEpisodeNumbers;
+
+ const handleExpandPress = useCallback(() => {
+ setIsExpanded((state) => !state);
+ }, []);
+
+ if (isExpanded) {
+ return (
+
+ {events.map((event) => {
+ return (
+
+ );
+ })}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
{series.title}
+
+
+ {isMissingAbsoluteNumber ? (
+
+ ) : null}
+
+ {anyDownloading ? (
+
+ ) : null}
+
+ {firstEpisode.episodeNumber === 1 && seasonNumber > 0 ? (
+
+ ) : null}
+
+ {showFinaleIcon && lastEpisode.finaleType ? (
+
+ ) : null}
+
+
+
+
+
+ {formatTime(airDateUtc, timeFormat)} -{' '}
+ {formatTime(endTime.toISOString(), timeFormat, {
+ includeMinuteZero: true,
+ })}
+
+
+ {showEpisodeInformation ? (
+
+ {seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-
+ {padNumber(lastEpisode.episodeNumber, 2)}
+ {series.seriesType === 'anime' &&
+ firstEpisode.absoluteEpisodeNumber &&
+ lastEpisode.absoluteEpisodeNumber ? (
+
+ ({firstEpisode.absoluteEpisodeNumber}-
+ {lastEpisode.absoluteEpisodeNumber})
+
+ ) : null}
+
+ ) : (
+
+
+
+ )}
+
+
+ {showEpisodeInformation ? (
+
+
+
+
+
+ ) : null}
+
+ );
+}
+
+export default CalendarEventGroup;
diff --git a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js
deleted file mode 100644
index dbd96778493..00000000000
--- a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
-import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
-import CalendarEventGroup from './CalendarEventGroup';
-
-function createIsDownloadingSelector() {
- return createSelector(
- (state, { episodeIds }) => episodeIds,
- (state) => state.queue.details,
- (episodeIds, details) => {
- return details.items.some((item) => {
- return item.episode && episodeIds.includes(item.episode.id);
- });
- }
- );
-}
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar.options,
- createSeriesSelector(),
- createIsDownloadingSelector(),
- createUISettingsSelector(),
- (calendarOptions, series, isDownloading, uiSettings) => {
- return {
- series,
- isDownloading,
- ...calendarOptions,
- timeFormat: uiSettings.timeFormat,
- colorImpairedMode: uiSettings.enableColorImpairedMode
- };
- }
- );
-}
-
-export default connect(createMapStateToProps)(CalendarEventGroup);
diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js
deleted file mode 100644
index db26eb1d281..00000000000
--- a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import QueueDetails from 'Activity/Queue/QueueDetails';
-import CircularProgressBar from 'Components/CircularProgressBar';
-
-function CalendarEventQueueDetails(props) {
- const {
- title,
- size,
- sizeleft,
- estimatedCompletionTime,
- status,
- trackedDownloadState,
- trackedDownloadStatus,
- statusMessages,
- errorMessage
- } = props;
-
- const progress = size ? (100 - sizeleft / size * 100) : 0;
-
- return (
-
- }
- />
- );
-}
-
-CalendarEventQueueDetails.propTypes = {
- title: PropTypes.string.isRequired,
- size: PropTypes.number.isRequired,
- sizeleft: PropTypes.number.isRequired,
- estimatedCompletionTime: PropTypes.string,
- status: PropTypes.string.isRequired,
- trackedDownloadState: PropTypes.string.isRequired,
- trackedDownloadStatus: PropTypes.string.isRequired,
- statusMessages: PropTypes.arrayOf(PropTypes.object),
- errorMessage: PropTypes.string
-};
-
-export default CalendarEventQueueDetails;
diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx
new file mode 100644
index 00000000000..2372bc78eef
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import QueueDetails from 'Activity/Queue/QueueDetails';
+import CircularProgressBar from 'Components/CircularProgressBar';
+import {
+ QueueTrackedDownloadState,
+ QueueTrackedDownloadStatus,
+ StatusMessage,
+} from 'typings/Queue';
+
+interface CalendarEventQueueDetailsProps {
+ title: string;
+ size: number;
+ sizeleft: number;
+ estimatedCompletionTime?: string;
+ status: string;
+ trackedDownloadState: QueueTrackedDownloadState;
+ trackedDownloadStatus: QueueTrackedDownloadStatus;
+ statusMessages?: StatusMessage[];
+ errorMessage?: string;
+}
+
+function CalendarEventQueueDetails({
+ title,
+ size,
+ sizeleft,
+ estimatedCompletionTime,
+ status,
+ trackedDownloadState,
+ trackedDownloadStatus,
+ statusMessages,
+ errorMessage,
+}: CalendarEventQueueDetailsProps) {
+ const progress = size ? 100 - (sizeleft / size) * 100 : 0;
+
+ return (
+
+ }
+ />
+ );
+}
+
+export default CalendarEventQueueDetails;
diff --git a/frontend/src/Calendar/Header/CalendarHeader.css.d.ts b/frontend/src/Calendar/Header/CalendarHeader.css.d.ts
new file mode 100644
index 00000000000..700b53652bc
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeader.css.d.ts
@@ -0,0 +1,14 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'header': string;
+ 'loading': string;
+ 'navigationButtons': string;
+ 'titleDesktop': string;
+ 'titleMobile': string;
+ 'todayButton': string;
+ 'viewButtonsContainer': string;
+ 'viewMenu': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js
deleted file mode 100644
index 71dcd67a85b..00000000000
--- a/frontend/src/Calendar/Header/CalendarHeader.js
+++ /dev/null
@@ -1,267 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import * as calendarViews from 'Calendar/calendarViews';
-import Icon from 'Components/Icon';
-import Button from 'Components/Link/Button';
-import LoadingIndicator from 'Components/Loading/LoadingIndicator';
-import Menu from 'Components/Menu/Menu';
-import MenuButton from 'Components/Menu/MenuButton';
-import MenuContent from 'Components/Menu/MenuContent';
-import ViewMenuItem from 'Components/Menu/ViewMenuItem';
-import { align, icons } from 'Helpers/Props';
-import CalendarHeaderViewButton from './CalendarHeaderViewButton';
-import styles from './CalendarHeader.css';
-
-function getTitle(time, start, end, view, longDateFormat) {
- const timeMoment = moment(time);
- const startMoment = moment(start);
- const endMoment = moment(end);
-
- if (view === 'day') {
- return timeMoment.format(longDateFormat);
- } else if (view === 'month') {
- return timeMoment.format('MMMM YYYY');
- } else if (view === 'agenda') {
- return 'Agenda';
- }
-
- let startFormat = 'MMM D YYYY';
- let endFormat = 'MMM D YYYY';
-
- if (startMoment.isSame(endMoment, 'month')) {
- startFormat = 'MMM D';
- endFormat = 'D YYYY';
- } else if (startMoment.isSame(endMoment, 'year')) {
- startFormat = 'MMM D';
- endFormat = 'MMM D YYYY';
- }
-
- return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`;
-}
-
-// TODO Convert to a stateful Component so we can track view internally when changed
-
-class CalendarHeader extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- view: props.view
- };
- }
-
- componentDidUpdate(prevProps) {
- const view = this.props.view;
-
- if (prevProps.view !== view) {
- this.setState({ view });
- }
- }
-
- //
- // Listeners
-
- onViewChange = (view) => {
- this.setState({ view }, () => {
- this.props.onViewChange(view);
- });
- };
-
- //
- // Render
-
- render() {
- const {
- isFetching,
- time,
- start,
- end,
- longDateFormat,
- isSmallScreen,
- collapseViewButtons,
- onTodayPress,
- onPreviousPress,
- onNextPress
- } = this.props;
-
- const view = this.state.view;
-
- const title = getTitle(time, start, end, view, longDateFormat);
-
- return (
-
- {
- isSmallScreen &&
-
- {title}
-
- }
-
-
-
-
-
-
-
-
-
-
-
-
- Today
-
-
-
- {
- !isSmallScreen &&
-
- {title}
-
- }
-
-
- {
- isFetching &&
-
- }
-
- {
- collapseViewButtons ?
-
-
-
-
-
-
- {
- isSmallScreen ?
- null :
-
- Month
-
- }
-
-
- Week
-
-
-
- Forecast
-
-
-
- Day
-
-
-
- Agenda
-
-
- :
-
-
-
-
-
-
-
-
-
-
-
-
- }
-
-
-
- );
- }
-}
-
-CalendarHeader.propTypes = {
- isFetching: PropTypes.bool.isRequired,
- time: PropTypes.string.isRequired,
- start: PropTypes.string.isRequired,
- end: PropTypes.string.isRequired,
- view: PropTypes.oneOf(calendarViews.all).isRequired,
- isSmallScreen: PropTypes.bool.isRequired,
- collapseViewButtons: PropTypes.bool.isRequired,
- longDateFormat: PropTypes.string.isRequired,
- onViewChange: PropTypes.func.isRequired,
- onTodayPress: PropTypes.func.isRequired,
- onPreviousPress: PropTypes.func.isRequired,
- onNextPress: PropTypes.func.isRequired
-};
-
-export default CalendarHeader;
diff --git a/frontend/src/Calendar/Header/CalendarHeader.tsx b/frontend/src/Calendar/Header/CalendarHeader.tsx
new file mode 100644
index 00000000000..2faaca25ef0
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeader.tsx
@@ -0,0 +1,221 @@
+import moment from 'moment';
+import React, { useCallback, useMemo } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import { CalendarView } from 'Calendar/calendarViews';
+import Icon from 'Components/Icon';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import ViewMenuItem from 'Components/Menu/ViewMenuItem';
+import { align, icons } from 'Helpers/Props';
+import {
+ gotoCalendarNextRange,
+ gotoCalendarPreviousRange,
+ gotoCalendarToday,
+ setCalendarView,
+} from 'Store/Actions/calendarActions';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import translate from 'Utilities/String/translate';
+import CalendarHeaderViewButton from './CalendarHeaderViewButton';
+import styles from './CalendarHeader.css';
+
+function CalendarHeader() {
+ const dispatch = useDispatch();
+
+ const { isFetching, view, time, start, end } = useSelector(
+ (state: AppState) => state.calendar
+ );
+
+ const { isSmallScreen, isLargeScreen } = useSelector(
+ createDimensionsSelector()
+ );
+
+ const { longDateFormat } = useSelector(createUISettingsSelector());
+
+ const handleViewChange = useCallback(
+ (newView: CalendarView) => {
+ dispatch(setCalendarView({ view: newView }));
+ },
+ [dispatch]
+ );
+
+ const handleTodayPress = useCallback(() => {
+ dispatch(gotoCalendarToday());
+ }, [dispatch]);
+
+ const handlePreviousPress = useCallback(() => {
+ dispatch(gotoCalendarPreviousRange());
+ }, [dispatch]);
+
+ const handleNextPress = useCallback(() => {
+ dispatch(gotoCalendarNextRange());
+ }, [dispatch]);
+
+ const title = useMemo(() => {
+ const timeMoment = moment(time);
+ const startMoment = moment(start);
+ const endMoment = moment(end);
+
+ if (view === 'day') {
+ return timeMoment.format(longDateFormat);
+ } else if (view === 'month') {
+ return timeMoment.format('MMMM YYYY');
+ } else if (view === 'agenda') {
+ return translate('Agenda');
+ }
+
+ let startFormat = 'MMM D YYYY';
+ let endFormat = 'MMM D YYYY';
+
+ if (startMoment.isSame(endMoment, 'month')) {
+ startFormat = 'MMM D';
+ endFormat = 'D YYYY';
+ } else if (startMoment.isSame(endMoment, 'year')) {
+ startFormat = 'MMM D';
+ endFormat = 'MMM D YYYY';
+ }
+
+ return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(
+ endFormat
+ )}`;
+ }, [time, start, end, view, longDateFormat]);
+
+ return (
+
+ {isSmallScreen ?
{title}
: null}
+
+
+
+
+
+
+
+
+
+
+
+
+ {translate('Today')}
+
+
+
+ {isSmallScreen ? null : (
+
{title}
+ )}
+
+
+ {isFetching ? (
+
+ ) : null}
+
+ {isLargeScreen ? (
+
+
+
+
+
+
+ {isSmallScreen ? null : (
+
+ {translate('Month')}
+
+ )}
+
+
+ {translate('Week')}
+
+
+
+ {translate('Forecast')}
+
+
+
+ {translate('Day')}
+
+
+
+ {translate('Agenda')}
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+export default CalendarHeader;
diff --git a/frontend/src/Calendar/Header/CalendarHeaderConnector.js b/frontend/src/Calendar/Header/CalendarHeaderConnector.js
deleted file mode 100644
index 616e48650c5..00000000000
--- a/frontend/src/Calendar/Header/CalendarHeaderConnector.js
+++ /dev/null
@@ -1,85 +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 { gotoCalendarNextRange, gotoCalendarPreviousRange, gotoCalendarToday, setCalendarView } from 'Store/Actions/calendarActions';
-import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
-import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
-import CalendarHeader from './CalendarHeader';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar,
- createDimensionsSelector(),
- createUISettingsSelector(),
- (calendar, dimensions, uiSettings) => {
- const result = _.pick(calendar, [
- 'isFetching',
- 'view',
- 'time',
- 'start',
- 'end'
- ]);
-
- result.isSmallScreen = dimensions.isSmallScreen;
- result.collapseViewButtons = dimensions.isLargeScreen;
- result.longDateFormat = uiSettings.longDateFormat;
-
- return result;
- }
- );
-}
-
-const mapDispatchToProps = {
- setCalendarView,
- gotoCalendarToday,
- gotoCalendarPreviousRange,
- gotoCalendarNextRange
-};
-
-class CalendarHeaderConnector extends Component {
-
- //
- // Listeners
-
- onViewChange = (view) => {
- this.props.setCalendarView({ view });
- };
-
- onTodayPress = () => {
- this.props.gotoCalendarToday();
- };
-
- onPreviousPress = () => {
- this.props.gotoCalendarPreviousRange();
- };
-
- onNextPress = () => {
- this.props.gotoCalendarNextRange();
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-CalendarHeaderConnector.propTypes = {
- setCalendarView: PropTypes.func.isRequired,
- gotoCalendarToday: PropTypes.func.isRequired,
- gotoCalendarPreviousRange: PropTypes.func.isRequired,
- gotoCalendarNextRange: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector);
diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js
deleted file mode 100644
index 98958af038f..00000000000
--- a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import * as calendarViews from 'Calendar/calendarViews';
-import Button from 'Components/Link/Button';
-import titleCase from 'Utilities/String/titleCase';
-// import styles from './CalendarHeaderViewButton.css';
-
-class CalendarHeaderViewButton extends Component {
-
- //
- // Listeners
-
- onPress = () => {
- this.props.onPress(this.props.view);
- };
-
- //
- // Render
-
- render() {
- const {
- view,
- selectedView,
- ...otherProps
- } = this.props;
-
- return (
-
- {titleCase(view)}
-
- );
- }
-}
-
-CalendarHeaderViewButton.propTypes = {
- view: PropTypes.oneOf(calendarViews.all).isRequired,
- selectedView: PropTypes.oneOf(calendarViews.all).isRequired,
- onPress: PropTypes.func.isRequired
-};
-
-export default CalendarHeaderViewButton;
diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx b/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx
new file mode 100644
index 00000000000..c9366f9ef87
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx
@@ -0,0 +1,34 @@
+import React, { useCallback } from 'react';
+import { CalendarView } from 'Calendar/calendarViews';
+import Button, { ButtonProps } from 'Components/Link/Button';
+import titleCase from 'Utilities/String/titleCase';
+
+interface CalendarHeaderViewButtonProps
+ extends Omit {
+ view: CalendarView;
+ selectedView: CalendarView;
+ onPress: (view: CalendarView) => void;
+}
+
+function CalendarHeaderViewButton({
+ view,
+ selectedView,
+ onPress,
+ ...otherProps
+}: CalendarHeaderViewButtonProps) {
+ const handlePress = useCallback(() => {
+ onPress(view);
+ }, [view, onPress]);
+
+ return (
+
+ {titleCase(view)}
+
+ );
+}
+
+export default CalendarHeaderViewButton;
diff --git a/frontend/src/Calendar/Legend/Legend.css.d.ts b/frontend/src/Calendar/Legend/Legend.css.d.ts
new file mode 100644
index 00000000000..19c0339b43e
--- /dev/null
+++ b/frontend/src/Calendar/Legend/Legend.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'legend': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.js
deleted file mode 100644
index ba0e2663a03..00000000000
--- a/frontend/src/Calendar/Legend/Legend.js
+++ /dev/null
@@ -1,144 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { icons, kinds } from 'Helpers/Props';
-import LegendIconItem from './LegendIconItem';
-import LegendItem from './LegendItem';
-import styles from './Legend.css';
-
-function Legend(props) {
- const {
- view,
- showFinaleIcon,
- showSpecialIcon,
- showCutoffUnmetIcon,
- fullColorEvents,
- colorImpairedMode
- } = props;
-
- const iconsToShow = [];
- const isAgendaView = view === 'agenda';
-
- if (showFinaleIcon) {
- iconsToShow.push(
-
- );
- }
-
- if (showSpecialIcon) {
- iconsToShow.push(
-
- );
- }
-
- if (showCutoffUnmetIcon) {
- iconsToShow.push(
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {iconsToShow[0]}
-
-
- {
- iconsToShow.length > 1 &&
-
- {iconsToShow[1]}
- {iconsToShow[2]}
-
- }
-
- );
-}
-
-Legend.propTypes = {
- view: PropTypes.string.isRequired,
- showFinaleIcon: PropTypes.bool.isRequired,
- showSpecialIcon: PropTypes.bool.isRequired,
- showCutoffUnmetIcon: PropTypes.bool.isRequired,
- fullColorEvents: PropTypes.bool.isRequired,
- colorImpairedMode: PropTypes.bool.isRequired
-};
-
-export default Legend;
diff --git a/frontend/src/Calendar/Legend/Legend.tsx b/frontend/src/Calendar/Legend/Legend.tsx
new file mode 100644
index 00000000000..b9887f856fd
--- /dev/null
+++ b/frontend/src/Calendar/Legend/Legend.tsx
@@ -0,0 +1,150 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import { icons, kinds } from 'Helpers/Props';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import translate from 'Utilities/String/translate';
+import LegendIconItem from './LegendIconItem';
+import LegendItem from './LegendItem';
+import styles from './Legend.css';
+
+function Legend() {
+ const view = useSelector((state: AppState) => state.calendar.view);
+ const {
+ showFinaleIcon,
+ showSpecialIcon,
+ showCutoffUnmetIcon,
+ fullColorEvents,
+ } = useSelector((state: AppState) => state.calendar.options);
+ const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
+
+ const iconsToShow = [];
+ const isAgendaView = view === 'agenda';
+
+ if (showFinaleIcon) {
+ iconsToShow.push(
+
+ );
+
+ iconsToShow.push(
+
+ );
+ }
+
+ if (showSpecialIcon) {
+ iconsToShow.push(
+
+ );
+ }
+
+ if (showCutoffUnmetIcon) {
+ iconsToShow.push(
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {iconsToShow[0]}
+
+
+ {iconsToShow.length > 1 ? (
+
+ {iconsToShow[1]}
+ {iconsToShow[2]}
+
+ ) : null}
+ {iconsToShow.length > 3 ?
{iconsToShow[3]}
: null}
+
+ );
+}
+
+export default Legend;
diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js
deleted file mode 100644
index 889b7a0024f..00000000000
--- a/frontend/src/Calendar/Legend/LegendConnector.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
-import Legend from './Legend';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar.options,
- (state) => state.calendar.view,
- createUISettingsSelector(),
- (calendarOptions, view, uiSettings) => {
- return {
- ...calendarOptions,
- view,
- colorImpairedMode: uiSettings.enableColorImpairedMode
- };
- }
- );
-}
-
-export default connect(createMapStateToProps)(Legend);
diff --git a/frontend/src/Calendar/Legend/LegendIconItem.css b/frontend/src/Calendar/Legend/LegendIconItem.css
index 01db0ba5a06..c6c12027d67 100644
--- a/frontend/src/Calendar/Legend/LegendIconItem.css
+++ b/frontend/src/Calendar/Legend/LegendIconItem.css
@@ -7,4 +7,8 @@
.icon {
margin-right: 5px;
+
+ &:global(.fullColorEvents) {
+ filter: var(--calendarFullColorFilter)
+ }
}
diff --git a/frontend/src/Calendar/Legend/LegendIconItem.css.d.ts b/frontend/src/Calendar/Legend/LegendIconItem.css.d.ts
new file mode 100644
index 00000000000..5d618d24b33
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendIconItem.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'icon': string;
+ 'legendIconItem': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js
deleted file mode 100644
index 5ce5f725be0..00000000000
--- a/frontend/src/Calendar/Legend/LegendIconItem.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Icon from 'Components/Icon';
-import styles from './LegendIconItem.css';
-
-function LegendIconItem(props) {
- const {
- name,
- icon,
- kind,
- darken,
- tooltip
- } = props;
-
- return (
-
-
-
- {name}
-
- );
-}
-
-LegendIconItem.propTypes = {
- name: PropTypes.string.isRequired,
- icon: PropTypes.object.isRequired,
- kind: PropTypes.string.isRequired,
- darken: PropTypes.bool.isRequired,
- tooltip: PropTypes.string.isRequired
-};
-
-LegendIconItem.defaultProps = {
- darken: false
-};
-
-export default LegendIconItem;
diff --git a/frontend/src/Calendar/Legend/LegendIconItem.tsx b/frontend/src/Calendar/Legend/LegendIconItem.tsx
new file mode 100644
index 00000000000..88a758c4495
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendIconItem.tsx
@@ -0,0 +1,33 @@
+import { FontAwesomeIconProps } from '@fortawesome/react-fontawesome';
+import classNames from 'classnames';
+import React from 'react';
+import Icon, { IconProps } from 'Components/Icon';
+import styles from './LegendIconItem.css';
+
+interface LegendIconItemProps extends Pick {
+ name: string;
+ fullColorEvents: boolean;
+ icon: FontAwesomeIconProps['icon'];
+ tooltip: string;
+}
+
+function LegendIconItem(props: LegendIconItemProps) {
+ const { name, fullColorEvents, icon, kind, tooltip } = props;
+
+ return (
+
+
+
+ {name}
+
+ );
+}
+
+export default LegendIconItem;
diff --git a/frontend/src/Calendar/Legend/LegendItem.css.d.ts b/frontend/src/Calendar/Legend/LegendItem.css.d.ts
new file mode 100644
index 00000000000..155e029c6e1
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendItem.css.d.ts
@@ -0,0 +1,14 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'downloaded': string;
+ 'downloading': string;
+ 'legendItem': string;
+ 'missing': string;
+ 'onAir': string;
+ 'premiere': string;
+ 'unaired': string;
+ 'unmonitored': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.js
deleted file mode 100644
index f0304b9e603..00000000000
--- a/frontend/src/Calendar/Legend/LegendItem.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import titleCase from 'Utilities/String/titleCase';
-import styles from './LegendItem.css';
-
-function LegendItem(props) {
- const {
- name,
- status,
- tooltip,
- isAgendaView,
- fullColorEvents,
- colorImpairedMode
- } = props;
-
- return (
-
- {name ? name : titleCase(status)}
-
- );
-}
-
-LegendItem.propTypes = {
- name: PropTypes.string,
- status: PropTypes.string.isRequired,
- tooltip: PropTypes.string.isRequired,
- isAgendaView: PropTypes.bool.isRequired,
- fullColorEvents: PropTypes.bool.isRequired,
- colorImpairedMode: PropTypes.bool.isRequired
-};
-
-export default LegendItem;
diff --git a/frontend/src/Calendar/Legend/LegendItem.tsx b/frontend/src/Calendar/Legend/LegendItem.tsx
new file mode 100644
index 00000000000..40466ab9dde
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendItem.tsx
@@ -0,0 +1,41 @@
+import classNames from 'classnames';
+import React from 'react';
+import { CalendarStatus } from 'typings/Calendar';
+import titleCase from 'Utilities/String/titleCase';
+import styles from './LegendItem.css';
+
+interface LegendItemProps {
+ name?: string;
+ status: CalendarStatus;
+ tooltip: string;
+ isAgendaView: boolean;
+ fullColorEvents: boolean;
+ colorImpairedMode: boolean;
+}
+
+function LegendItem(props: LegendItemProps) {
+ const {
+ name,
+ status,
+ tooltip,
+ isAgendaView,
+ fullColorEvents,
+ colorImpairedMode,
+ } = props;
+
+ return (
+
+ {name ? name : titleCase(status)}
+
+ );
+}
+
+export default LegendItem;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js
deleted file mode 100644
index b68c83f3011..00000000000
--- a/frontend/src/Calendar/Options/CalendarOptionsModal.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Modal from 'Components/Modal/Modal';
-import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector';
-
-function CalendarOptionsModal(props) {
- const {
- isOpen,
- onModalClose
- } = props;
-
- return (
-
-
-
- );
-}
-
-CalendarOptionsModal.propTypes = {
- isOpen: PropTypes.bool.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default CalendarOptionsModal;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.tsx b/frontend/src/Calendar/Options/CalendarOptionsModal.tsx
new file mode 100644
index 00000000000..ae782a684b6
--- /dev/null
+++ b/frontend/src/Calendar/Options/CalendarOptionsModal.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import CalendarOptionsModalContent from './CalendarOptionsModalContent';
+
+interface CalendarOptionsModalProps {
+ isOpen: boolean;
+ onModalClose: () => void;
+}
+
+function CalendarOptionsModal({
+ isOpen,
+ onModalClose,
+}: CalendarOptionsModalProps) {
+ return (
+
+
+
+ );
+}
+
+export default CalendarOptionsModal;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js
deleted file mode 100644
index b7e738e7223..00000000000
--- a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js
+++ /dev/null
@@ -1,275 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import FieldSet from 'Components/FieldSet';
-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 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 { firstDayOfWeekOptions, timeFormatOptions, weekColumnOptions } from 'Settings/UI/UISettings';
-
-class CalendarOptionsModalContent extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- const {
- firstDayOfWeek,
- calendarWeekColumnHeader,
- timeFormat,
- enableColorImpairedMode,
- fullColorEvents
- } = props;
-
- this.state = {
- firstDayOfWeek,
- calendarWeekColumnHeader,
- timeFormat,
- enableColorImpairedMode,
- fullColorEvents
- };
- }
-
- componentDidUpdate(prevProps) {
- const {
- firstDayOfWeek,
- calendarWeekColumnHeader,
- timeFormat,
- enableColorImpairedMode
- } = this.props;
-
- if (
- prevProps.firstDayOfWeek !== firstDayOfWeek ||
- prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader ||
- prevProps.timeFormat !== timeFormat ||
- prevProps.enableColorImpairedMode !== enableColorImpairedMode
- ) {
- this.setState({
- firstDayOfWeek,
- calendarWeekColumnHeader,
- timeFormat,
- enableColorImpairedMode
- });
- }
- }
-
- //
- // Listeners
-
- onOptionInputChange = ({ name, value }) => {
- const {
- dispatchSetCalendarOption
- } = this.props;
-
- dispatchSetCalendarOption({ [name]: value });
- };
-
- onGlobalInputChange = ({ name, value }) => {
- const {
- dispatchSaveUISettings
- } = this.props;
-
- const setting = { [name]: value };
-
- this.setState(setting, () => {
- dispatchSaveUISettings(setting);
- });
- };
-
- onLinkFocus = (event) => {
- event.target.select();
- };
-
- //
- // Render
-
- render() {
- const {
- collapseMultipleEpisodes,
- showEpisodeInformation,
- showFinaleIcon,
- showSpecialIcon,
- showCutoffUnmetIcon,
- fullColorEvents,
- onModalClose
- } = this.props;
-
- const {
- firstDayOfWeek,
- calendarWeekColumnHeader,
- timeFormat,
- enableColorImpairedMode
- } = this.state;
-
- return (
-
-
- Calendar Options
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Close
-
-
-
- );
- }
-}
-
-CalendarOptionsModalContent.propTypes = {
- collapseMultipleEpisodes: PropTypes.bool.isRequired,
- showEpisodeInformation: PropTypes.bool.isRequired,
- showFinaleIcon: PropTypes.bool.isRequired,
- showSpecialIcon: PropTypes.bool.isRequired,
- showCutoffUnmetIcon: PropTypes.bool.isRequired,
- firstDayOfWeek: PropTypes.number.isRequired,
- calendarWeekColumnHeader: PropTypes.string.isRequired,
- timeFormat: PropTypes.string.isRequired,
- enableColorImpairedMode: PropTypes.bool.isRequired,
- fullColorEvents: PropTypes.bool.isRequired,
- dispatchSetCalendarOption: PropTypes.func.isRequired,
- dispatchSaveUISettings: PropTypes.func.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default CalendarOptionsModalContent;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx
new file mode 100644
index 00000000000..4f974dda36d
--- /dev/null
+++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx
@@ -0,0 +1,228 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import FieldSet from 'Components/FieldSet';
+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 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 {
+ firstDayOfWeekOptions,
+ timeFormatOptions,
+ weekColumnOptions,
+} from 'Settings/UI/UISettings';
+import { setCalendarOption } from 'Store/Actions/calendarActions';
+import { saveUISettings } from 'Store/Actions/settingsActions';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import { InputChanged } from 'typings/inputs';
+import UiSettings from 'typings/Settings/UiSettings';
+import translate from 'Utilities/String/translate';
+
+interface CalendarOptionsModalContentProps {
+ onModalClose: () => void;
+}
+
+function CalendarOptionsModalContent({
+ onModalClose,
+}: CalendarOptionsModalContentProps) {
+ const dispatch = useDispatch();
+
+ const {
+ collapseMultipleEpisodes,
+ showEpisodeInformation,
+ showFinaleIcon,
+ showSpecialIcon,
+ showCutoffUnmetIcon,
+ fullColorEvents,
+ } = useSelector((state: AppState) => state.calendar.options);
+
+ const uiSettings = useSelector(createUISettingsSelector());
+
+ const [state, setState] = useState>({
+ firstDayOfWeek: uiSettings.firstDayOfWeek,
+ calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
+ timeFormat: uiSettings.timeFormat,
+ enableColorImpairedMode: uiSettings.enableColorImpairedMode,
+ });
+
+ const {
+ firstDayOfWeek,
+ calendarWeekColumnHeader,
+ timeFormat,
+ enableColorImpairedMode,
+ } = state;
+
+ const handleOptionInputChange = useCallback(
+ ({ name, value }: InputChanged) => {
+ dispatch(setCalendarOption({ [name]: value }));
+ },
+ [dispatch]
+ );
+
+ const handleGlobalInputChange = useCallback(
+ ({ name, value }: InputChanged) => {
+ setState((prevState) => ({ ...prevState, [name]: value }));
+
+ dispatch(saveUISettings({ [name]: value }));
+ },
+ [dispatch]
+ );
+
+ useEffect(() => {
+ setState({
+ firstDayOfWeek: uiSettings.firstDayOfWeek,
+ calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
+ timeFormat: uiSettings.timeFormat,
+ enableColorImpairedMode: uiSettings.enableColorImpairedMode,
+ });
+ }, [uiSettings]);
+
+ return (
+
+ {translate('CalendarOptions')}
+
+
+
+
+
+
+
+
+
+
+
+
+ {translate('Close')}
+
+
+ );
+}
+
+export default CalendarOptionsModalContent;
diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js
deleted file mode 100644
index 1f517b69898..00000000000
--- a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { setCalendarOption } from 'Store/Actions/calendarActions';
-import { saveUISettings } from 'Store/Actions/settingsActions';
-import CalendarOptionsModalContent from './CalendarOptionsModalContent';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.calendar.options,
- (state) => state.settings.ui.item,
- (options, uiSettings) => {
- return {
- ...options,
- ...uiSettings
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- dispatchSetCalendarOption: setCalendarOption,
- dispatchSaveUISettings: saveUISettings
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent);
diff --git a/frontend/src/Calendar/calendarViews.js b/frontend/src/Calendar/calendarViews.js
deleted file mode 100644
index 929958b66ca..00000000000
--- a/frontend/src/Calendar/calendarViews.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export const DAY = 'day';
-export const WEEK = 'week';
-export const MONTH = 'month';
-export const FORECAST = 'forecast';
-export const AGENDA = 'agenda';
-
-export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA];
diff --git a/frontend/src/Calendar/calendarViews.ts b/frontend/src/Calendar/calendarViews.ts
new file mode 100644
index 00000000000..4f5549dbd1b
--- /dev/null
+++ b/frontend/src/Calendar/calendarViews.ts
@@ -0,0 +1,9 @@
+export const DAY = 'day';
+export const WEEK = 'week';
+export const MONTH = 'month';
+export const FORECAST = 'forecast';
+export const AGENDA = 'agenda';
+
+export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA];
+
+export type CalendarView = 'agenda' | 'day' | 'forecast' | 'month' | 'week';
diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.js
deleted file mode 100644
index b149a8aab46..00000000000
--- a/frontend/src/Calendar/getStatusStyle.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/* eslint max-params: 0 */
-import moment from 'moment';
-
-function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) {
- const currentTime = moment();
-
- if (hasFile) {
- return 'downloaded';
- }
-
- if (downloading) {
- return 'downloading';
- }
-
- if (!isMonitored) {
- return 'unmonitored';
- }
-
- if (currentTime.isAfter(startTime) && currentTime.isBefore(endTime)) {
- return 'onAir';
- }
-
- if (endTime.isBefore(currentTime) && !hasFile) {
- return 'missing';
- }
-
- return 'unaired';
-}
-
-export default getStatusStyle;
diff --git a/frontend/src/Calendar/getStatusStyle.ts b/frontend/src/Calendar/getStatusStyle.ts
new file mode 100644
index 00000000000..678e6c2a1b5
--- /dev/null
+++ b/frontend/src/Calendar/getStatusStyle.ts
@@ -0,0 +1,36 @@
+import moment from 'moment';
+import { CalendarStatus } from 'typings/Calendar';
+
+function getStatusStyle(
+ hasFile: boolean,
+ downloading: boolean,
+ startTime: moment.Moment,
+ endTime: moment.Moment,
+ isMonitored: boolean
+): CalendarStatus {
+ const currentTime = moment();
+
+ if (hasFile) {
+ return 'downloaded';
+ }
+
+ if (downloading) {
+ return 'downloading';
+ }
+
+ if (!isMonitored) {
+ return 'unmonitored';
+ }
+
+ if (currentTime.isAfter(startTime) && currentTime.isBefore(endTime)) {
+ return 'onAir';
+ }
+
+ if (endTime.isBefore(currentTime) && !hasFile) {
+ return 'missing';
+ }
+
+ return 'unaired';
+}
+
+export default getStatusStyle;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js
deleted file mode 100644
index 8cc487c1622..00000000000
--- a/frontend/src/Calendar/iCal/CalendarLinkModal.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Modal from 'Components/Modal/Modal';
-import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector';
-
-function CalendarLinkModal(props) {
- const {
- isOpen,
- onModalClose
- } = props;
-
- return (
-
-
-
- );
-}
-
-CalendarLinkModal.propTypes = {
- isOpen: PropTypes.bool.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default CalendarLinkModal;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.tsx b/frontend/src/Calendar/iCal/CalendarLinkModal.tsx
new file mode 100644
index 00000000000..f0eecbd4a47
--- /dev/null
+++ b/frontend/src/Calendar/iCal/CalendarLinkModal.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import CalendarLinkModalContent from './CalendarLinkModalContent';
+
+interface CalendarLinkModalProps {
+ isOpen: boolean;
+ onModalClose: () => void;
+}
+
+function CalendarLinkModal(props: CalendarLinkModalProps) {
+ const { isOpen, onModalClose } = props;
+
+ return (
+
+
+
+ );
+}
+
+export default CalendarLinkModal;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js
deleted file mode 100644
index 2df0caf6d13..00000000000
--- a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js
+++ /dev/null
@@ -1,221 +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 FormInputButton from 'Components/Form/FormInputButton';
-import FormInputGroup from 'Components/Form/FormInputGroup';
-import FormLabel from 'Components/Form/FormLabel';
-import Icon from 'Components/Icon';
-import Button from 'Components/Link/Button';
-import ClipboardButton from 'Components/Link/ClipboardButton';
-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 { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
-
-function getUrls(state) {
- const {
- unmonitored,
- premieresOnly,
- asAllDay,
- tags
- } = state;
-
- let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
-
- if (unmonitored) {
- icalUrl += 'unmonitored=true&';
- }
-
- if (premieresOnly) {
- icalUrl += 'premieresOnly=true&';
- }
-
- if (asAllDay) {
- icalUrl += 'asAllDay=true&';
- }
-
- if (tags.length) {
- icalUrl += `tags=${tags.toString()}&`;
- }
-
- icalUrl += `apikey=${window.Sonarr.apiKey}`;
-
- const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`;
- const iCalWebCalUrl = `webcal://${icalUrl}`;
-
- return {
- iCalHttpUrl,
- iCalWebCalUrl
- };
-}
-
-class CalendarLinkModalContent extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- const defaultState = {
- unmonitored: false,
- premieresOnly: false,
- asAllDay: false,
- tags: []
- };
-
- const urls = getUrls(defaultState);
-
- this.state = {
- ...defaultState,
- ...urls
- };
- }
-
- //
- // Listeners
-
- onInputChange = ({ name, value }) => {
- const state = {
- ...this.state,
- [name]: value
- };
-
- const urls = getUrls(state);
-
- this.setState({
- [name]: value,
- ...urls
- });
- };
-
- onLinkFocus = (event) => {
- event.target.select();
- };
-
- //
- // Render
-
- render() {
- const {
- onModalClose
- } = this.props;
-
- const {
- unmonitored,
- premieresOnly,
- asAllDay,
- tags,
- iCalHttpUrl,
- iCalWebCalUrl
- } = this.state;
-
- return (
-
-
- Sonarr Calendar Feed
-
-
-
-
-
-
-
-
- Close
-
-
-
- );
- }
-}
-
-CalendarLinkModalContent.propTypes = {
- tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default CalendarLinkModalContent;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx b/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx
new file mode 100644
index 00000000000..aa90db30137
--- /dev/null
+++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx
@@ -0,0 +1,166 @@
+import React, { FocusEvent, useCallback, useMemo, useState } from 'react';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormInputButton from 'Components/Form/FormInputButton';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import Icon from 'Components/Icon';
+import Button from 'Components/Link/Button';
+import ClipboardButton from 'Components/Link/ClipboardButton';
+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 { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
+import { InputChanged } from 'typings/inputs';
+import translate from 'Utilities/String/translate';
+
+interface CalendarLinkModalContentProps {
+ onModalClose: () => void;
+}
+
+function CalendarLinkModalContent({
+ onModalClose,
+}: CalendarLinkModalContentProps) {
+ const [state, setState] = useState({
+ unmonitored: false,
+ premieresOnly: false,
+ asAllDay: false,
+ tags: [],
+ });
+
+ const { unmonitored, premieresOnly, asAllDay, tags } = state;
+
+ const handleInputChange = useCallback(({ name, value }: InputChanged) => {
+ setState((prevState) => ({ ...prevState, [name]: value }));
+ }, []);
+
+ const handleLinkFocus = useCallback(
+ (event: FocusEvent) => {
+ event.target.select();
+ },
+ []
+ );
+
+ const { iCalHttpUrl, iCalWebCalUrl } = useMemo(() => {
+ let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
+
+ if (unmonitored) {
+ icalUrl += 'unmonitored=true&';
+ }
+
+ if (premieresOnly) {
+ icalUrl += 'premieresOnly=true&';
+ }
+
+ if (asAllDay) {
+ icalUrl += 'asAllDay=true&';
+ }
+
+ if (tags.length) {
+ icalUrl += `tags=${tags.toString()}&`;
+ }
+
+ icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`;
+
+ return {
+ iCalHttpUrl: `${window.location.protocol}//${icalUrl}`,
+ iCalWebCalUrl: `webcal://${icalUrl}`,
+ };
+ }, [unmonitored, premieresOnly, asAllDay, tags]);
+
+ return (
+
+ {translate('CalendarFeed')}
+
+
+
+
+
+
+ {translate('Close')}
+
+
+ );
+}
+
+export default CalendarLinkModalContent;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js
deleted file mode 100644
index e10c5c3f909..00000000000
--- a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import createTagsSelector from 'Store/Selectors/createTagsSelector';
-import CalendarLinkModalContent from './CalendarLinkModalContent';
-
-function createMapStateToProps() {
- return createSelector(
- createTagsSelector(),
- (tagList) => {
- return {
- tagList
- };
- }
- );
-}
-
-export default connect(createMapStateToProps)(CalendarLinkModalContent);
diff --git a/frontend/src/Commands/Command.ts b/frontend/src/Commands/Command.ts
new file mode 100644
index 00000000000..cd875d56b28
--- /dev/null
+++ b/frontend/src/Commands/Command.ts
@@ -0,0 +1,52 @@
+import ModelBase from 'App/ModelBase';
+
+export type CommandStatus =
+ | 'queued'
+ | 'started'
+ | 'completed'
+ | 'failed'
+ | 'aborted'
+ | 'cancelled'
+ | 'orphaned';
+
+export type CommandResult = 'unknown' | 'successful' | 'unsuccessful';
+
+export interface CommandBody {
+ sendUpdatesToClient: boolean;
+ updateScheduledTask: boolean;
+ completionMessage: string;
+ requiresDiskAccess: boolean;
+ isExclusive: boolean;
+ isLongRunning: boolean;
+ name: string;
+ lastExecutionTime: string;
+ lastStartTime: string;
+ trigger: string;
+ suppressMessages: boolean;
+ seriesId?: number;
+ seriesIds?: number[];
+ seasonNumber?: number;
+ episodeIds?: number[];
+ [key: string]: string | number | boolean | number[] | undefined;
+}
+
+interface Command extends ModelBase {
+ name: string;
+ commandName: string;
+ message: string;
+ body: CommandBody;
+ priority: string;
+ status: CommandStatus;
+ result: CommandResult;
+ queued: string;
+ started: string;
+ ended: string;
+ duration: string;
+ trigger: string;
+ stateChangeTime: string;
+ sendUpdatesToClient: boolean;
+ updateScheduledTask: boolean;
+ lastExecutionTime: string;
+}
+
+export default Command;
diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js
index c2edf05bd4a..13ac9d62c1f 100644
--- a/frontend/src/Commands/commandNames.js
+++ b/frontend/src/Commands/commandNames.js
@@ -6,7 +6,7 @@ export const CLEAR_LOGS = 'ClearLog';
export const CUTOFF_UNMET_EPISODE_SEARCH = 'CutoffUnmetEpisodeSearch';
export const DELETE_LOG_FILES = 'DeleteLogFiles';
export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles';
-export const DOWNLOADED_EPSIODES_SCAN = 'DownloadedEpisodesScan';
+export const DOWNLOADED_EPISODES_SCAN = 'DownloadedEpisodesScan';
export const EPISODE_SEARCH = 'EpisodeSearch';
export const INTERACTIVE_IMPORT = 'ManualImport';
export const MISSING_EPISODE_SEARCH = 'MissingEpisodeSearch';
diff --git a/frontend/src/Components/Alert.css.d.ts b/frontend/src/Components/Alert.css.d.ts
new file mode 100644
index 00000000000..daffec2e634
--- /dev/null
+++ b/frontend/src/Components/Alert.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'alert': string;
+ 'danger': string;
+ 'info': string;
+ 'success': string;
+ 'warning': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js
deleted file mode 100644
index 10f124c7856..00000000000
--- a/frontend/src/Components/Alert.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { kinds } from 'Helpers/Props';
-import styles from './Alert.css';
-
-function Alert({ className, kind, children, ...otherProps }) {
- return (
-
- {children}
-
- );
-}
-
-Alert.propTypes = {
- className: PropTypes.string.isRequired,
- kind: PropTypes.oneOf(kinds.all).isRequired,
- children: PropTypes.node.isRequired
-};
-
-Alert.defaultProps = {
- className: styles.alert,
- kind: kinds.INFO
-};
-
-export default Alert;
diff --git a/frontend/src/Components/Alert.tsx b/frontend/src/Components/Alert.tsx
new file mode 100644
index 00000000000..92c89e74134
--- /dev/null
+++ b/frontend/src/Components/Alert.tsx
@@ -0,0 +1,18 @@
+import classNames from 'classnames';
+import React from 'react';
+import { Kind } from 'Helpers/Props/kinds';
+import styles from './Alert.css';
+
+interface AlertProps {
+ className?: string;
+ kind?: Extract;
+ children: React.ReactNode;
+}
+
+function Alert(props: AlertProps) {
+ const { className = styles.alert, kind = 'info', children } = props;
+
+ return {children}
;
+}
+
+export default Alert;
diff --git a/frontend/src/Components/Card.css.d.ts b/frontend/src/Components/Card.css.d.ts
new file mode 100644
index 00000000000..fb3ea792e54
--- /dev/null
+++ b/frontend/src/Components/Card.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'card': string;
+ 'overlay': string;
+ 'underlay': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js
deleted file mode 100644
index c5a4d164c14..00000000000
--- a/frontend/src/Components/Card.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import Link from 'Components/Link/Link';
-import styles from './Card.css';
-
-class Card extends Component {
-
- //
- // Render
-
- render() {
- const {
- className,
- overlayClassName,
- overlayContent,
- children,
- onPress
- } = this.props;
-
- if (overlayContent) {
- return (
-
- );
- }
-
- return (
-
- {children}
-
- );
- }
-}
-
-Card.propTypes = {
- className: PropTypes.string.isRequired,
- overlayClassName: PropTypes.string.isRequired,
- overlayContent: PropTypes.bool.isRequired,
- children: PropTypes.node.isRequired,
- onPress: PropTypes.func.isRequired
-};
-
-Card.defaultProps = {
- className: styles.card,
- overlayClassName: styles.overlay,
- overlayContent: false
-};
-
-export default Card;
diff --git a/frontend/src/Components/Card.tsx b/frontend/src/Components/Card.tsx
new file mode 100644
index 00000000000..24588c841c7
--- /dev/null
+++ b/frontend/src/Components/Card.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import Link, { LinkProps } from 'Components/Link/Link';
+import styles from './Card.css';
+
+interface CardProps extends Pick {
+ // TODO: Consider using different properties for classname depending if it's overlaying content or not
+ className?: string;
+ overlayClassName?: string;
+ overlayContent?: boolean;
+ children: React.ReactNode;
+}
+
+function Card(props: CardProps) {
+ const {
+ className = styles.card,
+ overlayClassName = styles.overlay,
+ overlayContent = false,
+ children,
+ onPress,
+ } = props;
+
+ if (overlayContent) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default Card;
diff --git a/frontend/src/Components/CircularProgressBar.css.d.ts b/frontend/src/Components/CircularProgressBar.css.d.ts
new file mode 100644
index 00000000000..45179620cbf
--- /dev/null
+++ b/frontend/src/Components/CircularProgressBar.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'circularProgressBar': string;
+ 'circularProgressBarContainer': string;
+ 'circularProgressBarText': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/CircularProgressBar.js b/frontend/src/Components/CircularProgressBar.js
deleted file mode 100644
index 3af5665a95e..00000000000
--- a/frontend/src/Components/CircularProgressBar.js
+++ /dev/null
@@ -1,138 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import styles from './CircularProgressBar.css';
-
-class CircularProgressBar extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- progress: 0
- };
- }
-
- componentDidMount() {
- this._progressStep();
- }
-
- componentDidUpdate(prevProps) {
- const progress = this.props.progress;
-
- if (prevProps.progress !== progress) {
- this._cancelProgressStep();
- this._progressStep();
- }
- }
-
- componentWillUnmount() {
- this._cancelProgressStep();
- }
-
- //
- // Control
-
- _progressStep() {
- this.requestAnimationFrame = window.requestAnimationFrame(() => {
- this.setState({
- progress: this.state.progress + 1
- }, () => {
- if (this.state.progress < this.props.progress) {
- this._progressStep();
- }
- });
- });
- }
-
- _cancelProgressStep() {
- if (this.requestAnimationFrame) {
- window.cancelAnimationFrame(this.requestAnimationFrame);
- }
- }
-
- //
- // Render
-
- render() {
- const {
- className,
- containerClassName,
- size,
- strokeWidth,
- strokeColor,
- showProgressText
- } = this.props;
-
- const progress = this.state.progress;
-
- const center = size / 2;
- const radius = center - strokeWidth;
- const circumference = Math.PI * (radius * 2);
- const sizeInPixels = `${size}px`;
- const strokeDashoffset = ((100 - progress) / 100) * circumference;
- const progressText = `${Math.round(progress)}%`;
-
- return (
-
-
-
-
-
- {
- showProgressText &&
-
- {progressText}
-
- }
-
- );
- }
-}
-
-CircularProgressBar.propTypes = {
- className: PropTypes.string,
- containerClassName: PropTypes.string,
- size: PropTypes.number,
- progress: PropTypes.number.isRequired,
- strokeWidth: PropTypes.number,
- strokeColor: PropTypes.string,
- showProgressText: PropTypes.bool
-};
-
-CircularProgressBar.defaultProps = {
- className: styles.circularProgressBar,
- containerClassName: styles.circularProgressBarContainer,
- size: 60,
- strokeWidth: 5,
- strokeColor: '#35c5f4',
- showProgressText: false
-};
-
-export default CircularProgressBar;
diff --git a/frontend/src/Components/CircularProgressBar.tsx b/frontend/src/Components/CircularProgressBar.tsx
new file mode 100644
index 00000000000..b14f5fc6aa9
--- /dev/null
+++ b/frontend/src/Components/CircularProgressBar.tsx
@@ -0,0 +1,99 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import styles from './CircularProgressBar.css';
+
+interface CircularProgressBarProps {
+ className?: string;
+ containerClassName?: string;
+ size?: number;
+ progress: number;
+ strokeWidth?: number;
+ strokeColor?: string;
+ showProgressText?: boolean;
+}
+
+function CircularProgressBar({
+ className = styles.circularProgressBar,
+ containerClassName = styles.circularProgressBarContainer,
+ size = 60,
+ strokeWidth = 5,
+ strokeColor = '#35c5f4',
+ showProgressText = false,
+ progress,
+}: CircularProgressBarProps) {
+ const [currentProgress, setCurrentProgress] = useState(0);
+ const raf = React.useRef(0);
+ const center = size / 2;
+ const radius = center - strokeWidth;
+ const circumference = Math.PI * (radius * 2);
+ const sizeInPixels = `${size}px`;
+ const strokeDashoffset = ((100 - currentProgress) / 100) * circumference;
+ const progressText = `${Math.round(currentProgress)}%`;
+
+ const handleAnimation = useCallback(
+ (p: number) => {
+ setCurrentProgress((prevProgress) => {
+ if (prevProgress < p) {
+ return prevProgress + Math.min(1, p - prevProgress);
+ }
+
+ return prevProgress;
+ });
+ },
+ [setCurrentProgress]
+ );
+
+ useEffect(() => {
+ if (progress > currentProgress) {
+ cancelAnimationFrame(raf.current);
+
+ raf.current = requestAnimationFrame(() => handleAnimation(progress));
+ }
+ }, [progress, currentProgress, handleAnimation]);
+
+ useEffect(
+ () => {
+ return () => cancelAnimationFrame(raf.current);
+ },
+ // We only want to run this effect once
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ );
+
+ return (
+
+
+
+
+
+ {showProgressText && (
+
{progressText}
+ )}
+
+ );
+}
+
+export default CircularProgressBar;
diff --git a/frontend/src/Components/DescriptionList/DescriptionList.css.d.ts b/frontend/src/Components/DescriptionList/DescriptionList.css.d.ts
new file mode 100644
index 00000000000..34c1578a48d
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionList.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'descriptionList': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js
deleted file mode 100644
index be2c87c550c..00000000000
--- a/frontend/src/Components/DescriptionList/DescriptionList.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import styles from './DescriptionList.css';
-
-class DescriptionList extends Component {
-
- //
- // Render
-
- render() {
- const {
- className,
- children
- } = this.props;
-
- return (
-
- {children}
-
- );
- }
-}
-
-DescriptionList.propTypes = {
- className: PropTypes.string.isRequired,
- children: PropTypes.node
-};
-
-DescriptionList.defaultProps = {
- className: styles.descriptionList
-};
-
-export default DescriptionList;
diff --git a/frontend/src/Components/DescriptionList/DescriptionList.tsx b/frontend/src/Components/DescriptionList/DescriptionList.tsx
new file mode 100644
index 00000000000..6deee77e5e9
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionList.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import styles from './DescriptionList.css';
+
+interface DescriptionListProps {
+ className?: string;
+ children?: React.ReactNode;
+}
+
+function DescriptionList(props: DescriptionListProps) {
+ const { className = styles.descriptionList, children } = props;
+
+ return {children} ;
+}
+
+export default DescriptionList;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js
deleted file mode 100644
index 39f634cc95d..00000000000
--- a/frontend/src/Components/DescriptionList/DescriptionListItem.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import DescriptionListItemDescription from './DescriptionListItemDescription';
-import DescriptionListItemTitle from './DescriptionListItemTitle';
-
-class DescriptionListItem extends Component {
-
- //
- // Render
-
- render() {
- const {
- titleClassName,
- descriptionClassName,
- title,
- data
- } = this.props;
-
- return (
-
-
- {title}
-
-
-
- {data}
-
-
- );
- }
-}
-
-DescriptionListItem.propTypes = {
- titleClassName: PropTypes.string,
- descriptionClassName: PropTypes.string,
- title: PropTypes.string,
- data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
-};
-
-export default DescriptionListItem;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.tsx b/frontend/src/Components/DescriptionList/DescriptionListItem.tsx
new file mode 100644
index 00000000000..13a7efdd035
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItem.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import DescriptionListItemDescription, {
+ DescriptionListItemDescriptionProps,
+} from './DescriptionListItemDescription';
+import DescriptionListItemTitle, {
+ DescriptionListItemTitleProps,
+} from './DescriptionListItemTitle';
+
+interface DescriptionListItemProps {
+ className?: string;
+ titleClassName?: DescriptionListItemTitleProps['className'];
+ descriptionClassName?: DescriptionListItemDescriptionProps['className'];
+ title?: DescriptionListItemTitleProps['children'];
+ data?: DescriptionListItemDescriptionProps['children'];
+}
+
+function DescriptionListItem(props: DescriptionListItemProps) {
+ const { className, titleClassName, descriptionClassName, title, data } =
+ props;
+
+ return (
+
+
+ {title}
+
+
+
+ {data}
+
+
+ );
+}
+
+export default DescriptionListItem;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css
index b23415a76d5..786123fb7a4 100644
--- a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css
@@ -1,9 +1,7 @@
-.description {
- line-height: $lineHeight;
-}
-
.description {
margin-left: 0;
+ line-height: $lineHeight;
+ overflow-wrap: break-word;
}
@media (min-width: 768px) {
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css.d.ts b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css.d.ts
new file mode 100644
index 00000000000..ff7055b0f08
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'description': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js
deleted file mode 100644
index 4ef3c015e66..00000000000
--- a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import styles from './DescriptionListItemDescription.css';
-
-function DescriptionListItemDescription(props) {
- const {
- className,
- children
- } = props;
-
- return (
-
- {children}
-
- );
-}
-
-DescriptionListItemDescription.propTypes = {
- className: PropTypes.string.isRequired,
- children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
-};
-
-DescriptionListItemDescription.defaultProps = {
- className: styles.description
-};
-
-export default DescriptionListItemDescription;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx
new file mode 100644
index 00000000000..e08c117dc84
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx
@@ -0,0 +1,17 @@
+import React, { ReactNode } from 'react';
+import styles from './DescriptionListItemDescription.css';
+
+export interface DescriptionListItemDescriptionProps {
+ className?: string;
+ children?: ReactNode;
+}
+
+function DescriptionListItemDescription(
+ props: DescriptionListItemDescriptionProps
+) {
+ const { className = styles.description, children } = props;
+
+ return {children} ;
+}
+
+export default DescriptionListItemDescription;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css.d.ts b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css.d.ts
new file mode 100644
index 00000000000..86bceec0622
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'title': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js
deleted file mode 100644
index e1632c1cfef..00000000000
--- a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import styles from './DescriptionListItemTitle.css';
-
-function DescriptionListItemTitle(props) {
- const {
- className,
- children
- } = props;
-
- return (
-
- {children}
-
- );
-}
-
-DescriptionListItemTitle.propTypes = {
- className: PropTypes.string.isRequired,
- children: PropTypes.string
-};
-
-DescriptionListItemTitle.defaultProps = {
- className: styles.title
-};
-
-export default DescriptionListItemTitle;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx
new file mode 100644
index 00000000000..59ea6955c05
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx
@@ -0,0 +1,15 @@
+import React, { ReactNode } from 'react';
+import styles from './DescriptionListItemTitle.css';
+
+export interface DescriptionListItemTitleProps {
+ className?: string;
+ children?: ReactNode;
+}
+
+function DescriptionListItemTitle(props: DescriptionListItemTitleProps) {
+ const { className = styles.title, children } = props;
+
+ return {children} ;
+}
+
+export default DescriptionListItemTitle;
diff --git a/frontend/src/Components/DragPreviewLayer.css.d.ts b/frontend/src/Components/DragPreviewLayer.css.d.ts
new file mode 100644
index 00000000000..6944a829d15
--- /dev/null
+++ b/frontend/src/Components/DragPreviewLayer.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'dragLayer': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/DragPreviewLayer.js b/frontend/src/Components/DragPreviewLayer.js
deleted file mode 100644
index a111df70e4c..00000000000
--- a/frontend/src/Components/DragPreviewLayer.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import styles from './DragPreviewLayer.css';
-
-function DragPreviewLayer({ children, ...otherProps }) {
- return (
-
- {children}
-
- );
-}
-
-DragPreviewLayer.propTypes = {
- children: PropTypes.node,
- className: PropTypes.string
-};
-
-DragPreviewLayer.defaultProps = {
- className: styles.dragLayer
-};
-
-export default DragPreviewLayer;
diff --git a/frontend/src/Components/DragPreviewLayer.tsx b/frontend/src/Components/DragPreviewLayer.tsx
new file mode 100644
index 00000000000..2e578504bc8
--- /dev/null
+++ b/frontend/src/Components/DragPreviewLayer.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import styles from './DragPreviewLayer.css';
+
+interface DragPreviewLayerProps {
+ className?: string;
+ children?: React.ReactNode;
+}
+
+function DragPreviewLayer({
+ className = styles.dragLayer,
+ children,
+ ...otherProps
+}: DragPreviewLayerProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default DragPreviewLayer;
diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js
deleted file mode 100644
index 88412ad19a9..00000000000
--- a/frontend/src/Components/Error/ErrorBoundary.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import * as sentry from '@sentry/browser';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-
-class ErrorBoundary extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- error: null,
- info: null
- };
- }
-
- componentDidCatch(error, info) {
- this.setState({
- error,
- info
- });
-
- sentry.captureException(error);
- }
-
- //
- // Render
-
- render() {
- const {
- children,
- errorComponent: ErrorComponent,
- ...otherProps
- } = this.props;
-
- const {
- error,
- info
- } = this.state;
-
- if (error) {
- return (
-
- );
- }
-
- return children;
- }
-}
-
-ErrorBoundary.propTypes = {
- children: PropTypes.node.isRequired,
- errorComponent: PropTypes.elementType.isRequired
-};
-
-export default ErrorBoundary;
diff --git a/frontend/src/Components/Error/ErrorBoundary.tsx b/frontend/src/Components/Error/ErrorBoundary.tsx
new file mode 100644
index 00000000000..6b27f7a093d
--- /dev/null
+++ b/frontend/src/Components/Error/ErrorBoundary.tsx
@@ -0,0 +1,46 @@
+import * as sentry from '@sentry/browser';
+import React, { Component, ErrorInfo } from 'react';
+
+interface ErrorBoundaryProps {
+ children: React.ReactNode;
+ errorComponent: React.ElementType;
+}
+
+interface ErrorBoundaryState {
+ error: Error | null;
+ info: ErrorInfo | null;
+}
+
+// Class component until componentDidCatch is supported in functional components
+class ErrorBoundary extends Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+
+ this.state = {
+ error: null,
+ info: null,
+ };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ this.setState({
+ error,
+ info,
+ });
+
+ sentry.captureException(error);
+ }
+
+ render() {
+ const { children, errorComponent: ErrorComponent } = this.props;
+ const { error, info } = this.state;
+
+ if (error) {
+ return ;
+ }
+
+ return children;
+ }
+}
+
+export default ErrorBoundary;
diff --git a/frontend/src/Components/Error/ErrorBoundaryError.css b/frontend/src/Components/Error/ErrorBoundaryError.css
index b6d1f917e64..3e7a0430278 100644
--- a/frontend/src/Components/Error/ErrorBoundaryError.css
+++ b/frontend/src/Components/Error/ErrorBoundaryError.css
@@ -25,6 +25,10 @@
white-space: pre-wrap;
}
+.version {
+ margin-top: 20px;
+}
+
@media only screen and (max-width: $breakpointMedium) {
.image {
height: 250px;
diff --git a/frontend/src/Components/Error/ErrorBoundaryError.css.d.ts b/frontend/src/Components/Error/ErrorBoundaryError.css.d.ts
new file mode 100644
index 00000000000..e19fd804dcb
--- /dev/null
+++ b/frontend/src/Components/Error/ErrorBoundaryError.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'container': string;
+ 'details': string;
+ 'image': string;
+ 'imageContainer': string;
+ 'message': string;
+ 'version': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Error/ErrorBoundaryError.js b/frontend/src/Components/Error/ErrorBoundaryError.js
deleted file mode 100644
index e0181db9634..00000000000
--- a/frontend/src/Components/Error/ErrorBoundaryError.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import styles from './ErrorBoundaryError.css';
-
-function ErrorBoundaryError(props) {
- const {
- className,
- messageClassName,
- detailsClassName,
- message,
- error,
- info
- } = props;
-
- return (
-
-
- {message}
-
-
-
-
-
-
-
- {
- error &&
-
- {error.toString()}
-
- }
-
-
- {info.componentStack}
-
-
-
- );
-}
-
-ErrorBoundaryError.propTypes = {
- className: PropTypes.string.isRequired,
- messageClassName: PropTypes.string.isRequired,
- detailsClassName: PropTypes.string.isRequired,
- message: PropTypes.string.isRequired,
- error: PropTypes.object.isRequired,
- info: PropTypes.object.isRequired
-};
-
-ErrorBoundaryError.defaultProps = {
- className: styles.container,
- messageClassName: styles.message,
- detailsClassName: styles.details,
- message: 'There was an error loading this content'
-};
-
-export default ErrorBoundaryError;
diff --git a/frontend/src/Components/Error/ErrorBoundaryError.tsx b/frontend/src/Components/Error/ErrorBoundaryError.tsx
new file mode 100644
index 00000000000..870b280589e
--- /dev/null
+++ b/frontend/src/Components/Error/ErrorBoundaryError.tsx
@@ -0,0 +1,73 @@
+import React, { useEffect, useState } from 'react';
+import StackTrace from 'stacktrace-js';
+import translate from 'Utilities/String/translate';
+import styles from './ErrorBoundaryError.css';
+
+interface ErrorBoundaryErrorProps {
+ className: string;
+ messageClassName: string;
+ detailsClassName: string;
+ message: string;
+ error: Error;
+ info: {
+ componentStack: string;
+ };
+}
+
+function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
+ const {
+ className = styles.container,
+ messageClassName = styles.message,
+ detailsClassName = styles.details,
+ message = translate('ErrorLoadingContent'),
+ error,
+ info,
+ } = props;
+
+ const [detailedError, setDetailedError] = useState<
+ StackTrace.StackFrame[] | null
+ >(null);
+
+ useEffect(() => {
+ if (error) {
+ StackTrace.fromError(error).then((de) => {
+ setDetailedError(de);
+ });
+ } else {
+ setDetailedError(null);
+ }
+ }, [error, setDetailedError]);
+
+ return (
+
+
{message}
+
+
+
+
+
+
+ {error ? {error.message}
: null}
+
+ {detailedError ? (
+ detailedError.map((d, index) => {
+ return (
+
+ {` at ${d.functionName} (${d.fileName}:${d.lineNumber}:${d.columnNumber})`}
+
+ );
+ })
+ ) : (
+ {info.componentStack}
+ )}
+
+ Version: {window.Sonarr.version}
+
+
+ );
+}
+
+export default ErrorBoundaryError;
diff --git a/frontend/src/Components/FieldSet.css.d.ts b/frontend/src/Components/FieldSet.css.d.ts
new file mode 100644
index 00000000000..74e99779a5f
--- /dev/null
+++ b/frontend/src/Components/FieldSet.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'fieldSet': string;
+ 'legend': string;
+ 'small': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/FieldSet.js b/frontend/src/Components/FieldSet.js
deleted file mode 100644
index 8243fd00c9c..00000000000
--- a/frontend/src/Components/FieldSet.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { sizes } from 'Helpers/Props';
-import styles from './FieldSet.css';
-
-class FieldSet extends Component {
-
- //
- // Render
-
- render() {
- const {
- size,
- legend,
- children
- } = this.props;
-
- return (
-
-
- {legend}
-
- {children}
-
- );
- }
-
-}
-
-FieldSet.propTypes = {
- size: PropTypes.oneOf(sizes.all).isRequired,
- legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
- children: PropTypes.node
-};
-
-FieldSet.defaultProps = {
- size: sizes.MEDIUM
-};
-
-export default FieldSet;
diff --git a/frontend/src/Components/FieldSet.tsx b/frontend/src/Components/FieldSet.tsx
new file mode 100644
index 00000000000..c2ff03a7f58
--- /dev/null
+++ b/frontend/src/Components/FieldSet.tsx
@@ -0,0 +1,29 @@
+import classNames from 'classnames';
+import React, { ComponentProps } from 'react';
+import { sizes } from 'Helpers/Props';
+import { Size } from 'Helpers/Props/sizes';
+import styles from './FieldSet.css';
+
+interface FieldSetProps {
+ size?: Size;
+ legend?: ComponentProps<'legend'>['children'];
+ children?: React.ReactNode;
+}
+
+function FieldSet({ size = sizes.MEDIUM, legend, children }: FieldSetProps) {
+ return (
+
+
+ {legend}
+
+ {children}
+
+ );
+}
+
+export default FieldSet;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.css.d.ts b/frontend/src/Components/FileBrowser/FileBrowserModal.css.d.ts
new file mode 100644
index 00000000000..5d00cca7ea5
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModal.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'modal': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.js b/frontend/src/Components/FileBrowser/FileBrowserModal.js
deleted file mode 100644
index 6b58dbb8c2a..00000000000
--- a/frontend/src/Components/FileBrowser/FileBrowserModal.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import Modal from 'Components/Modal/Modal';
-import FileBrowserModalContentConnector from './FileBrowserModalContentConnector';
-import styles from './FileBrowserModal.css';
-
-class FileBrowserModal extends Component {
-
- //
- // Render
-
- render() {
- const {
- isOpen,
- onModalClose,
- ...otherProps
- } = this.props;
-
- return (
-
-
-
- );
- }
-}
-
-FileBrowserModal.propTypes = {
- isOpen: PropTypes.bool.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default FileBrowserModal;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.tsx b/frontend/src/Components/FileBrowser/FileBrowserModal.tsx
new file mode 100644
index 00000000000..0925890de23
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModal.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import FileBrowserModalContent, {
+ FileBrowserModalContentProps,
+} from './FileBrowserModalContent';
+import styles from './FileBrowserModal.css';
+
+interface FileBrowserModalProps extends FileBrowserModalContentProps {
+ isOpen: boolean;
+ onModalClose: () => void;
+}
+
+function FileBrowserModal(props: FileBrowserModalProps) {
+ const { isOpen, onModalClose, ...otherProps } = props;
+
+ return (
+
+
+
+ );
+}
+
+export default FileBrowserModal;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.css.d.ts b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css.d.ts
new file mode 100644
index 00000000000..e83c1307526
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'faqLink': string;
+ 'loading': string;
+ 'mappedDrivesWarning': string;
+ 'modalBody': string;
+ 'pathInput': string;
+ 'scroller': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js
deleted file mode 100644
index 2fbeaaf8417..00000000000
--- a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js
+++ /dev/null
@@ -1,257 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import ReactDOM from 'react-dom';
-import Alert from 'Components/Alert';
-import PathInput from 'Components/Form/PathInput';
-import Button from 'Components/Link/Button';
-import Link from 'Components/Link/Link';
-import LoadingIndicator from 'Components/Loading/LoadingIndicator';
-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 Scroller from 'Components/Scroller/Scroller';
-import Table from 'Components/Table/Table';
-import TableBody from 'Components/Table/TableBody';
-import { kinds, scrollDirections } from 'Helpers/Props';
-import FileBrowserRow from './FileBrowserRow';
-import styles from './FileBrowserModalContent.css';
-
-const columns = [
- {
- name: 'type',
- label: 'Type',
- isVisible: true
- },
- {
- name: 'name',
- label: 'Name',
- isVisible: true
- }
-];
-
-class FileBrowserModalContent extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._scrollerNode = null;
-
- this.state = {
- isFileBrowserModalOpen: false,
- currentPath: props.value
- };
- }
-
- componentDidUpdate(prevProps, prevState) {
- const {
- currentPath
- } = this.props;
-
- if (
- currentPath !== this.state.currentPath &&
- currentPath !== prevState.currentPath
- ) {
- this.setState({ currentPath });
- this._scrollerNode.scrollTop = 0;
- }
- }
-
- //
- // Control
-
- setScrollerRef = (ref) => {
- if (ref) {
- this._scrollerNode = ReactDOM.findDOMNode(ref);
- } else {
- this._scrollerNode = null;
- }
- };
-
- //
- // Listeners
-
- onPathInputChange = ({ value }) => {
- this.setState({ currentPath: value });
- };
-
- onRowPress = (path) => {
- this.props.onFetchPaths(path);
- };
-
- onOkPress = () => {
- this.props.onChange({
- name: this.props.name,
- value: this.state.currentPath
- });
-
- this.props.onClearPaths();
- this.props.onModalClose();
- };
-
- //
- // Render
-
- render() {
- const {
- isFetching,
- isPopulated,
- error,
- parent,
- directories,
- files,
- isWindowsService,
- onModalClose,
- ...otherProps
- } = this.props;
-
- const emptyParent = parent === '';
-
- return (
-
-
- File Browser
-
-
-
- {
- isWindowsService &&
-
- Mapped network drives are not available when running as a Windows Service, see the FAQ for more information.
-
- }
-
-
-
-
- {
- !!error &&
- Error loading contents
- }
-
- {
- isPopulated && !error &&
-
-
- {
- emptyParent &&
-
- }
-
- {
- !emptyParent && parent &&
-
- }
-
- {
- directories.map((directory) => {
- return (
-
- );
- })
- }
-
- {
- files.map((file) => {
- return (
-
- );
- })
- }
-
-
- }
-
-
-
-
- {
- isFetching &&
-
- }
-
-
- Cancel
-
-
-
- Ok
-
-
-
- );
- }
-}
-
-FileBrowserModalContent.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.string.isRequired,
- isFetching: PropTypes.bool.isRequired,
- isPopulated: PropTypes.bool.isRequired,
- error: PropTypes.object,
- parent: PropTypes.string,
- currentPath: PropTypes.string.isRequired,
- directories: PropTypes.arrayOf(PropTypes.object).isRequired,
- files: PropTypes.arrayOf(PropTypes.object).isRequired,
- isWindowsService: PropTypes.bool.isRequired,
- onFetchPaths: PropTypes.func.isRequired,
- onClearPaths: PropTypes.func.isRequired,
- onChange: PropTypes.func.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default FileBrowserModalContent;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx
new file mode 100644
index 00000000000..41338cb39be
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx
@@ -0,0 +1,237 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import Alert from 'Components/Alert';
+import { PathInputInternal } from 'Components/Form/PathInput';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+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 Scroller from 'Components/Scroller/Scroller';
+import Column from 'Components/Table/Column';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import { kinds, scrollDirections } from 'Helpers/Props';
+import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import { InputChanged } from 'typings/inputs';
+import translate from 'Utilities/String/translate';
+import createPathsSelector from './createPathsSelector';
+import FileBrowserRow from './FileBrowserRow';
+import styles from './FileBrowserModalContent.css';
+
+const columns: Column[] = [
+ {
+ name: 'type',
+ label: () => translate('Type'),
+ isVisible: true,
+ },
+ {
+ name: 'name',
+ label: () => translate('Name'),
+ isVisible: true,
+ },
+];
+
+const handleClearPaths = () => {};
+
+export interface FileBrowserModalContentProps {
+ name: string;
+ value: string;
+ includeFiles?: boolean;
+ onChange: (args: InputChanged) => unknown;
+ onModalClose: () => void;
+}
+
+function FileBrowserModalContent(props: FileBrowserModalContentProps) {
+ const { name, value, includeFiles = true, onChange, onModalClose } = props;
+
+ const dispatch = useDispatch();
+
+ const { isWindows, mode } = useSelector(createSystemStatusSelector());
+ const { isFetching, isPopulated, error, parent, directories, files, paths } =
+ useSelector(createPathsSelector());
+
+ const [currentPath, setCurrentPath] = useState(value);
+ const scrollerRef = useRef(null);
+ const previousValue = usePrevious(value);
+
+ const emptyParent = parent === '';
+ const isWindowsService = isWindows && mode === 'service';
+
+ const handlePathInputChange = useCallback(
+ ({ value }: InputChanged) => {
+ setCurrentPath(value);
+ },
+ []
+ );
+
+ const handleRowPress = useCallback(
+ (path: string) => {
+ setCurrentPath(path);
+
+ dispatch(
+ fetchPaths({
+ path,
+ allowFoldersWithoutTrailingSlashes: true,
+ includeFiles,
+ })
+ );
+ },
+ [includeFiles, dispatch, setCurrentPath]
+ );
+
+ const handleOkPress = useCallback(() => {
+ onChange({
+ name,
+ value: currentPath,
+ });
+
+ dispatch(clearPaths());
+ onModalClose();
+ }, [name, currentPath, dispatch, onChange, onModalClose]);
+
+ const handleFetchPaths = useCallback(
+ (path: string) => {
+ dispatch(
+ fetchPaths({
+ path,
+ allowFoldersWithoutTrailingSlashes: true,
+ includeFiles,
+ })
+ );
+ },
+ [includeFiles, dispatch]
+ );
+
+ useEffect(() => {
+ if (value !== previousValue && value !== currentPath) {
+ setCurrentPath(value);
+ }
+ }, [value, previousValue, currentPath, setCurrentPath]);
+
+ useEffect(
+ () => {
+ dispatch(
+ fetchPaths({
+ path: currentPath,
+ allowFoldersWithoutTrailingSlashes: true,
+ includeFiles,
+ })
+ );
+
+ return () => {
+ dispatch(clearPaths());
+ };
+ },
+ // This should only run once when the component mounts,
+ // so we don't need to include the other dependencies.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [dispatch]
+ );
+
+ return (
+
+ {translate('FileBrowser')}
+
+
+ {isWindowsService ? (
+
+
+
+ ) : null}
+
+
+
+
+ {error ? {translate('ErrorLoadingContents')}
: null}
+
+ {isPopulated && !error ? (
+
+
+ {emptyParent ? (
+
+ ) : null}
+
+ {!emptyParent && parent ? (
+
+ ) : null}
+
+ {directories.map((directory) => {
+ return (
+
+ );
+ })}
+
+ {files.map((file) => {
+ return (
+
+ );
+ })}
+
+
+ ) : null}
+
+
+
+
+ {isFetching ? (
+
+ ) : null}
+
+ {translate('Cancel')}
+
+ {translate('Ok')}
+
+
+ );
+}
+
+export default FileBrowserModalContent;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js
deleted file mode 100644
index 1a3a41ef0af..00000000000
--- a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js
+++ /dev/null
@@ -1,119 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
-import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
-import FileBrowserModalContent from './FileBrowserModalContent';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.paths,
- createSystemStatusSelector(),
- (paths, systemStatus) => {
- const {
- isFetching,
- isPopulated,
- error,
- parent,
- currentPath,
- directories,
- files
- } = paths;
-
- const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
- return path.toLowerCase().startsWith(currentPath.toLowerCase());
- });
-
- return {
- isFetching,
- isPopulated,
- error,
- parent,
- currentPath,
- directories,
- files,
- paths: filteredPaths,
- isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service'
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- dispatchFetchPaths: fetchPaths,
- dispatchClearPaths: clearPaths
-};
-
-class FileBrowserModalContentConnector extends Component {
-
- // Lifecycle
-
- componentDidMount() {
- const {
- value,
- includeFiles,
- dispatchFetchPaths
- } = this.props;
-
- dispatchFetchPaths({
- path: value,
- allowFoldersWithoutTrailingSlashes: true,
- includeFiles
- });
- }
-
- //
- // Listeners
-
- onFetchPaths = (path) => {
- const {
- includeFiles,
- dispatchFetchPaths
- } = this.props;
-
- dispatchFetchPaths({
- path,
- allowFoldersWithoutTrailingSlashes: true,
- includeFiles
- });
- };
-
- onClearPaths = () => {
- // this.props.dispatchClearPaths();
- };
-
- onModalClose = () => {
- this.props.dispatchClearPaths();
- this.props.onModalClose();
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-FileBrowserModalContentConnector.propTypes = {
- value: PropTypes.string,
- includeFiles: PropTypes.bool.isRequired,
- dispatchFetchPaths: PropTypes.func.isRequired,
- dispatchClearPaths: PropTypes.func.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-FileBrowserModalContentConnector.defaultProps = {
- includeFiles: false
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector);
diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.css.d.ts b/frontend/src/Components/FileBrowser/FileBrowserRow.css.d.ts
new file mode 100644
index 00000000000..127d009287d
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserRow.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'type': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.js b/frontend/src/Components/FileBrowser/FileBrowserRow.js
deleted file mode 100644
index 06bb3029dd4..00000000000
--- a/frontend/src/Components/FileBrowser/FileBrowserRow.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import Icon from 'Components/Icon';
-import TableRowCell from 'Components/Table/Cells/TableRowCell';
-import TableRowButton from 'Components/Table/TableRowButton';
-import { icons } from 'Helpers/Props';
-import styles from './FileBrowserRow.css';
-
-function getIconName(type) {
- switch (type) {
- case 'computer':
- return icons.COMPUTER;
- case 'drive':
- return icons.DRIVE;
- case 'file':
- return icons.FILE;
- case 'parent':
- return icons.PARENT;
- default:
- return icons.FOLDER;
- }
-}
-
-class FileBrowserRow extends Component {
-
- //
- // Listeners
-
- onPress = () => {
- this.props.onPress(this.props.path);
- };
-
- //
- // Render
-
- render() {
- const {
- type,
- name
- } = this.props;
-
- return (
-
-
-
-
-
- {name}
-
- );
- }
-
-}
-
-FileBrowserRow.propTypes = {
- type: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- path: PropTypes.string.isRequired,
- onPress: PropTypes.func.isRequired
-};
-
-export default FileBrowserRow;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.tsx b/frontend/src/Components/FileBrowser/FileBrowserRow.tsx
new file mode 100644
index 00000000000..fe47f1664fe
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserRow.tsx
@@ -0,0 +1,49 @@
+import React, { useCallback } from 'react';
+import { PathType } from 'App/State/PathsAppState';
+import Icon from 'Components/Icon';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableRowButton from 'Components/Table/TableRowButton';
+import { icons } from 'Helpers/Props';
+import styles from './FileBrowserRow.css';
+
+function getIconName(type: PathType) {
+ switch (type) {
+ case 'computer':
+ return icons.COMPUTER;
+ case 'drive':
+ return icons.DRIVE;
+ case 'file':
+ return icons.FILE;
+ case 'parent':
+ return icons.PARENT;
+ default:
+ return icons.FOLDER;
+ }
+}
+
+interface FileBrowserRowProps {
+ type: PathType;
+ name: string;
+ path: string;
+ onPress: (path: string) => void;
+}
+
+function FileBrowserRow(props: FileBrowserRowProps) {
+ const { type, name, path, onPress } = props;
+
+ const handlePress = useCallback(() => {
+ onPress(path);
+ }, [path, onPress]);
+
+ return (
+
+
+
+
+
+ {name}
+
+ );
+}
+
+export default FileBrowserRow;
diff --git a/frontend/src/Components/FileBrowser/createPathsSelector.ts b/frontend/src/Components/FileBrowser/createPathsSelector.ts
new file mode 100644
index 00000000000..5da830bd5e6
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/createPathsSelector.ts
@@ -0,0 +1,36 @@
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+
+function createPathsSelector() {
+ return createSelector(
+ (state: AppState) => state.paths,
+ (paths) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ parent,
+ currentPath,
+ directories,
+ files,
+ } = paths;
+
+ const filteredPaths = [...directories, ...files].filter(({ path }) => {
+ return path.toLowerCase().startsWith(currentPath.toLowerCase());
+ });
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ parent,
+ currentPath,
+ directories,
+ files,
+ paths: filteredPaths,
+ };
+ }
+ );
+}
+
+export default createPathsSelector;
diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css.d.ts b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css.d.ts
new file mode 100644
index 00000000000..d391a1f3092
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'container': string;
+ 'numberInput': string;
+ 'selectInput': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css.d.ts b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css.d.ts
new file mode 100644
index 00000000000..033d2edca56
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'label': string;
+ 'labelContainer': string;
+ 'labelInputContainer': string;
+ 'rows': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js
index d718aab0cf5..0c4a31657b2 100644
--- a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js
@@ -1,3 +1,4 @@
+import { maxBy } from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormInputGroup from 'Components/Form/FormInputGroup';
@@ -8,6 +9,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
import FilterBuilderRow from './FilterBuilderRow';
import styles from './FilterBuilderModalContent.css';
@@ -49,7 +51,7 @@ class FilterBuilderModalContent extends Component {
if (id) {
dispatchSetFilter({ selectedFilterKey: id });
} else {
- const last = customFilters[customFilters.length -1];
+ const last = maxBy(customFilters, 'id');
dispatchSetFilter({ selectedFilterKey: last.id });
}
@@ -107,7 +109,7 @@ class FilterBuilderModalContent extends Component {
this.setState({
labelErrors: [
{
- message: 'Label is required'
+ message: translate('LabelIsRequired')
}
]
});
@@ -145,13 +147,13 @@ class FilterBuilderModalContent extends Component {
return (
- Custom Filter
+ {translate('CustomFilter')}
- Label
+ {translate('Label')}
@@ -165,7 +167,9 @@ class FilterBuilderModalContent extends Component {
- Filters
+
+ {translate('Filters')}
+
{
@@ -192,7 +196,7 @@ class FilterBuilderModalContent extends Component {
- Cancel
+ {translate('Cancel')}
- Save
+ {translate('Save')}
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.css.d.ts b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css.d.ts
new file mode 100644
index 00000000000..aba698af493
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actionsContainer': string;
+ 'filterRow': string;
+ 'inputContainer': string;
+ 'valueInputContainer': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
index 491829434fd..0b00c0f03e7 100644
--- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
@@ -3,13 +3,19 @@ import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput';
import IconButton from 'Components/Link/IconButton';
import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props';
+import sortByProp from 'Utilities/Array/sortByProp';
import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
+import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue';
import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector';
+import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
-import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
+import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
+import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue';
+import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
+import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue';
import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector';
@@ -57,9 +63,15 @@ function getRowValueConnector(selectedFilterBuilderProp) {
case filterBuilderValueTypes.DATE:
return DateFilterBuilderRowValue;
+ case filterBuilderValueTypes.HISTORY_EVENT_TYPE:
+ return HistoryEventTypeFilterBuilderRowValue;
+
case filterBuilderValueTypes.INDEXER:
return IndexerFilterBuilderRowValueConnector;
+ case filterBuilderValueTypes.LANGUAGE:
+ return LanguageFilterBuilderRowValue;
+
case filterBuilderValueTypes.PROTOCOL:
return ProtocolFilterBuilderRowValue;
@@ -67,7 +79,16 @@ function getRowValueConnector(selectedFilterBuilderProp) {
return QualityFilterBuilderRowValueConnector;
case filterBuilderValueTypes.QUALITY_PROFILE:
- return QualityProfileFilterBuilderRowValueConnector;
+ return QualityProfileFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.QUEUE_STATUS:
+ return QueueStatusFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
+ return SeasonsMonitoredStatusFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.SERIES:
+ return SeriesFilterBuilderRowValue;
case filterBuilderValueTypes.SERIES_STATUS:
return SeriesStatusFilterBuilderRowValue;
@@ -206,11 +227,13 @@ class FilterBuilderRow extends Component {
const selectedFilterBuilderProp = this.selectedFilterBuilderProp;
const keyOptions = filterBuilderProps.map((availablePropFilter) => {
+ const { name, label } = availablePropFilter;
+
return {
- key: availablePropFilter.name,
- value: availablePropFilter.label
+ key: name,
+ value: typeof label === 'function' ? label() : label
};
- }).sort((a, b) => a.value.localeCompare(b.value));
+ }).sort(sortByProp('value'));
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
index 68fa5c557e0..217626c90a1 100644
--- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
@@ -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';
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js
index a7aed80b6be..d1419327a23 100644
--- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { filterBuilderTypes } from 'Helpers/Props';
import * as filterTypes from 'Helpers/Props/filterTypes';
-import sortByName from 'Utilities/Array/sortByName';
+import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createTagListSelector() {
@@ -38,7 +38,7 @@ function createTagListSelector() {
}
return acc;
- }, []).sort(sortByName);
+ }, []).sort(sortByProp('name'));
}
return _.uniqBy(items, 'id');
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueProps.ts b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueProps.ts
new file mode 100644
index 00000000000..5bf9e57851d
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueProps.ts
@@ -0,0 +1,16 @@
+import { FilterBuilderProp } from 'App/State/AppState';
+
+interface FilterBuilderRowOnChangeProps {
+ name: string;
+ value: unknown[];
+}
+
+interface FilterBuilderRowValueProps {
+ filterType?: string;
+ filterValue: string | number | object | string[] | number[] | object[];
+ selectedFilterBuilderProp: FilterBuilderProp
;
+ sectionItem: unknown[];
+ onChange: (payload: FilterBuilderRowOnChangeProps) => void;
+}
+
+export default FilterBuilderRowValueProps;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css.d.ts b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css.d.ts
new file mode 100644
index 00000000000..80bcf146406
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'isLastTag': string;
+ 'label': string;
+ 'or': string;
+ 'tag': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js
index 6b5846594cf..063a973466c 100644
--- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js
@@ -1,7 +1,8 @@
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';
function FilterBuilderRowValueTag(props) {
@@ -18,7 +19,7 @@ function FilterBuilderRowValueTag(props) {
props.isLastTag ?
null :
- or
+ {translate('Or')}
}
diff --git a/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx
new file mode 100644
index 00000000000..4ecddf64627
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import translate from 'Utilities/String/translate';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
+
+const EVENT_TYPE_OPTIONS = [
+ {
+ id: 1,
+ get name() {
+ return translate('Grabbed');
+ },
+ },
+ {
+ id: 3,
+ get name() {
+ return translate('Imported');
+ },
+ },
+ {
+ id: 4,
+ get name() {
+ return translate('Failed');
+ },
+ },
+ {
+ id: 5,
+ get name() {
+ return translate('Deleted');
+ },
+ },
+ {
+ id: 6,
+ get name() {
+ return translate('Renamed');
+ },
+ },
+ {
+ id: 7,
+ get name() {
+ return translate('Ignored');
+ },
+ },
+];
+
+function HistoryEventTypeFilterBuilderRowValue(
+ props: FilterBuilderRowValueProps
+) {
+ return ;
+}
+
+export default HistoryEventTypeFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/LanguageFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/LanguageFilterBuilderRowValue.tsx
new file mode 100644
index 00000000000..e828fd8483a
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/LanguageFilterBuilderRowValue.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
+
+function LanguageFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
+ const { items } = useSelector(createLanguagesSelector());
+
+ return ;
+}
+
+export default LanguageFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx
new file mode 100644
index 00000000000..50036cb90c2
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps';
+import sortByProp from 'Utilities/Array/sortByProp';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createQualityProfilesSelector() {
+ return createSelector(
+ (state: AppState) => state.settings.qualityProfiles.items,
+ (qualityProfiles) => {
+ return qualityProfiles;
+ }
+ );
+}
+
+function QualityProfileFilterBuilderRowValue(
+ props: FilterBuilderRowValueProps
+) {
+ const qualityProfiles = useSelector(createQualityProfilesSelector());
+
+ const tagList = qualityProfiles
+ .map(({ id, name }) => ({ id, name }))
+ .sort(sortByProp('name'));
+
+ return ;
+}
+
+export default QualityProfileFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js
deleted file mode 100644
index 4a8b82283d1..00000000000
--- a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import FilterBuilderRowValue from './FilterBuilderRowValue';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.settings.qualityProfiles,
- (qualityProfiles) => {
- const tagList = qualityProfiles.items.map((qualityProfile) => {
- const {
- id,
- name
- } = qualityProfile;
-
- return {
- id,
- name
- };
- });
-
- return {
- tagList
- };
- }
- );
-}
-
-export default connect(createMapStateToProps)(FilterBuilderRowValue);
diff --git a/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx
new file mode 100644
index 00000000000..1127493a5c3
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import translate from 'Utilities/String/translate';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
+
+const statusTagList = [
+ {
+ id: 'queued',
+ get name() {
+ return translate('Queued');
+ },
+ },
+ {
+ id: 'paused',
+ get name() {
+ return translate('Paused');
+ },
+ },
+ {
+ id: 'downloading',
+ get name() {
+ return translate('Downloading');
+ },
+ },
+ {
+ id: 'completed',
+ get name() {
+ return translate('Completed');
+ },
+ },
+ {
+ id: 'failed',
+ get name() {
+ return translate('Failed');
+ },
+ },
+ {
+ id: 'warning',
+ get name() {
+ return translate('Warning');
+ },
+ },
+ {
+ id: 'delay',
+ get name() {
+ return translate('Delay');
+ },
+ },
+ {
+ id: 'downloadClientUnavailable',
+ get name() {
+ return translate('DownloadClientUnavailable');
+ },
+ },
+ {
+ id: 'fallback',
+ get name() {
+ return translate('Fallback');
+ },
+ },
+];
+
+function QueueStatusFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
+ return ;
+}
+
+export default QueueStatusFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js
new file mode 100644
index 00000000000..b84260e3c9f
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import translate from 'Utilities/String/translate';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+const seasonsMonitoredStatusList = [
+ {
+ id: 'all',
+ get name() {
+ return translate('SeasonsMonitoredAll');
+ }
+ },
+ {
+ id: 'partial',
+ get name() {
+ return translate('SeasonsMonitoredPartial');
+ }
+ },
+ {
+ id: 'none',
+ get name() {
+ return translate('SeasonsMonitoredNone');
+ }
+ }
+];
+
+function SeasonsMonitoredStatusFilterBuilderRowValue(props) {
+ return (
+
+ );
+}
+
+export default SeasonsMonitoredStatusFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx
new file mode 100644
index 00000000000..88b34509ad7
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import Series from 'Series/Series';
+import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
+import sortByProp from 'Utilities/Array/sortByProp';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
+
+function SeriesFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
+ const allSeries: Series[] = useSelector(createAllSeriesSelector());
+
+ const tagList = allSeries
+ .map((series) => ({ id: series.id, name: series.title }))
+ .sort(sortByProp('name'));
+
+ return ;
+}
+
+export default SeriesFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js
index b52cb489972..e017f72e745 100644
--- a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js
+++ b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js
@@ -1,16 +1,38 @@
import React from 'react';
+import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
-const seriesStatusList = [
- { id: 'continuing', name: 'Continuing' },
- { id: 'upcoming', name: 'Upcoming' },
- { id: 'ended', name: 'Ended' }
+const statusTagList = [
+ {
+ id: 'continuing',
+ get name() {
+ return translate('Continuing');
+ }
+ },
+ {
+ id: 'upcoming',
+ get name() {
+ return translate('Upcoming');
+ }
+ },
+ {
+ id: 'ended',
+ get name() {
+ return translate('Ended');
+ }
+ },
+ {
+ id: 'deleted',
+ get name() {
+ return translate('Deleted');
+ }
+ }
];
function SeriesStatusFilterBuilderRowValue(props) {
return (
);
diff --git a/frontend/src/Components/Filter/Builder/SeriesTypeFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesTypeFilterBuilderRowValue.js
index 263c9e9daf6..2e62e558d1c 100644
--- a/frontend/src/Components/Filter/Builder/SeriesTypeFilterBuilderRowValue.js
+++ b/frontend/src/Components/Filter/Builder/SeriesTypeFilterBuilderRowValue.js
@@ -1,10 +1,26 @@
import React from 'react';
+import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const seriesTypeList = [
- { id: 'anime', name: 'Anime' },
- { id: 'daily', name: 'Daily' },
- { id: 'standard', name: 'Standard' }
+ {
+ id: 'anime',
+ get name() {
+ return translate('Anime');
+ }
+ },
+ {
+ id: 'daily',
+ get name() {
+ return translate('Daily');
+ }
+ },
+ {
+ id: 'standard',
+ get name() {
+ return translate('Standard');
+ }
+ }
];
function SeriesTypeFilterBuilderRowValue(props) {
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.css.d.ts b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css.d.ts
new file mode 100644
index 00000000000..af5bfa9677e
--- /dev/null
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'actions': string;
+ 'customFilter': string;
+ 'label': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js
index e87d088b364..9f378d5a2aa 100644
--- a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js
@@ -3,6 +3,7 @@ import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
import styles from './CustomFilter.css';
class CustomFilter extends Component {
@@ -36,8 +37,8 @@ class CustomFilter extends Component {
dispatchSetFilter
} = this.props;
- // Assume that delete and then unmounting means the delete was successful.
- // Moving this check to a ancestor would be more accurate, but would have
+ // Assume that delete and then unmounting means the deletion was successful.
+ // Moving this check to an ancestor would be more accurate, but would have
// more boilerplate.
if (this.state.isDeleting && id === selectedFilterKey) {
dispatchSetFilter({ selectedFilterKey: 'all' });
@@ -89,7 +90,7 @@ class CustomFilter extends Component {
/>
- Custom Filters
+ {translate('CustomFilters')}
{
- customFilters.map((customFilter) => {
- return (
-
- );
- })
+ customFilters
+ .sort((a, b) => sortByProp(a, b, 'label'))
+ .map((customFilter) => {
+ return (
+
+ );
+ })
}
- Add Custom Filter
+ {translate('AddCustomFilter')}
@@ -58,7 +62,7 @@ function CustomFiltersModalContent(props) {
- Close
+ {translate('Close')}
diff --git a/frontend/src/Components/Form/AutoCompleteInput.js b/frontend/src/Components/Form/AutoCompleteInput.js
deleted file mode 100644
index d35969c4c37..00000000000
--- a/frontend/src/Components/Form/AutoCompleteInput.js
+++ /dev/null
@@ -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 (
-
- );
- }
-}
-
-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;
diff --git a/frontend/src/Components/Form/AutoCompleteInput.tsx b/frontend/src/Components/Form/AutoCompleteInput.tsx
new file mode 100644
index 00000000000..7ba11412545
--- /dev/null
+++ b/frontend/src/Components/Form/AutoCompleteInput.tsx
@@ -0,0 +1,81 @@
+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';
+
+interface AutoCompleteInputProps {
+ name: string;
+ value?: string;
+ values: string[];
+ onChange: (change: InputChanged) => unknown;
+}
+
+function AutoCompleteInput({
+ name,
+ value = '',
+ values,
+ onChange,
+ ...otherProps
+}: AutoCompleteInputProps) {
+ const [suggestions, setSuggestions] = useState([]);
+
+ 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 (
+
+ );
+}
+
+export default AutoCompleteInput;
diff --git a/frontend/src/Components/Form/AutoSuggestInput.css.d.ts b/frontend/src/Components/Form/AutoSuggestInput.css.d.ts
new file mode 100644
index 00000000000..2b8f51924e5
--- /dev/null
+++ b/frontend/src/Components/Form/AutoSuggestInput.css.d.ts
@@ -0,0 +1,15 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'hasError': string;
+ 'hasWarning': string;
+ 'input': string;
+ 'inputContainer': string;
+ 'suggestion': string;
+ 'suggestionHighlighted': string;
+ 'suggestionsContainer': string;
+ 'suggestionsContainerOpen': string;
+ 'suggestionsList': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/AutoSuggestInput.js b/frontend/src/Components/Form/AutoSuggestInput.js
deleted file mode 100644
index 34ec7530bc1..00000000000
--- a/frontend/src/Components/Form/AutoSuggestInput.js
+++ /dev/null
@@ -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 (
-
- {({ ref }) => {
- if (renderInputComponent) {
- return renderInputComponent(inputProps, ref);
- }
-
- return (
-
-
-
- );
- }}
-
- );
- };
-
- renderSuggestionsContainer = ({ containerProps, children }) => {
- return (
-
-
- {({ ref: popperRef, style, scheduleUpdate }) => {
- this._scheduleUpdate = scheduleUpdate;
-
- return (
-
- );
- }}
-
-
- );
- };
-
- //
- // 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 (
-
-
-
- );
- }
-}
-
-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;
diff --git a/frontend/src/Components/Form/AutoSuggestInput.tsx b/frontend/src/Components/Form/AutoSuggestInput.tsx
new file mode 100644
index 00000000000..b3a7c31b0f4
--- /dev/null
+++ b/frontend/src/Components/Form/AutoSuggestInput.tsx
@@ -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
+ extends Omit, 'renderInputComponent' | 'inputProps'> {
+ forwardedRef?: MutableRefObject | 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
+ ) => ReactNode;
+ onInputChange: (
+ event: FormEvent,
+ params: ChangeEvent
+ ) => unknown;
+ onInputKeyDown?: KeyboardEventHandler;
+ onInputFocus?: (event: SyntheticEvent) => unknown;
+ onInputBlur: (
+ event: FocusEvent,
+ params?: BlurEvent
+ ) => unknown;
+ onChange?: (change: InputChanged) => unknown;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function AutoSuggestInput(props: AutoSuggestInputProps) {
+ 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 (
+
+ {({ ref }) => {
+ if (renderInputComponent) {
+ return renderInputComponent(inputProps, ref);
+ }
+
+ return (
+
+
+
+ );
+ }}
+
+ );
+ },
+ [renderInputComponent]
+ );
+
+ const renderSuggestionsContainer = useCallback(
+ ({ containerProps, children }: RenderSuggestionsContainerParams) => {
+ return (
+
+
+ {({ ref: popperRef, style, scheduleUpdate }) => {
+ updater.current = scheduleUpdate;
+
+ return (
+
+ );
+ }}
+
+
+ );
+ },
+ [minHeight, handleComputeMaxHeight]
+ );
+
+ const handleInputKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ 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 (
+
+
+
+ );
+}
+
+export default AutoSuggestInput;
diff --git a/frontend/src/Components/Form/CaptchaInput.css.d.ts b/frontend/src/Components/Form/CaptchaInput.css.d.ts
new file mode 100644
index 00000000000..b6844144e95
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInput.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'captchaInputWrapper': string;
+ 'hasButton': string;
+ 'hasError': string;
+ 'hasWarning': string;
+ 'input': string;
+ 'recaptchaWrapper': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js
deleted file mode 100644
index b422198b5ac..00000000000
--- a/frontend/src/Components/Form/CaptchaInput.js
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
-
-
- {
- !!siteKey && !!secretToken &&
-
-
-
- }
-
- );
-}
-
-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;
diff --git a/frontend/src/Components/Form/CaptchaInput.tsx b/frontend/src/Components/Form/CaptchaInput.tsx
new file mode 100644
index 00000000000..d5a3f11f7ce
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInput.tsx
@@ -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';
+
+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) => 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 (
+
+
+
+
+
+
+
+
+
+ {siteKey && secretToken ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+export default CaptchaInput;
diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js
deleted file mode 100644
index ad83bf02fb9..00000000000
--- a/frontend/src/Components/Form/CaptchaInputConnector.js
+++ /dev/null
@@ -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 (
-
- );
- }
-}
-
-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);
diff --git a/frontend/src/Components/Form/CheckInput.css.d.ts b/frontend/src/Components/Form/CheckInput.css.d.ts
new file mode 100644
index 00000000000..bba6b63bbff
--- /dev/null
+++ b/frontend/src/Components/Form/CheckInput.css.d.ts
@@ -0,0 +1,18 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'checkbox': string;
+ 'container': string;
+ 'dangerIsChecked': string;
+ 'helpText': string;
+ 'input': string;
+ 'isDisabled': string;
+ 'isIndeterminate': string;
+ 'isNotChecked': string;
+ 'label': string;
+ 'primaryIsChecked': string;
+ 'successIsChecked': string;
+ 'warningIsChecked': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js
deleted file mode 100644
index 26d9158803e..00000000000
--- a/frontend/src/Components/Form/CheckInput.js
+++ /dev/null
@@ -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 (
-
-
-
-
-
- {
- isChecked &&
-
- }
-
- {
- isIndeterminate &&
-
- }
-
-
- {
- helpText &&
-
- }
-
- {
- !helpText && helpTextWarning &&
-
- }
-
-
- );
- }
-}
-
-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;
diff --git a/frontend/src/Components/Form/CheckInput.tsx b/frontend/src/Components/Form/CheckInput.tsx
new file mode 100644
index 00000000000..b7080cfdd2b
--- /dev/null
+++ b/frontend/src/Components/Form/CheckInput.tsx
@@ -0,0 +1,141 @@
+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 extends SyntheticEvent {
+ target: EventTarget & T;
+}
+
+interface CheckInputProps {
+ className?: string;
+ containerClassName?: string;
+ name: string;
+ checkedValue?: boolean;
+ uncheckedValue?: boolean;
+ value?: string | boolean;
+ helpText?: string;
+ helpTextWarning?: string;
+ isDisabled?: boolean;
+ kind?: Extract;
+ 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(null);
+
+ const isChecked = value === checkedValue;
+ const isUnchecked = value === uncheckedValue;
+ const isIndeterminate = !isChecked && !isUnchecked;
+ const isCheckClass: keyof typeof styles = `${kind}IsChecked`;
+
+ 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) => {
+ 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) => {
+ 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 (
+
+
+
+
+
+ {isChecked ? : null}
+
+ {isIndeterminate ? : null}
+
+
+ {helpText ? (
+
+ ) : null}
+
+ {!helpText && helpTextWarning ? (
+
+ ) : null}
+
+
+ );
+}
+
+export default CheckInput;
diff --git a/frontend/src/Components/Form/DeviceInput.css b/frontend/src/Components/Form/DeviceInput.css
deleted file mode 100644
index 7abe83db503..00000000000
--- a/frontend/src/Components/Form/DeviceInput.css
+++ /dev/null
@@ -1,8 +0,0 @@
-.deviceInputWrapper {
- display: flex;
-}
-
-.input {
- composes: input from '~./TagInput.css';
- composes: hasButton from '~Components/Form/Input.css';
-}
diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js
deleted file mode 100644
index 55c239cb825..00000000000
--- a/frontend/src/Components/Form/DeviceInput.js
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
- );
- }
-}
-
-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;
diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js
deleted file mode 100644
index 2af9a79f6ab..00000000000
--- a/frontend/src/Components/Form/DeviceInputConnector.js
+++ /dev/null
@@ -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 (
-
- );
- }
-}
-
-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);
diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js
deleted file mode 100644
index c8901686952..00000000000
--- a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js
+++ /dev/null
@@ -1,100 +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 sortByName from 'Utilities/Array/sortByName';
-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(sortByName), (downloadClient) => {
- return {
- key: downloadClient.id,
- value: downloadClient.name
- };
- });
-
- if (includeAny) {
- values.unshift({
- key: 0,
- value: '(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 (
-
- );
- }
-}
-
-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);
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js
deleted file mode 100644
index 4df54092cda..00000000000
--- a/frontend/src/Components/Form/EnhancedSelectInput.js
+++ /dev/null
@@ -1,608 +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 = (value) => {
- if (Array.isArray(this.props.value)) {
- let newValue = null;
- const index = this.props.value.indexOf(value);
- if (index === -1) {
- newValue = this.props.values.map((v) => v.key).filter((v) => (v === value) || this.props.value.includes(v));
- } else {
- newValue = [...this.props.value];
- newValue.splice(index, 1);
- }
- this.props.onChange({
- name: this.props.name,
- value: newValue
- });
- } else {
- this.setState({ isOpen: false });
-
- this.props.onChange({
- name: this.props.name,
- value
- });
- }
- };
-
- 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 (
-
-
-
- {({ ref }) => (
-
- )}
-
-
-
- {({ ref, style, scheduleUpdate }) => {
- this._scheduleUpdate = scheduleUpdate;
-
- return (
-
- {
- isOpen && !isMobile ?
-
- {
- values.map((v, index) => {
- const hasParent = v.parentKey !== undefined;
- const depth = hasParent ? 1 : 0;
- const parentSelected = hasParent && value.includes(v.parentKey);
- return (
-
- {v.value}
-
- );
- })
- }
- :
- null
- }
-
- );
- }
- }
-
-
-
-
- {
- isMobile ?
-
-
-
-
-
-
-
-
-
- {
- values.map((v, index) => {
- const hasParent = v.parentKey !== undefined;
- const depth = hasParent ? 1 : 0;
- const parentSelected = hasParent && value.includes(v.parentKey);
- return (
-
- {v.value}
-
- );
- })
- }
-
-
- :
- null
- }
-
- );
- }
-}
-
-EnhancedSelectInput.propTypes = {
- className: PropTypes.string,
- disabledClassName: PropTypes.string,
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, 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;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputConnector.js b/frontend/src/Components/Form/EnhancedSelectInputConnector.js
deleted file mode 100644
index f2af4a58550..00000000000
--- a/frontend/src/Components/Form/EnhancedSelectInputConnector.js
+++ /dev/null
@@ -1,159 +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'
-];
-
-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
- };
- });
-}
-
-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 (
-
- );
- }
-}
-
-EnhancedSelectInputConnector.propTypes = {
- provider: PropTypes.string.isRequired,
- providerData: PropTypes.object.isRequired,
- name: PropTypes.string.isRequired,
- value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).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);
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js
deleted file mode 100644
index b2783dbaad6..00000000000
--- a/frontend/src/Components/Form/EnhancedSelectInputOption.js
+++ /dev/null
@@ -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 (
-
-
- {
- depth !== 0 &&
-
- }
-
- {
- isMultiSelect &&
-
- }
-
- {children}
-
- {
- isMobile &&
-
-
-
- }
-
- );
- }
-}
-
-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;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
deleted file mode 100644
index 21ddebb0278..00000000000
--- a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
+++ /dev/null
@@ -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 (
-
- {children}
-
- );
-}
-
-EnhancedSelectInputSelectedValue.propTypes = {
- className: PropTypes.string.isRequired,
- children: PropTypes.node,
- isDisabled: PropTypes.bool.isRequired
-};
-
-EnhancedSelectInputSelectedValue.defaultProps = {
- className: styles.selectedValue,
- isDisabled: false
-};
-
-export default EnhancedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Form.css.d.ts b/frontend/src/Components/Form/Form.css.d.ts
new file mode 100644
index 00000000000..178f2fec174
--- /dev/null
+++ b/frontend/src/Components/Form/Form.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'validationFailures': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js
deleted file mode 100644
index 859911a8b62..00000000000
--- a/frontend/src/Components/Form/Form.js
+++ /dev/null
@@ -1,58 +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({ children, validationErrors, validationWarnings, ...otherProps }) {
- return (
-
- {
- validationErrors.length || validationWarnings.length ?
-
- {
- validationErrors.map((error, index) => {
- return (
-
- {error.errorMessage}
-
- );
- })
- }
-
- {
- validationWarnings.map((warning, index) => {
- return (
-
- {warning.errorMessage}
-
- );
- })
- }
-
:
- null
- }
-
- {children}
-
- );
-}
-
-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;
diff --git a/frontend/src/Components/Form/Form.tsx b/frontend/src/Components/Form/Form.tsx
new file mode 100644
index 00000000000..d522019e7bd
--- /dev/null
+++ b/frontend/src/Components/Form/Form.tsx
@@ -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 (
+
+ {validationErrors.length || validationWarnings.length ? (
+
+ {validationErrors.map((error, index) => {
+ return (
+
+ {error.errorMessage}
+
+ );
+ })}
+
+ {validationWarnings.map((warning, index) => {
+ return (
+
+ {warning.errorMessage}
+
+ );
+ })}
+
+ ) : null}
+
+ {children}
+
+ );
+}
+
+export default Form;
diff --git a/frontend/src/Components/Form/FormGroup.css.d.ts b/frontend/src/Components/Form/FormGroup.css.d.ts
new file mode 100644
index 00000000000..86145f643d3
--- /dev/null
+++ b/frontend/src/Components/Form/FormGroup.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'extraSmall': string;
+ 'group': string;
+ 'large': string;
+ 'medium': string;
+ 'small': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js
deleted file mode 100644
index f538daa2f17..00000000000
--- a/frontend/src/Components/Form/FormGroup.js
+++ /dev/null
@@ -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 (
-
- {
- map(children, (child) => {
- return React.cloneElement(child, childProps);
- })
- }
-
- );
-}
-
-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;
diff --git a/frontend/src/Components/Form/FormGroup.tsx b/frontend/src/Components/Form/FormGroup.tsx
new file mode 100644
index 00000000000..1dd879897af
--- /dev/null
+++ b/frontend/src/Components/Form/FormGroup.tsx
@@ -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;
+ 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 (
+
+ {Children.map(children, (child) => {
+ if (!React.isValidElement(child)) {
+ return child;
+ }
+
+ return React.cloneElement(child, childProps);
+ })}
+
+ );
+}
+
+export default FormGroup;
diff --git a/frontend/src/Components/Form/FormInputButton.css.d.ts b/frontend/src/Components/Form/FormInputButton.css.d.ts
new file mode 100644
index 00000000000..d469cdfe3be
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputButton.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'button': string;
+ 'middleButton': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js
deleted file mode 100644
index a7145363af0..00000000000
--- a/frontend/src/Components/Form/FormInputButton.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import Button from 'Components/Link/Button';
-import SpinnerButton from 'Components/Link/SpinnerButton';
-import { kinds } from 'Helpers/Props';
-import styles from './FormInputButton.css';
-
-function FormInputButton(props) {
- const {
- className,
- canSpin,
- isLastButton,
- ...otherProps
- } = props;
-
- if (canSpin) {
- return (
-
- );
- }
-
- return (
-
- );
-}
-
-FormInputButton.propTypes = {
- className: PropTypes.string.isRequired,
- isLastButton: PropTypes.bool.isRequired,
- canSpin: PropTypes.bool.isRequired
-};
-
-FormInputButton.defaultProps = {
- className: styles.button,
- isLastButton: true,
- canSpin: false
-};
-
-export default FormInputButton;
diff --git a/frontend/src/Components/Form/FormInputButton.tsx b/frontend/src/Components/Form/FormInputButton.tsx
new file mode 100644
index 00000000000..e5149ab14e6
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputButton.tsx
@@ -0,0 +1,39 @@
+import classNames from 'classnames';
+import React from 'react';
+import Button, { ButtonProps } from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import { kinds } from 'Helpers/Props';
+import styles from './FormInputButton.css';
+
+export interface FormInputButtonProps extends ButtonProps {
+ canSpin?: boolean;
+ isLastButton?: boolean;
+}
+
+function FormInputButton({
+ className = styles.button,
+ canSpin = false,
+ isLastButton = true,
+ kind = kinds.PRIMARY,
+ ...otherProps
+}: FormInputButtonProps) {
+ if (canSpin) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export default FormInputButton;
diff --git a/frontend/src/Components/Form/FormInputGroup.css.d.ts b/frontend/src/Components/Form/FormInputGroup.css.d.ts
new file mode 100644
index 00000000000..267257c4407
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.css.d.ts
@@ -0,0 +1,14 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'helpLink': string;
+ 'inputContainer': string;
+ 'inputGroup': string;
+ 'inputGroupContainer': string;
+ 'inputUnit': string;
+ 'inputUnitNumber': string;
+ 'pendingChangesContainer': string;
+ 'pendingChangesIcon': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js
deleted file mode 100644
index 94780aba265..00000000000
--- a/frontend/src/Components/Form/FormInputGroup.js
+++ /dev/null
@@ -1,281 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Link from 'Components/Link/Link';
-import { inputTypes } from 'Helpers/Props';
-import AutoCompleteInput from './AutoCompleteInput';
-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 IndexerSelectInputConnector from './IndexerSelectInputConnector';
-import KeyValueListInput from './KeyValueListInput';
-import MonitorEpisodesSelectInput from './MonitorEpisodesSelectInput';
-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 SeriesTypeSelectInput from './SeriesTypeSelectInput';
-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.CAPTCHA:
- return CaptchaInputConnector;
-
- case inputTypes.CHECK:
- return CheckInput;
-
- case inputTypes.DEVICE:
- return DeviceInputConnector;
-
- case inputTypes.KEY_VALUE_LIST:
- return KeyValueListInput;
-
- case inputTypes.MONITOR_EPISODES_SELECT:
- return MonitorEpisodesSelectInput;
-
- 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.DOWNLOAD_CLIENT_SELECT:
- return DownloadClientSelectInputConnector;
-
- case inputTypes.ROOT_FOLDER_SELECT:
- return RootFolderSelectInputConnector;
-
- case inputTypes.SELECT:
- return EnhancedSelectInput;
-
- case inputTypes.DYNAMIC_SELECT:
- return EnhancedSelectInputConnector;
-
- case inputTypes.SERIES_TYPE_SELECT:
- return SeriesTypeSelectInput;
-
- 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 (
-
-
-
-
-
- {
- unit &&
-
- {unit}
-
- }
-
-
- {
- buttonsArray.map((button, index) => {
- return React.cloneElement(
- button,
- {
- isLastButton: index === lastButtonIndex
- }
- );
- })
- }
-
- {/*
- {
- pending &&
-
- }
-
*/}
-
-
- {
- !checkInput && helpText &&
-
- }
-
- {
- !checkInput && helpTexts &&
-
- {
- helpTexts.map((text, index) => {
- return (
-
- );
- })
- }
-
- }
-
- {
- (!checkInput || helpText) && helpTextWarning &&
-
- }
-
- {
- helpLink &&
-
- More Info
-
- }
-
- {
- errors.map((error, index) => {
- return (
-
- );
- })
- }
-
- {
- warnings.map((warning, index) => {
- return (
-
- );
- })
- }
-
- );
-}
-
-FormInputGroup.propTypes = {
- className: PropTypes.string.isRequired,
- containerClassName: PropTypes.string.isRequired,
- inputClassName: PropTypes.string,
- type: PropTypes.string.isRequired,
- 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,
- pending: PropTypes.bool,
- errors: PropTypes.arrayOf(PropTypes.object),
- warnings: PropTypes.arrayOf(PropTypes.object)
-};
-
-FormInputGroup.defaultProps = {
- className: styles.inputGroup,
- containerClassName: styles.inputGroupContainer,
- type: inputTypes.TEXT,
- buttons: [],
- helpTexts: [],
- errors: [],
- warnings: []
-};
-
-export default FormInputGroup;
diff --git a/frontend/src/Components/Form/FormInputGroup.tsx b/frontend/src/Components/Form/FormInputGroup.tsx
new file mode 100644
index 00000000000..98c6e586ae9
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.tsx
@@ -0,0 +1,303 @@
+import React, { FocusEvent, ReactNode } from 'react';
+import Link from 'Components/Link/Link';
+import { inputTypes } from 'Helpers/Props';
+import { InputType } from 'Helpers/Props/inputTypes';
+import { Kind } from 'Helpers/Props/kinds';
+import { ValidationError, ValidationWarning } from 'typings/pending';
+import translate from 'Utilities/String/translate';
+import AutoCompleteInput from './AutoCompleteInput';
+import CaptchaInput from './CaptchaInput';
+import CheckInput from './CheckInput';
+import { FormInputButtonProps } from './FormInputButton';
+import FormInputHelpText from './FormInputHelpText';
+import KeyValueListInput from './KeyValueListInput';
+import NumberInput from './NumberInput';
+import OAuthInput from './OAuthInput';
+import PasswordInput from './PasswordInput';
+import PathInput from './PathInput';
+import DownloadClientSelectInput from './Select/DownloadClientSelectInput';
+import EnhancedSelectInput from './Select/EnhancedSelectInput';
+import IndexerFlagsSelectInput from './Select/IndexerFlagsSelectInput';
+import IndexerSelectInput from './Select/IndexerSelectInput';
+import LanguageSelectInput from './Select/LanguageSelectInput';
+import MonitorEpisodesSelectInput from './Select/MonitorEpisodesSelectInput';
+import MonitorNewItemsSelectInput from './Select/MonitorNewItemsSelectInput';
+import ProviderDataSelectInput from './Select/ProviderOptionSelectInput';
+import QualityProfileSelectInput from './Select/QualityProfileSelectInput';
+import RootFolderSelectInput from './Select/RootFolderSelectInput';
+import SeriesTypeSelectInput from './Select/SeriesTypeSelectInput';
+import UMaskInput from './Select/UMaskInput';
+import DeviceInput from './Tag/DeviceInput';
+import SeriesTagInput from './Tag/SeriesTagInput';
+import TagSelectInput from './Tag/TagSelectInput';
+import TextTagInput from './Tag/TextTagInput';
+import TextArea from './TextArea';
+import TextInput from './TextInput';
+import styles from './FormInputGroup.css';
+
+function getComponent(type: InputType) {
+ switch (type) {
+ case inputTypes.AUTO_COMPLETE:
+ return AutoCompleteInput;
+
+ case inputTypes.CAPTCHA:
+ return CaptchaInput;
+
+ case inputTypes.CHECK:
+ return CheckInput;
+
+ case inputTypes.DEVICE:
+ return DeviceInput;
+
+ case inputTypes.KEY_VALUE_LIST:
+ return KeyValueListInput;
+
+ case inputTypes.LANGUAGE_SELECT:
+ return LanguageSelectInput;
+
+ case inputTypes.MONITOR_EPISODES_SELECT:
+ return MonitorEpisodesSelectInput;
+
+ case inputTypes.MONITOR_NEW_ITEMS_SELECT:
+ return MonitorNewItemsSelectInput;
+
+ case inputTypes.NUMBER:
+ return NumberInput;
+
+ case inputTypes.OAUTH:
+ return OAuthInput;
+
+ case inputTypes.PASSWORD:
+ return PasswordInput;
+
+ case inputTypes.PATH:
+ return PathInput;
+
+ case inputTypes.QUALITY_PROFILE_SELECT:
+ return QualityProfileSelectInput;
+
+ case inputTypes.INDEXER_SELECT:
+ return IndexerSelectInput;
+
+ case inputTypes.INDEXER_FLAGS_SELECT:
+ return IndexerFlagsSelectInput;
+
+ case inputTypes.DOWNLOAD_CLIENT_SELECT:
+ return DownloadClientSelectInput;
+
+ case inputTypes.ROOT_FOLDER_SELECT:
+ return RootFolderSelectInput;
+
+ case inputTypes.SELECT:
+ return EnhancedSelectInput;
+
+ case inputTypes.DYNAMIC_SELECT:
+ return ProviderDataSelectInput;
+
+ case inputTypes.TAG:
+ case inputTypes.SERIES_TAG:
+ return SeriesTagInput;
+
+ case inputTypes.SERIES_TYPE_SELECT:
+ return SeriesTypeSelectInput;
+
+ case inputTypes.TEXT_AREA:
+ return TextArea;
+
+ case inputTypes.TEXT_TAG:
+ return TextTagInput;
+
+ case inputTypes.TAG_SELECT:
+ return TagSelectInput;
+
+ case inputTypes.UMASK:
+ return UMaskInput;
+
+ default:
+ return TextInput;
+ }
+}
+
+// TODO: Remove once all parent components are updated to TSX and we can refactor to a consistent type
+interface ValidationMessage {
+ message: string;
+}
+
+interface FormInputGroupProps {
+ className?: string;
+ containerClassName?: string;
+ inputClassName?: string;
+ name: string;
+ value?: unknown;
+ values?: unknown[];
+ isDisabled?: boolean;
+ type?: InputType;
+ kind?: Kind;
+ min?: number;
+ max?: number;
+ unit?: string;
+ buttons?: ReactNode | ReactNode[];
+ helpText?: string;
+ helpTexts?: string[];
+ helpTextWarning?: string;
+ helpLink?: string;
+ placeholder?: string;
+ autoFocus?: boolean;
+ includeNoChange?: boolean;
+ includeNoChangeDisabled?: boolean;
+ valueOptions?: object;
+ selectedValueOptions?: object;
+ indexerFlags?: number;
+ pending?: boolean;
+ canEdit?: boolean;
+ includeAny?: boolean;
+ delimiters?: string[];
+ readOnly?: boolean;
+ errors?: (ValidationMessage | ValidationError)[];
+ warnings?: (ValidationMessage | ValidationWarning)[];
+ onChange: (args: T) => void;
+ onFocus?: (event: FocusEvent) => void;
+}
+
+function FormInputGroup(props: FormInputGroupProps) {
+ const {
+ className = styles.inputGroup,
+ containerClassName = styles.inputGroupContainer,
+ inputClassName,
+ type = 'text',
+ 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 (
+
+
+
+ {/* @ts-expect-error - need to pass through all the expected options */}
+
+
+ {unit && (
+
+ {unit}
+
+ )}
+
+
+ {buttonsArray.map((button, index) => {
+ if (!React.isValidElement
(button)) {
+ return button;
+ }
+
+ return React.cloneElement(button, {
+ isLastButton: index === lastButtonIndex,
+ });
+ })}
+
+ {/*
+ {
+ pending &&
+
+ }
+
*/}
+
+
+ {!checkInput && helpText ?
: null}
+
+ {!checkInput && helpTexts ? (
+
+ {helpTexts.map((text, index) => {
+ return (
+
+ );
+ })}
+
+ ) : null}
+
+ {(!checkInput || helpText) && helpTextWarning ? (
+
+ ) : null}
+
+ {helpLink ?
{translate('MoreInfo')} : null}
+
+ {errors.map((error, index) => {
+ return 'errorMessage' in error ? (
+
+ ) : (
+
+ );
+ })}
+
+ {warnings.map((warning, index) => {
+ return 'errorMessage' in warning ? (
+
+ ) : (
+
+ );
+ })}
+
+ );
+}
+
+export default FormInputGroup;
diff --git a/frontend/src/Components/Form/FormInputHelpText.css.d.ts b/frontend/src/Components/Form/FormInputHelpText.css.d.ts
new file mode 100644
index 00000000000..0e163f1348b
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputHelpText.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'details': string;
+ 'helpText': string;
+ 'isCheckInput': string;
+ 'isError': string;
+ 'isWarning': string;
+ 'link': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/FormInputHelpText.js b/frontend/src/Components/Form/FormInputHelpText.js
deleted file mode 100644
index 00024684e9f..00000000000
--- a/frontend/src/Components/Form/FormInputHelpText.js
+++ /dev/null
@@ -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 (
-
- {text}
-
- {
- link ?
-
-
- :
- null
- }
-
- {
- !link && tooltip ?
- :
- null
- }
-
- );
-}
-
-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;
diff --git a/frontend/src/Components/Form/FormInputHelpText.tsx b/frontend/src/Components/Form/FormInputHelpText.tsx
new file mode 100644
index 00000000000..1531d9585b3
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputHelpText.tsx
@@ -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 (
+
+ {text}
+
+ {link ? (
+
+
+
+ ) : null}
+
+ {!link && tooltip ? (
+
+ ) : null}
+
+ );
+}
+
+export default FormInputHelpText;
diff --git a/frontend/src/Components/Form/FormLabel.css b/frontend/src/Components/Form/FormLabel.css
index 074b6091db0..54a4678e856 100644
--- a/frontend/src/Components/Form/FormLabel.css
+++ b/frontend/src/Components/Form/FormLabel.css
@@ -2,8 +2,10 @@
display: flex;
justify-content: flex-end;
margin-right: $formLabelRightMarginWidth;
+ padding-top: 8px;
+ min-height: 35px;
+ text-align: end;
font-weight: bold;
- line-height: 35px;
}
.hasError {
diff --git a/frontend/src/Components/Form/FormLabel.css.d.ts b/frontend/src/Components/Form/FormLabel.css.d.ts
new file mode 100644
index 00000000000..c23dd30a422
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'hasError': string;
+ 'isAdvanced': string;
+ 'label': string;
+ 'large': string;
+ 'small': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/FormLabel.js b/frontend/src/Components/Form/FormLabel.js
deleted file mode 100644
index d419039b374..00000000000
--- a/frontend/src/Components/Form/FormLabel.js
+++ /dev/null
@@ -1,50 +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({
- children,
- className,
- errorClassName,
- size,
- name,
- hasError,
- isAdvanced,
- ...otherProps
-}) {
- return (
-
- {children}
-
- );
-}
-
-FormLabel.propTypes = {
- children: PropTypes.node.isRequired,
- className: PropTypes.string,
- errorClassName: PropTypes.string,
- size: PropTypes.oneOf(sizes.all),
- name: PropTypes.string,
- hasError: PropTypes.bool,
- isAdvanced: PropTypes.bool.isRequired
-};
-
-FormLabel.defaultProps = {
- className: styles.label,
- errorClassName: styles.hasError,
- isAdvanced: false,
- size: sizes.LARGE
-};
-
-export default FormLabel;
diff --git a/frontend/src/Components/Form/FormLabel.tsx b/frontend/src/Components/Form/FormLabel.tsx
new file mode 100644
index 00000000000..4f29e6ac6ed
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.tsx
@@ -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;
+ 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 (
+
+ {children}
+
+ );
+}
+
+export default FormLabel;
diff --git a/frontend/src/Components/Form/HintedSelectInputOption.js b/frontend/src/Components/Form/HintedSelectInputOption.js
deleted file mode 100644
index 4957ece2a3f..00000000000
--- a/frontend/src/Components/Form/HintedSelectInputOption.js
+++ /dev/null
@@ -1,66 +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,
- isMultiSelect,
- isMobile,
- ...otherProps
- } = props;
-
- return (
-
-
-
{value}
-
- {
- hint != null &&
-
- {hint}
-
- }
-
-
- );
-}
-
-HintedSelectInputOption.propTypes = {
- id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
- value: PropTypes.string.isRequired,
- hint: PropTypes.node,
- depth: PropTypes.number,
- isSelected: PropTypes.bool.isRequired,
- isDisabled: PropTypes.bool.isRequired,
- isMultiSelect: PropTypes.bool.isRequired,
- isMobile: PropTypes.bool.isRequired
-};
-
-HintedSelectInputOption.defaultProps = {
- isDisabled: false,
- isHidden: false,
- isMultiSelect: false
-};
-
-export default HintedSelectInputOption;
diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.js b/frontend/src/Components/Form/HintedSelectInputSelectedValue.js
deleted file mode 100644
index a3fecf32470..00000000000
--- a/frontend/src/Components/Form/HintedSelectInputSelectedValue.js
+++ /dev/null
@@ -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 (
-
-
- {
- isMultiSelect ?
- value.map((key, index) => {
- const v = valuesMap[key];
- return (
-
- {v ? v.value : key}
-
- );
- }) :
- null
- }
-
- {
- isMultiSelect ? null : value
- }
-
-
- {
- hint != null && includeHint ?
-
- {hint}
-
:
- null
- }
-
- );
-}
-
-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;
diff --git a/frontend/src/Components/Form/IndexerSelectInputConnector.js b/frontend/src/Components/Form/IndexerSelectInputConnector.js
deleted file mode 100644
index cd58270eb3e..00000000000
--- a/frontend/src/Components/Form/IndexerSelectInputConnector.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { fetchIndexers } from 'Store/Actions/settingsActions';
-import sortByName from 'Utilities/Array/sortByName';
-import EnhancedSelectInput from './EnhancedSelectInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.settings.indexers,
- (state, { includeAny }) => includeAny,
- (indexers, includeAny) => {
- const {
- isFetching,
- isPopulated,
- error,
- items
- } = indexers;
-
- const values = _.map(items.sort(sortByName), (indexer) => {
- return {
- 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 (
-
- );
- }
-}
-
-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);
diff --git a/frontend/src/Components/Form/KeyValueListInput.css.d.ts b/frontend/src/Components/Form/KeyValueListInput.css.d.ts
new file mode 100644
index 00000000000..972f108c96d
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInput.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'hasError': string;
+ 'hasWarning': string;
+ 'inputContainer': string;
+ 'isFocused': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/KeyValueListInput.js b/frontend/src/Components/Form/KeyValueListInput.js
deleted file mode 100644
index 3e73d74f3d6..00000000000
--- a/frontend/src/Components/Form/KeyValueListInput.js
+++ /dev/null
@@ -1,156 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import KeyValueListInputItem from './KeyValueListInputItem';
-import styles from './KeyValueListInput.css';
-
-class KeyValueListInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isFocused: false
- };
- }
-
- //
- // Listeners
-
- onItemChange = (index, itemValue) => {
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const newValue = [...value];
-
- if (index == null) {
- newValue.push(itemValue);
- } else {
- newValue.splice(index, 1, itemValue);
- }
-
- onChange({
- name,
- value: newValue
- });
- };
-
- onRemoveItem = (index) => {
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const newValue = [...value];
- newValue.splice(index, 1);
-
- onChange({
- name,
- value: newValue
- });
- };
-
- onFocus = () => {
- this.setState({
- isFocused: true
- });
- };
-
- onBlur = () => {
- this.setState({
- isFocused: false
- });
-
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const newValue = value.reduce((acc, v) => {
- if (v.key || v.value) {
- acc.push(v);
- }
-
- return acc;
- }, []);
-
- if (newValue.length !== value.length) {
- onChange({
- name,
- value: newValue
- });
- }
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- value,
- keyPlaceholder,
- valuePlaceholder,
- hasError,
- hasWarning
- } = this.props;
-
- const { isFocused } = this.state;
-
- return (
-
- {
- [...value, { key: '', value: '' }].map((v, index) => {
- return (
-
- );
- })
- }
-
- );
- }
-}
-
-KeyValueListInput.propTypes = {
- className: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- value: PropTypes.arrayOf(PropTypes.object).isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- keyPlaceholder: PropTypes.string,
- valuePlaceholder: PropTypes.string,
- onChange: PropTypes.func.isRequired
-};
-
-KeyValueListInput.defaultProps = {
- className: styles.inputContainer,
- value: []
-};
-
-export default KeyValueListInput;
diff --git a/frontend/src/Components/Form/KeyValueListInput.tsx b/frontend/src/Components/Form/KeyValueListInput.tsx
new file mode 100644
index 00000000000..f5c6ac19beb
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInput.tsx
@@ -0,0 +1,104 @@
+import classNames from 'classnames';
+import React, { useCallback, useState } from 'react';
+import { InputOnChange } from 'typings/inputs';
+import KeyValueListInputItem from './KeyValueListInputItem';
+import styles from './KeyValueListInput.css';
+
+interface KeyValue {
+ key: string;
+ value: string;
+}
+
+export interface KeyValueListInputProps {
+ className?: string;
+ name: string;
+ value: KeyValue[];
+ hasError?: boolean;
+ hasWarning?: boolean;
+ keyPlaceholder?: string;
+ valuePlaceholder?: string;
+ onChange: InputOnChange;
+}
+
+function KeyValueListInput({
+ className = styles.inputContainer,
+ name,
+ value = [],
+ hasError = false,
+ hasWarning = false,
+ keyPlaceholder,
+ valuePlaceholder,
+ onChange,
+}: KeyValueListInputProps): JSX.Element {
+ const [isFocused, setIsFocused] = useState(false);
+
+ const handleItemChange = useCallback(
+ (index: number | null, itemValue: KeyValue) => {
+ const newValue = [...value];
+
+ if (index === null) {
+ newValue.push(itemValue);
+ } else {
+ newValue.splice(index, 1, itemValue);
+ }
+
+ onChange({ name, value: newValue });
+ },
+ [value, name, onChange]
+ );
+
+ const handleRemoveItem = useCallback(
+ (index: number) => {
+ const newValue = [...value];
+ newValue.splice(index, 1);
+ onChange({ name, value: newValue });
+ },
+ [value, name, onChange]
+ );
+
+ const onFocus = useCallback(() => setIsFocused(true), []);
+
+ const onBlur = useCallback(() => {
+ setIsFocused(false);
+
+ const newValue = value.reduce((acc: KeyValue[], v) => {
+ if (v.key || v.value) {
+ acc.push(v);
+ }
+ return acc;
+ }, []);
+
+ if (newValue.length !== value.length) {
+ onChange({ name, value: newValue });
+ }
+ }, [value, name, onChange]);
+
+ return (
+
+ {[...value, { key: '', value: '' }].map((v, index) => (
+
+ ))}
+
+ );
+}
+
+export default KeyValueListInput;
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css b/frontend/src/Components/Form/KeyValueListInputItem.css
index 75d37b74f02..ed82db4592e 100644
--- a/frontend/src/Components/Form/KeyValueListInputItem.css
+++ b/frontend/src/Components/Form/KeyValueListInputItem.css
@@ -5,11 +5,12 @@
&:last-child {
margin-bottom: 0;
+ border-bottom: 0;
}
}
.keyInputWrapper {
- flex: 6 0 0;
+ flex: 1 0 0;
}
.valueInputWrapper {
@@ -25,4 +26,10 @@
.valueInput {
width: 100%;
border: none;
+ background-color: transparent;
+ color: var(--textColor);
+
+ &::placeholder {
+ color: var(--helpTextColor);
+ }
}
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts b/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts
new file mode 100644
index 00000000000..aa0c1be134c
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'buttonWrapper': string;
+ 'itemContainer': string;
+ 'keyInput': string;
+ 'keyInputWrapper': string;
+ 'valueInput': string;
+ 'valueInputWrapper': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.js b/frontend/src/Components/Form/KeyValueListInputItem.js
deleted file mode 100644
index 9f5abce2fbb..00000000000
--- a/frontend/src/Components/Form/KeyValueListInputItem.js
+++ /dev/null
@@ -1,124 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import IconButton from 'Components/Link/IconButton';
-import { icons } from 'Helpers/Props';
-import TextInput from './TextInput';
-import styles from './KeyValueListInputItem.css';
-
-class KeyValueListInputItem extends Component {
-
- //
- // Listeners
-
- onKeyChange = ({ value: keyValue }) => {
- const {
- index,
- value,
- onChange
- } = this.props;
-
- onChange(index, { key: keyValue, value });
- };
-
- onValueChange = ({ value }) => {
- // TODO: Validate here or validate at a lower level component
-
- const {
- index,
- keyValue,
- onChange
- } = this.props;
-
- onChange(index, { key: keyValue, value });
- };
-
- onRemovePress = () => {
- const {
- index,
- onRemove
- } = this.props;
-
- onRemove(index);
- };
-
- onFocus = () => {
- this.props.onFocus();
- };
-
- onBlur = () => {
- this.props.onBlur();
- };
-
- //
- // Render
-
- render() {
- const {
- keyValue,
- value,
- keyPlaceholder,
- valuePlaceholder,
- isNew
- } = this.props;
-
- return (
-
-
-
-
-
-
-
-
-
-
- {
- isNew ?
- null :
-
- }
-
-
- );
- }
-}
-
-KeyValueListInputItem.propTypes = {
- index: PropTypes.number,
- keyValue: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
- keyPlaceholder: PropTypes.string.isRequired,
- valuePlaceholder: PropTypes.string.isRequired,
- isNew: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired,
- onRemove: PropTypes.func.isRequired,
- onFocus: PropTypes.func.isRequired,
- onBlur: PropTypes.func.isRequired
-};
-
-KeyValueListInputItem.defaultProps = {
- keyPlaceholder: 'Key',
- valuePlaceholder: 'Value'
-};
-
-export default KeyValueListInputItem;
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.tsx b/frontend/src/Components/Form/KeyValueListInputItem.tsx
new file mode 100644
index 00000000000..c63ad50a929
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInputItem.tsx
@@ -0,0 +1,89 @@
+import React, { useCallback } from 'react';
+import IconButton from 'Components/Link/IconButton';
+import { icons } from 'Helpers/Props';
+import TextInput from './TextInput';
+import styles from './KeyValueListInputItem.css';
+
+interface KeyValueListInputItemProps {
+ index: number;
+ keyValue: string;
+ value: string;
+ keyPlaceholder?: string;
+ valuePlaceholder?: string;
+ isNew: boolean;
+ onChange: (index: number, itemValue: { key: string; value: string }) => void;
+ onRemove: (index: number) => void;
+ onFocus: () => void;
+ onBlur: () => void;
+}
+
+function KeyValueListInputItem({
+ index,
+ keyValue,
+ value,
+ keyPlaceholder = 'Key',
+ valuePlaceholder = 'Value',
+ isNew,
+ onChange,
+ onRemove,
+ onFocus,
+ onBlur,
+}: KeyValueListInputItemProps): JSX.Element {
+ const handleKeyChange = useCallback(
+ ({ value: keyValue }: { value: string }) => {
+ onChange(index, { key: keyValue, value });
+ },
+ [index, value, onChange]
+ );
+
+ const handleValueChange = useCallback(
+ ({ value }: { value: string }) => {
+ onChange(index, { key: keyValue, value });
+ },
+ [index, keyValue, onChange]
+ );
+
+ const handleRemovePress = useCallback(() => {
+ onRemove(index);
+ }, [index, onRemove]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {isNew ? null : (
+
+ )}
+
+
+ );
+}
+
+export default KeyValueListInputItem;
diff --git a/frontend/src/Components/Form/LanguageSelectInputConnector.js b/frontend/src/Components/Form/LanguageSelectInputConnector.js
deleted file mode 100644
index dd3a52017b6..00000000000
--- a/frontend/src/Components/Form/LanguageSelectInputConnector.js
+++ /dev/null
@@ -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 (
-
- );
- }
-}
-
-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);
diff --git a/frontend/src/Components/Form/MonitorEpisodesSelectInput.js b/frontend/src/Components/Form/MonitorEpisodesSelectInput.js
deleted file mode 100644
index f26693e6426..00000000000
--- a/frontend/src/Components/Form/MonitorEpisodesSelectInput.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import monitorOptions from 'Utilities/Series/monitorOptions';
-import SelectInput from './SelectInput';
-
-function MonitorEpisodesSelectInput(props) {
- const {
- includeNoChange,
- includeMixed,
- ...otherProps
- } = props;
-
- const values = [...monitorOptions];
-
- if (includeNoChange) {
- values.unshift({
- key: 'noChange',
- value: 'No Change',
- disabled: true
- });
- }
-
- if (includeMixed) {
- values.unshift({
- key: 'mixed',
- value: '(Mixed)',
- disabled: true
- });
- }
-
- return (
-
- );
-}
-
-MonitorEpisodesSelectInput.propTypes = {
- includeNoChange: PropTypes.bool.isRequired,
- includeMixed: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-MonitorEpisodesSelectInput.defaultProps = {
- includeNoChange: false,
- includeMixed: false
-};
-
-export default MonitorEpisodesSelectInput;
diff --git a/frontend/src/Components/Form/NumberInput.js b/frontend/src/Components/Form/NumberInput.js
deleted file mode 100644
index 8db1cd7b67e..00000000000
--- a/frontend/src/Components/Form/NumberInput.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import TextInput from './TextInput';
-
-function parseValue(props, value) {
- const {
- isFloat,
- min,
- max
- } = props;
-
- if (value == null || value === '') {
- return min;
- }
-
- let newValue = isFloat ? parseFloat(value) : parseInt(value);
-
- if (min != null && newValue != null && newValue < min) {
- newValue = min;
- } else if (max != null && newValue != null && newValue > max) {
- newValue = max;
- }
-
- return newValue;
-}
-
-class NumberInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- value: props.value == null ? '' : props.value.toString(),
- isFocused: false
- };
- }
-
- componentDidUpdate(prevProps, prevState) {
- const { value } = this.props;
-
- if (value !== prevProps.value && !this.state.isFocused) {
- this.setState({
- value: value == null ? '' : value.toString()
- });
- }
- }
-
- //
- // Listeners
-
- onChange = ({ name, value }) => {
- this.setState({ value });
-
- this.props.onChange({
- name,
- value: parseValue(this.props, value)
- });
-
- };
-
- onFocus = () => {
- this.setState({ isFocused: true });
- };
-
- onBlur = () => {
- const {
- name,
- onChange
- } = this.props;
-
- const { value } = this.state;
- const parsedValue = parseValue(this.props, value);
- const stringValue = parsedValue == null ? '' : parsedValue.toString();
-
- if (stringValue === value) {
- this.setState({ isFocused: false });
- } else {
- this.setState({
- value: stringValue,
- isFocused: false
- });
- }
-
- onChange({
- name,
- value: parsedValue
- });
- };
-
- //
- // Render
-
- render() {
- const value = this.state.value;
-
- return (
-
- );
- }
-}
-
-NumberInput.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.number,
- min: PropTypes.number,
- max: PropTypes.number,
- isFloat: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-NumberInput.defaultProps = {
- value: null,
- isFloat: false
-};
-
-export default NumberInput;
diff --git a/frontend/src/Components/Form/NumberInput.tsx b/frontend/src/Components/Form/NumberInput.tsx
new file mode 100644
index 00000000000..a5e1fcb64bc
--- /dev/null
+++ b/frontend/src/Components/Form/NumberInput.tsx
@@ -0,0 +1,108 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import { InputChanged } from 'typings/inputs';
+import TextInput, { TextInputProps } from './TextInput';
+
+function parseValue(
+ value: string | null | undefined,
+ isFloat: boolean,
+ min: number | undefined,
+ max: number | undefined
+) {
+ if (value == null || value === '') {
+ return null;
+ }
+
+ let newValue = isFloat ? parseFloat(value) : parseInt(value);
+
+ if (min != null && newValue != null && newValue < min) {
+ newValue = min;
+ } else if (max != null && newValue != null && newValue > max) {
+ newValue = max;
+ }
+
+ return newValue;
+}
+
+interface NumberInputProps
+ extends Omit, 'value'> {
+ value?: number | null;
+ min?: number;
+ max?: number;
+ isFloat?: boolean;
+}
+
+function NumberInput({
+ name,
+ value: inputValue = null,
+ isFloat = false,
+ min,
+ max,
+ onChange,
+ ...otherProps
+}: NumberInputProps) {
+ const [value, setValue] = useState(
+ inputValue == null ? '' : inputValue.toString()
+ );
+ const isFocused = useRef(false);
+ const previousValue = usePrevious(inputValue);
+
+ const handleChange = useCallback(
+ ({ name, value: newValue }: InputChanged) => {
+ setValue(newValue);
+
+ onChange({
+ name,
+ value: parseValue(newValue, isFloat, min, max),
+ });
+ },
+ [isFloat, min, max, onChange, setValue]
+ );
+
+ const handleFocus = useCallback(() => {
+ isFocused.current = true;
+ }, []);
+
+ const handleBlur = useCallback(() => {
+ const parsedValue = parseValue(value, isFloat, min, max);
+ const stringValue = parsedValue == null ? '' : parsedValue.toString();
+
+ if (stringValue !== value) {
+ setValue(stringValue);
+ }
+
+ onChange({
+ name,
+ value: parsedValue,
+ });
+
+ isFocused.current = false;
+ }, [name, value, isFloat, min, max, onChange]);
+
+ useEffect(() => {
+ if (
+ // @ts-expect-error inputValue may be null
+ !isNaN(inputValue) &&
+ inputValue !== previousValue &&
+ !isFocused.current
+ ) {
+ setValue(inputValue == null ? '' : inputValue.toString());
+ }
+ }, [inputValue, previousValue, setValue]);
+
+ return (
+
+ );
+}
+
+export default NumberInput;
diff --git a/frontend/src/Components/Form/OAuthInput.js b/frontend/src/Components/Form/OAuthInput.js
deleted file mode 100644
index 4ecd625bc79..00000000000
--- a/frontend/src/Components/Form/OAuthInput.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
-import { kinds } from 'Helpers/Props';
-
-function OAuthInput(props) {
- const {
- label,
- authorizing,
- error,
- onPress
- } = props;
-
- return (
-
-
- {label}
-
-
- );
-}
-
-OAuthInput.propTypes = {
- label: PropTypes.string.isRequired,
- authorizing: PropTypes.bool.isRequired,
- error: PropTypes.object,
- onPress: PropTypes.func.isRequired
-};
-
-OAuthInput.defaultProps = {
- label: 'Start OAuth'
-};
-
-export default OAuthInput;
diff --git a/frontend/src/Components/Form/OAuthInput.tsx b/frontend/src/Components/Form/OAuthInput.tsx
new file mode 100644
index 00000000000..04d2a0cafaa
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInput.tsx
@@ -0,0 +1,72 @@
+import React, { useCallback, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import { kinds } from 'Helpers/Props';
+import { resetOAuth, startOAuth } from 'Store/Actions/oAuthActions';
+import { InputOnChange } from 'typings/inputs';
+
+interface OAuthInputProps {
+ label?: string;
+ name: string;
+ provider: string;
+ providerData: object;
+ section: string;
+ onChange: InputOnChange;
+}
+
+function OAuthInput({
+ label = 'Start OAuth',
+ name,
+ provider,
+ providerData,
+ section,
+ onChange,
+}: OAuthInputProps) {
+ const dispatch = useDispatch();
+ const { authorizing, error, result } = useSelector(
+ (state: AppState) => state.oAuth
+ );
+
+ const handlePress = useCallback(() => {
+ dispatch(
+ startOAuth({
+ name,
+ provider,
+ providerData,
+ section,
+ })
+ );
+ }, [name, provider, providerData, section, dispatch]);
+
+ useEffect(() => {
+ if (!result) {
+ return;
+ }
+
+ Object.keys(result).forEach((key) => {
+ onChange({ name: key, value: result[key] });
+ });
+ }, [result, onChange]);
+
+ useEffect(() => {
+ return () => {
+ dispatch(resetOAuth());
+ };
+ }, [dispatch]);
+
+ return (
+
+
+ {label}
+
+
+ );
+}
+
+export default OAuthInput;
diff --git a/frontend/src/Components/Form/OAuthInputConnector.js b/frontend/src/Components/Form/OAuthInputConnector.js
deleted file mode 100644
index 1567c7e6c0b..00000000000
--- a/frontend/src/Components/Form/OAuthInputConnector.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { resetOAuth, startOAuth } from 'Store/Actions/oAuthActions';
-import OAuthInput from './OAuthInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.oAuth,
- (oAuth) => {
- return oAuth;
- }
- );
-}
-
-const mapDispatchToProps = {
- startOAuth,
- resetOAuth
-};
-
-class OAuthInputConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidUpdate(prevProps) {
- const {
- result,
- onChange
- } = this.props;
-
- if (!result || result === prevProps.result) {
- return;
- }
-
- Object.keys(result).forEach((key) => {
- onChange({ name: key, value: result[key] });
- });
- }
-
- componentWillUnmount = () => {
- this.props.resetOAuth();
- };
-
- //
- // Listeners
-
- onPress = () => {
- const {
- name,
- provider,
- providerData,
- section
- } = this.props;
-
- this.props.startOAuth({
- name,
- provider,
- providerData,
- section
- });
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-OAuthInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- result: PropTypes.object,
- provider: PropTypes.string.isRequired,
- providerData: PropTypes.object.isRequired,
- section: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
- startOAuth: PropTypes.func.isRequired,
- resetOAuth: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(OAuthInputConnector);
diff --git a/frontend/src/Components/Form/PasswordInput.css b/frontend/src/Components/Form/PasswordInput.css
deleted file mode 100644
index 6cb1627844b..00000000000
--- a/frontend/src/Components/Form/PasswordInput.css
+++ /dev/null
@@ -1,5 +0,0 @@
-.input {
- composes: input from '~Components/Form/TextInput.css';
-
- font-family: $passwordFamily;
-}
diff --git a/frontend/src/Components/Form/PasswordInput.js b/frontend/src/Components/Form/PasswordInput.js
deleted file mode 100644
index fef54fd5a44..00000000000
--- a/frontend/src/Components/Form/PasswordInput.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import TextInput from './TextInput';
-import styles from './PasswordInput.css';
-
-// Prevent a user from copying (or cutting) the password from the input
-function onCopy(e) {
- e.preventDefault();
- e.nativeEvent.stopImmediatePropagation();
-}
-
-function PasswordInput(props) {
- return (
-
- );
-}
-
-PasswordInput.propTypes = {
- className: PropTypes.string.isRequired
-};
-
-PasswordInput.defaultProps = {
- className: styles.input
-};
-
-export default PasswordInput;
diff --git a/frontend/src/Components/Form/PasswordInput.tsx b/frontend/src/Components/Form/PasswordInput.tsx
new file mode 100644
index 00000000000..776c2b9132f
--- /dev/null
+++ b/frontend/src/Components/Form/PasswordInput.tsx
@@ -0,0 +1,14 @@
+import React, { SyntheticEvent } from 'react';
+import TextInput, { TextInputProps } from './TextInput';
+
+// Prevent a user from copying (or cutting) the password from the input
+function onCopy(e: SyntheticEvent) {
+ e.preventDefault();
+ e.nativeEvent.stopImmediatePropagation();
+}
+
+function PasswordInput(props: TextInputProps) {
+ return ;
+}
+
+export default PasswordInput;
diff --git a/frontend/src/Components/Form/PathInput.css b/frontend/src/Components/Form/PathInput.css
index 3b32b16f0df..327a85ef800 100644
--- a/frontend/src/Components/Form/PathInput.css
+++ b/frontend/src/Components/Form/PathInput.css
@@ -16,3 +16,7 @@
height: 35px;
}
+
+.fileBrowserMiddleButton {
+ composes: middleButton from '~./FormInputButton.css';
+}
diff --git a/frontend/src/Components/Form/PathInput.css.d.ts b/frontend/src/Components/Form/PathInput.css.d.ts
new file mode 100644
index 00000000000..82be3d1ff4d
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'fileBrowserButton': string;
+ 'fileBrowserMiddleButton': string;
+ 'hasFileBrowser': string;
+ 'inputWrapper': string;
+ 'pathMatch': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/PathInput.js b/frontend/src/Components/Form/PathInput.js
deleted file mode 100644
index 972d8f99fc1..00000000000
--- a/frontend/src/Components/Form/PathInput.js
+++ /dev/null
@@ -1,195 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
-import Icon from 'Components/Icon';
-import { icons } from 'Helpers/Props';
-import AutoSuggestInput from './AutoSuggestInput';
-import FormInputButton from './FormInputButton';
-import styles from './PathInput.css';
-
-class PathInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._node = document.getElementById('portal-root');
-
- this.state = {
- value: props.value,
- isFileBrowserModalOpen: false
- };
- }
-
- componentDidUpdate(prevProps) {
- const { value } = this.props;
-
- if (prevProps.value !== value) {
- this.setState({ value });
- }
- }
-
- //
- // Control
-
- getSuggestionValue({ path }) {
- return path;
- }
-
- renderSuggestion({ path }, { query }) {
- const lastSeparatorIndex = query.lastIndexOf('\\') || query.lastIndexOf('/');
-
- if (lastSeparatorIndex === -1) {
- return (
- {path}
- );
- }
-
- return (
-
-
- {path.substr(0, lastSeparatorIndex)}
-
- {path.substr(lastSeparatorIndex)}
-
- );
- }
-
- //
- // Listeners
-
- onInputChange = ({ value }) => {
- this.setState({ value });
- };
-
- onInputKeyDown = (event) => {
- if (event.key === 'Tab') {
- event.preventDefault();
- const path = this.props.paths[0];
-
- if (path) {
- this.props.onChange({
- name: this.props.name,
- value: path.path
- });
-
- if (path.type !== 'file') {
- this.props.onFetchPaths(path.path);
- }
- }
- }
- };
-
- onInputBlur = () => {
- this.props.onChange({
- name: this.props.name,
- value: this.state.value
- });
-
- this.props.onClearPaths();
- };
-
- onSuggestionsFetchRequested = ({ value }) => {
- this.props.onFetchPaths(value);
- };
-
- onSuggestionsClearRequested = () => {
- // Required because props aren't always rendered, but no-op
- // because we don't want to reset the paths after a path is selected.
- };
-
- onSuggestionSelected = (event, { suggestionValue }) => {
- this.props.onFetchPaths(suggestionValue);
- };
-
- onFileBrowserOpenPress = () => {
- this.setState({ isFileBrowserModalOpen: true });
- };
-
- onFileBrowserModalClose = () => {
- this.setState({ isFileBrowserModalOpen: false });
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- name,
- paths,
- includeFiles,
- hasFileBrowser,
- onChange,
- ...otherProps
- } = this.props;
-
- const {
- value,
- isFileBrowserModalOpen
- } = this.state;
-
- return (
-
-
-
- {
- hasFileBrowser &&
-
-
-
-
-
-
-
- }
-
- );
- }
-}
-
-PathInput.propTypes = {
- className: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- value: PropTypes.string,
- paths: PropTypes.array.isRequired,
- includeFiles: PropTypes.bool.isRequired,
- hasFileBrowser: PropTypes.bool,
- onChange: PropTypes.func.isRequired,
- onFetchPaths: PropTypes.func.isRequired,
- onClearPaths: PropTypes.func.isRequired
-};
-
-PathInput.defaultProps = {
- className: styles.inputWrapper,
- value: '',
- hasFileBrowser: true
-};
-
-export default PathInput;
diff --git a/frontend/src/Components/Form/PathInput.tsx b/frontend/src/Components/Form/PathInput.tsx
new file mode 100644
index 00000000000..0caf66905d2
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.tsx
@@ -0,0 +1,258 @@
+import classNames from 'classnames';
+import React, {
+ KeyboardEvent,
+ SyntheticEvent,
+ useCallback,
+ useEffect,
+ useState,
+} from 'react';
+import {
+ ChangeEvent,
+ SuggestionsFetchRequestedParams,
+} from 'react-autosuggest';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import { Path } from 'App/State/PathsAppState';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import Icon from 'Components/Icon';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import { icons } from 'Helpers/Props';
+import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
+import { InputChanged } from 'typings/inputs';
+import AutoSuggestInput from './AutoSuggestInput';
+import FormInputButton from './FormInputButton';
+import styles from './PathInput.css';
+
+interface PathInputProps {
+ className?: string;
+ name: string;
+ value?: string;
+ placeholder?: string;
+ includeFiles: boolean;
+ hasButton?: boolean;
+ hasFileBrowser?: boolean;
+ onChange: (change: InputChanged) => void;
+}
+
+interface PathInputInternalProps extends PathInputProps {
+ paths: Path[];
+ onFetchPaths: (path: string) => void;
+ onClearPaths: () => void;
+}
+
+function handleSuggestionsClearRequested() {
+ // Required because props aren't always rendered, but no-op
+ // because we don't want to reset the paths after a path is selected.
+}
+
+function createPathsSelector() {
+ return createSelector(
+ (state: AppState) => state.paths,
+ (paths) => {
+ const { currentPath, directories, files } = paths;
+
+ const filteredPaths = [...directories, ...files].filter(({ path }) => {
+ return path.toLowerCase().startsWith(currentPath.toLowerCase());
+ });
+
+ return filteredPaths;
+ }
+ );
+}
+
+function PathInput(props: PathInputProps) {
+ const { includeFiles } = props;
+
+ const dispatch = useDispatch();
+
+ const paths = useSelector(createPathsSelector());
+
+ const handleFetchPaths = useCallback(
+ (path: string) => {
+ dispatch(fetchPaths({ path, includeFiles }));
+ },
+ [includeFiles, dispatch]
+ );
+
+ const handleClearPaths = useCallback(() => {
+ dispatch(clearPaths);
+ }, [dispatch]);
+
+ return (
+
+ );
+}
+
+export default PathInput;
+
+export function PathInputInternal(props: PathInputInternalProps) {
+ const {
+ className = styles.inputWrapper,
+ name,
+ value: inputValue = '',
+ paths,
+ includeFiles,
+ hasButton,
+ hasFileBrowser = true,
+ onChange,
+ onFetchPaths,
+ onClearPaths,
+ ...otherProps
+ } = props;
+
+ const [value, setValue] = useState(inputValue);
+ const [isFileBrowserModalOpen, setIsFileBrowserModalOpen] = useState(false);
+ const previousInputValue = usePrevious(inputValue);
+ const dispatch = useDispatch();
+
+ const handleFetchPaths = useCallback(
+ (path: string) => {
+ dispatch(fetchPaths({ path, includeFiles }));
+ },
+ [includeFiles, dispatch]
+ );
+
+ const handleInputChange = useCallback(
+ (_event: SyntheticEvent, { newValue }: ChangeEvent) => {
+ setValue(newValue);
+ },
+ [setValue]
+ );
+
+ const handleInputKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key === 'Tab') {
+ event.preventDefault();
+ const path = paths[0];
+
+ if (path) {
+ onChange({
+ name,
+ value: path.path,
+ });
+
+ if (path.type !== 'file') {
+ handleFetchPaths(path.path);
+ }
+ }
+ }
+ },
+ [name, paths, handleFetchPaths, onChange]
+ );
+ const handleInputBlur = useCallback(() => {
+ onChange({
+ name,
+ value,
+ });
+
+ onClearPaths();
+ }, [name, value, onClearPaths, onChange]);
+
+ const handleSuggestionSelected = useCallback(
+ (_event: SyntheticEvent, { suggestion }: { suggestion: Path }) => {
+ handleFetchPaths(suggestion.path);
+ },
+ [handleFetchPaths]
+ );
+
+ const handleSuggestionsFetchRequested = useCallback(
+ ({ value: newValue }: SuggestionsFetchRequestedParams) => {
+ handleFetchPaths(newValue);
+ },
+ [handleFetchPaths]
+ );
+
+ const handleFileBrowserOpenPress = useCallback(() => {
+ setIsFileBrowserModalOpen(true);
+ }, [setIsFileBrowserModalOpen]);
+
+ const handleFileBrowserModalClose = useCallback(() => {
+ setIsFileBrowserModalOpen(false);
+ }, [setIsFileBrowserModalOpen]);
+
+ const handleChange = useCallback(
+ (change: InputChanged) => {
+ onChange({ name, value: change.value.path });
+ },
+ [name, onChange]
+ );
+
+ const getSuggestionValue = useCallback(({ path }: Path) => path, []);
+
+ const renderSuggestion = useCallback(
+ ({ path }: Path, { query }: { query: string }) => {
+ const lastSeparatorIndex =
+ query.lastIndexOf('\\') || query.lastIndexOf('/');
+
+ if (lastSeparatorIndex === -1) {
+ return {path} ;
+ }
+
+ return (
+
+
+ {path.substring(0, lastSeparatorIndex)}
+
+ {path.substring(lastSeparatorIndex)}
+
+ );
+ },
+ []
+ );
+
+ useEffect(() => {
+ if (inputValue !== previousInputValue) {
+ setValue(inputValue);
+ }
+ }, [inputValue, previousInputValue, setValue]);
+
+ return (
+
+
+
+ {hasFileBrowser ? (
+ <>
+
+
+
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/Components/Form/PathInputConnector.js b/frontend/src/Components/Form/PathInputConnector.js
deleted file mode 100644
index 3917a8d3f9e..00000000000
--- a/frontend/src/Components/Form/PathInputConnector.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
-import PathInput from './PathInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.paths,
- (paths) => {
- const {
- currentPath,
- directories,
- files
- } = paths;
-
- const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
- return path.toLowerCase().startsWith(currentPath.toLowerCase());
- });
-
- return {
- paths: filteredPaths
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- dispatchFetchPaths: fetchPaths,
- dispatchClearPaths: clearPaths
-};
-
-class PathInputConnector extends Component {
-
- //
- // Listeners
-
- onFetchPaths = (path) => {
- const {
- includeFiles,
- dispatchFetchPaths
- } = this.props;
-
- dispatchFetchPaths({
- path,
- includeFiles
- });
- };
-
- onClearPaths = () => {
- this.props.dispatchClearPaths();
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-PathInputConnector.propTypes = {
- includeFiles: PropTypes.bool.isRequired,
- dispatchFetchPaths: PropTypes.func.isRequired,
- dispatchClearPaths: PropTypes.func.isRequired
-};
-
-PathInputConnector.defaultProps = {
- includeFiles: false
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(PathInputConnector);
diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js
index eb0844b8c53..f081f5906f8 100644
--- a/frontend/src/Components/Form/ProviderFieldFormGroup.js
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -14,6 +14,8 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.CHECK;
case 'device':
return inputTypes.DEVICE;
+ case 'keyValueList':
+ return inputTypes.KEY_VALUE_LIST;
case 'password':
return inputTypes.PASSWORD;
case 'number':
@@ -27,6 +29,8 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.DYNAMIC_SELECT;
}
return inputTypes.SELECT;
+ case 'seriesTag':
+ return inputTypes.SERIES_TAG;
case 'tag':
return inputTypes.TEXT_TAG;
case 'tagSelect':
@@ -37,6 +41,8 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.OAUTH;
case 'rootFolder':
return inputTypes.ROOT_FOLDER_SELECT;
+ case 'qualityProfile':
+ return inputTypes.QUALITY_PROFILE_SELECT;
default:
return inputTypes.TEXT;
}
@@ -64,7 +70,9 @@ function ProviderFieldFormGroup(props) {
name,
label,
helpText,
+ helpTextWarning,
helpLink,
+ placeholder,
value,
type,
advanced,
@@ -96,7 +104,9 @@ function ProviderFieldFormGroup(props) {
name={name}
label={label}
helpText={helpText}
+ helpTextWarning={helpTextWarning}
helpLink={helpLink}
+ placeholder={placeholder}
value={value}
values={getSelectValues(selectOptions)}
errors={errors}
@@ -121,11 +131,15 @@ ProviderFieldFormGroup.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
helpText: PropTypes.string,
+ helpTextWarning: PropTypes.string,
helpLink: PropTypes.string,
+ placeholder: PropTypes.string,
value: PropTypes.any,
type: PropTypes.string.isRequired,
advanced: PropTypes.bool.isRequired,
hidden: PropTypes.string,
+ isDisabled: PropTypes.bool,
+ provider: PropTypes.string,
pending: PropTypes.bool.isRequired,
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
diff --git a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js
deleted file mode 100644
index a18f4468ead..00000000000
--- a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
-import sortByName from 'Utilities/Array/sortByName';
-import SelectInput from './SelectInput';
-
-function createMapStateToProps() {
- return createSelector(
- createSortedSectionSelector('settings.qualityProfiles', sortByName),
- (state, { includeNoChange }) => includeNoChange,
- (state, { includeMixed }) => includeMixed,
- (qualityProfiles, includeNoChange, includeMixed) => {
- const values = _.map(qualityProfiles.items, (qualityProfile) => {
- return {
- key: qualityProfile.id,
- value: qualityProfile.name
- };
- });
-
- if (includeNoChange) {
- values.unshift({
- key: 'noChange',
- value: 'No Change',
- disabled: true
- });
- }
-
- if (includeMixed) {
- values.unshift({
- key: 'mixed',
- value: '(Mixed)',
- disabled: true
- });
- }
-
- return {
- values
- };
- }
- );
-}
-
-class QualityProfileSelectInputConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidMount() {
- const {
- name,
- value,
- values
- } = this.props;
-
- if (!value || !_.some(values, (option) => parseInt(option.key) === value)) {
- const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key)));
-
- if (firstValue) {
- this.onChange({ name, value: firstValue.key });
- }
- }
- }
-
- //
- // Listeners
-
- onChange = ({ name, value }) => {
- this.props.onChange({ name, value: parseInt(value) });
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-QualityProfileSelectInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- includeNoChange: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-QualityProfileSelectInputConnector.defaultProps = {
- includeNoChange: false
-};
-
-export default connect(createMapStateToProps)(QualityProfileSelectInputConnector);
diff --git a/frontend/src/Components/Form/RootFolderSelectInput.js b/frontend/src/Components/Form/RootFolderSelectInput.js
deleted file mode 100644
index 1d76ad9462f..00000000000
--- a/frontend/src/Components/Form/RootFolderSelectInput.js
+++ /dev/null
@@ -1,109 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
-import EnhancedSelectInput from './EnhancedSelectInput';
-import RootFolderSelectInputOption from './RootFolderSelectInputOption';
-import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
-
-class RootFolderSelectInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isAddNewRootFolderModalOpen: false,
- newRootFolderPath: ''
- };
- }
-
- componentDidUpdate(prevProps) {
- const {
- name,
- isSaving,
- saveError,
- onChange
- } = this.props;
-
- const newRootFolderPath = this.state.newRootFolderPath;
-
- if (
- prevProps.isSaving &&
- !isSaving &&
- !saveError &&
- newRootFolderPath
- ) {
- onChange({ name, value: newRootFolderPath });
- this.setState({ newRootFolderPath: '' });
- }
- }
-
- //
- // Listeners
-
- onChange = ({ name, value }) => {
- if (value === 'addNew') {
- this.setState({ isAddNewRootFolderModalOpen: true });
- } else {
- this.props.onChange({ name, value });
- }
- };
-
- onNewRootFolderSelect = ({ value }) => {
- this.setState({ newRootFolderPath: value }, () => {
- this.props.onNewRootFolderSelect(value);
- });
- };
-
- onAddRootFolderModalClose = () => {
- this.setState({ isAddNewRootFolderModalOpen: false });
- };
-
- //
- // Render
-
- render() {
- const {
- includeNoChange,
- onNewRootFolderSelect,
- ...otherProps
- } = this.props;
-
- return (
-
-
-
-
-
- );
- }
-}
-
-RootFolderSelectInput.propTypes = {
- name: PropTypes.string.isRequired,
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- isSaving: PropTypes.bool.isRequired,
- saveError: PropTypes.object,
- includeNoChange: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired,
- onNewRootFolderSelect: PropTypes.func.isRequired
-};
-
-RootFolderSelectInput.defaultProps = {
- includeNoChange: false
-};
-
-export default RootFolderSelectInput;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
deleted file mode 100644
index f61fed78ad0..00000000000
--- a/frontend/src/Components/Form/RootFolderSelectInputConnector.js
+++ /dev/null
@@ -1,170 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { addRootFolder } from 'Store/Actions/rootFolderActions';
-import RootFolderSelectInput from './RootFolderSelectInput';
-
-const ADD_NEW_KEY = 'addNew';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.rootFolders,
- (state, { value }) => value,
- (state, { includeMissingValue }) => includeMissingValue,
- (state, { includeNoChange }) => includeNoChange,
- (rootFolders, value, includeMissingValue, includeNoChange) => {
- const values = rootFolders.items.map((rootFolder) => {
- return {
- key: rootFolder.path,
- value: rootFolder.path,
- freeSpace: rootFolder.freeSpace,
- isMissing: false
- };
- });
-
- if (includeNoChange) {
- values.unshift({
- key: 'noChange',
- value: 'No Change',
- isDisabled: true,
- isMissing: false
- });
- }
-
- if (!values.length) {
- values.push({
- key: '',
- value: '',
- isDisabled: true,
- isHidden: true
- });
- }
-
- if (includeMissingValue && !values.find((v) => v.key === value)) {
- values.push({
- key: value,
- value,
- isMissing: true,
- isDisabled: true
- });
- }
-
- values.push({
- key: ADD_NEW_KEY,
- value: 'Add a new path'
- });
-
- return {
- values,
- isSaving: rootFolders.isSaving,
- saveError: rootFolders.saveError
- };
- }
- );
-}
-
-function createMapDispatchToProps(dispatch, props) {
- return {
- dispatchAddRootFolder(path) {
- dispatch(addRootFolder({ path }));
- }
- };
-}
-
-class RootFolderSelectInputConnector extends Component {
-
- //
- // Lifecycle
-
- componentWillMount() {
- const {
- value,
- values,
- onChange
- } = this.props;
-
- if (value == null && values[0].key === '') {
- onChange({ name, value: '' });
- }
- }
-
- componentDidMount() {
- const {
- name,
- value,
- values,
- onChange
- } = this.props;
-
- if (!value || !values.some((v) => v.key === value) || value === ADD_NEW_KEY) {
- const defaultValue = values[0];
-
- if (defaultValue.key === ADD_NEW_KEY) {
- onChange({ name, value: '' });
- } else {
- onChange({ name, value: defaultValue.key });
- }
- }
- }
-
- componentDidUpdate(prevProps) {
- const {
- name,
- value,
- values,
- onChange
- } = this.props;
-
- if (prevProps.values === values) {
- return;
- }
-
- if (!value && values.length && values.some((v) => !!v.key && v.key !== ADD_NEW_KEY)) {
- const defaultValue = values[0];
-
- if (defaultValue.key !== ADD_NEW_KEY) {
- onChange({ name, value: defaultValue.key });
- }
- }
- }
-
- //
- // Listeners
-
- onNewRootFolderSelect = (path) => {
- this.props.dispatchAddRootFolder(path);
- };
-
- //
- // Render
-
- render() {
- const {
- dispatchAddRootFolder,
- ...otherProps
- } = this.props;
-
- return (
-
- );
- }
-}
-
-RootFolderSelectInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.string,
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- includeNoChange: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired,
- dispatchAddRootFolder: PropTypes.func.isRequired
-};
-
-RootFolderSelectInputConnector.defaultProps = {
- includeNoChange: false
-};
-
-export default connect(createMapStateToProps, createMapDispatchToProps)(RootFolderSelectInputConnector);
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.js b/frontend/src/Components/Form/RootFolderSelectInputOption.js
deleted file mode 100644
index 3197ba87531..00000000000
--- a/frontend/src/Components/Form/RootFolderSelectInputOption.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import formatBytes from 'Utilities/Number/formatBytes';
-import EnhancedSelectInputOption from './EnhancedSelectInputOption';
-import styles from './RootFolderSelectInputOption.css';
-
-function RootFolderSelectInputOption(props) {
- const {
- id,
- value,
- freeSpace,
- isMissing,
- seriesFolder,
- isMobile,
- isWindows,
- ...otherProps
- } = props;
-
- const slashCharacter = isWindows ? '\\' : '/';
-
- return (
-
-
-
- {value}
-
- {
- seriesFolder && id !== 'addNew' ?
-
- {slashCharacter}
- {seriesFolder}
-
:
- null
- }
-
-
- {
- freeSpace == null ?
- null :
-
- {formatBytes(freeSpace)} Free
-
- }
-
- {
- isMissing ?
-
- Missing
-
:
- null
- }
-
-
- );
-}
-
-RootFolderSelectInputOption.propTypes = {
- id: PropTypes.string.isRequired,
- value: PropTypes.string.isRequired,
- freeSpace: PropTypes.number,
- isMissing: PropTypes.bool,
- seriesFolder: PropTypes.string,
- isMobile: PropTypes.bool.isRequired,
- isWindows: PropTypes.bool
-};
-
-export default RootFolderSelectInputOption;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
deleted file mode 100644
index 69b1453f3dd..00000000000
--- a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import formatBytes from 'Utilities/Number/formatBytes';
-import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
-import styles from './RootFolderSelectInputSelectedValue.css';
-
-function RootFolderSelectInputSelectedValue(props) {
- const {
- value,
- freeSpace,
- seriesFolder,
- includeFreeSpace,
- isWindows,
- ...otherProps
- } = props;
-
- const slashCharacter = isWindows ? '\\' : '/';
-
- return (
-
-
-
- {value}
-
-
- {
- seriesFolder ?
-
- {slashCharacter}
- {seriesFolder}
-
:
- null
- }
-
-
- {
- freeSpace != null && includeFreeSpace &&
-
- {formatBytes(freeSpace)} Free
-
- }
-
- );
-}
-
-RootFolderSelectInputSelectedValue.propTypes = {
- value: PropTypes.string,
- freeSpace: PropTypes.number,
- seriesFolder: PropTypes.string,
- isWindows: PropTypes.bool,
- includeFreeSpace: PropTypes.bool.isRequired
-};
-
-RootFolderSelectInputSelectedValue.defaultProps = {
- includeFreeSpace: true
-};
-
-export default RootFolderSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Select/DownloadClientSelectInput.tsx b/frontend/src/Components/Form/Select/DownloadClientSelectInput.tsx
new file mode 100644
index 00000000000..4ed3e0952fe
--- /dev/null
+++ b/frontend/src/Components/Form/Select/DownloadClientSelectInput.tsx
@@ -0,0 +1,88 @@
+import React, { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import { fetchDownloadClients } from 'Store/Actions/settingsActions';
+import { Protocol } from 'typings/DownloadClient';
+import { EnhancedSelectInputChanged } from 'typings/inputs';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+function createDownloadClientsSelector(
+ includeAny: boolean,
+ protocol: Protocol
+) {
+ return createSelector(
+ (state: AppState) => state.settings.downloadClients,
+ (downloadClients) => {
+ const { isFetching, isPopulated, error, items } = downloadClients;
+
+ const filteredItems = items.filter((item) => item.protocol === protocol);
+
+ const values = filteredItems
+ .sort(sortByProp('name'))
+ .map((downloadClient) => {
+ return {
+ key: downloadClient.id,
+ value: downloadClient.name,
+ hint: `(${downloadClient.id})`,
+ };
+ });
+
+ if (includeAny) {
+ values.unshift({
+ key: 0,
+ value: `(${translate('Any')})`,
+ hint: '',
+ });
+ }
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ values,
+ };
+ }
+ );
+}
+
+interface DownloadClientSelectInputProps
+ extends EnhancedSelectInputProps, number> {
+ name: string;
+ value: number;
+ includeAny?: boolean;
+ protocol?: Protocol;
+ onChange: (change: EnhancedSelectInputChanged) => void;
+}
+
+function DownloadClientSelectInput({
+ includeAny = false,
+ protocol = 'torrent',
+ ...otherProps
+}: DownloadClientSelectInputProps) {
+ const dispatch = useDispatch();
+ const { isFetching, isPopulated, values } = useSelector(
+ createDownloadClientsSelector(includeAny, protocol)
+ );
+
+ useEffect(() => {
+ if (!isPopulated) {
+ dispatch(fetchDownloadClients());
+ }
+ }, [isPopulated, dispatch]);
+
+ return (
+
+ );
+}
+
+export default DownloadClientSelectInput;
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/Select/EnhancedSelectInput.css
similarity index 92%
rename from frontend/src/Components/Form/EnhancedSelectInput.css
rename to frontend/src/Components/Form/Select/EnhancedSelectInput.css
index 56f5564b99d..735d6357330 100644
--- a/frontend/src/Components/Form/EnhancedSelectInput.css
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.css
@@ -19,7 +19,7 @@
.isDisabled {
opacity: 0.7;
- cursor: not-allowed;
+ cursor: not-allowed !important;
}
.dropdownArrowContainer {
@@ -73,6 +73,12 @@
padding: 10px 0;
}
+.optionsInnerModalBody {
+ composes: innerModalBody from '~Components/Modal/ModalBody.css';
+
+ padding: 0;
+}
+
.optionsModalScroller {
composes: scroller from '~Components/Scroller/Scroller.css';
diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts b/frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts
new file mode 100644
index 00000000000..98167a9b580
--- /dev/null
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts
@@ -0,0 +1,23 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'dropdownArrowContainer': string;
+ 'dropdownArrowContainerDisabled': string;
+ 'dropdownArrowContainerEditable': string;
+ 'editableContainer': string;
+ 'enhancedSelect': string;
+ 'hasError': string;
+ 'hasWarning': string;
+ 'isDisabled': string;
+ 'loading': string;
+ 'mobileCloseButton': string;
+ 'mobileCloseButtonContainer': string;
+ 'options': string;
+ 'optionsContainer': string;
+ 'optionsInnerModalBody': string;
+ 'optionsModal': string;
+ 'optionsModalBody': string;
+ 'optionsModalScroller': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx
new file mode 100644
index 00000000000..5ae1753571e
--- /dev/null
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx
@@ -0,0 +1,627 @@
+import classNames from 'classnames';
+import React, {
+ ElementType,
+ KeyboardEvent,
+ ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} 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 ArrayElement from 'typings/Helpers/ArrayElement';
+import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
+import { isMobile as isMobileUtil } from 'Utilities/browser';
+import * as keyCodes from 'Utilities/Constants/keyCodes';
+import getUniqueElementId from 'Utilities/getUniqueElementId';
+import TextInput from '../TextInput';
+import HintedSelectInputOption from './HintedSelectInputOption';
+import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
+import styles from './EnhancedSelectInput.css';
+
+const MINIMUM_DISTANCE_FROM_EDGE = 10;
+
+function isArrowKey(keyCode: number) {
+ return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
+}
+
+function getSelectedOption, V>(
+ selectedIndex: number,
+ values: T[]
+) {
+ return values[selectedIndex];
+}
+
+function findIndex, V>(
+ startingIndex: number,
+ direction: 1 | -1,
+ values: T[]
+) {
+ 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;
+ }
+ }
+
+ return null;
+}
+
+function previousIndex, V>(
+ selectedIndex: number,
+ values: T[]
+) {
+ return findIndex(selectedIndex, -1, values);
+}
+
+function nextIndex, V>(
+ selectedIndex: number,
+ values: T[]
+) {
+ return findIndex(selectedIndex, 1, values);
+}
+
+function getSelectedIndex, V>(
+ value: V,
+ values: T[]
+) {
+ if (Array.isArray(value)) {
+ return values.findIndex((v) => {
+ return v.key === value[0];
+ });
+ }
+
+ return values.findIndex((v) => {
+ return v.key === value;
+ });
+}
+
+function isSelectedItem, V>(
+ index: number,
+ value: V,
+ values: T[]
+) {
+ if (Array.isArray(value)) {
+ return value.includes(values[index].key);
+ }
+
+ return values[index].key === value;
+}
+
+export interface EnhancedSelectInputValue {
+ key: ArrayElement;
+ value: string;
+ hint?: ReactNode;
+ isDisabled?: boolean;
+ isHidden?: boolean;
+ parentKey?: V;
+ additionalProperties?: object;
+}
+
+export interface EnhancedSelectInputProps<
+ T extends EnhancedSelectInputValue,
+ V
+> {
+ className?: string;
+ disabledClassName?: string;
+ name: string;
+ value: V;
+ values: T[];
+ isDisabled?: boolean;
+ isFetching?: boolean;
+ isEditable?: boolean;
+ hasError?: boolean;
+ hasWarning?: boolean;
+ valueOptions?: object;
+ selectedValueOptions?: object;
+ selectedValueComponent?: string | ElementType;
+ optionComponent?: ElementType;
+ onOpen?: () => void;
+ onChange: (change: EnhancedSelectInputChanged) => void;
+}
+
+function EnhancedSelectInput, V>(
+ props: EnhancedSelectInputProps
+) {
+ const {
+ className = styles.enhancedSelect,
+ disabledClassName = styles.isDisabled,
+ name,
+ value,
+ values,
+ isDisabled = false,
+ isEditable,
+ isFetching,
+ hasError,
+ hasWarning,
+ valueOptions,
+ selectedValueOptions,
+ selectedValueComponent:
+ SelectedValueComponent = HintedSelectInputSelectedValue,
+ optionComponent: OptionComponent = HintedSelectInputOption,
+ onChange,
+ onOpen,
+ } = props;
+
+ const updater = useRef<(() => void) | null>(null);
+ const buttonId = useMemo(() => getUniqueElementId(), []);
+ const optionsId = useMemo(() => getUniqueElementId(), []);
+ const [selectedIndex, setSelectedIndex] = useState(
+ getSelectedIndex(value, values)
+ );
+ const [width, setWidth] = useState(0);
+ const [isOpen, setIsOpen] = useState(false);
+ const isMobile = useMemo(() => isMobileUtil(), []);
+
+ const isMultiSelect = Array.isArray(value);
+ const selectedOption = getSelectedOption(selectedIndex, values);
+
+ const selectedValue = useMemo(() => {
+ if (values.length) {
+ return value;
+ }
+
+ if (isMultiSelect) {
+ return [];
+ } else if (typeof value === 'number') {
+ return 0;
+ }
+
+ return '';
+ }, [value, values, isMultiSelect]);
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const handleComputeMaxHeight = useCallback((data: any) => {
+ const windowHeight = window.innerHeight;
+
+ data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
+
+ return data;
+ }, []);
+
+ const handleWindowClick = useCallback(
+ (event: MouseEvent) => {
+ const button = document.getElementById(buttonId);
+ const options = document.getElementById(optionsId);
+ const eventTarget = event.target as HTMLElement;
+
+ if (!button || !eventTarget.isConnected || isMobile) {
+ return;
+ }
+
+ if (
+ !button.contains(eventTarget) &&
+ options &&
+ !options.contains(eventTarget) &&
+ isOpen
+ ) {
+ setIsOpen(false);
+ window.removeEventListener('click', handleWindowClick);
+ }
+ },
+ [isMobile, isOpen, buttonId, optionsId, setIsOpen]
+ );
+
+ const addListener = useCallback(() => {
+ window.addEventListener('click', handleWindowClick);
+ }, [handleWindowClick]);
+
+ const removeListener = useCallback(() => {
+ window.removeEventListener('click', handleWindowClick);
+ }, [handleWindowClick]);
+
+ const handlePress = useCallback(() => {
+ if (!isOpen && onOpen) {
+ onOpen();
+ }
+
+ setIsOpen(!isOpen);
+ }, [isOpen, setIsOpen, onOpen]);
+
+ const handleSelect = useCallback(
+ (newValue: ArrayElement) => {
+ const additionalProperties = values.find(
+ (v) => v.key === newValue
+ )?.additionalProperties;
+
+ if (Array.isArray(value)) {
+ const index = value.indexOf(newValue);
+
+ if (index === -1) {
+ const arrayValue = values
+ .map((v) => v.key)
+ .filter((v) => v === newValue || value.includes(v));
+
+ onChange({
+ name,
+ value: arrayValue as V,
+ additionalProperties,
+ });
+ } else {
+ const arrayValue = [...value];
+ arrayValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: arrayValue as V,
+ additionalProperties,
+ });
+ }
+ } else {
+ setIsOpen(false);
+
+ onChange({
+ name,
+ value: newValue as V,
+ additionalProperties,
+ });
+ }
+ },
+ [name, value, values, onChange, setIsOpen]
+ );
+
+ const handleBlur = useCallback(() => {
+ if (!isEditable) {
+ // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
+ const origIndex = getSelectedIndex(value, values);
+
+ if (origIndex !== selectedIndex) {
+ setSelectedIndex(origIndex);
+ }
+ }
+ }, [value, values, isEditable, selectedIndex, setSelectedIndex]);
+
+ const handleFocus = useCallback(() => {
+ if (isOpen) {
+ removeListener();
+ setIsOpen(false);
+ }
+ }, [isOpen, setIsOpen, removeListener]);
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ const keyCode = event.keyCode;
+ let nextIsOpen: boolean | null = null;
+ let nextSelectedIndex: number | null = null;
+
+ if (!isOpen) {
+ if (isArrowKey(keyCode)) {
+ event.preventDefault();
+ nextIsOpen = true;
+ }
+
+ if (
+ selectedIndex == null ||
+ selectedIndex === -1 ||
+ getSelectedOption(selectedIndex, values).isDisabled
+ ) {
+ if (keyCode === keyCodes.UP_ARROW) {
+ nextSelectedIndex = previousIndex(0, values);
+ } else if (keyCode === keyCodes.DOWN_ARROW) {
+ nextSelectedIndex = nextIndex(values.length - 1, values);
+ }
+ }
+
+ if (nextIsOpen !== null) {
+ setIsOpen(nextIsOpen);
+ }
+
+ if (nextSelectedIndex !== null) {
+ setSelectedIndex(nextSelectedIndex);
+ }
+ return;
+ }
+
+ if (keyCode === keyCodes.UP_ARROW) {
+ event.preventDefault();
+ nextSelectedIndex = previousIndex(selectedIndex, values);
+ }
+
+ if (keyCode === keyCodes.DOWN_ARROW) {
+ event.preventDefault();
+ nextSelectedIndex = nextIndex(selectedIndex, values);
+ }
+
+ if (keyCode === keyCodes.ENTER) {
+ event.preventDefault();
+ nextIsOpen = false;
+ handleSelect(values[selectedIndex].key);
+ }
+
+ if (keyCode === keyCodes.TAB) {
+ nextIsOpen = false;
+ handleSelect(values[selectedIndex].key);
+ }
+
+ if (keyCode === keyCodes.ESCAPE) {
+ event.preventDefault();
+ event.stopPropagation();
+ nextIsOpen = false;
+ nextSelectedIndex = getSelectedIndex(value, values);
+ }
+
+ if (nextIsOpen !== null) {
+ setIsOpen(nextIsOpen);
+ }
+
+ if (nextSelectedIndex !== null) {
+ setSelectedIndex(nextSelectedIndex);
+ }
+ },
+ [
+ value,
+ isOpen,
+ selectedIndex,
+ values,
+ setIsOpen,
+ setSelectedIndex,
+ handleSelect,
+ ]
+ );
+
+ const handleMeasure = useCallback(
+ ({ width: newWidth }: { width: number }) => {
+ setWidth(newWidth);
+ },
+ [setWidth]
+ );
+
+ const handleOptionsModalClose = useCallback(() => {
+ setIsOpen(false);
+ }, [setIsOpen]);
+
+ const handleEditChange = useCallback(
+ (change: InputChanged) => {
+ onChange(change as EnhancedSelectInputChanged);
+ },
+ [onChange]
+ );
+
+ useEffect(() => {
+ if (updater.current) {
+ updater.current();
+ }
+ });
+
+ useEffect(() => {
+ if (isOpen) {
+ addListener();
+ } else {
+ removeListener();
+ }
+
+ return removeListener;
+ }, [isOpen, addListener, removeListener]);
+
+ return (
+
+
+
+ {({ ref }) => (
+
+ )}
+
+
+
+ {({ ref, style, scheduleUpdate }) => {
+ updater.current = scheduleUpdate;
+
+ return (
+
+ {isOpen && !isMobile ? (
+
+ {values.map((v, index) => {
+ const hasParent = v.parentKey !== undefined;
+ const depth = hasParent ? 1 : 0;
+ const parentSelected =
+ v.parentKey !== undefined &&
+ Array.isArray(value) &&
+ value.includes(v.parentKey);
+
+ const { key, ...other } = v;
+
+ return (
+
+ {v.value}
+
+ );
+ })}
+
+ ) : null}
+
+ );
+ }}
+
+
+
+
+ {isMobile ? (
+
+
+
+
+
+
+
+
+
+ {values.map((v, index) => {
+ const hasParent = v.parentKey !== undefined;
+ const depth = hasParent ? 1 : 0;
+ const parentSelected =
+ v.parentKey !== undefined &&
+ isMultiSelect &&
+ value.includes(v.parentKey);
+
+ const { key, ...other } = v;
+
+ return (
+
+ {v.value}
+
+ );
+ })}
+
+
+
+ ) : null}
+
+ );
+}
+
+export default EnhancedSelectInput;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css
similarity index 82%
rename from frontend/src/Components/Form/EnhancedSelectInputOption.css
rename to frontend/src/Components/Form/Select/EnhancedSelectInputOption.css
index 8dcfa25d502..bfdaa903696 100644
--- a/frontend/src/Components/Form/EnhancedSelectInputOption.css
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css
@@ -9,16 +9,20 @@
&:hover {
background-color: var(--inputHoverBackgroundColor);
}
+
+ &.isDisabled {
+ cursor: not-allowed;
+ }
}
.optionCheck {
- composes: container from '~./CheckInput.css';
+ composes: container from '~Components/Form/CheckInput.css';
flex: 0 0 0;
}
.optionCheckInput {
- composes: input from '~./CheckInput.css';
+ composes: input from '~Components/Form/CheckInput.css';
margin-top: 0;
}
diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css.d.ts b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css.d.ts
new file mode 100644
index 00000000000..59675cd9118
--- /dev/null
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css.d.ts
@@ -0,0 +1,14 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'iconContainer': string;
+ 'isDisabled': string;
+ 'isHidden': string;
+ 'isMobile': string;
+ 'isSelected': string;
+ 'option': string;
+ 'optionCheck': string;
+ 'optionCheckInput': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputOption.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.tsx
new file mode 100644
index 00000000000..c866a5060a3
--- /dev/null
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.tsx
@@ -0,0 +1,84 @@
+import classNames from 'classnames';
+import React, { SyntheticEvent, useCallback } 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';
+
+function handleCheckPress() {
+ // CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation.
+}
+
+export interface EnhancedSelectInputOptionProps {
+ className?: string;
+ id: string | number;
+ depth?: number;
+ isSelected: boolean;
+ isDisabled?: boolean;
+ isHidden?: boolean;
+ isMultiSelect?: boolean;
+ isMobile: boolean;
+ children: React.ReactNode;
+ onSelect: (...args: unknown[]) => unknown;
+}
+
+function EnhancedSelectInputOption({
+ className = styles.option,
+ id,
+ depth = 0,
+ isSelected,
+ isDisabled = false,
+ isHidden = false,
+ isMultiSelect = false,
+ isMobile,
+ children,
+ onSelect,
+}: EnhancedSelectInputOptionProps) {
+ const handlePress = useCallback(
+ (event: SyntheticEvent) => {
+ event.preventDefault();
+
+ onSelect(id);
+ },
+ [id, onSelect]
+ );
+
+ return (
+
+ {depth !== 0 &&
}
+
+ {isMultiSelect && (
+
+ )}
+
+ {children}
+
+ {isMobile && (
+
+
+
+ )}
+
+ );
+}
+
+export default EnhancedSelectInputOption;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css
similarity index 100%
rename from frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
rename to frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css
diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css.d.ts
new file mode 100644
index 00000000000..5377239a862
--- /dev/null
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'isDisabled': string;
+ 'selectedValue': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.tsx
new file mode 100644
index 00000000000..88afdb18a40
--- /dev/null
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.tsx
@@ -0,0 +1,23 @@
+import classNames from 'classnames';
+import React, { ReactNode } from 'react';
+import styles from './EnhancedSelectInputSelectedValue.css';
+
+interface EnhancedSelectInputSelectedValueProps {
+ className?: string;
+ children: ReactNode;
+ isDisabled?: boolean;
+}
+
+function EnhancedSelectInputSelectedValue({
+ className = styles.selectedValue,
+ children,
+ isDisabled = false,
+}: EnhancedSelectInputSelectedValueProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default EnhancedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/HintedSelectInputOption.css b/frontend/src/Components/Form/Select/HintedSelectInputOption.css
similarity index 100%
rename from frontend/src/Components/Form/HintedSelectInputOption.css
rename to frontend/src/Components/Form/Select/HintedSelectInputOption.css
diff --git a/frontend/src/Components/Form/Select/HintedSelectInputOption.css.d.ts b/frontend/src/Components/Form/Select/HintedSelectInputOption.css.d.ts
new file mode 100644
index 00000000000..b598ce4842c
--- /dev/null
+++ b/frontend/src/Components/Form/Select/HintedSelectInputOption.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'hintText': string;
+ 'isMobile': string;
+ 'optionText': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Select/HintedSelectInputOption.tsx b/frontend/src/Components/Form/Select/HintedSelectInputOption.tsx
new file mode 100644
index 00000000000..faa9081c5ad
--- /dev/null
+++ b/frontend/src/Components/Form/Select/HintedSelectInputOption.tsx
@@ -0,0 +1,52 @@
+import classNames from 'classnames';
+import React from 'react';
+import EnhancedSelectInputOption, {
+ EnhancedSelectInputOptionProps,
+} from './EnhancedSelectInputOption';
+import styles from './HintedSelectInputOption.css';
+
+interface HintedSelectInputOptionProps extends EnhancedSelectInputOptionProps {
+ value: string;
+ hint?: React.ReactNode;
+}
+
+function HintedSelectInputOption(props: HintedSelectInputOptionProps) {
+ const {
+ id,
+ value,
+ hint,
+ depth,
+ isSelected = false,
+ isDisabled,
+ isMobile,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
{value}
+
+ {hint != null &&
{hint}
}
+
+
+ );
+}
+
+HintedSelectInputOption.defaultProps = {
+ isDisabled: false,
+ isHidden: false,
+ isMultiSelect: false,
+};
+
+export default HintedSelectInputOption;
diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.css b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css
similarity index 100%
rename from frontend/src/Components/Form/HintedSelectInputSelectedValue.css
rename to frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css
diff --git a/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css.d.ts
new file mode 100644
index 00000000000..57114fbff72
--- /dev/null
+++ b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'hintText': string;
+ 'selectedValue': string;
+ 'valueText': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.tsx
new file mode 100644
index 00000000000..7c4cba1154b
--- /dev/null
+++ b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.tsx
@@ -0,0 +1,55 @@
+import React, { ReactNode, useMemo } from 'react';
+import Label from 'Components/Label';
+import ArrayElement from 'typings/Helpers/ArrayElement';
+import { EnhancedSelectInputValue } from './EnhancedSelectInput';
+import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
+import styles from './HintedSelectInputSelectedValue.css';
+
+interface HintedSelectInputSelectedValueProps {
+ selectedValue: V;
+ values: T[];
+ hint?: ReactNode;
+ isMultiSelect?: boolean;
+ includeHint?: boolean;
+}
+
+function HintedSelectInputSelectedValue<
+ T extends EnhancedSelectInputValue,
+ V extends number | string
+>(props: HintedSelectInputSelectedValueProps) {
+ const {
+ selectedValue,
+ values,
+ hint,
+ isMultiSelect = false,
+ includeHint = true,
+ ...otherProps
+ } = props;
+
+ const valuesMap = useMemo(() => {
+ return new Map(values.map((v) => [v.key, v.value]));
+ }, [values]);
+
+ return (
+
+
+ {isMultiSelect && Array.isArray(selectedValue)
+ ? selectedValue.map((key) => {
+ const v = valuesMap.get(key);
+
+ return {v ? v : key} ;
+ })
+ : valuesMap.get(selectedValue as ArrayElement)}
+
+
+ {hint != null && includeHint ? (
+ {hint}
+ ) : null}
+
+ );
+}
+
+export default HintedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx b/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx
new file mode 100644
index 00000000000..a43044156bf
--- /dev/null
+++ b/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx
@@ -0,0 +1,70 @@
+import React, { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import { EnhancedSelectInputChanged } from 'typings/inputs';
+import EnhancedSelectInput from './EnhancedSelectInput';
+
+const selectIndexerFlagsValues = (selectedFlags: number) =>
+ createSelector(
+ (state: AppState) => state.settings.indexerFlags,
+ (indexerFlags) => {
+ const value = indexerFlags.items.reduce((acc: number[], { id }) => {
+ // eslint-disable-next-line no-bitwise
+ if ((selectedFlags & id) === id) {
+ acc.push(id);
+ }
+
+ return acc;
+ }, []);
+
+ const values = indexerFlags.items.map(({ id, name }) => ({
+ key: id,
+ value: name,
+ }));
+
+ return {
+ value,
+ values,
+ };
+ }
+ );
+
+interface IndexerFlagsSelectInputProps {
+ name: string;
+ indexerFlags: number;
+ onChange(payload: EnhancedSelectInputChanged): void;
+}
+
+function IndexerFlagsSelectInput({
+ name,
+ indexerFlags,
+ onChange,
+ ...otherProps
+}: IndexerFlagsSelectInputProps) {
+ const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags));
+
+ const handleChange = useCallback(
+ (change: EnhancedSelectInputChanged) => {
+ const indexerFlags = change.value.reduce(
+ (acc, flagId) => acc + flagId,
+ 0
+ );
+
+ onChange({ name, value: indexerFlags });
+ },
+ [name, onChange]
+ );
+
+ return (
+
+ );
+}
+
+export default IndexerFlagsSelectInput;
diff --git a/frontend/src/Components/Form/Select/IndexerSelectInput.tsx b/frontend/src/Components/Form/Select/IndexerSelectInput.tsx
new file mode 100644
index 00000000000..4bb4ff78727
--- /dev/null
+++ b/frontend/src/Components/Form/Select/IndexerSelectInput.tsx
@@ -0,0 +1,81 @@
+import React, { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import { fetchIndexers } from 'Store/Actions/settingsActions';
+import { EnhancedSelectInputChanged } from 'typings/inputs';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput from './EnhancedSelectInput';
+
+function createIndexersSelector(includeAny: boolean) {
+ return createSelector(
+ (state: AppState) => state.settings.indexers,
+ (indexers) => {
+ const { isFetching, isPopulated, error, items } = indexers;
+
+ const values = items.sort(sortByProp('name')).map((indexer) => {
+ return {
+ key: indexer.id,
+ value: indexer.name,
+ };
+ });
+
+ if (includeAny) {
+ values.unshift({
+ key: 0,
+ value: `(${translate('Any')})`,
+ });
+ }
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ values,
+ };
+ }
+ );
+}
+
+interface IndexerSelectInputConnectorProps {
+ name: string;
+ value: number;
+ includeAny?: boolean;
+ values: object[];
+ onChange: (change: EnhancedSelectInputChanged) => void;
+}
+
+function IndexerSelectInput({
+ name,
+ value,
+ includeAny = false,
+ onChange,
+}: IndexerSelectInputConnectorProps) {
+ const dispatch = useDispatch();
+ const { isFetching, isPopulated, values } = useSelector(
+ createIndexersSelector(includeAny)
+ );
+
+ useEffect(() => {
+ if (!isPopulated) {
+ dispatch(fetchIndexers());
+ }
+ }, [isPopulated, dispatch]);
+
+ return (
+
+ );
+}
+
+IndexerSelectInput.defaultProps = {
+ includeAny: false,
+};
+
+export default IndexerSelectInput;
diff --git a/frontend/src/Components/Form/Select/LanguageSelectInput.tsx b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx
new file mode 100644
index 00000000000..3c9bbc1500a
--- /dev/null
+++ b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx
@@ -0,0 +1,95 @@
+import React, { useCallback, useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import Language from 'Language/Language';
+import createFilteredLanguagesSelector from 'Store/Selectors/createFilteredLanguagesSelector';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput, {
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+interface LanguageSelectInputOnChangeProps {
+ name: string;
+ value: number | string | Language;
+}
+
+interface LanguageSelectInputProps {
+ name: string;
+ value: number | string | Language;
+ includeNoChange: boolean;
+ includeNoChangeDisabled?: boolean;
+ includeMixed: boolean;
+ onChange: (payload: LanguageSelectInputOnChangeProps) => void;
+}
+
+export default function LanguageSelectInput({
+ value,
+ includeNoChange,
+ includeNoChangeDisabled,
+ includeMixed,
+ onChange,
+ ...otherProps
+}: LanguageSelectInputProps) {
+ const { items } = useSelector(createFilteredLanguagesSelector(true));
+
+ const values = useMemo(() => {
+ const result: EnhancedSelectInputValue[] = items.map(
+ (item) => {
+ return {
+ key: item.id,
+ value: item.name,
+ };
+ }
+ );
+
+ if (includeNoChange) {
+ result.unshift({
+ key: 'noChange',
+ value: translate('NoChange'),
+ isDisabled: includeNoChangeDisabled,
+ });
+ }
+
+ if (includeMixed) {
+ result.unshift({
+ key: 'mixed',
+ value: `(${translate('Mixed')})`,
+ isDisabled: true,
+ });
+ }
+
+ return result;
+ }, [includeNoChange, includeNoChangeDisabled, includeMixed, items]);
+
+ const selectValue =
+ typeof value === 'number' || typeof value === 'string' ? value : value.id;
+
+ const handleChange = useCallback(
+ (payload: LanguageSelectInputOnChangeProps) => {
+ if (typeof value === 'number') {
+ onChange(payload);
+ } else {
+ const language = items.find((i) => i.id === payload.value);
+
+ onChange({
+ ...payload,
+ value: language
+ ? {
+ id: language.id,
+ name: language.name,
+ }
+ : ({ id: payload.value } as Language),
+ });
+ }
+ },
+ [value, items, onChange]
+ );
+
+ return (
+
+ );
+}
diff --git a/frontend/src/Components/Form/Select/MonitorEpisodesSelectInput.tsx b/frontend/src/Components/Form/Select/MonitorEpisodesSelectInput.tsx
new file mode 100644
index 00000000000..59fd085131b
--- /dev/null
+++ b/frontend/src/Components/Form/Select/MonitorEpisodesSelectInput.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import monitorOptions from 'Utilities/Series/monitorOptions';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+interface MonitorEpisodesSelectInputProps
+ extends Omit<
+ EnhancedSelectInputProps, string>,
+ 'values'
+ > {
+ includeNoChange: boolean;
+ includeMixed: boolean;
+}
+
+function MonitorEpisodesSelectInput(props: MonitorEpisodesSelectInputProps) {
+ const {
+ includeNoChange = false,
+ includeMixed = false,
+ ...otherProps
+ } = props;
+
+ const values: EnhancedSelectInputValue[] = [...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 ;
+}
+
+export default MonitorEpisodesSelectInput;
diff --git a/frontend/src/Components/Form/Select/MonitorNewItemsSelectInput.tsx b/frontend/src/Components/Form/Select/MonitorNewItemsSelectInput.tsx
new file mode 100644
index 00000000000..ac11f1fca1e
--- /dev/null
+++ b/frontend/src/Components/Form/Select/MonitorNewItemsSelectInput.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import monitorNewItemsOptions from 'Utilities/Series/monitorNewItemsOptions';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+interface MonitorNewItemsSelectInputProps
+ extends Omit<
+ EnhancedSelectInputProps, string>,
+ 'values'
+ > {
+ includeNoChange?: boolean;
+ includeMixed?: boolean;
+ onChange: (...args: unknown[]) => unknown;
+}
+
+function MonitorNewItemsSelectInput(props: MonitorNewItemsSelectInputProps) {
+ const {
+ includeNoChange = false,
+ includeMixed = false,
+ ...otherProps
+ } = props;
+
+ const values: EnhancedSelectInputValue[] = [
+ ...monitorNewItemsOptions,
+ ];
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ isDisabled: true,
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ value: '(Mixed)',
+ isDisabled: true,
+ });
+ }
+
+ return ;
+}
+
+export default MonitorNewItemsSelectInput;
diff --git a/frontend/src/Components/Form/Select/ProviderOptionSelectInput.tsx b/frontend/src/Components/Form/Select/ProviderOptionSelectInput.tsx
new file mode 100644
index 00000000000..e4a8003eb31
--- /dev/null
+++ b/frontend/src/Components/Form/Select/ProviderOptionSelectInput.tsx
@@ -0,0 +1,164 @@
+import { isEqual } from 'lodash';
+import React, { useCallback, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import ProviderOptionsAppState, {
+ ProviderOptions,
+} from 'App/State/ProviderOptionsAppState';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import {
+ clearOptions,
+ fetchOptions,
+} from 'Store/Actions/providerOptionActions';
+import { FieldSelectOption } from 'typings/Field';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+const importantFieldNames = ['baseUrl', 'apiPath', 'apiKey', 'authToken'];
+
+function getProviderDataKey(providerData: ProviderOptions) {
+ 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: FieldSelectOption[]) {
+ 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 createProviderOptionsSelector(
+ selectOptionsProviderAction: keyof Omit
+) {
+ return createSelector(
+ (state: AppState) => state.providerOptions[selectOptionsProviderAction],
+ (options) => {
+ if (!options) {
+ return {
+ isFetching: false,
+ values: [],
+ };
+ }
+
+ return {
+ isFetching: options.isFetching,
+ values: getSelectOptions(options.items),
+ };
+ }
+ );
+}
+
+interface ProviderOptionSelectInputProps
+ extends Omit<
+ EnhancedSelectInputProps, unknown>,
+ 'values'
+ > {
+ provider: string;
+ providerData: ProviderOptions;
+ name: string;
+ value: unknown;
+ selectOptionsProviderAction: keyof Omit;
+}
+
+function ProviderOptionSelectInput({
+ provider,
+ providerData,
+ selectOptionsProviderAction,
+ ...otherProps
+}: ProviderOptionSelectInputProps) {
+ const dispatch = useDispatch();
+ const [isRefetchRequired, setIsRefetchRequired] = useState(false);
+ const previousProviderData = usePrevious(providerData);
+ const { isFetching, values } = useSelector(
+ createProviderOptionsSelector(selectOptionsProviderAction)
+ );
+
+ const handleOpen = useCallback(() => {
+ if (isRefetchRequired && selectOptionsProviderAction) {
+ setIsRefetchRequired(false);
+
+ dispatch(
+ fetchOptions({
+ section: selectOptionsProviderAction,
+ action: selectOptionsProviderAction,
+ provider,
+ providerData,
+ })
+ );
+ }
+ }, [
+ isRefetchRequired,
+ provider,
+ providerData,
+ selectOptionsProviderAction,
+ dispatch,
+ ]);
+
+ useEffect(() => {
+ if (selectOptionsProviderAction) {
+ dispatch(
+ fetchOptions({
+ section: selectOptionsProviderAction,
+ action: selectOptionsProviderAction,
+ provider,
+ providerData,
+ })
+ );
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectOptionsProviderAction, dispatch]);
+
+ useEffect(() => {
+ if (!previousProviderData) {
+ return;
+ }
+
+ const prevKey = getProviderDataKey(previousProviderData);
+ const nextKey = getProviderDataKey(providerData);
+
+ if (!isEqual(prevKey, nextKey)) {
+ setIsRefetchRequired(true);
+ }
+ }, [providerData, previousProviderData, setIsRefetchRequired]);
+
+ useEffect(() => {
+ return () => {
+ if (selectOptionsProviderAction) {
+ dispatch(clearOptions({ section: selectOptionsProviderAction }));
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ );
+}
+
+export default ProviderOptionSelectInput;
diff --git a/frontend/src/Components/Form/Select/QualityProfileSelectInput.tsx b/frontend/src/Components/Form/Select/QualityProfileSelectInput.tsx
new file mode 100644
index 00000000000..036f0f82c01
--- /dev/null
+++ b/frontend/src/Components/Form/Select/QualityProfileSelectInput.tsx
@@ -0,0 +1,126 @@
+import React, { useCallback, useEffect } from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import { QualityProfilesAppState } from 'App/State/SettingsAppState';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import { EnhancedSelectInputChanged } from 'typings/inputs';
+import QualityProfile from 'typings/QualityProfile';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+function createQualityProfilesSelector(
+ includeNoChange: boolean,
+ includeNoChangeDisabled: boolean,
+ includeMixed: boolean
+) {
+ return createSelector(
+ createSortedSectionSelector(
+ 'settings.qualityProfiles',
+ sortByProp('name')
+ ),
+ (qualityProfiles: QualityProfilesAppState) => {
+ const values: EnhancedSelectInputValue[] =
+ qualityProfiles.items.map((qualityProfile) => {
+ return {
+ key: qualityProfile.id,
+ value: qualityProfile.name,
+ };
+ });
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ get value() {
+ return translate('NoChange');
+ },
+ isDisabled: includeNoChangeDisabled,
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ get value() {
+ return `(${translate('Mixed')})`;
+ },
+ isDisabled: true,
+ });
+ }
+
+ return values;
+ }
+ );
+}
+
+interface QualityProfileSelectInputConnectorProps
+ extends Omit<
+ EnhancedSelectInputProps<
+ EnhancedSelectInputValue,
+ number | string
+ >,
+ 'values'
+ > {
+ name: string;
+ includeNoChange?: boolean;
+ includeNoChangeDisabled?: boolean;
+ includeMixed?: boolean;
+}
+
+function QualityProfileSelectInput({
+ name,
+ value,
+ includeNoChange = false,
+ includeNoChangeDisabled = true,
+ includeMixed = false,
+ onChange,
+ ...otherProps
+}: QualityProfileSelectInputConnectorProps) {
+ const values = useSelector(
+ createQualityProfilesSelector(
+ includeNoChange,
+ includeNoChangeDisabled,
+ includeMixed
+ )
+ );
+
+ const handleChange = useCallback(
+ ({ value: newValue }: EnhancedSelectInputChanged) => {
+ onChange({
+ name,
+ value: newValue === 'noChange' ? value : newValue,
+ });
+ },
+ [name, value, onChange]
+ );
+
+ useEffect(() => {
+ if (
+ !value ||
+ !values.some((option) => option.key === value || option.key === value)
+ ) {
+ const firstValue = values.find(
+ (option) => typeof option.key === 'number'
+ );
+
+ if (firstValue) {
+ onChange({ name, value: firstValue.key });
+ }
+ }
+ }, [name, value, values, onChange]);
+
+ return (
+
+ );
+}
+
+export default QualityProfileSelectInput;
diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx
new file mode 100644
index 00000000000..8b278ded727
--- /dev/null
+++ b/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx
@@ -0,0 +1,222 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import {
+ addRootFolder,
+ fetchRootFolders,
+} from 'Store/Actions/rootFolderActions';
+import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
+import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+import RootFolderSelectInputOption from './RootFolderSelectInputOption';
+import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
+
+const ADD_NEW_KEY = 'addNew';
+
+export interface RootFolderSelectInputValue
+ extends EnhancedSelectInputValue {
+ isMissing?: boolean;
+}
+
+interface RootFolderSelectInputProps
+ extends Omit<
+ EnhancedSelectInputProps, string>,
+ 'value' | 'values'
+ > {
+ name: string;
+ value?: string;
+ isSaving: boolean;
+ saveError?: object;
+ includeNoChange: boolean;
+}
+
+function createRootFolderOptionsSelector(
+ value: string | undefined,
+ includeMissingValue: boolean,
+ includeNoChange: boolean,
+ includeNoChangeDisabled: boolean
+) {
+ return createSelector(
+ createRootFoldersSelector(),
+
+ (rootFolders) => {
+ const values: RootFolderSelectInputValue[] = rootFolders.items.map(
+ (rootFolder) => {
+ return {
+ key: rootFolder.path,
+ value: rootFolder.path,
+ freeSpace: rootFolder.freeSpace,
+ isMissing: false,
+ };
+ }
+ );
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ get value() {
+ return translate('NoChange');
+ },
+ isDisabled: includeNoChangeDisabled,
+ isMissing: false,
+ });
+ }
+
+ if (!values.length) {
+ values.push({
+ key: '',
+ value: '',
+ isDisabled: true,
+ isHidden: true,
+ });
+ }
+
+ if (
+ includeMissingValue &&
+ value &&
+ !values.find((v) => v.key === value)
+ ) {
+ values.push({
+ key: value,
+ value,
+ isMissing: true,
+ isDisabled: true,
+ });
+ }
+
+ values.push({
+ key: ADD_NEW_KEY,
+ value: translate('AddANewPath'),
+ });
+
+ return {
+ values,
+ isSaving: rootFolders.isSaving,
+ saveError: rootFolders.saveError,
+ };
+ }
+ );
+}
+
+function RootFolderSelectInput({
+ name,
+ value,
+ includeNoChange = false,
+ onChange,
+ ...otherProps
+}: RootFolderSelectInputProps) {
+ const dispatch = useDispatch();
+ const { values, isSaving, saveError } = useSelector(
+ createRootFolderOptionsSelector(value, true, includeNoChange, false)
+ );
+ const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
+ useState(false);
+ const [newRootFolderPath, setNewRootFolderPath] = useState('');
+ const previousIsSaving = usePrevious(isSaving);
+
+ const handleChange = useCallback(
+ ({ value: newValue }: EnhancedSelectInputChanged) => {
+ if (newValue === 'addNew') {
+ setIsAddNewRootFolderModalOpen(true);
+ } else {
+ onChange({ name, value: newValue });
+ }
+ },
+ [name, setIsAddNewRootFolderModalOpen, onChange]
+ );
+
+ const handleNewRootFolderSelect = useCallback(
+ ({ value: newValue }: InputChanged) => {
+ setNewRootFolderPath(newValue);
+ dispatch(addRootFolder({ path: newValue }));
+ },
+ [setNewRootFolderPath, dispatch]
+ );
+
+ const handleAddRootFolderModalClose = useCallback(() => {
+ setIsAddNewRootFolderModalOpen(false);
+ }, [setIsAddNewRootFolderModalOpen]);
+
+ useEffect(() => {
+ if (
+ !value &&
+ values.length &&
+ values.some((v) => !!v.key && v.key !== ADD_NEW_KEY)
+ ) {
+ const defaultValue = values[0];
+
+ if (defaultValue.key !== ADD_NEW_KEY) {
+ onChange({ name, value: defaultValue.key });
+ }
+ }
+
+ if (previousIsSaving && !isSaving && !saveError && newRootFolderPath) {
+ onChange({ name, value: newRootFolderPath });
+ setNewRootFolderPath('');
+ }
+ }, [
+ name,
+ value,
+ values,
+ isSaving,
+ saveError,
+ previousIsSaving,
+ newRootFolderPath,
+ onChange,
+ ]);
+
+ useEffect(() => {
+ if (value == null && values[0].key === '') {
+ onChange({ name, value: '' });
+ } else if (
+ !value ||
+ !values.some((v) => v.key === value) ||
+ value === ADD_NEW_KEY
+ ) {
+ const defaultValue = values[0];
+
+ if (defaultValue.key === ADD_NEW_KEY) {
+ onChange({ name, value: '' });
+ } else {
+ onChange({ name, value: defaultValue.key });
+ }
+ }
+
+ // Only run on mount
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ dispatch(fetchRootFolders());
+ }, [dispatch]);
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+export default RootFolderSelectInput;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.css b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.css
similarity index 100%
rename from frontend/src/Components/Form/RootFolderSelectInputOption.css
rename to frontend/src/Components/Form/Select/RootFolderSelectInputOption.css
diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInputOption.css.d.ts b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.css.d.ts
new file mode 100644
index 00000000000..ade446dd01e
--- /dev/null
+++ b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'freeSpace': string;
+ 'isMissing': string;
+ 'isMobile': string;
+ 'optionText': string;
+ 'seriesFolder': string;
+ 'value': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInputOption.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.tsx
new file mode 100644
index 00000000000..d71f0d63894
--- /dev/null
+++ b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.tsx
@@ -0,0 +1,67 @@
+import classNames from 'classnames';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInputOption, {
+ EnhancedSelectInputOptionProps,
+} from './EnhancedSelectInputOption';
+import styles from './RootFolderSelectInputOption.css';
+
+interface RootFolderSelectInputOptionProps
+ extends EnhancedSelectInputOptionProps {
+ id: string;
+ value: string;
+ freeSpace?: number;
+ isMissing?: boolean;
+ seriesFolder?: string;
+ isMobile: boolean;
+ isWindows?: boolean;
+}
+
+function RootFolderSelectInputOption(props: RootFolderSelectInputOptionProps) {
+ const {
+ id,
+ value,
+ freeSpace,
+ isMissing,
+ seriesFolder,
+ isMobile,
+ isWindows,
+ ...otherProps
+ } = props;
+
+ const slashCharacter = isWindows ? '\\' : '/';
+
+ return (
+
+
+
+ {value}
+
+ {seriesFolder && id !== 'addNew' ? (
+
+ {slashCharacter}
+ {seriesFolder}
+
+ ) : null}
+
+
+ {freeSpace == null ? null : (
+
+ {translate('RootFolderSelectFreeSpace', {
+ freeSpace: formatBytes(freeSpace),
+ })}
+
+ )}
+
+ {isMissing ? (
+
{translate('Missing')}
+ ) : null}
+
+
+ );
+}
+
+export default RootFolderSelectInputOption;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css
similarity index 100%
rename from frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css
rename to frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css
diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css.d.ts
new file mode 100644
index 00000000000..72675a71089
--- /dev/null
+++ b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'freeSpace': string;
+ 'path': string;
+ 'pathContainer': string;
+ 'selectedValue': string;
+ 'seriesFolder': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.tsx
new file mode 100644
index 00000000000..e06101f2afe
--- /dev/null
+++ b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
+import { RootFolderSelectInputValue } from './RootFolderSelectInput';
+import styles from './RootFolderSelectInputSelectedValue.css';
+
+interface RootFolderSelectInputSelectedValueProps {
+ selectedValue: string;
+ values: RootFolderSelectInputValue[];
+ freeSpace?: number;
+ seriesFolder?: string;
+ isWindows?: boolean;
+ includeFreeSpace?: boolean;
+}
+
+function RootFolderSelectInputSelectedValue(
+ props: RootFolderSelectInputSelectedValueProps
+) {
+ const {
+ selectedValue,
+ values,
+ freeSpace,
+ seriesFolder,
+ includeFreeSpace = true,
+ isWindows,
+ ...otherProps
+ } = props;
+
+ const slashCharacter = isWindows ? '\\' : '/';
+ const value = values.find((v) => v.key === selectedValue)?.value;
+
+ return (
+
+
+
{value}
+
+ {seriesFolder ? (
+
+ {slashCharacter}
+ {seriesFolder}
+
+ ) : null}
+
+
+ {freeSpace != null && includeFreeSpace ? (
+
+ {translate('RootFolderSelectFreeSpace', {
+ freeSpace: formatBytes(freeSpace),
+ })}
+
+ ) : null}
+
+ );
+}
+
+export default RootFolderSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Select/SeriesTypeSelectInput.tsx b/frontend/src/Components/Form/Select/SeriesTypeSelectInput.tsx
new file mode 100644
index 00000000000..6a3bba650cf
--- /dev/null
+++ b/frontend/src/Components/Form/Select/SeriesTypeSelectInput.tsx
@@ -0,0 +1,88 @@
+import React, { useMemo } from 'react';
+import * as seriesTypes from 'Utilities/Series/seriesTypes';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+import SeriesTypeSelectInputOption from './SeriesTypeSelectInputOption';
+import SeriesTypeSelectInputSelectedValue from './SeriesTypeSelectInputSelectedValue';
+
+interface SeriesTypeSelectInputProps
+ extends EnhancedSelectInputProps, string> {
+ includeNoChange: boolean;
+ includeNoChangeDisabled?: boolean;
+ includeMixed: boolean;
+}
+
+export interface ISeriesTypeOption {
+ key: string;
+ value: string;
+ format?: string;
+ isDisabled?: boolean;
+}
+
+const seriesTypeOptions: ISeriesTypeOption[] = [
+ {
+ key: seriesTypes.STANDARD,
+ value: 'Standard',
+ get format() {
+ return translate('StandardEpisodeTypeFormat', { format: 'S01E05' });
+ },
+ },
+ {
+ key: seriesTypes.DAILY,
+ value: 'Daily / Date',
+ get format() {
+ return translate('DailyEpisodeTypeFormat', { format: '2020-05-25' });
+ },
+ },
+ {
+ key: seriesTypes.ANIME,
+ value: 'Anime / Absolute',
+ get format() {
+ return translate('AnimeEpisodeTypeFormat', { format: '005' });
+ },
+ },
+];
+
+function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
+ const {
+ includeNoChange = false,
+ includeNoChangeDisabled = true,
+ includeMixed = false,
+ } = props;
+
+ const values = useMemo(() => {
+ const result = [...seriesTypeOptions];
+
+ if (includeNoChange) {
+ result.unshift({
+ key: 'noChange',
+ value: translate('NoChange'),
+ isDisabled: includeNoChangeDisabled,
+ });
+ }
+
+ if (includeMixed) {
+ result.unshift({
+ key: 'mixed',
+ value: `(${translate('Mixed')})`,
+ isDisabled: true,
+ });
+ }
+
+ return result;
+ }, [includeNoChange, includeNoChangeDisabled, includeMixed]);
+
+ return (
+
+ );
+}
+
+export default SeriesTypeSelectInput;
diff --git a/frontend/src/Components/Form/Select/SeriesTypeSelectInputOption.css b/frontend/src/Components/Form/Select/SeriesTypeSelectInputOption.css
new file mode 100644
index 00000000000..54954a72179
--- /dev/null
+++ b/frontend/src/Components/Form/Select/SeriesTypeSelectInputOption.css
@@ -0,0 +1,24 @@
+.optionText {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex: 1 0 0;
+
+ &.isMobile {
+ display: block;
+
+ .format {
+ margin-left: 0;
+ }
+ }
+}
+
+.value {
+ display: flex;
+}
+
+.format {
+ margin-left: 15px;
+ color: var(--darkGray);
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Components/Form/Select/SeriesTypeSelectInputOption.css.d.ts b/frontend/src/Components/Form/Select/SeriesTypeSelectInputOption.css.d.ts
new file mode 100644
index 00000000000..8345cd430da
--- /dev/null
+++ b/frontend/src/Components/Form/Select/SeriesTypeSelectInputOption.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'format': string;
+ 'isMobile': string;
+ 'optionText': string;
+ 'value': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Select/SeriesTypeSelectInputOption.tsx b/frontend/src/Components/Form/Select/SeriesTypeSelectInputOption.tsx
new file mode 100644
index 00000000000..0bfc3b6e9fb
--- /dev/null
+++ b/frontend/src/Components/Form/Select/SeriesTypeSelectInputOption.tsx
@@ -0,0 +1,32 @@
+import classNames from 'classnames';
+import React from 'react';
+import EnhancedSelectInputOption, {
+ EnhancedSelectInputOptionProps,
+} from './EnhancedSelectInputOption';
+import styles from './SeriesTypeSelectInputOption.css';
+
+interface SeriesTypeSelectInputOptionProps
+ extends EnhancedSelectInputOptionProps {
+ id: string;
+ value: string;
+ format: string;
+ isMobile: boolean;
+}
+
+function SeriesTypeSelectInputOption(props: SeriesTypeSelectInputOptionProps) {
+ const { id, value, format, isMobile, ...otherProps } = props;
+
+ return (
+
+
+
{value}
+
+ {format == null ? null :
{format}
}
+
+
+ );
+}
+
+export default SeriesTypeSelectInputOption;
diff --git a/frontend/src/Components/Form/Select/SeriesTypeSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/SeriesTypeSelectInputSelectedValue.tsx
new file mode 100644
index 00000000000..b6470f1a4c5
--- /dev/null
+++ b/frontend/src/Components/Form/Select/SeriesTypeSelectInputSelectedValue.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
+import { ISeriesTypeOption } from './SeriesTypeSelectInput';
+
+interface SeriesTypeSelectInputOptionProps {
+ selectedValue: string;
+ values: ISeriesTypeOption[];
+ format: string;
+}
+function SeriesTypeSelectInputSelectedValue(
+ props: SeriesTypeSelectInputOptionProps
+) {
+ const { selectedValue, values, ...otherProps } = props;
+ const format = values.find((v) => v.key === selectedValue)?.format;
+
+ return (
+
+ );
+}
+
+export default SeriesTypeSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/UMaskInput.css b/frontend/src/Components/Form/Select/UMaskInput.css
similarity index 93%
rename from frontend/src/Components/Form/UMaskInput.css
rename to frontend/src/Components/Form/Select/UMaskInput.css
index 91486687e7a..a777aaeef66 100644
--- a/frontend/src/Components/Form/UMaskInput.css
+++ b/frontend/src/Components/Form/Select/UMaskInput.css
@@ -1,53 +1,53 @@
-.inputWrapper {
- display: flex;
-}
-
-.inputFolder {
- composes: input from '~Components/Form/Input.css';
-
- max-width: 100px;
-}
-
-.inputUnitWrapper {
- position: relative;
- width: 100%;
-}
-
-.inputUnit {
- composes: inputUnit from '~Components/Form/FormInputGroup.css';
-
- right: 40px;
- font-family: $monoSpaceFontFamily;
-}
-
-.unit {
- font-family: $monoSpaceFontFamily;
-}
-
-.details {
- margin-top: 5px;
- margin-left: 17px;
- line-height: 20px;
-
- > div {
- display: flex;
-
- label {
- flex: 0 0 50px;
- }
-
- .value {
- width: 50px;
- text-align: right;
- }
-
- .unit {
- width: 90px;
- text-align: right;
- }
- }
-}
-
-.readOnly {
- background-color: var(--inputReadOnlyBackgroundColor);
-}
+.inputWrapper {
+ display: flex;
+}
+
+.inputFolder {
+ composes: input from '~Components/Form/Input.css';
+
+ max-width: 100px;
+}
+
+.inputUnitWrapper {
+ position: relative;
+ width: 100%;
+}
+
+.inputUnit {
+ composes: inputUnit from '~Components/Form/FormInputGroup.css';
+
+ right: 40px;
+ font-family: $monoSpaceFontFamily;
+}
+
+.unit {
+ font-family: $monoSpaceFontFamily;
+}
+
+.details {
+ margin-top: 5px;
+ margin-left: 17px;
+ line-height: 20px;
+
+ > div {
+ display: flex;
+
+ label {
+ flex: 0 0 50px;
+ }
+
+ .value {
+ width: 50px;
+ text-align: right;
+ }
+
+ .unit {
+ width: 90px;
+ text-align: right;
+ }
+ }
+}
+
+.readOnly {
+ background-color: var(--inputReadOnlyBackgroundColor);
+}
diff --git a/frontend/src/Components/Form/Select/UMaskInput.css.d.ts b/frontend/src/Components/Form/Select/UMaskInput.css.d.ts
new file mode 100644
index 00000000000..2ae8dacadcd
--- /dev/null
+++ b/frontend/src/Components/Form/Select/UMaskInput.css.d.ts
@@ -0,0 +1,14 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'details': string;
+ 'inputFolder': string;
+ 'inputUnit': string;
+ 'inputUnitWrapper': string;
+ 'inputWrapper': string;
+ 'readOnly': string;
+ 'unit': string;
+ 'value': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Select/UMaskInput.tsx b/frontend/src/Components/Form/Select/UMaskInput.tsx
new file mode 100644
index 00000000000..1f537f968bd
--- /dev/null
+++ b/frontend/src/Components/Form/Select/UMaskInput.tsx
@@ -0,0 +1,142 @@
+/* eslint-disable no-bitwise */
+import PropTypes from 'prop-types';
+import React, { SyntheticEvent } from 'react';
+import { InputChanged } from 'typings/inputs';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput from './EnhancedSelectInput';
+import styles from './UMaskInput.css';
+
+const umaskOptions = [
+ {
+ key: '755',
+ get value() {
+ return translate('Umask755Description', { octal: '755' });
+ },
+ hint: 'drwxr-xr-x',
+ },
+ {
+ key: '775',
+ get value() {
+ return translate('Umask775Description', { octal: '775' });
+ },
+ hint: 'drwxrwxr-x',
+ },
+ {
+ key: '770',
+ get value() {
+ return translate('Umask770Description', { octal: '770' });
+ },
+ hint: 'drwxrwx---',
+ },
+ {
+ key: '750',
+ get value() {
+ return translate('Umask750Description', { octal: '750' });
+ },
+ hint: 'drwxr-x---',
+ },
+ {
+ key: '777',
+ get value() {
+ return translate('Umask777Description', { octal: '777' });
+ },
+ hint: 'drwxrwxrwx',
+ },
+];
+
+function formatPermissions(permissions: number) {
+ const hasSticky = permissions & 0o1000;
+ const hasSetGID = permissions & 0o2000;
+ const hasSetUID = permissions & 0o4000;
+
+ let result = '';
+
+ for (let i = 0; i < 9; i++) {
+ const bit = (permissions & (1 << i)) !== 0;
+ let digit = bit ? 'xwr'[i % 3] : '-';
+ if (i === 6 && hasSetUID) {
+ digit = bit ? 's' : 'S';
+ } else if (i === 3 && hasSetGID) {
+ digit = bit ? 's' : 'S';
+ } else if (i === 0 && hasSticky) {
+ digit = bit ? 't' : 'T';
+ }
+ result = digit + result;
+ }
+
+ return result;
+}
+
+interface UMaskInputProps {
+ name: string;
+ value: string;
+ hasError?: boolean;
+ hasWarning?: boolean;
+ onChange: (change: InputChanged) => void;
+ onFocus?: (event: SyntheticEvent) => void;
+ onBlur?: (event: SyntheticEvent) => void;
+}
+
+function UMaskInput({ name, value, onChange }: UMaskInputProps) {
+ const valueNum = parseInt(value, 8);
+ const umaskNum = 0o777 & ~valueNum;
+ const umask = umaskNum.toString(8).padStart(4, '0');
+ const folderNum = 0o777 & ~umaskNum;
+ const folder = folderNum.toString(8).padStart(3, '0');
+ const fileNum = 0o666 & ~umaskNum;
+ const file = fileNum.toString(8).padStart(3, '0');
+ const unit = formatPermissions(folderNum);
+
+ const values = umaskOptions.map((v) => {
+ return { ...v, hint: {v.hint} };
+ });
+
+ return (
+
+
+
+
+
+
{translate('Umask')}
+
{umask}
+
+
+
+
{translate('Folder')}
+
{folder}
+
d{formatPermissions(folderNum)}
+
+
+
+
{translate('File')}
+
{file}
+
{formatPermissions(fileNum)}
+
+
+
+ );
+}
+
+UMaskInput.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onFocus: PropTypes.func,
+ onBlur: PropTypes.func,
+};
+
+export default UMaskInput;
diff --git a/frontend/src/Components/Form/SelectInput.css.d.ts b/frontend/src/Components/Form/SelectInput.css.d.ts
new file mode 100644
index 00000000000..fe8fa30691c
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'hasError': string;
+ 'hasWarning': string;
+ 'isDisabled': string;
+ 'select': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/SelectInput.js b/frontend/src/Components/Form/SelectInput.js
deleted file mode 100644
index 0a60ffe1e01..00000000000
--- a/frontend/src/Components/Form/SelectInput.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import styles from './SelectInput.css';
-
-class SelectInput extends Component {
-
- //
- // Listeners
-
- onChange = (event) => {
- this.props.onChange({
- name: this.props.name,
- value: event.target.value
- });
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- disabledClassName,
- name,
- value,
- values,
- isDisabled,
- hasError,
- hasWarning,
- autoFocus,
- onBlur
- } = this.props;
-
- return (
-
- {
- values.map((option) => {
- const {
- key,
- value: optionValue,
- ...otherOptionProps
- } = option;
-
- return (
-
- {optionValue}
-
- );
- })
- }
-
- );
- }
-}
-
-SelectInput.propTypes = {
- className: PropTypes.string,
- disabledClassName: PropTypes.string,
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- isDisabled: PropTypes.bool,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- autoFocus: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired,
- onBlur: PropTypes.func
-};
-
-SelectInput.defaultProps = {
- className: styles.select,
- disabledClassName: styles.isDisabled,
- isDisabled: false,
- autoFocus: false
-};
-
-export default SelectInput;
diff --git a/frontend/src/Components/Form/SelectInput.tsx b/frontend/src/Components/Form/SelectInput.tsx
new file mode 100644
index 00000000000..4716c2dfd81
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.tsx
@@ -0,0 +1,76 @@
+import classNames from 'classnames';
+import React, { ChangeEvent, SyntheticEvent, useCallback } from 'react';
+import { InputChanged } from 'typings/inputs';
+import styles from './SelectInput.css';
+
+interface SelectInputOption {
+ key: string;
+ value: string | number | (() => string | number);
+}
+
+interface SelectInputProps {
+ className?: string;
+ disabledClassName?: string;
+ name: string;
+ value: string | number;
+ values: SelectInputOption[];
+ isDisabled?: boolean;
+ hasError?: boolean;
+ hasWarning?: boolean;
+ autoFocus?: boolean;
+ onChange: (change: InputChanged) => void;
+ onBlur?: (event: SyntheticEvent) => void;
+}
+
+function SelectInput({
+ className = styles.select,
+ disabledClassName = styles.isDisabled,
+ name,
+ value,
+ values,
+ isDisabled = false,
+ hasError,
+ hasWarning,
+ autoFocus = false,
+ onBlur,
+ onChange,
+}: SelectInputProps) {
+ const handleChange = useCallback(
+ (event: ChangeEvent) => {
+ onChange({
+ name,
+ value: event.target.value as T,
+ });
+ },
+ [name, onChange]
+ );
+
+ return (
+
+ {values.map((option) => {
+ const { key, value: optionValue, ...otherOptionProps } = option;
+
+ return (
+
+ {typeof optionValue === 'function' ? optionValue() : optionValue}
+
+ );
+ })}
+
+ );
+}
+
+export default SelectInput;
diff --git a/frontend/src/Components/Form/SeriesTypeSelectInput.js b/frontend/src/Components/Form/SeriesTypeSelectInput.js
deleted file mode 100644
index 5c9ad0cacb2..00000000000
--- a/frontend/src/Components/Form/SeriesTypeSelectInput.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import * as seriesTypes from 'Utilities/Series/seriesTypes';
-import SelectInput from './SelectInput';
-
-const seriesTypeOptions = [
- { key: seriesTypes.STANDARD, value: 'Standard' },
- { key: seriesTypes.DAILY, value: 'Daily' },
- { key: seriesTypes.ANIME, value: 'Anime' }
-];
-
-function SeriesTypeSelectInput(props) {
- const values = [...seriesTypeOptions];
-
- const {
- includeNoChange,
- includeMixed
- } = props;
-
- if (includeNoChange) {
- values.unshift({
- key: 'noChange',
- value: 'No Change',
- disabled: true
- });
- }
-
- if (includeMixed) {
- values.unshift({
- key: 'mixed',
- value: '(Mixed)',
- disabled: true
- });
- }
-
- return (
-
- );
-}
-
-SeriesTypeSelectInput.propTypes = {
- includeNoChange: PropTypes.bool.isRequired,
- includeMixed: PropTypes.bool.isRequired
-};
-
-SeriesTypeSelectInput.defaultProps = {
- includeNoChange: false,
- includeMixed: false
-};
-
-export default SeriesTypeSelectInput;
diff --git a/frontend/src/Components/Form/Tag/DeviceInput.css b/frontend/src/Components/Form/Tag/DeviceInput.css
new file mode 100644
index 00000000000..189cafc6b40
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/DeviceInput.css
@@ -0,0 +1,8 @@
+.deviceInputWrapper {
+ display: flex;
+}
+
+.input {
+ composes: input from '~Components/Form/Tag/TagInput.css';
+ composes: hasButton from '~Components/Form/Input.css';
+}
diff --git a/frontend/src/Components/Form/Tag/DeviceInput.css.d.ts b/frontend/src/Components/Form/Tag/DeviceInput.css.d.ts
new file mode 100644
index 00000000000..e44e3fce045
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/DeviceInput.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'deviceInputWrapper': string;
+ 'input': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Tag/DeviceInput.tsx b/frontend/src/Components/Form/Tag/DeviceInput.tsx
new file mode 100644
index 00000000000..3c483d1f229
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/DeviceInput.tsx
@@ -0,0 +1,149 @@
+import React, { useCallback, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import FormInputButton from 'Components/Form/FormInputButton';
+import Icon from 'Components/Icon';
+import { icons } from 'Helpers/Props';
+import {
+ clearOptions,
+ defaultState,
+ fetchOptions,
+} from 'Store/Actions/providerOptionActions';
+import { InputChanged } from 'typings/inputs';
+import TagInput, { TagInputProps } from './TagInput';
+import styles from './DeviceInput.css';
+
+interface DeviceTag {
+ id: string;
+ name: string;
+}
+
+interface DeviceInputProps extends TagInputProps {
+ className?: string;
+ name: string;
+ value: string[];
+ hasError?: boolean;
+ hasWarning?: boolean;
+ provider: string;
+ providerData: object;
+ onChange: (change: InputChanged) => unknown;
+}
+
+function createDeviceTagsSelector(value: string[]) {
+ return createSelector(
+ (state: AppState) => state.providerOptions.devices || defaultState,
+ (devices) => {
+ return {
+ ...devices,
+ selectedDevices: value.map((valueDevice) => {
+ 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})`,
+ };
+ }),
+ };
+ }
+ );
+}
+
+function DeviceInput({
+ className = styles.deviceInputWrapper,
+ name,
+ value,
+ hasError,
+ hasWarning,
+ provider,
+ providerData,
+ onChange,
+}: DeviceInputProps) {
+ const dispatch = useDispatch();
+ const { items, selectedDevices, isFetching } = useSelector(
+ createDeviceTagsSelector(value)
+ );
+
+ const handleRefreshPress = useCallback(() => {
+ dispatch(
+ fetchOptions({
+ section: 'devices',
+ action: 'getDevices',
+ provider,
+ providerData,
+ })
+ );
+ }, [provider, providerData, dispatch]);
+
+ const handleTagAdd = useCallback(
+ (device: DeviceTag) => {
+ // New tags won't have an ID, only a name.
+ const deviceId = device.id || device.name;
+
+ onChange({
+ name,
+ value: [...value, deviceId],
+ });
+ },
+ [name, value, onChange]
+ );
+
+ const handleTagDelete = useCallback(
+ ({ index }: { index: number }) => {
+ const newValue = value.slice();
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue,
+ });
+ },
+ [name, value, onChange]
+ );
+
+ useEffect(() => {
+ dispatch(
+ fetchOptions({
+ section: 'devices',
+ action: 'getDevices',
+ provider,
+ providerData,
+ })
+ );
+
+ return () => {
+ dispatch(clearOptions({ section: 'devices' }));
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dispatch]);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default DeviceInput;
diff --git a/frontend/src/Components/Form/Tag/SeriesTagInput.tsx b/frontend/src/Components/Form/Tag/SeriesTagInput.tsx
new file mode 100644
index 00000000000..f72248cf5e3
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/SeriesTagInput.tsx
@@ -0,0 +1,145 @@
+import React, { useCallback, useMemo } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import { addTag } from 'Store/Actions/tagActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import { InputChanged } from 'typings/inputs';
+import sortByProp from 'Utilities/Array/sortByProp';
+import TagInput, { TagBase } from './TagInput';
+
+interface SeriesTag extends TagBase {
+ id: number;
+ name: string;
+}
+
+interface SeriesTagInputProps {
+ name: string;
+ value: number | number[];
+ onChange: (change: InputChanged) => void;
+}
+
+const VALID_TAG_REGEX = new RegExp('[^-_a-z0-9]', 'i');
+
+function isValidTag(tagName: string) {
+ try {
+ return !VALID_TAG_REGEX.test(tagName);
+ } catch {
+ return false;
+ }
+}
+
+function createSeriesTagsSelector(tags: number[]) {
+ return createSelector(createTagsSelector(), (tagList) => {
+ const sortedTags = tagList.sort(sortByProp('label'));
+ const filteredTagList = sortedTags.filter((tag) => !tags.includes(tag.id));
+
+ return {
+ tags: tags.reduce((acc: SeriesTag[], tag) => {
+ const matchingTag = tagList.find((t) => t.id === tag);
+
+ if (matchingTag) {
+ acc.push({
+ id: tag,
+ name: matchingTag.label,
+ });
+ }
+
+ return acc;
+ }, []),
+
+ tagList: filteredTagList.map(({ id, label: name }) => {
+ return {
+ id,
+ name,
+ };
+ }),
+
+ allTags: sortedTags,
+ };
+ });
+}
+
+export default function SeriesTagInput({
+ name,
+ value,
+ onChange,
+}: SeriesTagInputProps) {
+ const dispatch = useDispatch();
+ const isArray = Array.isArray(value);
+
+ const arrayValue = useMemo(() => {
+ if (isArray) {
+ return value;
+ }
+
+ return value === 0 ? [] : [value];
+ }, [isArray, value]);
+
+ const { tags, tagList, allTags } = useSelector(
+ createSeriesTagsSelector(arrayValue)
+ );
+
+ const handleTagCreated = useCallback(
+ (tag: SeriesTag) => {
+ if (isArray) {
+ onChange({ name, value: [...value, tag.id] });
+ } else {
+ onChange({
+ name,
+ value: tag.id,
+ });
+ }
+ },
+ [name, value, isArray, onChange]
+ );
+
+ const handleTagAdd = useCallback(
+ (newTag: SeriesTag) => {
+ if (newTag.id) {
+ if (isArray) {
+ onChange({ name, value: [...value, newTag.id] });
+ } else {
+ onChange({ name, value: newTag.id });
+ }
+
+ return;
+ }
+
+ const existingTag = allTags.some((t) => t.label === newTag.name);
+
+ if (isValidTag(newTag.name) && !existingTag) {
+ dispatch(
+ addTag({
+ tag: { label: newTag.name },
+ onTagCreated: handleTagCreated,
+ })
+ );
+ }
+ },
+ [name, value, isArray, allTags, handleTagCreated, onChange, dispatch]
+ );
+
+ const handleTagDelete = useCallback(
+ ({ index }: { index: number }) => {
+ if (isArray) {
+ const newValue = value.slice();
+ newValue.splice(index, 1);
+
+ onChange({ name, value: newValue });
+ } else {
+ onChange({ name, value: 0 });
+ }
+ },
+ [name, value, isArray, onChange]
+ );
+
+ return (
+
+ );
+}
diff --git a/frontend/src/Components/Form/Tag/TagInput.css b/frontend/src/Components/Form/Tag/TagInput.css
new file mode 100644
index 00000000000..2ca02825eb3
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagInput.css
@@ -0,0 +1,34 @@
+.input {
+ composes: input from '~Components/Form/AutoSuggestInput.css';
+
+ padding: 0;
+ min-height: 35px;
+ height: auto;
+
+ &.isFocused {
+ outline: 0;
+ border-color: var(--inputFocusBorderColor);
+ box-shadow: inset 0 1px 1px var(--inputBoxShadowColor),
+ 0 0 8px var(--inputFocusBoxShadowColor);
+ }
+}
+
+.hasError {
+ composes: hasError from '~Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from '~Components/Form/Input.css';
+}
+
+.internalInput {
+ flex: 1 1 0%;
+ margin-left: 3px;
+ min-width: 20%;
+ max-width: 100%;
+ width: 0%;
+ height: 31px;
+ border: none;
+ background-color: var(--inputBackground);
+ color: var(--textColor);
+}
diff --git a/frontend/src/Components/Form/Tag/TagInput.css.d.ts b/frontend/src/Components/Form/Tag/TagInput.css.d.ts
new file mode 100644
index 00000000000..8b3b412a55b
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagInput.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'hasError': string;
+ 'hasWarning': string;
+ 'input': string;
+ 'internalInput': string;
+ 'isFocused': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Tag/TagInput.tsx b/frontend/src/Components/Form/Tag/TagInput.tsx
new file mode 100644
index 00000000000..bde24f36995
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagInput.tsx
@@ -0,0 +1,371 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, {
+ KeyboardEvent,
+ Ref,
+ SyntheticEvent,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import {
+ ChangeEvent,
+ RenderInputComponentProps,
+ RenderSuggestion,
+ SuggestionsFetchRequestedParams,
+} from 'react-autosuggest';
+import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback';
+import { kinds } from 'Helpers/Props';
+import { Kind } from 'Helpers/Props/kinds';
+import tagShape from 'Helpers/Props/Shapes/tagShape';
+import { InputChanged } from 'typings/inputs';
+import AutoSuggestInput from '../AutoSuggestInput';
+import TagInputInput from './TagInputInput';
+import TagInputTag, { EditedTag, TagInputTagProps } from './TagInputTag';
+import styles from './TagInput.css';
+
+export interface TagBase {
+ id: boolean | number | string | null;
+ name: string | number;
+}
+
+function getTag(
+ value: string,
+ selectedIndex: number,
+ suggestions: T[],
+ allowNew: boolean
+) {
+ if (selectedIndex == null && value) {
+ const existingTag = suggestions.find(
+ (suggestion) => suggestion.name === value
+ );
+
+ if (existingTag) {
+ return existingTag;
+ } else if (allowNew) {
+ return { name: value } as T;
+ }
+ } else if (selectedIndex != null) {
+ return suggestions[selectedIndex];
+ }
+
+ return null;
+}
+
+function handleSuggestionsClearRequested() {
+ // Required because props aren't always rendered, but no-op
+ // because we don't want to reset the paths after a path is selected.
+}
+
+export interface ReplacementTag {
+ index: number;
+ id: T['id'];
+}
+
+export interface TagInputProps {
+ className?: string;
+ inputContainerClassName?: string;
+ name: string;
+ tags: T[];
+ tagList: T[];
+ allowNew?: boolean;
+ kind?: Kind;
+ placeholder?: string;
+ delimiters?: string[];
+ minQueryLength?: number;
+ canEdit?: boolean;
+ hasError?: boolean;
+ hasWarning?: boolean;
+ tagComponent?: React.ElementType;
+ onChange?: (change: InputChanged) => void;
+ onTagAdd: (newTag: T) => void;
+ onTagDelete: TagInputTagProps['onDelete'];
+ onTagReplace?: (
+ tagToReplace: ReplacementTag,
+ newTagName: T['name']
+ ) => void;
+}
+
+function TagInput({
+ className = styles.internalInput,
+ inputContainerClassName = styles.input,
+ name,
+ tags,
+ tagList,
+ allowNew = true,
+ kind = 'info',
+ placeholder = '',
+ delimiters = ['Tab', 'Enter', ' ', ','],
+ minQueryLength = 1,
+ canEdit = false,
+ tagComponent = TagInputTag,
+ hasError,
+ hasWarning,
+ onChange,
+ onTagAdd,
+ onTagDelete,
+ onTagReplace,
+ ...otherProps
+}: TagInputProps) {
+ const [value, setValue] = useState('');
+ const [suggestions, setSuggestions] = useState([]);
+ const [isFocused, setIsFocused] = useState(false);
+ const autoSuggestRef = useRef(null);
+
+ const addTag = useDebouncedCallback(
+ (tag: T | null) => {
+ if (!tag) {
+ return;
+ }
+
+ onTagAdd(tag);
+
+ setValue('');
+ setSuggestions([]);
+ },
+ 250,
+ {
+ leading: true,
+ trailing: false,
+ }
+ );
+
+ const handleEditTag = useCallback(
+ ({ value: newValue, ...otherProps }: EditedTag) => {
+ if (value && onTagReplace) {
+ onTagReplace(otherProps, value);
+ } else {
+ onTagDelete(otherProps);
+ }
+
+ setValue(String(newValue));
+ },
+ [value, setValue, onTagDelete, onTagReplace]
+ );
+
+ const handleInputContainerPress = useCallback(() => {
+ // @ts-expect-error Ref isn't typed yet
+ autoSuggestRef?.current?.input.focus();
+ }, []);
+
+ const handleInputChange = useCallback(
+ (_event: SyntheticEvent, { newValue, method }: ChangeEvent) => {
+ const finalValue =
+ // @ts-expect-error newValue may be an object?
+ typeof newValue === 'object' ? newValue.name : newValue;
+
+ if (method === 'type') {
+ setValue(finalValue);
+ }
+ },
+ [setValue]
+ );
+
+ const handleSuggestionsFetchRequested = useCallback(
+ ({ value: newValue }: SuggestionsFetchRequestedParams) => {
+ const lowerCaseValue = newValue.toLowerCase();
+
+ const suggestions = tagList.filter((tag) => {
+ return (
+ String(tag.name).toLowerCase().includes(lowerCaseValue) &&
+ !tags.some((t) => t.id === tag.id)
+ );
+ });
+
+ setSuggestions(suggestions);
+ },
+ [tags, tagList, setSuggestions]
+ );
+
+ const handleInputKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ const key = event.key;
+
+ if (!autoSuggestRef.current) {
+ return;
+ }
+
+ if (key === 'Backspace' && !value.length) {
+ const index = tags.length - 1;
+
+ if (index >= 0) {
+ onTagDelete({ index, id: tags[index].id });
+ }
+
+ setTimeout(() => {
+ handleSuggestionsFetchRequested({
+ value: '',
+ reason: 'input-changed',
+ });
+ });
+
+ event.preventDefault();
+ }
+
+ if (delimiters.includes(key)) {
+ // @ts-expect-error Ref isn't typed yet
+ const selectedIndex = autoSuggestRef.current.highlightedSuggestionIndex;
+ const tag = getTag(value, selectedIndex, suggestions, allowNew);
+
+ if (tag) {
+ addTag(tag);
+ event.preventDefault();
+ }
+ }
+ },
+ [
+ tags,
+ allowNew,
+ delimiters,
+ onTagDelete,
+ value,
+ suggestions,
+ addTag,
+ handleSuggestionsFetchRequested,
+ ]
+ );
+
+ const handleInputFocus = useCallback(() => {
+ setIsFocused(true);
+ }, [setIsFocused]);
+
+ const handleInputBlur = useCallback(() => {
+ setIsFocused(false);
+
+ if (!autoSuggestRef.current) {
+ return;
+ }
+
+ // @ts-expect-error Ref isn't typed yet
+ const selectedIndex = autoSuggestRef.current.highlightedSuggestionIndex;
+ const tag = getTag(value, selectedIndex, suggestions, allowNew);
+
+ if (tag) {
+ addTag(tag);
+ }
+ }, [allowNew, value, suggestions, autoSuggestRef, addTag, setIsFocused]);
+
+ const handleSuggestionSelected = useCallback(
+ (_event: SyntheticEvent, { suggestion }: { suggestion: T }) => {
+ addTag(suggestion);
+ },
+ [addTag]
+ );
+
+ const getSuggestionValue = useCallback(({ name }: T): string => {
+ return String(name);
+ }, []);
+
+ const shouldRenderSuggestions = useCallback(
+ (v: string) => {
+ return v.length >= minQueryLength;
+ },
+ [minQueryLength]
+ );
+
+ const renderSuggestion: RenderSuggestion = useCallback(({ name }: T) => {
+ return name;
+ }, []);
+
+ const renderInputComponent = useCallback(
+ (
+ inputProps: RenderInputComponentProps,
+ forwardedRef: Ref
+ ) => {
+ return (
+
+ );
+ },
+ [
+ tags,
+ kind,
+ canEdit,
+ isFocused,
+ tagComponent,
+ handleInputContainerPress,
+ handleEditTag,
+ onTagDelete,
+ ]
+ );
+
+ useEffect(() => {
+ return () => {
+ addTag.cancel();
+ };
+ }, [addTag]);
+
+ return (
+
+ );
+}
+
+TagInput.propTypes = {
+ className: PropTypes.string,
+ inputContainerClassName: PropTypes.string,
+ tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ allowNew: PropTypes.bool,
+ kind: PropTypes.oneOf(kinds.all),
+ placeholder: PropTypes.string,
+ delimiters: PropTypes.arrayOf(PropTypes.string),
+ minQueryLength: PropTypes.number,
+ canEdit: PropTypes.bool,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ tagComponent: PropTypes.elementType,
+ onTagAdd: PropTypes.func.isRequired,
+ onTagDelete: PropTypes.func.isRequired,
+ onTagReplace: PropTypes.func,
+};
+
+TagInput.defaultProps = {
+ className: styles.internalInput,
+ inputContainerClassName: styles.input,
+ allowNew: true,
+ kind: kinds.INFO,
+ placeholder: '',
+ delimiters: ['Tab', 'Enter', ' ', ','],
+ minQueryLength: 1,
+ canEdit: false,
+ tagComponent: TagInputTag,
+};
+
+export default TagInput;
diff --git a/frontend/src/Components/Form/Tag/TagInputInput.css b/frontend/src/Components/Form/Tag/TagInputInput.css
new file mode 100644
index 00000000000..ab9d08d61e8
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagInputInput.css
@@ -0,0 +1,9 @@
+.inputContainer {
+ inset: -1px;
+ display: flex;
+ align-items: start;
+ flex-wrap: wrap;
+ padding: 1px 16px;
+ min-height: 33px;
+ cursor: default;
+}
diff --git a/frontend/src/Components/Form/Tag/TagInputInput.css.d.ts b/frontend/src/Components/Form/Tag/TagInputInput.css.d.ts
new file mode 100644
index 00000000000..d0a03ef53cf
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagInputInput.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'inputContainer': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Tag/TagInputInput.tsx b/frontend/src/Components/Form/Tag/TagInputInput.tsx
new file mode 100644
index 00000000000..d181136b8ef
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagInputInput.tsx
@@ -0,0 +1,71 @@
+import React, { MouseEvent, Ref, useCallback } from 'react';
+import { Kind } from 'Helpers/Props/kinds';
+import { TagBase } from './TagInput';
+import { TagInputTagProps } from './TagInputTag';
+import styles from './TagInputInput.css';
+
+interface TagInputInputProps {
+ forwardedRef?: Ref;
+ className?: string;
+ tags: TagBase[];
+ inputProps: object;
+ kind: Kind;
+ isFocused: boolean;
+ canEdit: boolean;
+ tagComponent: React.ElementType;
+ onTagDelete: TagInputTagProps['onDelete'];
+ onTagEdit: TagInputTagProps['onEdit'];
+ onInputContainerPress: () => void;
+}
+
+function TagInputInput(props: TagInputInputProps) {
+ const {
+ forwardedRef,
+ className = styles.inputContainer,
+ tags,
+ inputProps,
+ kind,
+ isFocused,
+ canEdit,
+ tagComponent: TagComponent,
+ onTagDelete,
+ onTagEdit,
+ onInputContainerPress,
+ } = props;
+
+ const handleMouseDown = useCallback(
+ (event: MouseEvent) => {
+ event.preventDefault();
+
+ if (isFocused) {
+ return;
+ }
+
+ onInputContainerPress();
+ },
+ [isFocused, onInputContainerPress]
+ );
+
+ return (
+
+ {tags.map((tag, index) => {
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+export default TagInputInput;
diff --git a/frontend/src/Components/Form/TagInputTag.css b/frontend/src/Components/Form/Tag/TagInputTag.css
similarity index 96%
rename from frontend/src/Components/Form/TagInputTag.css
rename to frontend/src/Components/Form/Tag/TagInputTag.css
index 7e66a4d1207..1a8ff45d6f9 100644
--- a/frontend/src/Components/Form/TagInputTag.css
+++ b/frontend/src/Components/Form/Tag/TagInputTag.css
@@ -30,5 +30,6 @@
.label {
composes: label from '~Components/Label.css';
+ display: flex;
max-width: 100%;
}
diff --git a/frontend/src/Components/Form/Tag/TagInputTag.css.d.ts b/frontend/src/Components/Form/Tag/TagInputTag.css.d.ts
new file mode 100644
index 00000000000..510189d2c40
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagInputTag.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'editButton': string;
+ 'editContainer': string;
+ 'label': string;
+ 'link': string;
+ 'linkWithEdit': string;
+ 'tag': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/Tag/TagInputTag.tsx b/frontend/src/Components/Form/Tag/TagInputTag.tsx
new file mode 100644
index 00000000000..7b549767c7c
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagInputTag.tsx
@@ -0,0 +1,79 @@
+import React, { useCallback } from 'react';
+import Label, { LabelProps } from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import MiddleTruncate from 'Components/MiddleTruncate';
+import { icons } from 'Helpers/Props';
+import { TagBase } from './TagInput';
+import styles from './TagInputTag.css';
+
+export interface DeletedTag {
+ index: number;
+ id: T['id'];
+}
+
+export interface EditedTag {
+ index: number;
+ id: T['id'];
+ value: T['name'];
+}
+
+export interface TagInputTagProps {
+ index: number;
+ tag: T;
+ kind: LabelProps['kind'];
+ canEdit: boolean;
+ onDelete: (deletedTag: DeletedTag) => void;
+ onEdit: (editedTag: EditedTag) => void;
+}
+
+function TagInputTag({
+ tag,
+ kind,
+ index,
+ canEdit,
+ onDelete,
+ onEdit,
+}: TagInputTagProps) {
+ const handleDelete = useCallback(() => {
+ onDelete({
+ index,
+ id: tag.id,
+ });
+ }, [index, tag, onDelete]);
+
+ const handleEdit = useCallback(() => {
+ onEdit({
+ index,
+ id: tag.id,
+ value: tag.name,
+ });
+ }, [index, tag, onEdit]);
+
+ return (
+
+
+
+
+
+
+ {canEdit ? (
+
+
+
+ ) : null}
+
+
+ );
+}
+
+export default TagInputTag;
diff --git a/frontend/src/Components/Form/Tag/TagSelectInput.tsx b/frontend/src/Components/Form/Tag/TagSelectInput.tsx
new file mode 100644
index 00000000000..21fde893c64
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagSelectInput.tsx
@@ -0,0 +1,97 @@
+import React, { useCallback, useMemo } from 'react';
+import { InputChanged } from 'typings/inputs';
+import TagInput, { TagBase, TagInputProps } from './TagInput';
+
+interface SelectTag extends TagBase {
+ id: number;
+ name: string;
+}
+
+interface TagSelectValue {
+ value: string;
+ key: number;
+ order: number;
+}
+
+interface TagSelectInputProps extends TagInputProps {
+ name: string;
+ value: number[];
+ values: TagSelectValue[];
+ onChange: (change: InputChanged) => unknown;
+}
+
+function TagSelectInput({
+ name,
+ value,
+ values,
+ onChange,
+ ...otherProps
+}: TagSelectInputProps) {
+ const { tags, tagList, allTags } = useMemo(() => {
+ const sortedTags = values.sort((a, b) => a.key - b.key);
+
+ return {
+ tags: value.reduce((acc: SelectTag[], tag) => {
+ const matchingTag = values.find((t) => t.key === tag);
+
+ if (matchingTag) {
+ acc.push({
+ id: tag,
+ name: matchingTag.value,
+ });
+ }
+
+ return acc;
+ }, []),
+
+ tagList: sortedTags.map((sorted) => {
+ return {
+ id: sorted.key,
+ name: sorted.value,
+ };
+ }),
+
+ allTags: sortedTags,
+ };
+ }, [value, values]);
+
+ const handleTagAdd = useCallback(
+ (newTag: SelectTag) => {
+ const existingTag = allTags.some((tag) => tag.key === newTag.id);
+ const newValue = value.slice();
+
+ if (existingTag) {
+ newValue.push(newTag.id);
+ }
+
+ onChange({ name, value: newValue });
+ },
+ [name, value, allTags, onChange]
+ );
+
+ const handleTagDelete = useCallback(
+ ({ index }: { index: number }) => {
+ const newValue = value.slice();
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue,
+ });
+ },
+ [name, value, onChange]
+ );
+
+ return (
+
+ );
+}
+
+export default TagSelectInput;
diff --git a/frontend/src/Components/Form/Tag/TextTagInput.tsx b/frontend/src/Components/Form/Tag/TextTagInput.tsx
new file mode 100644
index 00000000000..6e2082c5076
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TextTagInput.tsx
@@ -0,0 +1,109 @@
+import React, { useCallback, useMemo } from 'react';
+import { InputChanged } from 'typings/inputs';
+import split from 'Utilities/String/split';
+import TagInput, { ReplacementTag, TagBase, TagInputProps } from './TagInput';
+
+interface TextTag extends TagBase {
+ id: string;
+ name: string;
+}
+
+interface TextTagInputProps extends TagInputProps {
+ name: string;
+ value: string | string[];
+ onChange: (change: InputChanged) => unknown;
+}
+
+function TextTagInput({
+ name,
+ value,
+ onChange,
+ ...otherProps
+}: TextTagInputProps) {
+ const { tags, tagList, valueArray } = useMemo(() => {
+ const tagsArray = Array.isArray(value) ? value : split(value);
+
+ return {
+ tags: tagsArray.reduce((result: TextTag[], tag) => {
+ if (tag) {
+ result.push({
+ id: tag,
+ name: tag,
+ });
+ }
+
+ return result;
+ }, []),
+ tagList: [],
+ valueArray: tagsArray,
+ };
+ }, [value]);
+
+ const handleTagAdd = useCallback(
+ (newTag: TextTag) => {
+ // Split and trim tags before adding them to the list, this will
+ // cleanse tags pasted in that had commas and spaces which leads
+ // to oddities with restrictions (as an example).
+
+ const newValue = [...valueArray];
+ const newTags = newTag.name.startsWith('/')
+ ? [newTag.name]
+ : split(newTag.name);
+
+ newTags.forEach((newTag) => {
+ const newTagValue = newTag.trim();
+
+ if (newTagValue) {
+ newValue.push(newTagValue);
+ }
+ });
+
+ onChange({ name, value: newValue });
+ },
+ [name, valueArray, onChange]
+ );
+
+ const handleTagDelete = useCallback(
+ ({ index }: { index: number }) => {
+ const newValue = [...valueArray];
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue,
+ });
+ },
+ [name, valueArray, onChange]
+ );
+
+ const handleTagReplace = useCallback(
+ (tagToReplace: ReplacementTag, newTagName: string) => {
+ const newValue = [...valueArray];
+ newValue.splice(tagToReplace.index, 1);
+
+ const newTagValue = newTagName.trim();
+
+ if (newTagValue) {
+ newValue.push(newTagValue);
+ }
+
+ onChange({ name, value: newValue });
+ },
+ [name, valueArray, onChange]
+ );
+
+ return (
+
+ );
+}
+
+export default TextTagInput;
diff --git a/frontend/src/Components/Form/TagInput.css b/frontend/src/Components/Form/TagInput.css
deleted file mode 100644
index eeddab5b4b8..00000000000
--- a/frontend/src/Components/Form/TagInput.css
+++ /dev/null
@@ -1,33 +0,0 @@
-.input {
- composes: input from '~./AutoSuggestInput.css';
-
- padding: 0;
- min-height: 35px;
- height: auto;
-
- &.isFocused {
- outline: 0;
- border-color: var(--inputFocusBorderColor);
- box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), 0 0 8px var(--inputFocusBoxShadowColor);
- }
-}
-
-.hasError {
- composes: hasError from '~Components/Form/Input.css';
-}
-
-.hasWarning {
- composes: hasWarning from '~Components/Form/Input.css';
-}
-
-.internalInput {
- flex: 1 1 0%;
- margin-left: 3px;
- min-width: 20%;
- max-width: 100%;
- width: 0%;
- height: 31px;
- border: none;
- background-color: var(--inputBackground);
- color: var(--textColor);
-}
diff --git a/frontend/src/Components/Form/TagInput.js b/frontend/src/Components/Form/TagInput.js
deleted file mode 100644
index 1c34ec1c38c..00000000000
--- a/frontend/src/Components/Form/TagInput.js
+++ /dev/null
@@ -1,294 +0,0 @@
-import classNames from 'classnames';
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { kinds } from 'Helpers/Props';
-import tagShape from 'Helpers/Props/Shapes/tagShape';
-import AutoSuggestInput from './AutoSuggestInput';
-import TagInputInput from './TagInputInput';
-import TagInputTag from './TagInputTag';
-import styles from './TagInput.css';
-
-function getTag(value, selectedIndex, suggestions, allowNew) {
- if (selectedIndex == null && value) {
- const existingTag = suggestions.find((suggestion) => suggestion.name === value);
-
- if (existingTag) {
- return existingTag;
- } else if (allowNew) {
- return { name: value };
- }
- } else if (selectedIndex != null) {
- return suggestions[selectedIndex];
- }
-}
-
-class TagInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- value: '',
- suggestions: [],
- isFocused: false
- };
-
- this._autosuggestRef = null;
- }
-
- componentWillUnmount() {
- this.addTag.cancel();
- }
-
- //
- // Control
-
- _setAutosuggestRef = (ref) => {
- this._autosuggestRef = ref;
- };
-
- getSuggestionValue({ name }) {
- return name;
- }
-
- shouldRenderSuggestions = (value) => {
- return value.length >= this.props.minQueryLength;
- };
-
- renderSuggestion({ name }) {
- return name;
- }
-
- addTag = _.debounce((tag) => {
- this.props.onTagAdd(tag);
-
- this.setState({
- value: '',
- suggestions: []
- });
- }, 250, { leading: true, trailing: false });
-
- //
- // Listeners
-
- onTagEdit = ({ value, ...otherProps }) => {
- this.setState({ value });
-
- this.props.onTagDelete(otherProps);
- };
-
- onInputContainerPress = () => {
- this._autosuggestRef.input.focus();
- };
-
- onInputChange = (event, { newValue, method }) => {
- const value = _.isObject(newValue) ? newValue.name : newValue;
-
- if (method === 'type') {
- this.setState({ value });
- }
- };
-
- onInputKeyDown = (event) => {
- const {
- tags,
- allowNew,
- delimiters,
- onTagDelete
- } = this.props;
-
- const {
- value,
- suggestions
- } = this.state;
-
- const key = event.key;
-
- if (key === 'Backspace' && !value.length) {
- const index = tags.length - 1;
-
- if (index >= 0) {
- onTagDelete({ index, id: tags[index].id });
- }
-
- setTimeout(() => {
- this.onSuggestionsFetchRequested({ value: '' });
- });
-
- event.preventDefault();
- }
-
- if (delimiters.includes(key)) {
- const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
- const tag = getTag(value, selectedIndex, suggestions, allowNew);
-
- if (tag) {
- this.addTag(tag);
- event.preventDefault();
- }
- }
- };
-
- onInputFocus = () => {
- this.setState({ isFocused: true });
- };
-
- onInputBlur = () => {
- this.setState({ isFocused: false });
-
- if (!this._autosuggestRef) {
- return;
- }
-
- const {
- allowNew
- } = this.props;
-
- const {
- value,
- suggestions
- } = this.state;
-
- const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
- const tag = getTag(value, selectedIndex, suggestions, allowNew);
-
- if (tag) {
- this.addTag(tag);
- }
- };
-
- onSuggestionsFetchRequested = ({ value }) => {
- const lowerCaseValue = value.toLowerCase();
-
- const {
- tags,
- tagList
- } = this.props;
-
- const suggestions = tagList.filter((tag) => {
- return (
- tag.name.toLowerCase().includes(lowerCaseValue) &&
- !tags.some((t) => t.id === tag.id));
- });
-
- this.setState({ suggestions });
- };
-
- onSuggestionsClearRequested = () => {
- // Required because props aren't always rendered, but no-op
- // because we don't want to reset the paths after a path is selected.
- };
-
- onSuggestionSelected = (event, { suggestion }) => {
- this.addTag(suggestion);
- };
-
- //
- // Render
-
- renderInputComponent = (inputProps, forwardedRef) => {
- const {
- tags,
- kind,
- canEdit,
- tagComponent,
- onTagDelete
- } = this.props;
-
- return (
-
- );
- };
-
- render() {
- const {
- className,
- inputContainerClassName,
- hasError,
- hasWarning,
- ...otherProps
- } = this.props;
-
- const {
- value,
- suggestions,
- isFocused
- } = this.state;
-
- return (
-
- );
- }
-}
-
-TagInput.propTypes = {
- className: PropTypes.string.isRequired,
- inputContainerClassName: PropTypes.string.isRequired,
- tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
- tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
- allowNew: PropTypes.bool.isRequired,
- kind: PropTypes.oneOf(kinds.all).isRequired,
- placeholder: PropTypes.string.isRequired,
- delimiters: PropTypes.arrayOf(PropTypes.string).isRequired,
- minQueryLength: PropTypes.number.isRequired,
- canEdit: PropTypes.bool,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- tagComponent: PropTypes.elementType.isRequired,
- onTagAdd: PropTypes.func.isRequired,
- onTagDelete: PropTypes.func.isRequired
-};
-
-TagInput.defaultProps = {
- className: styles.internalInput,
- inputContainerClassName: styles.input,
- allowNew: true,
- kind: kinds.INFO,
- placeholder: '',
- delimiters: ['Tab', 'Enter', ' ', ','],
- minQueryLength: 1,
- canEdit: false,
- tagComponent: TagInputTag
-};
-
-export default TagInput;
diff --git a/frontend/src/Components/Form/TagInputConnector.js b/frontend/src/Components/Form/TagInputConnector.js
deleted file mode 100644
index 9504a2d73c4..00000000000
--- a/frontend/src/Components/Form/TagInputConnector.js
+++ /dev/null
@@ -1,156 +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 { addTag } from 'Store/Actions/tagActions';
-import createTagsSelector from 'Store/Selectors/createTagsSelector';
-import TagInput from './TagInput';
-
-const validTagRegex = new RegExp('[^-_a-z0-9]', 'i');
-
-function isValidTag(tagName) {
- try {
- return !validTagRegex.test(tagName);
- } catch (e) {
- return false;
- }
-}
-
-function createMapStateToProps() {
- return createSelector(
- (state, { value }) => value,
- createTagsSelector(),
- (tags, tagList) => {
- const sortedTags = _.sortBy(tagList, 'label');
- const filteredTagList = _.filter(sortedTags, (tag) => _.indexOf(tags, tag.id) === -1);
-
- return {
- tags: tags.reduce((acc, tag) => {
- const matchingTag = _.find(tagList, { id: tag });
-
- if (matchingTag) {
- acc.push({
- id: tag,
- name: matchingTag.label
- });
- }
-
- return acc;
- }, []),
-
- tagList: filteredTagList.map(({ id, label: name }) => {
- return {
- id,
- name
- };
- }),
-
- allTags: sortedTags
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- addTag
-};
-
-class TagInputConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidMount() {
- const {
- name,
- value,
- tags,
- onChange
- } = this.props;
-
- if (value.length !== tags.length) {
- onChange({ name, value: tags.map((tag) => tag.id) });
- }
- }
-
- //
- // Listeners
-
- onTagAdd = (tag) => {
- const {
- name,
- value,
- allTags
- } = this.props;
-
- if (!tag.id) {
- const existingTag =_.some(allTags, { label: tag.name });
-
- if (isValidTag(tag.name) && !existingTag) {
- this.props.addTag({
- tag: { label: tag.name },
- onTagCreated: this.onTagCreated
- });
- }
-
- return;
- }
-
- const newValue = value.slice();
- newValue.push(tag.id);
-
- this.props.onChange({ name, value: newValue });
- };
-
- onTagDelete = ({ index }) => {
- const {
- name,
- value
- } = this.props;
-
- const newValue = value.slice();
- newValue.splice(index, 1);
-
- this.props.onChange({
- name,
- value: newValue
- });
- };
-
- onTagCreated = (tag) => {
- const {
- name,
- value
- } = this.props;
-
- const newValue = value.slice();
- newValue.push(tag.id);
-
- this.props.onChange({ name, value: newValue });
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-TagInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.arrayOf(PropTypes.number).isRequired,
- tags: PropTypes.arrayOf(PropTypes.object).isRequired,
- allTags: PropTypes.arrayOf(PropTypes.object).isRequired,
- onChange: PropTypes.func.isRequired,
- addTag: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(TagInputConnector);
diff --git a/frontend/src/Components/Form/TagInputInput.css b/frontend/src/Components/Form/TagInputInput.css
deleted file mode 100644
index 292f1a0891a..00000000000
--- a/frontend/src/Components/Form/TagInputInput.css
+++ /dev/null
@@ -1,12 +0,0 @@
-.inputContainer {
- top: -1px;
- right: -1px;
- bottom: -1px;
- left: -1px;
- display: flex;
- align-items: start;
- flex-wrap: wrap;
- padding: 1px 16px;
- min-height: 33px;
- cursor: default;
-}
diff --git a/frontend/src/Components/Form/TagInputInput.js b/frontend/src/Components/Form/TagInputInput.js
deleted file mode 100644
index d88c0ce8a76..00000000000
--- a/frontend/src/Components/Form/TagInputInput.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { kinds } from 'Helpers/Props';
-import tagShape from 'Helpers/Props/Shapes/tagShape';
-import styles from './TagInputInput.css';
-
-class TagInputInput extends Component {
-
- onMouseDown = (event) => {
- event.preventDefault();
-
- const {
- isFocused,
- onInputContainerPress
- } = this.props;
-
- if (isFocused) {
- return;
- }
-
- onInputContainerPress();
- };
-
- render() {
- const {
- forwardedRef,
- className,
- tags,
- inputProps,
- kind,
- canEdit,
- tagComponent: TagComponent,
- onTagDelete,
- onTagEdit
- } = this.props;
-
- return (
-
- {
- tags.map((tag, index) => {
- return (
-
- );
- })
- }
-
-
-
- );
- }
-}
-
-TagInputInput.propTypes = {
- forwardedRef: PropTypes.func,
- className: PropTypes.string.isRequired,
- tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
- inputProps: PropTypes.object.isRequired,
- kind: PropTypes.oneOf(kinds.all).isRequired,
- isFocused: PropTypes.bool.isRequired,
- canEdit: PropTypes.bool.isRequired,
- tagComponent: PropTypes.elementType.isRequired,
- onTagDelete: PropTypes.func.isRequired,
- onTagEdit: PropTypes.func.isRequired,
- onInputContainerPress: PropTypes.func.isRequired
-};
-
-TagInputInput.defaultProps = {
- className: styles.inputContainer
-};
-
-export default TagInputInput;
diff --git a/frontend/src/Components/Form/TagInputTag.js b/frontend/src/Components/Form/TagInputTag.js
deleted file mode 100644
index 05a78044281..00000000000
--- a/frontend/src/Components/Form/TagInputTag.js
+++ /dev/null
@@ -1,101 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import MiddleTruncate from 'react-middle-truncate';
-import Label from 'Components/Label';
-import IconButton from 'Components/Link/IconButton';
-import Link from 'Components/Link/Link';
-import { icons, kinds } from 'Helpers/Props';
-import tagShape from 'Helpers/Props/Shapes/tagShape';
-import styles from './TagInputTag.css';
-
-class TagInputTag extends Component {
-
- //
- // Listeners
-
- onDelete = () => {
- const {
- index,
- tag,
- onDelete
- } = this.props;
-
- onDelete({
- index,
- id: tag.id
- });
- };
-
- onEdit = () => {
- const {
- index,
- tag,
- onEdit
- } = this.props;
-
- onEdit({
- index,
- id: tag.id,
- value: tag.name
- });
- };
-
- //
- // Render
-
- render() {
- const {
- tag,
- kind,
- canEdit
- } = this.props;
-
- return (
-
-
-
-
-
-
- {
- canEdit ?
-
-
-
:
- null
- }
-
-
- );
- }
-}
-
-TagInputTag.propTypes = {
- index: PropTypes.number.isRequired,
- tag: PropTypes.shape(tagShape),
- kind: PropTypes.oneOf(kinds.all).isRequired,
- canEdit: PropTypes.bool.isRequired,
- onDelete: PropTypes.func.isRequired,
- onEdit: PropTypes.func.isRequired
-};
-
-export default TagInputTag;
diff --git a/frontend/src/Components/Form/TagSelectInputConnector.js b/frontend/src/Components/Form/TagSelectInputConnector.js
deleted file mode 100644
index 23afe6da196..00000000000
--- a/frontend/src/Components/Form/TagSelectInputConnector.js
+++ /dev/null
@@ -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 TagInput from './TagInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state, { value }) => value,
- (state, { values }) => values,
- (tags, tagList) => {
- const sortedTags = _.sortBy(tagList, 'value');
-
- return {
- tags: tags.reduce((acc, tag) => {
- const matchingTag = _.find(tagList, { key: tag });
-
- if (matchingTag) {
- acc.push({
- id: tag,
- name: matchingTag.value
- });
- }
-
- return acc;
- }, []),
-
- tagList: sortedTags.map(({ key: id, value: name }) => {
- return {
- id,
- name
- };
- }),
-
- allTags: sortedTags
- };
- }
- );
-}
-
-class TagSelectInputConnector extends Component {
-
- //
- // Listeners
-
- onTagAdd = (tag) => {
- const {
- name,
- value,
- allTags
- } = this.props;
-
- const existingTag =_.some(allTags, { key: tag.id });
-
- const newValue = value.slice();
-
- if (existingTag) {
- newValue.push(tag.id);
- }
-
- this.props.onChange({ name, value: newValue });
- };
-
- onTagDelete = ({ index }) => {
- const {
- name,
- value
- } = this.props;
-
- const newValue = value.slice();
- newValue.splice(index, 1);
-
- this.props.onChange({
- name,
- value: newValue
- });
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-TagSelectInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.arrayOf(PropTypes.number).isRequired,
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- allTags: PropTypes.arrayOf(PropTypes.object).isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps)(TagSelectInputConnector);
diff --git a/frontend/src/Components/Form/TextArea.css.d.ts b/frontend/src/Components/Form/TextArea.css.d.ts
new file mode 100644
index 00000000000..59c6aad69a1
--- /dev/null
+++ b/frontend/src/Components/Form/TextArea.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'hasError': string;
+ 'hasWarning': string;
+ 'input': string;
+ 'readOnly': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/TextArea.js b/frontend/src/Components/Form/TextArea.js
deleted file mode 100644
index 44fd3a2498a..00000000000
--- a/frontend/src/Components/Form/TextArea.js
+++ /dev/null
@@ -1,172 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import styles from './TextArea.css';
-
-class TextArea extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._input = null;
- this._selectionStart = null;
- this._selectionEnd = null;
- this._selectionTimeout = null;
- this._isMouseTarget = false;
- }
-
- componentDidMount() {
- window.addEventListener('mouseup', this.onDocumentMouseUp);
- }
-
- componentWillUnmount() {
- window.removeEventListener('mouseup', this.onDocumentMouseUp);
-
- if (this._selectionTimeout) {
- this._selectionTimeout = clearTimeout(this._selectionTimeout);
- }
- }
-
- //
- // Control
-
- setInputRef = (ref) => {
- this._input = ref;
- };
-
- selectionChange() {
- if (this._selectionTimeout) {
- this._selectionTimeout = clearTimeout(this._selectionTimeout);
- }
-
- this._selectionTimeout = setTimeout(() => {
- const selectionStart = this._input.selectionStart;
- const selectionEnd = this._input.selectionEnd;
-
- const selectionChanged = (
- this._selectionStart !== selectionStart ||
- this._selectionEnd !== selectionEnd
- );
-
- this._selectionStart = selectionStart;
- this._selectionEnd = selectionEnd;
-
- if (this.props.onSelectionChange && selectionChanged) {
- this.props.onSelectionChange(selectionStart, selectionEnd);
- }
- }, 10);
- }
-
- //
- // Listeners
-
- onChange = (event) => {
- const {
- name,
- onChange
- } = this.props;
-
- const payload = {
- name,
- value: event.target.value
- };
-
- onChange(payload);
- };
-
- onFocus = (event) => {
- if (this.props.onFocus) {
- this.props.onFocus(event);
- }
-
- this.selectionChange();
- };
-
- onKeyUp = () => {
- this.selectionChange();
- };
-
- onMouseDown = () => {
- this._isMouseTarget = true;
- };
-
- onMouseUp = () => {
- this.selectionChange();
- };
-
- onDocumentMouseUp = () => {
- if (this._isMouseTarget) {
- this.selectionChange();
- }
-
- this._isMouseTarget = false;
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- readOnly,
- autoFocus,
- placeholder,
- name,
- value,
- hasError,
- hasWarning,
- onBlur
- } = this.props;
-
- return (
-
- );
- }
-}
-
-TextArea.propTypes = {
- className: PropTypes.string.isRequired,
- readOnly: PropTypes.bool,
- autoFocus: PropTypes.bool,
- placeholder: PropTypes.string,
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]).isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- onChange: PropTypes.func.isRequired,
- onFocus: PropTypes.func,
- onBlur: PropTypes.func,
- onSelectionChange: PropTypes.func
-};
-
-TextArea.defaultProps = {
- className: styles.input,
- type: 'text',
- readOnly: false,
- autoFocus: false,
- value: ''
-};
-
-export default TextArea;
diff --git a/frontend/src/Components/Form/TextArea.tsx b/frontend/src/Components/Form/TextArea.tsx
new file mode 100644
index 00000000000..f37d5cb5f16
--- /dev/null
+++ b/frontend/src/Components/Form/TextArea.tsx
@@ -0,0 +1,143 @@
+import classNames from 'classnames';
+import React, {
+ ChangeEvent,
+ SyntheticEvent,
+ useCallback,
+ useEffect,
+ useRef,
+} from 'react';
+import { InputChanged } from 'typings/inputs';
+import styles from './TextArea.css';
+
+interface TextAreaProps {
+ className?: string;
+ readOnly?: boolean;
+ autoFocus?: boolean;
+ placeholder?: string;
+ name: string;
+ value?: string;
+ hasError?: boolean;
+ hasWarning?: boolean;
+ onChange: (change: InputChanged) => void;
+ onFocus?: (event: SyntheticEvent) => void;
+ onBlur?: (event: SyntheticEvent) => void;
+ onSelectionChange?: (start: number | null, end: number | null) => void;
+}
+
+function TextArea({
+ className = styles.input,
+ readOnly = false,
+ autoFocus = false,
+ placeholder,
+ name,
+ value = '',
+ hasError,
+ hasWarning,
+ onBlur,
+ onFocus,
+ onChange,
+ onSelectionChange,
+}: TextAreaProps) {
+ const inputRef = useRef(null);
+ const selectionTimeout = useRef>();
+ const selectionStart = useRef();
+ const selectionEnd = useRef();
+ const isMouseTarget = useRef(false);
+
+ const selectionChanged = useCallback(() => {
+ if (selectionTimeout.current) {
+ clearTimeout(selectionTimeout.current);
+ }
+
+ selectionTimeout.current = setTimeout(() => {
+ if (!inputRef.current) {
+ return;
+ }
+
+ const start = inputRef.current.selectionStart;
+ const end = inputRef.current.selectionEnd;
+
+ const selectionChanged =
+ selectionStart.current !== start || selectionEnd.current !== end;
+
+ selectionStart.current = start;
+ selectionEnd.current = end;
+
+ if (selectionChanged) {
+ onSelectionChange?.(start, end);
+ }
+ }, 10);
+ }, [onSelectionChange]);
+
+ const handleChange = useCallback(
+ (event: ChangeEvent) => {
+ onChange({
+ name,
+ value: event.target.value,
+ });
+ },
+ [name, onChange]
+ );
+
+ const handleFocus = useCallback(
+ (event: SyntheticEvent) => {
+ onFocus?.(event);
+
+ selectionChanged();
+ },
+ [selectionChanged, onFocus]
+ );
+
+ const handleKeyUp = useCallback(() => {
+ selectionChanged();
+ }, [selectionChanged]);
+
+ const handleMouseDown = useCallback(() => {
+ isMouseTarget.current = true;
+ }, []);
+
+ const handleMouseUp = useCallback(() => {
+ selectionChanged();
+ }, [selectionChanged]);
+
+ const handleDocumentMouseUp = useCallback(() => {
+ if (isMouseTarget.current) {
+ selectionChanged();
+ }
+
+ isMouseTarget.current = false;
+ }, [selectionChanged]);
+
+ useEffect(() => {
+ window.addEventListener('mouseup', handleDocumentMouseUp);
+
+ return () => {
+ window.removeEventListener('mouseup', handleDocumentMouseUp);
+ };
+ }, [handleDocumentMouseUp]);
+
+ return (
+
+ );
+}
+
+export default TextArea;
diff --git a/frontend/src/Components/Form/TextInput.css.d.ts b/frontend/src/Components/Form/TextInput.css.d.ts
new file mode 100644
index 00000000000..2c71cfc686c
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'hasButton': string;
+ 'hasError': string;
+ 'hasWarning': string;
+ 'input': string;
+ 'readOnly': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Form/TextInput.js b/frontend/src/Components/Form/TextInput.js
deleted file mode 100644
index e2808ce5472..00000000000
--- a/frontend/src/Components/Form/TextInput.js
+++ /dev/null
@@ -1,198 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import styles from './TextInput.css';
-
-class TextInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._input = null;
- this._selectionStart = null;
- this._selectionEnd = null;
- this._selectionTimeout = null;
- this._isMouseTarget = false;
- }
-
- componentDidMount() {
- window.addEventListener('mouseup', this.onDocumentMouseUp);
- }
-
- componentWillUnmount() {
- window.removeEventListener('mouseup', this.onDocumentMouseUp);
-
- if (this._selectionTimeout) {
- this._selectionTimeout = clearTimeout(this._selectionTimeout);
- }
- }
-
- //
- // Control
-
- setInputRef = (ref) => {
- this._input = ref;
- };
-
- selectionChange() {
- if (this._selectionTimeout) {
- this._selectionTimeout = clearTimeout(this._selectionTimeout);
- }
-
- this._selectionTimeout = setTimeout(() => {
- const selectionStart = this._input.selectionStart;
- const selectionEnd = this._input.selectionEnd;
-
- const selectionChanged = (
- this._selectionStart !== selectionStart ||
- this._selectionEnd !== selectionEnd
- );
-
- this._selectionStart = selectionStart;
- this._selectionEnd = selectionEnd;
-
- if (this.props.onSelectionChange && selectionChanged) {
- this.props.onSelectionChange(selectionStart, selectionEnd);
- }
- }, 10);
- }
-
- //
- // Listeners
-
- onChange = (event) => {
- const {
- name,
- type,
- onChange
- } = this.props;
-
- const payload = {
- name,
- value: event.target.value
- };
-
- // Also return the files for a file input type.
-
- if (type === 'file') {
- payload.files = event.target.files;
- }
-
- onChange(payload);
- };
-
- onFocus = (event) => {
- if (this.props.onFocus) {
- this.props.onFocus(event);
- }
-
- this.selectionChange();
- };
-
- onKeyUp = () => {
- this.selectionChange();
- };
-
- onMouseDown = () => {
- this._isMouseTarget = true;
- };
-
- onMouseUp = () => {
- this.selectionChange();
- };
-
- onDocumentMouseUp = () => {
- if (this._isMouseTarget) {
- this.selectionChange();
- }
-
- this._isMouseTarget = false;
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- type,
- readOnly,
- autoFocus,
- placeholder,
- name,
- value,
- hasError,
- hasWarning,
- hasButton,
- step,
- min,
- max,
- onBlur,
- onCopy
- } = this.props;
-
- return (
-
- );
- }
-}
-
-TextInput.propTypes = {
- className: PropTypes.string.isRequired,
- type: PropTypes.string.isRequired,
- readOnly: PropTypes.bool,
- autoFocus: PropTypes.bool,
- placeholder: PropTypes.string,
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]).isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- hasButton: PropTypes.bool,
- step: PropTypes.number,
- min: PropTypes.number,
- max: PropTypes.number,
- onChange: PropTypes.func.isRequired,
- onFocus: PropTypes.func,
- onBlur: PropTypes.func,
- onCopy: PropTypes.func,
- onSelectionChange: PropTypes.func
-};
-
-TextInput.defaultProps = {
- className: styles.input,
- type: 'text',
- readOnly: false,
- autoFocus: false,
- value: ''
-};
-
-export default TextInput;
diff --git a/frontend/src/Components/Form/TextInput.tsx b/frontend/src/Components/Form/TextInput.tsx
new file mode 100644
index 00000000000..647b9f2ac73
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.tsx
@@ -0,0 +1,178 @@
+import classNames from 'classnames';
+import React, {
+ ChangeEvent,
+ FocusEvent,
+ SyntheticEvent,
+ useCallback,
+ useEffect,
+ useRef,
+} from 'react';
+import { InputType } from 'Helpers/Props/inputTypes';
+import { FileInputChanged, InputChanged } from 'typings/inputs';
+import styles from './TextInput.css';
+
+export interface TextInputProps {
+ className?: string;
+ type?: InputType;
+ readOnly?: boolean;
+ autoFocus?: boolean;
+ placeholder?: string;
+ name: string;
+ value: string | number | string[];
+ hasError?: boolean;
+ hasWarning?: boolean;
+ hasButton?: boolean;
+ step?: number;
+ min?: number;
+ max?: number;
+ onChange: (change: InputChanged | FileInputChanged) => void;
+ onFocus?: (event: FocusEvent) => void;
+ onBlur?: (event: SyntheticEvent) => void;
+ onCopy?: (event: SyntheticEvent) => void;
+ onSelectionChange?: (start: number | null, end: number | null) => void;
+}
+
+function TextInput({
+ className = styles.input,
+ type = 'text',
+ readOnly = false,
+ autoFocus = false,
+ placeholder,
+ name,
+ value = '',
+ hasError,
+ hasWarning,
+ hasButton,
+ step,
+ min,
+ max,
+ onBlur,
+ onFocus,
+ onCopy,
+ onChange,
+ onSelectionChange,
+}: TextInputProps) {
+ const inputRef = useRef(null);
+ const selectionTimeout = useRef>();
+ const selectionStart = useRef();
+ const selectionEnd = useRef();
+ const isMouseTarget = useRef(false);
+
+ const selectionChanged = useCallback(() => {
+ if (selectionTimeout.current) {
+ clearTimeout(selectionTimeout.current);
+ }
+
+ selectionTimeout.current = setTimeout(() => {
+ if (!inputRef.current) {
+ return;
+ }
+
+ const start = inputRef.current.selectionStart;
+ const end = inputRef.current.selectionEnd;
+
+ const selectionChanged =
+ selectionStart.current !== start || selectionEnd.current !== end;
+
+ selectionStart.current = start;
+ selectionEnd.current = end;
+
+ if (selectionChanged) {
+ onSelectionChange?.(start, end);
+ }
+ }, 10);
+ }, [onSelectionChange]);
+
+ const handleChange = useCallback(
+ (event: ChangeEvent) => {
+ onChange({
+ name,
+ value: event.target.value,
+ files: type === 'file' ? event.target.files : undefined,
+ });
+ },
+ [name, type, onChange]
+ );
+
+ const handleFocus = useCallback(
+ (event: FocusEvent) => {
+ onFocus?.(event);
+
+ selectionChanged();
+ },
+ [selectionChanged, onFocus]
+ );
+
+ const handleKeyUp = useCallback(() => {
+ selectionChanged();
+ }, [selectionChanged]);
+
+ const handleMouseDown = useCallback(() => {
+ isMouseTarget.current = true;
+ }, []);
+
+ const handleMouseUp = useCallback(() => {
+ selectionChanged();
+ }, [selectionChanged]);
+
+ const handleWheel = useCallback(() => {
+ if (type === 'number') {
+ inputRef.current?.blur();
+ }
+ }, [type]);
+
+ const handleDocumentMouseUp = useCallback(() => {
+ if (isMouseTarget.current) {
+ selectionChanged();
+ }
+
+ isMouseTarget.current = false;
+ }, [selectionChanged]);
+
+ useEffect(() => {
+ window.addEventListener('mouseup', handleDocumentMouseUp);
+
+ return () => {
+ window.removeEventListener('mouseup', handleDocumentMouseUp);
+ };
+ }, [handleDocumentMouseUp]);
+
+ useEffect(() => {
+ return () => {
+ clearTimeout(selectionTimeout.current);
+ };
+ }, []);
+
+ return (
+
+ );
+}
+
+export default TextInput;
diff --git a/frontend/src/Components/Form/TextTagInputConnector.js b/frontend/src/Components/Form/TextTagInputConnector.js
deleted file mode 100644
index bd2d0c9d0f6..00000000000
--- a/frontend/src/Components/Form/TextTagInputConnector.js
+++ /dev/null
@@ -1,95 +0,0 @@
-
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import split from 'Utilities/String/split';
-import TagInput from './TagInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state, { value }) => value,
- (tags) => {
- const tagsArray = Array.isArray(tags) ? tags : split(tags);
-
- return {
- tags: tagsArray.reduce((result, tag) => {
- if (tag) {
- result.push({
- id: tag,
- name: tag
- });
- }
-
- return result;
- }, []),
- valueArray: tagsArray
- };
- }
- );
-}
-
-class TextTagInputConnector extends Component {
-
- //
- // Listeners
-
- onTagAdd = (tag) => {
- const {
- name,
- valueArray,
- onChange
- } = this.props;
-
- // Split and trim tags before adding them to the list, this will
- // cleanse tags pasted in that had commas and spaces which leads
- // to oddities with restrictions (as an example).
-
- const newValue = [...valueArray];
- const newTags = tag.name.startsWith('/') ? [tag.name] : split(tag.name);
-
- newTags.forEach((newTag) => {
- newValue.push(newTag.trim());
- });
-
- onChange({ name, value: newValue });
- };
-
- onTagDelete = ({ index }) => {
- const {
- name,
- valueArray,
- onChange
- } = this.props;
-
- const newValue = [...valueArray];
- newValue.splice(index, 1);
-
- onChange({
- name,
- value: newValue
- });
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-TextTagInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- valueArray: PropTypes.arrayOf(PropTypes.string).isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, null)(TextTagInputConnector);
diff --git a/frontend/src/Components/Form/UMaskInput.js b/frontend/src/Components/Form/UMaskInput.js
deleted file mode 100644
index 22f51c8fc0c..00000000000
--- a/frontend/src/Components/Form/UMaskInput.js
+++ /dev/null
@@ -1,133 +0,0 @@
-/* eslint-disable no-bitwise */
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import EnhancedSelectInput from './EnhancedSelectInput';
-import styles from './UMaskInput.css';
-
-const umaskOptions = [
- {
- key: '755',
- value: '755 - Owner write, Everyone else read',
- hint: 'drwxr-xr-x'
- },
- {
- key: '775',
- value: '775 - Owner & Group write, Other read',
- hint: 'drwxrwxr-x'
- },
- {
- key: '770',
- value: '770 - Owner & Group write',
- hint: 'drwxrwx---'
- },
- {
- key: '750',
- value: '750 - Owner write, Group read',
- hint: 'drwxr-x---'
- },
- {
- key: '777',
- value: '777 - Everyone write',
- hint: 'drwxrwxrwx'
- }
-];
-
-function formatPermissions(permissions) {
-
- const hasSticky = permissions & 0o1000;
- const hasSetGID = permissions & 0o2000;
- const hasSetUID = permissions & 0o4000;
-
- let result = '';
-
- for (let i = 0; i < 9; i++) {
- const bit = (permissions & (1 << i)) !== 0;
- let digit = bit ? 'xwr'[i % 3] : '-';
- if (i === 6 && hasSetUID) {
- digit = bit ? 's' : 'S';
- } else if (i === 3 && hasSetGID) {
- digit = bit ? 's' : 'S';
- } else if (i === 0 && hasSticky) {
- digit = bit ? 't' : 'T';
- }
- result = digit + result;
- }
-
- return result;
-}
-
-class UMaskInput extends Component {
-
- //
- // Render
-
- render() {
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const valueNum = parseInt(value, 8);
- const umaskNum = 0o777 & ~valueNum;
- const umask = umaskNum.toString(8).padStart(4, '0');
- const folderNum = 0o777 & ~umaskNum;
- const folder = folderNum.toString(8).padStart(3, '0');
- const fileNum = 0o666 & ~umaskNum;
- const file = fileNum.toString(8).padStart(3, '0');
-
- const unit = formatPermissions(folderNum);
-
- const values = umaskOptions.map((v) => {
- return { ...v, hint: {v.hint} };
- });
-
- return (
-
-
-
-
-
-
Folder
-
{folder}
-
d{formatPermissions(folderNum)}
-
-
-
File
-
{file}
-
{formatPermissions(fileNum)}
-
-
-
- );
- }
-}
-
-UMaskInput.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.string.isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- onChange: PropTypes.func.isRequired,
- onFocus: PropTypes.func,
- onBlur: PropTypes.func
-};
-
-export default UMaskInput;
diff --git a/frontend/src/Components/HeartRating.css.d.ts b/frontend/src/Components/HeartRating.css.d.ts
new file mode 100644
index 00000000000..bacaa749a21
--- /dev/null
+++ b/frontend/src/Components/HeartRating.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'heart': string;
+ 'rating': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/HeartRating.js b/frontend/src/Components/HeartRating.js
deleted file mode 100644
index fe53a4e5f7b..00000000000
--- a/frontend/src/Components/HeartRating.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Icon from 'Components/Icon';
-import { icons } from 'Helpers/Props';
-import styles from './HeartRating.css';
-
-function HeartRating({ rating, iconSize }) {
- return (
-
-
-
- {rating * 10}%
-
- );
-}
-
-HeartRating.propTypes = {
- rating: PropTypes.number.isRequired,
- iconSize: PropTypes.number.isRequired
-};
-
-HeartRating.defaultProps = {
- iconSize: 14
-};
-
-export default HeartRating;
diff --git a/frontend/src/Components/HeartRating.tsx b/frontend/src/Components/HeartRating.tsx
new file mode 100644
index 00000000000..774cb4239f4
--- /dev/null
+++ b/frontend/src/Components/HeartRating.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import Icon, { IconProps } from 'Components/Icon';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './HeartRating.css';
+
+interface HeartRatingProps {
+ rating: number;
+ votes?: number;
+ iconSize?: IconProps['size'];
+}
+
+function HeartRating({ rating, votes = 0, iconSize = 14 }: HeartRatingProps) {
+ return (
+
+
+ {rating * 10}%
+
+ }
+ tooltip={translate('CountVotes', { votes })}
+ kind={kinds.INVERSE}
+ position={tooltipPositions.TOP}
+ />
+ );
+}
+
+export default HeartRating;
diff --git a/frontend/src/Components/Icon.css b/frontend/src/Components/Icon.css
index 69ffc40f5fd..51c09226b89 100644
--- a/frontend/src/Components/Icon.css
+++ b/frontend/src/Components/Icon.css
@@ -12,18 +12,10 @@
.info {
color: var(--infoColor);
-
- &:global(.darken) {
- color: color(var(--infoColor) shade(30%));
- }
}
.pink {
color: var(--pink);
-
- &:global(.darken) {
- color: color(var(--pink) shade(30%));
- }
}
.success {
diff --git a/frontend/src/Components/Icon.css.d.ts b/frontend/src/Components/Icon.css.d.ts
new file mode 100644
index 00000000000..b38646df0bd
--- /dev/null
+++ b/frontend/src/Components/Icon.css.d.ts
@@ -0,0 +1,14 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'danger': string;
+ 'default': string;
+ 'disabled': string;
+ 'info': string;
+ 'pink': string;
+ 'purple': string;
+ 'success': string;
+ 'warning': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Icon.js b/frontend/src/Components/Icon.js
deleted file mode 100644
index 8c534d8d463..00000000000
--- a/frontend/src/Components/Icon.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { PureComponent } from 'react';
-import { kinds } from 'Helpers/Props';
-import styles from './Icon.css';
-
-class Icon extends PureComponent {
-
- //
- // Render
-
- render() {
- const {
- containerClassName,
- className,
- name,
- kind,
- size,
- title,
- darken,
- isSpinning,
- ...otherProps
- } = this.props;
-
- const icon = (
-
- );
-
- if (title) {
- return (
-
- {icon}
-
- );
- }
-
- return icon;
- }
-}
-
-Icon.propTypes = {
- containerClassName: PropTypes.string,
- className: PropTypes.string,
- name: PropTypes.object.isRequired,
- kind: PropTypes.string.isRequired,
- size: PropTypes.number.isRequired,
- title: PropTypes.string,
- darken: PropTypes.bool.isRequired,
- isSpinning: PropTypes.bool.isRequired,
- fixedWidth: PropTypes.bool.isRequired
-};
-
-Icon.defaultProps = {
- kind: kinds.DEFAULT,
- size: 14,
- darken: false,
- isSpinning: false,
- fixedWidth: false
-};
-
-export default Icon;
diff --git a/frontend/src/Components/Icon.tsx b/frontend/src/Components/Icon.tsx
new file mode 100644
index 00000000000..a04463b5160
--- /dev/null
+++ b/frontend/src/Components/Icon.tsx
@@ -0,0 +1,60 @@
+import {
+ FontAwesomeIcon,
+ FontAwesomeIconProps,
+} from '@fortawesome/react-fontawesome';
+import classNames from 'classnames';
+import React, { ComponentProps } from 'react';
+import { kinds } from 'Helpers/Props';
+import { Kind } from 'Helpers/Props/kinds';
+import styles from './Icon.css';
+
+export interface IconProps
+ extends Omit<
+ FontAwesomeIconProps,
+ 'icon' | 'spin' | 'name' | 'title' | 'size'
+ > {
+ containerClassName?: ComponentProps<'span'>['className'];
+ name: FontAwesomeIconProps['icon'];
+ kind?: Extract;
+ size?: number;
+ isSpinning?: FontAwesomeIconProps['spin'];
+ title?: string | (() => string) | null;
+}
+
+export default function Icon({
+ containerClassName,
+ className,
+ name,
+ kind = kinds.DEFAULT,
+ size = 14,
+ title,
+ isSpinning = false,
+ fixedWidth = false,
+ ...otherProps
+}: IconProps) {
+ const icon = (
+
+ );
+
+ if (title) {
+ return (
+
+ {icon}
+
+ );
+ }
+
+ return icon;
+}
diff --git a/frontend/src/Components/Label.css b/frontend/src/Components/Label.css
index f3ff839937d..c7512987a2b 100644
--- a/frontend/src/Components/Label.css
+++ b/frontend/src/Components/Label.css
@@ -88,6 +88,15 @@
}
}
+.purple {
+ border-color: var(--purple);
+ background-color: var(--purple);
+
+ &.outline {
+ color: var(--purple);
+ }
+}
+
/** Sizes **/
.small {
diff --git a/frontend/src/Components/Label.css.d.ts b/frontend/src/Components/Label.css.d.ts
new file mode 100644
index 00000000000..778ba6faf2c
--- /dev/null
+++ b/frontend/src/Components/Label.css.d.ts
@@ -0,0 +1,20 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'danger': string;
+ 'default': string;
+ 'disabled': string;
+ 'info': string;
+ 'inverse': string;
+ 'label': string;
+ 'large': string;
+ 'medium': string;
+ 'outline': string;
+ 'primary': string;
+ 'purple': string;
+ 'small': string;
+ 'success': string;
+ 'warning': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Label.js b/frontend/src/Components/Label.js
deleted file mode 100644
index 6f662ec7da6..00000000000
--- a/frontend/src/Components/Label.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { kinds, sizes } from 'Helpers/Props';
-import styles from './Label.css';
-
-function Label(props) {
- const {
- className,
- kind,
- size,
- outline,
- children,
- ...otherProps
- } = props;
-
- return (
-
- {children}
-
- );
-}
-
-Label.propTypes = {
- className: PropTypes.string.isRequired,
- kind: PropTypes.oneOf(kinds.all).isRequired,
- size: PropTypes.oneOf(sizes.all).isRequired,
- outline: PropTypes.bool.isRequired,
- children: PropTypes.node.isRequired
-};
-
-Label.defaultProps = {
- className: styles.label,
- kind: kinds.DEFAULT,
- size: sizes.SMALL,
- outline: false
-};
-
-export default Label;
diff --git a/frontend/src/Components/Label.tsx b/frontend/src/Components/Label.tsx
new file mode 100644
index 00000000000..9ab360f4207
--- /dev/null
+++ b/frontend/src/Components/Label.tsx
@@ -0,0 +1,33 @@
+import classNames from 'classnames';
+import React, { ComponentProps, ReactNode } from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import { Kind } from 'Helpers/Props/kinds';
+import { Size } from 'Helpers/Props/sizes';
+import styles from './Label.css';
+
+export interface LabelProps extends ComponentProps<'span'> {
+ kind?: Extract;
+ size?: Extract;
+ outline?: boolean;
+ children: ReactNode;
+}
+
+export default function Label({
+ className = styles.label,
+ kind = kinds.DEFAULT,
+ size = sizes.SMALL,
+ outline = false,
+ ...otherProps
+}: LabelProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/Components/Link/Button.css.d.ts b/frontend/src/Components/Link/Button.css.d.ts
new file mode 100644
index 00000000000..889864a81c1
--- /dev/null
+++ b/frontend/src/Components/Link/Button.css.d.ts
@@ -0,0 +1,18 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'button': string;
+ 'center': string;
+ 'danger': string;
+ 'default': string;
+ 'large': string;
+ 'left': string;
+ 'medium': string;
+ 'primary': string;
+ 'right': string;
+ 'small': string;
+ 'success': string;
+ 'warning': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Link/Button.js b/frontend/src/Components/Link/Button.js
deleted file mode 100644
index cbe4691d435..00000000000
--- a/frontend/src/Components/Link/Button.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { align, kinds, sizes } from 'Helpers/Props';
-import Link from './Link';
-import styles from './Button.css';
-
-class Button extends Component {
-
- //
- // Render
-
- render() {
- const {
- className,
- buttonGroupPosition,
- kind,
- size,
- children,
- ...otherProps
- } = this.props;
-
- return (
-
- {children}
-
- );
- }
-
-}
-
-Button.propTypes = {
- className: PropTypes.string.isRequired,
- buttonGroupPosition: PropTypes.oneOf(align.all),
- kind: PropTypes.oneOf(kinds.all),
- size: PropTypes.oneOf(sizes.all),
- children: PropTypes.node
-};
-
-Button.defaultProps = {
- className: styles.button,
- kind: kinds.DEFAULT,
- size: sizes.MEDIUM
-};
-
-export default Button;
diff --git a/frontend/src/Components/Link/Button.tsx b/frontend/src/Components/Link/Button.tsx
new file mode 100644
index 00000000000..610350a8dcd
--- /dev/null
+++ b/frontend/src/Components/Link/Button.tsx
@@ -0,0 +1,35 @@
+import classNames from 'classnames';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import { Align } from 'Helpers/Props/align';
+import { Kind } from 'Helpers/Props/kinds';
+import { Size } from 'Helpers/Props/sizes';
+import Link, { LinkProps } from './Link';
+import styles from './Button.css';
+
+export interface ButtonProps extends Omit {
+ buttonGroupPosition?: Extract;
+ kind?: Extract;
+ size?: Extract;
+ children: Required;
+}
+
+export default function Button({
+ className = styles.button,
+ buttonGroupPosition,
+ kind = kinds.DEFAULT,
+ size = sizes.MEDIUM,
+ ...otherProps
+}: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/Components/Link/ClipboardButton.css.d.ts b/frontend/src/Components/Link/ClipboardButton.css.d.ts
new file mode 100644
index 00000000000..c1ad078d811
--- /dev/null
+++ b/frontend/src/Components/Link/ClipboardButton.css.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'button': string;
+ 'clipboardIconContainer': string;
+ 'showStateIcon': string;
+ 'stateIconContainer': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Components/Link/ClipboardButton.js b/frontend/src/Components/Link/ClipboardButton.js
deleted file mode 100644
index 55843f05ff6..00000000000
--- a/frontend/src/Components/Link/ClipboardButton.js
+++ /dev/null
@@ -1,139 +0,0 @@
-import Clipboard from 'clipboard';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import FormInputButton from 'Components/Form/FormInputButton';
-import Icon from 'Components/Icon';
-import { icons, kinds } from 'Helpers/Props';
-import getUniqueElememtId from 'Utilities/getUniqueElementId';
-import styles from './ClipboardButton.css';
-
-class ClipboardButton extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._id = getUniqueElememtId();
- this._successTimeout = null;
- this._testResultTimeout = null;
-
- this.state = {
- showSuccess: false,
- showError: false
- };
- }
-
- componentDidMount() {
- this._clipboard = new Clipboard(`#${this._id}`, {
- text: () => this.props.value,
- container: document.getElementById(this._id)
- });
-
- this._clipboard.on('success', this.onSuccess);
- }
-
- componentDidUpdate() {
- const {
- showSuccess,
- showError
- } = this.state;
-
- if (showSuccess || showError) {
- this._testResultTimeout = setTimeout(this.resetState, 3000);
- }
- }
-
- componentWillUnmount() {
- if (this._clipboard) {
- this._clipboard.destroy();
- }
-
- if (this._testResultTimeout) {
- clearTimeout(this._testResultTimeout);
- }
- }
-
- //
- // Control
-
- resetState = () => {
- this.setState({
- showSuccess: false,
- showError: false
- });
- };
-
- //
- // Listeners
-
- onSuccess = () => {
- this.setState({
- showSuccess: true
- });
- };
-
- onError = () => {
- this.setState({
- showError: true
- });
- };
-
- //
- // Render
-
- render() {
- const {
- value,
- className,
- ...otherProps
- } = this.props;
-
- const {
- showSuccess,
- showError
- } = this.state;
-
- const showStateIcon = showSuccess || showError;
- const iconName = showError ? icons.DANGER : icons.CHECK;
- const iconKind = showError ? kinds.DANGER : kinds.SUCCESS;
-
- return (
-
-
- {
- showSuccess &&
-
-