Compare commits

...

86 Commits

Author SHA1 Message Date
Qstick
4528d03931 Update Sentry DSN 2023-07-23 21:49:21 -05:00
Bogdan
e0b30d34b1 New: (Apps) Add Go To Application in UI 2023-07-23 22:03:49 +03:00
Bogdan
8edf483e69 Bump version to 1.7.4 2023-07-23 07:09:59 +03:00
Bogdan
cea6aae9e1 Add support for deprecated values in field select options
(cherry picked from commit d9786887f3fe30ef60ad9c50b3272bf60dfef309)
2023-07-23 05:05:41 +03:00
Bogdan
1697cee680 Add hover background color in Indexer Table Index 2023-07-22 05:34:39 +03:00
Bogdan
ce8c90a125 Added magnetUrl prop in Search Index Row 2023-07-22 05:34:39 +03:00
Weblate
c8ad3d6edd Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translation: Servarr/Prowlarr
2023-07-21 13:25:57 +03:00
Bogdan
ebe01913c2 Fix selecting guids from search results 2023-07-21 10:35:04 +03:00
Mark McDowall
07cb19f9f3 Sort available filters options in custom filters
(cherry picked from commit 9e694c7b06c6d54fd652792fa1d81cc27ec1f311)
2023-07-21 07:40:36 +03:00
Bogdan
7f51c44829 Fixed: (UI) Ensure proper parsing for size values in custom filters 2023-07-21 07:26:43 +03:00
Bogdan
07f816f9fd Fixed: (BeyondHD) Add search types option 2023-07-21 01:24:29 +03:00
Bogdan
a4a50b880c Add GetAttribute for enums 2023-07-21 01:24:29 +03:00
Bogdan
79361d92cb Ensure No search results found isn't shown without a search 2023-07-20 23:05:48 +03:00
Bogdan
ecda75152e Cache busting for CSS files 2023-07-20 19:57:45 +03:00
Bogdan
37a4e7c228 Rename decisions to releases in Search Controller 2023-07-20 19:25:04 +03:00
Bogdan
1a66d23bfe Fixed: (UI) Improved mobile search form and show indexer flags 2023-07-20 19:24:00 +03:00
Bogdan
a26aa4bd1e New: (UI) Show indexer id as hint in IndexerSelect 2023-07-20 18:46:12 +03:00
Bogdan
a5d83459e9 New: (BeyondHD) Add pagination support 2023-07-20 15:54:05 +03:00
Mark McDowall
4bfaab4b21 Typings cleanup and improvements
(cherry picked from commit b2c43fb2a67965d68d3d35b72302b0cddb5aca23)
2023-07-20 14:40:21 +03:00
Bogdan
5764950b10 Show implementation name in Application Modal's header 2023-07-20 14:40:21 +03:00
Mark McDowall
470b57316a Add type number to value prop in HintedSelectInputSelectedValue
(cherry picked from commit fea66cb7bccc7e94523614db38b157fa38f55ea5)
2023-07-20 14:40:21 +03:00
Bogdan
f546b9a3b0 Fixed: (SubsPlease) Update indexer urls 2023-07-20 02:45:07 +03:00
Bogdan
cc28c90e39 Combine cleanse rules for passkey and rsskey 2023-07-20 02:20:55 +03:00
Mark McDowall
6e21e892bc Fix chunk IDs and source map file names
(cherry picked from commit bb8fed94eb2c44040031643e8c20ff72de759535)
2023-07-20 01:03:16 +03:00
Weblate
62d868f0e9 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translation: Servarr/Prowlarr
2023-07-19 15:08:48 +03:00
Bogdan
27b36fe501 Tidy up input fields on mobile in Add Indexer Modal 2023-07-19 05:35:26 +03:00
Mark McDowall
fc80efd15f Fixed: List jump bar click issues
(cherry picked from commit 9c7378625112088d022239fdbdb90c0dc941d61d)
2023-07-18 23:37:26 +03:00
Bogdan
9b75ba6ca0 New: (BeyondHD) Add internal indexer flag 2023-07-18 04:47:05 +03:00
Bogdan
d42649c4df New: (BeyondHD) Add limited, refund and rewind search options 2023-07-18 04:47:05 +03:00
Bogdan
53adfb750c New: (Shazbat) Add scene indexer flag to all releases 2023-07-18 04:19:41 +03:00
Bogdan
ac487f9b40 Fixed: (BeyondHD) Add search by freeleech only 2023-07-17 20:25:20 +03:00
Bogdan
6dd354bf1a Fixed: (BeyondHD) Searching ImdbId has priority over TmdbId. 2023-07-17 19:40:14 +03:00
Weblate
b747d0a321 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Godwhitelight <godwhitelight1@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Qstick <qstick@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/is/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/sk/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_TW/
Translation: Servarr/Prowlarr
2023-07-17 06:57:53 +03:00
Weblate
0e6cec6f54 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Godwhitelight <godwhitelight1@gmail.com>
Co-authored-by: Guy Porat <guyporatmail@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/he/
Translation: Servarr/Prowlarr
2023-07-16 22:54:31 +03:00
Weblate
65cf7c1009 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/
Translation: Servarr/Prowlarr
2023-07-16 19:42:54 +03:00
Weblate
5f9c3585f4 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/
Translation: Servarr/Prowlarr
2023-07-16 19:42:19 +03:00
Weblate
a9d1d4be90 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/
Translation: Servarr/Prowlarr
2023-07-16 19:41:26 +03:00
Weblate
a94ed11b21 Translations update from Servarr Weblate
Co-authored-by: MoowGlax <matthieu.derouet.pro@gmail.com>
Co-authored-by: Thodoris Kalatzis <teo.kal@hotmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: emacsdias <emacs.dias@gmail.com>
Co-authored-by: reloxx <reloxx@interia.pl>
Co-authored-by: splifter <a.strahlke@gmail.com>
Co-authored-by: victor22265 <843427709@qq.com>
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/prowlarr/zh_CN/
Translation: Servarr/Prowlarr
2023-07-16 11:32:06 -05:00
Bogdan
3fab8fb0db Fixed: (Search) Ensure TvMazeId is parsed correctly on a repeat search 2023-07-16 17:04:08 +03:00
Mark McDowall
5e52627799 Fixed: Ensure translations are fetched before loading app
(cherry picked from commit ad2721dc55f3233e4c299babe5744418bc530418)
2023-07-16 08:29:30 +03:00
Bogdan
b9a28f243e Bump version to 1.7.3 2023-07-16 08:14:24 +03:00
Bogdan
146e7ca7b6 Use HelpTexts for sync levels in applications 2023-07-16 07:03:58 +03:00
Bogdan
1488fb7570 Revert "Fixed: Ensure translations are fetched before loading app"
This reverts commit 0fc52ae16f.
2023-07-16 04:07:54 +03:00
Mark McDowall
0fc52ae16f Fixed: Ensure translations are fetched before loading app
(cherry picked from commit ad2721dc55f3233e4c299babe5744418bc530418)
2023-07-16 02:21:39 +03:00
Mark McDowall
5218bea705 Use named keys for apply tags help text
(cherry picked from commit c1f8c7b17ba5775a0f6f76cebc3173e03124d000)
2023-07-16 02:11:34 +03:00
Bogdan
ac33330c7c Fix selection input in QueryParameterModal 2023-07-15 18:20:59 +03:00
Bogdan
041a7c571f Translate url type for indexer description 2023-07-15 18:00:52 +03:00
Bogdan
5d73c6aa91 Update webpack, eslint and core-js 2023-07-15 17:34:54 +03:00
Bogdan
ef9a3a4f2a Fixed: (AvistaZ) Allow search by episode 2023-07-15 17:34:28 +03:00
Bogdan
3ce3f8acdd Fixed: (Apps) Lower the severity for common messages 2023-07-13 06:21:05 +03:00
Bogdan
9bac2992b5 Fixed: (UI) Show available indexers count in Add Indexer 2023-07-13 00:37:57 +03:00
Bogdan
4a88b70f40 Show the correct total of releases when selecting 2023-07-13 00:30:12 +03:00
Bogdan
c9b1d0d958 Fixed: (API) Prevent search failed exception when using non-interactive search 2023-07-12 04:40:17 +03:00
Bogdan
a5b5e7a3a5 Fixed: (UI) Prevent passing NaN values to search API when using invalid ids 2023-07-12 04:34:30 +03:00
Bogdan
376202e2af Fixed: (BTN) Prevent NullRef when Result.Torrents is null 2023-07-11 04:53:43 +03:00
Servarr
6b698b33be Automated API Docs update [skip ci] 2023-07-10 19:23:30 +03:00
Bogdan
1706728230 New: Bulk Manage Applications, Download Clients
Co-authored-by: Qstick <qstick@gmail.com>
2023-07-10 19:17:46 +03:00
Bogdan
cb520b2264 Bump version to 1.7.2 2023-07-09 14:59:01 +03:00
ricci2511
193335e2a8 New: Add support for search through url query params 2023-07-09 01:19:05 +03:00
Servarr
1c98727cf3 Automated API Docs update [skip ci] 2023-07-08 19:19:02 +03:00
Bogdan
ab5b321385 New: (UI) Add priority to Indexer Editor 2023-07-08 19:12:54 +03:00
Bogdan
96340909f1 Add translations to SearchFooter 2023-07-08 18:16:02 +03:00
Bogdan
bd6a37dc8c Fixed: (UI) Regain jump to character functionality for search releases 2023-07-08 17:02:01 +03:00
Bogdan
a663cebada Check indexer health checks on bulk updates 2023-07-08 03:52:21 +03:00
Bogdan
2ce5618499 Improve indexer multiple select functionality 2023-07-08 03:13:41 +03:00
Bogdan
94c91d4c3f Fix recursive call in translate() 2023-07-08 03:10:51 +03:00
Bogdan
79fbb2d0d7 New: (UI) Show advanced settings toggle in application modal content 2023-07-07 17:51:12 +03:00
Bogdan
e2e52746bb Fix repeat search when limits are empty 2023-07-07 17:26:56 +03:00
Bogdan
21cc96d683 Fixed: (History) Save limit and offset in history data 2023-07-07 16:21:20 +03:00
Bogdan
e68b45636e Minor refactoring in TorrentsCSV 2023-07-07 13:25:53 +03:00
Servarr
ce68fe4105 Automated API Docs update [skip ci] 2023-07-06 01:29:07 +03:00
Bogdan
712404ddca Show download client field only when download clients are set 2023-07-06 01:07:32 +03:00
ricci2511
826828e8ec New: Add download client per indexer setting 2023-07-06 01:07:32 +03:00
Bogdan
252740519f Remove unused prop in Stats 2023-07-06 00:39:33 +03:00
Bogdan
062fd77e1b Fixed: (UI) Prevent search results clearing when using header search with enter key 2023-07-06 00:17:16 +03:00
Bogdan
6769055b6b Fixed: (TorrentPotato) Allow use of custom APIs 2023-07-06 00:07:50 +03:00
Taloth Saldono
90e92c0b66 Ensure mousetrap instance exists in unbindShortcut
(cherry picked from commit 930742ae2c69a530afe60f76a5824f2722540df8)
2023-07-05 23:02:22 +03:00
Bogdan
7eac11f57a Fixed: (UI) Change default search results sorting to age 2023-07-05 16:52:39 +03:00
Bogdan
02a3c1b224 Align ProwlarrErrorPipeline with upstream 2023-07-04 23:51:10 +03:00
Bogdan
57efa6d0b1 Add Find() to BasicRepository 2023-07-04 22:38:52 +03:00
Qstick
cee52147bc Add package to Sentry release to ensure apps don't mix 2023-07-04 12:21:00 -05:00
Bogdan
a1abcd6c93 Fixed: (History) Reduce History Cleanup Days to 30 2023-07-04 06:56:13 +03:00
Bogdan
18e2757d37 Allow templating in JSON rows selector in Cardigann 2023-07-03 22:45:04 +03:00
Bogdan
8790a6f06a New: (HttpClient) Add HTTP/2 support 2023-07-03 18:55:13 +03:00
Bogdan
4fafdb2cd2 Add x265 categories for Movies and TV in Newznab 2023-07-03 18:54:34 +03:00
Bogdan
bfc06fc8bc Bump version to 1.7.1 2023-07-02 12:01:07 +03:00
263 changed files with 7275 additions and 3351 deletions

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '1.7.0'
majorVersion: '1.7.4'
minorVersion: $[counter('minorVersion', 1)]
prowlarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(prowlarrVersion)'
@@ -362,7 +362,7 @@ stages:
- bash: |
echo "Uploading source maps to sentry"
curl -sL https://sentry.io/get-cli/ | bash
RELEASENAME="${PROWLARRVERSION}-${BUILD_SOURCEBRANCHNAME}"
RELEASENAME="Prowlarr@${PROWLARRVERSION}-${BUILD_SOURCEBRANCHNAME}"
sentry-cli releases new --finalize -p prowlarr -p prowlarr-ui -p prowlarr-update "${RELEASENAME}"
sentry-cli releases -p prowlarr-ui files "${RELEASENAME}" upload-sourcemaps _output/UI/ --rewrite
sentry-cli releases set-commits --auto "${RELEASENAME}"

View File

@@ -65,23 +65,23 @@ module.exports = (env) => {
output: {
path: distFolder,
publicPath: '/',
filename: '[name].js',
filename: '[name]-[contenthash].js',
sourceMapFilename: '[file].map'
},
optimization: {
moduleIds: 'deterministic',
chunkIds: 'named',
splitChunks: {
chunks: 'initial',
name: 'vendors'
}
chunkIds: isProduction ? 'deterministic' : 'named'
},
performance: {
hints: false
},
experiments: {
topLevelAwait: true
},
plugins: [
new webpack.DefinePlugin({
__DEV__: !isProduction,
@@ -89,7 +89,8 @@ module.exports = (env) => {
}),
new MiniCssExtractPlugin({
filename: 'Content/styles.css'
filename: 'Content/styles.css',
chunkFilename: 'Content/[id]-[chunkhash].css'
}),
new HtmlWebpackPlugin({

View File

@@ -7,13 +7,13 @@ import PageConnector from 'Components/Page/PageConnector';
import ApplyTheme from './ApplyTheme';
import AppRoutes from './AppRoutes';
function App({ store, history }) {
function App({ store, history, hasTranslationsError }) {
return (
<DocumentTitle title={window.Prowlarr.instanceName}>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme>
<PageConnector>
<PageConnector hasTranslationsError={hasTranslationsError}>
<AppRoutes app={App} />
</PageConnector>
</ApplyTheme>
@@ -25,7 +25,8 @@ function App({ store, history }) {
App.propTypes = {
store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired
history: PropTypes.object.isRequired,
hasTranslationsError: PropTypes.bool.isRequired
};
export default App;

View File

@@ -0,0 +1,48 @@
import SortDirection from 'Helpers/Props/SortDirection';
export interface Error {
responseJSON: {
message: string;
};
}
export interface AppSectionDeleteState {
isDeleting: boolean;
deleteError: Error;
}
export interface AppSectionSaveState {
isSaving: boolean;
saveError: Error;
}
export interface PagedAppSectionState {
pageSize: number;
}
export interface AppSectionSchemaState<T> {
isSchemaFetching: boolean;
isSchemaPopulated: boolean;
schemaError: Error;
schema: {
items: T[];
};
}
export interface AppSectionItemState<T> {
isFetching: boolean;
isPopulated: boolean;
error: Error;
item: T;
}
interface AppSectionState<T> {
isFetching: boolean;
isPopulated: boolean;
error: Error;
items: T[];
sortKey: string;
sortDirection: SortDirection;
}
export default AppSectionState;

View File

@@ -0,0 +1,44 @@
import IndexerAppState, { IndexerIndexAppState } from './IndexerAppState';
import SettingsAppState from './SettingsAppState';
import TagsAppState from './TagsAppState';
interface FilterBuilderPropOption {
id: string;
name: string;
}
export interface FilterBuilderProp<T> {
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;
filers: PropertyFilter[];
}
export interface CustomFilter {
id: number;
type: string;
label: string;
filers: PropertyFilter[];
}
interface AppState {
indexerIndex: IndexerIndexAppState;
indexers: IndexerAppState;
settings: SettingsAppState;
tags: TagsAppState;
}
export default AppState;

View File

@@ -0,0 +1,8 @@
import { CustomFilter } from './AppState';
interface ClientSideCollectionAppState {
totalItems: number;
customFilters: CustomFilter[];
}
export default ClientSideCollectionAppState;

View File

@@ -0,0 +1,33 @@
import Column from 'Components/Table/Column';
import SortDirection from 'Helpers/Props/SortDirection';
import Indexer from 'Indexer/Indexer';
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from './AppSectionState';
import { Filter, FilterBuilderProp } from './AppState';
export interface IndexerIndexAppState {
isTestingAll: boolean;
sortKey: string;
sortDirection: SortDirection;
secondarySortKey: string;
secondarySortDirection: SortDirection;
view: string;
tableOptions: {
showSearchAction: boolean;
};
selectedFilterKey: string;
filterBuilderProps: FilterBuilderProp<Indexer>[];
filters: Filter[];
columns: Column[];
}
interface IndexerAppState
extends AppSectionState<Indexer>,
AppSectionDeleteState,
AppSectionSaveState {}
export default IndexerAppState;

View File

@@ -0,0 +1,33 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Application from 'typings/Application';
import DownloadClient from 'typings/DownloadClient';
import Notification from 'typings/Notification';
import { UiSettings } from 'typings/UiSettings';
export interface ApplicationAppState
extends AppSectionState<Application>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface NotificationAppState
extends AppSectionState<Notification>,
AppSectionDeleteState {}
export type UiSettingsAppState = AppSectionState<UiSettings>;
interface SettingsAppState {
applications: ApplicationAppState;
downloadClients: DownloadClientAppState;
notifications: NotificationAppState;
uiSettings: UiSettingsAppState;
}
export default SettingsAppState;

View File

@@ -0,0 +1,12 @@
import ModelBase from 'App/ModelBase';
import AppSectionState, {
AppSectionDeleteState,
} from 'App/State/AppSectionState';
export interface Tag extends ModelBase {
label: string;
}
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {}
export default TagsAppState;

View File

@@ -23,7 +23,9 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
info,
} = props;
const [detailedError, setDetailedError] = useState(null);
const [detailedError, setDetailedError] = useState<
StackTrace.StackFrame[] | null
>(null);
useEffect(() => {
if (error) {

View File

@@ -202,7 +202,7 @@ class FilterBuilderRow extends Component {
key: availablePropFilter.name,
value: availablePropFilter.label
};
});
}).sort((a, b) => a.value.localeCompare(b.value));
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);

View File

@@ -9,13 +9,13 @@ import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.indexers,
(qualityProfiles) => {
(indexers) => {
const {
isFetching,
isPopulated,
error,
items
} = qualityProfiles;
} = indexers;
const tagList = items.map((item) => {
return {

View File

@@ -5,6 +5,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByName from 'Utilities/Array/sortByName';
import translate from 'Utilities/String/translate';
import SelectInput from './SelectInput';
function createMapStateToProps() {
@@ -23,7 +24,7 @@ function createMapStateToProps() {
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: 'No Change',
value: translate('NoChange'),
disabled: true
});
}

View File

@@ -0,0 +1,98 @@
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 values = items
.filter((downloadClient) => downloadClient.protocol === protocolFilter)
.sort(sortByName)
.map((downloadClient) => ({
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 (
<EnhancedSelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
DownloadClientSelectInputConnector.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
includeAny: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
dispatchFetchDownloadClients: PropTypes.func.isRequired
};
DownloadClientSelectInputConnector.defaultProps = {
includeAny: false,
protocol: 'torrent'
};
export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector);

View File

@@ -10,6 +10,7 @@ import CaptchaInputConnector from './CaptchaInputConnector';
import CardigannCaptchaInputConnector from './CardigannCaptchaInputConnector';
import CheckInput from './CheckInput';
import DeviceInputConnector from './DeviceInputConnector';
import DownloadClientSelectInputConnector from './DownloadClientSelectInputConnector';
import EnhancedSelectInput from './EnhancedSelectInput';
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
import FormInputHelpText from './FormInputHelpText';
@@ -72,6 +73,9 @@ function getComponent(type) {
case inputTypes.CATEGORY_SELECT:
return NewznabCategorySelectInputConnector;
case inputTypes.DOWNLOAD_CLIENT_SELECT:
return DownloadClientSelectInputConnector;
case inputTypes.INDEXER_FLAGS_SELECT:
return IndexerFlagsSelectInputConnector;
@@ -258,6 +262,8 @@ FormInputGroup.propTypes = {
values: PropTypes.arrayOf(PropTypes.any),
type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all),
min: PropTypes.number,
max: PropTypes.number,
unit: PropTypes.string,
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
helpText: PropTypes.string,

View File

@@ -37,7 +37,7 @@ function HintedSelectInputOption(props) {
{
hint != null &&
<div className={styles.hintText}>
<div className={styles.hintText} title={hint}>
{hint}
</div>
}

View File

@@ -24,7 +24,7 @@ function HintedSelectInputSelectedValue(props) {
>
<div className={styles.valueText}>
{
isMultiSelect &&
isMultiSelect ?
value.map((key, index) => {
const v = valuesMap[key];
return (
@@ -32,26 +32,28 @@ function HintedSelectInputSelectedValue(props) {
{v ? v.value : key}
</Label>
);
})
}) :
null
}
{
!isMultiSelect && value
isMultiSelect ? null : value
}
</div>
{
hint != null && includeHint &&
hint != null && includeHint ?
<div className={styles.hintText}>
{hint}
</div>
</div> :
null
}
</EnhancedSelectInputSelectedValue>
);
}
HintedSelectInputSelectedValue.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number]))]).isRequired,
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,

View File

@@ -1,4 +1,4 @@
import _ from 'lodash';
import { groupBy, map } from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
@@ -12,7 +12,7 @@ function createMapStateToProps() {
(state) => state.indexers,
(value, indexers) => {
const values = [];
const groupedIndexers = _.map(_.groupBy(indexers.items, 'protocol'), (val, key) => ({ protocol: key, indexers: val }));
const groupedIndexers = map(groupBy(indexers.items, 'protocol'), (val, key) => ({ protocol: key, indexers: val }));
groupedIndexers.forEach((element) => {
values.push({
@@ -25,6 +25,7 @@ function createMapStateToProps() {
values.push({
key: indexer.id,
value: indexer.name,
hint: `(${indexer.id})`,
isDisabled: !indexer.enable,
parentKey: element.protocol === 'usenet' ? -1 : -2
});
@@ -50,7 +51,6 @@ class IndexersSelectInputConnector extends Component {
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}

View File

@@ -10,7 +10,7 @@ function parseValue(props, value) {
} = props;
if (value == null || value === '') {
return min;
return null;
}
let newValue = isFloat ? parseFloat(value) : parseInt(value);

View File

@@ -1,98 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
class Link extends Component {
//
// Listeners
onClick = (event) => {
const {
isDisabled,
onPress
} = this.props;
if (!isDisabled && onPress) {
onPress(event);
}
};
//
// Render
render() {
const {
className,
component,
to,
target,
isDisabled,
noRouter,
onPress,
...otherProps
} = this.props;
const linkProps = { target };
let el = component;
if (to) {
if ((/\w+?:\/\//).test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
el = RouterLink;
linkProps.to = `${window.Prowlarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
}
if (el === 'button' || el === 'input') {
linkProps.type = otherProps.type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
const props = {
...otherProps,
...linkProps
};
props.onClick = this.onClick;
return (
React.createElement(el, props)
);
}
}
Link.propTypes = {
className: PropTypes.string,
component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
to: PropTypes.string,
target: PropTypes.string,
isDisabled: PropTypes.bool,
noRouter: PropTypes.bool,
onPress: PropTypes.func
};
Link.defaultProps = {
component: 'button',
noRouter: false
};
export default Link;

View File

@@ -0,0 +1,96 @@
import classNames from 'classnames';
import React, {
ComponentClass,
FunctionComponent,
SyntheticEvent,
useCallback,
} from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
interface ReactRouterLinkProps {
to?: string;
}
export interface LinkProps extends React.HTMLProps<HTMLAnchorElement> {
className?: string;
component?:
| string
| FunctionComponent<LinkProps>
| ComponentClass<LinkProps, unknown>;
to?: string;
target?: string;
isDisabled?: boolean;
noRouter?: boolean;
onPress?(event: SyntheticEvent): void;
}
function Link(props: LinkProps) {
const {
className,
component = 'button',
to,
target,
type,
isDisabled,
noRouter = false,
onPress,
...otherProps
} = props;
const onClick = useCallback(
(event: SyntheticEvent) => {
if (!isDisabled && onPress) {
onPress(event);
}
},
[isDisabled, onPress]
);
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
target,
};
let el = component;
if (to) {
if (/\w+?:\/\//.test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
el = RouterLink;
linkProps.to = `${window.Prowlarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
}
if (el === 'button' || el === 'input') {
linkProps.type = type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
const elementProps = {
...otherProps,
type,
...linkProps,
};
elementProps.onClick = onClick;
return React.createElement(el, elementProps);
}
export default Link;

View File

@@ -7,6 +7,7 @@ function ErrorPage(props) {
const {
version,
isLocalStorageSupported,
hasTranslationsError,
indexersError,
indexerStatusError,
indexerCategoriesError,
@@ -21,6 +22,8 @@ function ErrorPage(props) {
if (!isLocalStorageSupported) {
errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
} else if (hasTranslationsError) {
errorMessage = 'Failed to load translations from API';
} else if (indexersError) {
errorMessage = getErrorMessage(indexersError, 'Failed to load indexers from API');
} else if (indexerStatusError) {
@@ -55,6 +58,7 @@ function ErrorPage(props) {
ErrorPage.propTypes = {
version: PropTypes.string.isRequired,
isLocalStorageSupported: PropTypes.bool.isRequired,
hasTranslationsError: PropTypes.bool.isRequired,
indexersError: PropTypes.object,
indexerStatusError: PropTypes.object,
indexerCategoriesError: PropTypes.object,

View File

@@ -232,6 +232,7 @@ class PageConnector extends Component {
render() {
const {
hasTranslationsError,
isPopulated,
hasError,
dispatchFetchTags,
@@ -245,11 +246,12 @@ class PageConnector extends Component {
...otherProps
} = this.props;
if (hasError || !this.state.isLocalStorageSupported) {
if (hasTranslationsError || hasError || !this.state.isLocalStorageSupported) {
return (
<ErrorPage
{...this.state}
{...otherProps}
hasTranslationsError={hasTranslationsError}
/>
);
}
@@ -270,6 +272,7 @@ class PageConnector extends Component {
}
PageConnector.propTypes = {
hasTranslationsError: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
hasError: PropTypes.bool.isRequired,
isSidebarVisible: PropTypes.bool.isRequired,

View File

@@ -1,22 +1,19 @@
import React, { forwardRef, ReactNode, useCallback } from 'react';
import Scroller from 'Components/Scroller/Scroller';
import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react';
import Scroller, { OnScroll } from 'Components/Scroller/Scroller';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import { isLocked } from 'Utilities/scrollLock';
import styles from './PageContentBody.css';
interface PageContentBodyProps {
className: string;
innerClassName: string;
className?: string;
innerClassName?: string;
children: ReactNode;
initialScrollTop?: number;
onScroll?: (payload) => void;
onScroll?: (payload: OnScroll) => void;
}
const PageContentBody = forwardRef(
(
props: PageContentBodyProps,
ref: React.MutableRefObject<HTMLDivElement>
) => {
(props: PageContentBodyProps, ref: ForwardedRef<HTMLDivElement>) => {
const {
className = styles.contentBody,
innerClassName = styles.innerContentBody,
@@ -26,7 +23,7 @@ const PageContentBody = forwardRef(
} = props;
const onScrollWrapper = useCallback(
(payload) => {
(payload: OnScroll) => {
if (onScroll && !isLocked()) {
onScroll(payload);
}

View File

@@ -1,4 +1,5 @@
.jumpBar {
z-index: $pageJumpBarZIndex;
display: flex;
align-content: stretch;
align-items: stretch;

View File

@@ -1,9 +1,21 @@
import classNames from 'classnames';
import { throttle } from 'lodash';
import React, { forwardRef, ReactNode, useEffect, useRef } from 'react';
import React, {
ForwardedRef,
forwardRef,
MutableRefObject,
ReactNode,
useEffect,
useRef,
} from 'react';
import ScrollDirection from 'Helpers/Props/ScrollDirection';
import styles from './Scroller.css';
export interface OnScroll {
scrollLeft: number;
scrollTop: number;
}
interface ScrollerProps {
className?: string;
scrollDirection?: ScrollDirection;
@@ -12,11 +24,11 @@ interface ScrollerProps {
scrollTop?: number;
initialScrollTop?: number;
children?: ReactNode;
onScroll?: (payload) => void;
onScroll?: (payload: OnScroll) => void;
}
const Scroller = forwardRef(
(props: ScrollerProps, ref: React.MutableRefObject<HTMLDivElement>) => {
(props: ScrollerProps, ref: ForwardedRef<HTMLDivElement>) => {
const {
className,
autoFocus = false,
@@ -30,7 +42,7 @@ const Scroller = forwardRef(
} = props;
const internalRef = useRef();
const currentRef = ref ?? internalRef;
const currentRef = (ref as MutableRefObject<HTMLDivElement>) ?? internalRef;
useEffect(
() => {

View File

@@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'input': string;
'selectCell': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,8 +1,10 @@
import React from 'react';
interface Column {
name: string;
label: string;
columnLabel: string;
isSortable: boolean;
label: string | React.ReactNode;
columnLabel?: string;
isSortable?: boolean;
isVisible: boolean;
isModifiable?: boolean;
}

View File

@@ -121,6 +121,7 @@ function Table(props) {
}
Table.propTypes = {
...TableHeaderCell.props,
className: PropTypes.string,
horizontalScroll: PropTypes.bool.isRequired,
selectAll: PropTypes.bool.isRequired,

View File

@@ -39,7 +39,8 @@ class VirtualTable extends Component {
super(props, context);
this.state = {
width: 0
width: 0,
scrollRestored: false
};
this._grid = null;
@@ -48,20 +49,25 @@ class VirtualTable extends Component {
componentDidUpdate(prevProps, prevState) {
const {
items,
scrollIndex
scrollIndex,
scrollTop
} = this.props;
const {
width
width,
scrollRestored
} = this.state;
if (this._grid &&
(prevState.width !== width ||
hasDifferentItemsOrOrder(prevProps.items, items))) {
if (this._grid && (prevState.width !== width || hasDifferentItemsOrOrder(prevProps.items, items))) {
// recomputeGridSize also forces Grid to discard its cache of rendered cells
this._grid.recomputeGridSize();
}
if (this._grid && scrollTop !== undefined && scrollTop !== 0 && !scrollRestored) {
this.setState({ scrollRestored: true });
this._grid.scrollToPosition({ scrollTop });
}
if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) {
this._grid.scrollToCell({
rowIndex: scrollIndex,
@@ -98,6 +104,7 @@ class VirtualTable extends Component {
focusScroller,
header,
headerHeight,
rowHeight,
rowRenderer,
...otherProps
} = this.props;
@@ -141,6 +148,7 @@ class VirtualTable extends Component {
{header}
<div ref={registerChild}>
<Grid
{...otherProps}
ref={this.setGridRef}
autoContainerWidth={true}
autoHeight={true}
@@ -148,7 +156,7 @@ class VirtualTable extends Component {
width={width}
height={height}
headerHeight={height - headerHeight}
rowHeight={ROW_HEIGHT}
rowHeight={rowHeight}
rowCount={items.length}
columnCount={1}
columnWidth={width}
@@ -162,7 +170,6 @@ class VirtualTable extends Component {
className={styles.tableBodyContainer}
style={gridStyle}
containerStyle={containerStyle}
{...otherProps}
/>
</div>
</Scroller>
@@ -180,16 +187,19 @@ VirtualTable.propTypes = {
className: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
scrollIndex: PropTypes.number,
scrollTop: PropTypes.number,
scroller: PropTypes.instanceOf(Element).isRequired,
focusScroller: PropTypes.bool.isRequired,
header: PropTypes.node.isRequired,
headerHeight: PropTypes.number.isRequired,
rowRenderer: PropTypes.func.isRequired
rowRenderer: PropTypes.func.isRequired,
rowHeight: PropTypes.number.isRequired
};
VirtualTable.defaultProps = {
className: styles.tableContainer,
headerHeight: 38,
rowHeight: ROW_HEIGHT,
focusScroller: true
};

View File

@@ -67,8 +67,10 @@ function keyboardShortcuts(WrappedComponent) {
};
unbindShortcut = (key) => {
delete this._mousetrapBindings[key];
this._mousetrap.unbind(key);
if (this._mousetrap != null) {
delete this._mousetrapBindings[key];
this._mousetrap.unbind(key);
}
};
unbindAllShortcuts = () => {

View File

@@ -1,24 +1,30 @@
import PropTypes from 'prop-types';
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import scrollPositions from 'Store/scrollPositions';
function withScrollPosition(WrappedComponent, scrollPositionKey) {
function ScrollPosition(props) {
interface WrappedComponentProps {
initialScrollTop: number;
}
interface ScrollPositionProps {
history: RouteComponentProps['history'];
location: RouteComponentProps['location'];
match: RouteComponentProps['match'];
}
function withScrollPosition(
WrappedComponent: React.FC<WrappedComponentProps>,
scrollPositionKey: string
) {
function ScrollPosition(props: ScrollPositionProps) {
const { history } = props;
const initialScrollTop =
history.action === 'POP' ||
(history.location.state && history.location.state.restoreScrollPosition)
? scrollPositions[scrollPositionKey]
: 0;
history.action === 'POP' ? scrollPositions[scrollPositionKey] : 0;
return <WrappedComponent {...props} initialScrollTop={initialScrollTop} />;
}
ScrollPosition.propTypes = {
history: PropTypes.object.isRequired,
};
return ScrollPosition;
}

View File

@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
export default function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}

View File

@@ -72,6 +72,7 @@ import {
faLanguage as fasLanguage,
faLaptop as fasLaptop,
faLevelUpAlt as fasLevelUpAlt,
faListCheck as fasListCheck,
faLocationArrow as fasLocationArrow,
faLock as fasLock,
faMedkit as fasMedkit,
@@ -180,6 +181,7 @@ export const INTERACTIVE = fasUser;
export const KEYBOARD = farKeyboard;
export const LOCK = fasLock;
export const LOGOUT = fasSignOutAlt;
export const MANAGE = fasListCheck;
export const MEDIA_INFO = farFileInvoice;
export const MISSING = fasExclamationTriangle;
export const MONITORED = fasBookmark;

View File

@@ -9,6 +9,7 @@ export const KEY_VALUE_LIST = 'keyValueList';
export const INFO = 'info';
export const MOVIE_MONITORED_SELECT = 'movieMonitoredSelect';
export const CATEGORY_SELECT = 'newznabCategorySelect';
export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect';
export const NUMBER = 'number';
export const OAUTH = 'oauth';
export const PASSWORD = 'password';

View File

@@ -18,6 +18,8 @@ function HistoryDetails(props) {
query,
queryResults,
categories,
limit,
offset,
source,
url
} = data;
@@ -31,43 +33,66 @@ function HistoryDetails(props) {
/>
{
!!indexer &&
indexer ?
<DescriptionListItem
title={translate('Indexer')}
data={indexer.name}
/>
/> :
null
}
{
!!data &&
data ?
<DescriptionListItem
title={translate('QueryResults')}
data={queryResults ? queryResults : '-'}
/>
/> :
null
}
{
!!data &&
data ?
<DescriptionListItem
title={translate('Categories')}
data={categories ? categories : '-'}
/>
/> :
null
}
{
!!data &&
limit ?
<DescriptionListItem
title={translate('Limit')}
data={limit}
/> :
null
}
{
offset ?
<DescriptionListItem
title={translate('Offset')}
data={offset}
/> :
null
}
{
data ?
<DescriptionListItem
title={translate('Source')}
data={source}
/>
/> :
null
}
{
!!data &&
data ?
<DescriptionListItem
title={translate('Url')}
data={url ? <Link to={url}>{translate('Link')}</Link> : '-'}
/>
/> :
null
}
</DescriptionList>
);
@@ -83,35 +108,39 @@ function HistoryDetails(props) {
return (
<DescriptionList>
{
!!indexer &&
indexer ?
<DescriptionListItem
title={translate('Indexer')}
data={indexer.name}
/>
/> :
null
}
{
!!data &&
data ?
<DescriptionListItem
title={translate('Source')}
data={source ? source : '-'}
/>
/> :
null
}
{
!!data &&
data ?
<DescriptionListItem
title={translate('GrabTitle')}
data={grabTitle ? grabTitle : '-'}
/>
/> :
null
}
{
!!data &&
data ?
<DescriptionListItem
title={translate('Url')}
data={url ? <Link to={url}>{translate('Link')}</Link> : '-'}
/>
/> :
null
}
</DescriptionList>
);
@@ -124,11 +153,12 @@ function HistoryDetails(props) {
title={translate('Auth')}
>
{
!!indexer &&
indexer ?
<DescriptionListItem
title={translate('Indexer')}
data={indexer.name}
/>
/> :
null
}
</DescriptionList>
);

View File

@@ -66,7 +66,7 @@ class HistoryRow extends Component {
data
} = this.props;
const { query, queryType } = data;
const { query, queryType, limit, offset } = data;
let searchQuery = query;
let categories = [];
@@ -111,7 +111,7 @@ class HistoryRow extends Component {
searchQuery += `${searchParams}`;
}
this.props.onSearchPress(searchQuery, indexer.id, categories, queryType);
this.props.onSearchPress(searchQuery, indexer.id, categories, queryType, parseInt(limit), parseInt(offset));
};
onDetailsPress = () => {
@@ -312,6 +312,12 @@ class HistoryRow extends Component {
key={name}
className={styles.details}
>
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
title={translate('HistoryDetails')}
/>
{
eventType === 'indexerQuery' ?
<IconButton
@@ -321,11 +327,6 @@ class HistoryRow extends Component {
/> :
null
}
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
title={translate('HistoryDetails')}
/>
</TableRowCell>
);
}

View File

@@ -1,4 +1,5 @@
import { push } from 'connected-react-router';
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
@@ -48,8 +49,15 @@ class HistoryRowConnector extends Component {
//
// Listeners
onSearchPress = (term, indexerId, categories, type) => {
this.props.setSearchDefault({ searchQuery: term, searchIndexerIds: [indexerId], searchCategories: categories, searchType: type });
onSearchPress = (query, indexerId, categories, type, limit, offset) => {
this.props.setSearchDefault(_.pickBy({
searchQuery: query,
searchIndexerIds: [indexerId],
searchCategories: categories,
searchType: type,
searchLimit: limit,
searchOffset: offset
}));
this.props.push(`${window.Prowlarr.urlBase}/search`);
};

View File

@@ -40,7 +40,6 @@
flex: 1;
flex-direction: column;
margin-right: 12px;
max-width: 50%;
}
.filterContainer:last-child {
@@ -53,17 +52,22 @@
}
@media only screen and (max-width: $breakpointSmall) {
.filterInput {
margin-bottom: 5px;
}
.alert {
display: none;
}
.filterRow {
flex-direction: column;
display: block;
margin-bottom: 10px;
}
.filterContainer {
margin-right: 0;
margin-bottom: 12px;
margin-bottom: 5px;
}
.scroller {
@@ -72,3 +76,30 @@
margin-left: -30px;
}
}
@media only screen and (min-width: $breakpointSmall) {
.filterContainer {
max-width: 50%;
}
}
.modalFooter {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
justify-content: space-between;
}
.available {
font-weight: bold;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.modalFooter {
flex-direction: column;
gap: 10px;
}
.available {
display: none;
}
}

View File

@@ -2,12 +2,14 @@
// Please do not change this file!
interface CssExports {
'alert': string;
'available': string;
'filterContainer': string;
'filterInput': string;
'filterLabel': string;
'filterRow': string;
'indexers': string;
'modalBody': string;
'modalFooter': string;
'scroller': string;
}
export const cssExports: CssExports;

View File

@@ -278,12 +278,18 @@ class AddIndexerModalContent extends Component {
</Scroller>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
<ModalFooter className={styles.modalFooter}>
<div className={styles.available}>
{
isPopulated ?
translate('CountIndexersAvailable', [filteredIndexers.length]) :
null
}
</div>
<div>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</div>
</ModalFooter>
</ModalContent>
);

View File

@@ -26,6 +26,8 @@ function EditIndexerModalContent(props) {
isTesting,
saveError,
item,
hasUsenetDownloadClients,
hasTorrentDownloadClients,
onInputChange,
onFieldChange,
onModalClose,
@@ -48,10 +50,13 @@ function EditIndexerModalContent(props) {
appProfileId,
tags,
fields,
priority
priority,
protocol,
downloadClientId
} = item;
const indexerDisplayName = implementationName === definitionName ? implementationName : `${implementationName} (${definitionName})`;
const showDownloadClientInput = downloadClientId.value > 0 || protocol.value === 'usenet' && hasUsenetDownloadClients || protocol.value === 'torrent' && hasTorrentDownloadClients;
return (
<ModalContent onModalClose={onModalClose}>
@@ -156,6 +161,25 @@ function EditIndexerModalContent(props) {
/>
</FormGroup>
{showDownloadClientInput ?
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('DownloadClient')}</FormLabel>
<FormInputGroup
type={inputTypes.DOWNLOAD_CLIENT_SELECT}
name="downloadClientId"
helpText={translate('IndexerDownloadClientHelpText')}
{...downloadClientId}
includeAny={true}
protocol={protocol.value}
onChange={onInputChange}
/>
</FormGroup> : null
}
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
@@ -222,6 +246,8 @@ EditIndexerModalContent.propTypes = {
isTesting: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
hasUsenetDownloadClients: PropTypes.bool.isRequired,
hasTorrentDownloadClients: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onFieldChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,

View File

@@ -3,17 +3,23 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveIndexer, setIndexerFieldValue, setIndexerValue, testIndexer } from 'Store/Actions/indexerActions';
import { toggleAdvancedSettings } from 'Store/Actions/settingsActions';
import { fetchDownloadClients, toggleAdvancedSettings } from 'Store/Actions/settingsActions';
import createIndexerSchemaSelector from 'Store/Selectors/createIndexerSchemaSelector';
import EditIndexerModalContent from './EditIndexerModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
(state) => state.settings.downloadClients,
createIndexerSchemaSelector(),
(advancedSettings, indexer) => {
(advancedSettings, downloadClients, indexer) => {
const usenetDownloadClients = downloadClients.items.filter((downloadClient) => downloadClient.protocol === 'usenet');
const torrentDownloadClients = downloadClients.items.filter((downloadClient) => downloadClient.protocol === 'torrent');
return {
advancedSettings,
hasUsenetDownloadClients: usenetDownloadClients.length > 0,
hasTorrentDownloadClients: torrentDownloadClients.length > 0,
...indexer
};
}
@@ -25,7 +31,8 @@ const mapDispatchToProps = {
setIndexerFieldValue,
saveIndexer,
testIndexer,
toggleAdvancedSettings
toggleAdvancedSettings,
dispatchFetchDownloadClients: fetchDownloadClients
};
class EditIndexerModalContentConnector extends Component {
@@ -33,6 +40,10 @@ class EditIndexerModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchDownloadClients();
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
@@ -90,7 +101,8 @@ EditIndexerModalContentConnector.propTypes = {
toggleAdvancedSettings: PropTypes.func.isRequired,
saveIndexer: PropTypes.func.isRequired,
testIndexer: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
onModalClose: PropTypes.func.isRequired,
dispatchFetchDownloadClients: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditIndexerModalContentConnector);

View File

@@ -1,6 +1,10 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { SelectProvider } from 'App/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import IndexerAppState, {
IndexerIndexAppState,
} from 'App/State/IndexerAppState';
import { APP_INDEXER_SYNC } from 'Commands/commandNames';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
@@ -64,19 +68,20 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
sortKey,
sortDirection,
view,
} = useSelector(
createIndexerClientSideCollectionItemsSelector('indexerIndex')
);
}: IndexerAppState & IndexerIndexAppState & ClientSideCollectionAppState =
useSelector(createIndexerClientSideCollectionItemsSelector('indexerIndex'));
const isSyncingIndexers = useSelector(
createCommandExecutingSelector(APP_INDEXER_SYNC)
);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const dispatch = useDispatch();
const scrollerRef = useRef<HTMLDivElement>();
const scrollerRef = useRef<HTMLDivElement>(null);
const [isAddIndexerModalOpen, setIsAddIndexerModalOpen] = useState(false);
const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false);
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(null);
const [jumpToCharacter, setJumpToCharacter] = useState<string | undefined>(
undefined
);
const [isSelectMode, setIsSelectMode] = useState(false);
const onAppIndexerSyncPress = useCallback(() => {
@@ -112,53 +117,53 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
}, [isSelectMode, setIsSelectMode]);
const onTableOptionChange = useCallback(
(payload) => {
(payload: unknown) => {
dispatch(setIndexerTableOption(payload));
},
[dispatch]
);
const onSortSelect = useCallback(
(value) => {
(value: string) => {
dispatch(setIndexerSort({ sortKey: value }));
},
[dispatch]
);
const onFilterSelect = useCallback(
(value) => {
(value: string) => {
dispatch(setIndexerFilter({ selectedFilterKey: value }));
},
[dispatch]
);
const onJumpBarItemPress = useCallback(
(character) => {
(character: string) => {
setJumpToCharacter(character);
},
[setJumpToCharacter]
);
const onScroll = useCallback(
({ scrollTop }) => {
setJumpToCharacter(null);
scrollPositions.seriesIndex = scrollTop;
({ scrollTop }: { scrollTop: number }) => {
setJumpToCharacter(undefined);
scrollPositions.indexerIndex = scrollTop;
},
[setJumpToCharacter]
);
const jumpBarItems = useMemo(() => {
// Reset if not sorting by sortTitle
if (sortKey !== 'sortTitle') {
// Reset if not sorting by sortName
if (sortKey !== 'sortName') {
return {
order: [],
};
}
const characters = items.reduce((acc, item) => {
let char = item.sortTitle.charAt(0);
const characters = items.reduce((acc: Record<string, number>, item) => {
let char = item.sortName.charAt(0);
if (!isNaN(char)) {
if (!isNaN(Number(char))) {
char = '#';
}
@@ -190,7 +195,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
return (
<SelectProvider items={items}>
<PageContent>
<PageContent title={translate('Indexers')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
@@ -225,7 +230,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
label={
isSelectMode
? translate('StopSelecting')
: translate('SelectIndexer')
: translate('SelectIndexers')
}
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
isSelectMode={isSelectMode}
@@ -277,6 +282,8 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
<PageContentBody
ref={scrollerRef}
className={styles.contentBody}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
innerClassName={styles[`${view}InnerContentBody`]}
initialScrollTop={props.initialScrollTop}
onScroll={onScroll}

View File

@@ -1,12 +1,13 @@
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 { setIndexerFilter } from 'Store/Actions/indexerIndexActions';
function createIndexerSelector() {
return createSelector(
(state) => state.indexers.items,
(state: AppState) => state.indexers.items,
(indexers) => {
return indexers;
}
@@ -15,14 +16,20 @@ function createIndexerSelector() {
function createFilterBuilderPropsSelector() {
return createSelector(
(state) => state.indexerIndex.filterBuilderProps,
(state: AppState) => state.indexerIndex.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
export default function IndexerIndexFilterModal(props) {
interface IndexerIndexFilterModalProps {
isOpen: boolean;
}
export default function IndexerIndexFilterModal(
props: IndexerIndexFilterModalProps
) {
const sectionItems = useSelector(createIndexerSelector());
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'indexerIndex';
@@ -30,7 +37,7 @@ export default function IndexerIndexFilterModal(props) {
const dispatch = useDispatch();
const dispatchSetFilter = useCallback(
(payload) => {
(payload: unknown) => {
dispatch(setIndexerFilter(payload));
},
[dispatch]
@@ -38,6 +45,7 @@ export default function IndexerIndexFilterModal(props) {
return (
<FilterModal
// TODO: Don't spread all the props
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}

View File

@@ -3,6 +3,7 @@ import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import IndexerAppState from 'App/State/IndexerAppState';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
@@ -13,7 +14,7 @@ import styles from './IndexerIndexFooter.css';
function createUnoptimizedSelector() {
return createSelector(
createClientSideCollectionSelector('indexers', 'indexerIndex'),
(indexers) => {
(indexers: IndexerAppState) => {
return indexers.items.map((s) => {
const { protocol, privacy, enable } = s;

View File

@@ -1,10 +1,18 @@
import PropTypes from 'prop-types';
import React from 'react';
import { CustomFilter } from 'App/State/AppState';
import FilterMenu from 'Components/Menu/FilterMenu';
import { align } from 'Helpers/Props';
import IndexerIndexFilterModal from 'Indexer/Index/IndexerIndexFilterModal';
function IndexerIndexFilterMenu(props) {
interface IndexerIndexFilterMenuProps {
selectedFilterKey: string | number;
filters: object[];
customFilters: CustomFilter[];
isDisabled: boolean;
onFilterSelect(filterName: string): unknown;
}
function IndexerIndexFilterMenu(props: IndexerIndexFilterMenuProps) {
const {
selectedFilterKey,
filters,
@@ -26,15 +34,6 @@ function IndexerIndexFilterMenu(props) {
);
}
IndexerIndexFilterMenu.propTypes = {
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired,
};
IndexerIndexFilterMenu.defaultProps = {
showCustomFilters: false,
};

View File

@@ -1,12 +1,19 @@
import PropTypes from 'prop-types';
import React from 'react';
import MenuContent from 'Components/Menu/MenuContent';
import SortMenu from 'Components/Menu/SortMenu';
import SortMenuItem from 'Components/Menu/SortMenuItem';
import { align, sortDirections } from 'Helpers/Props';
import { align } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import translate from 'Utilities/String/translate';
function IndexerIndexSortMenu(props) {
interface IndexerIndexSortMenuProps {
sortKey?: string;
sortDirection?: SortDirection;
isDisabled: boolean;
onSortSelect(sortKey: string): unknown;
}
function IndexerIndexSortMenu(props: IndexerIndexSortMenuProps) {
const { sortKey, sortDirection, isDisabled, onSortSelect } = props;
return (
@@ -79,11 +86,4 @@ function IndexerIndexSortMenu(props) {
);
}
IndexerIndexSortMenu.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
isDisabled: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired,
};
export default IndexerIndexSortMenu;

View File

@@ -7,8 +7,10 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import { bulkDeleteIndexers } from 'Store/Actions/indexerIndexActions';
import Indexer from 'Indexer/Indexer';
import { bulkDeleteIndexers } from 'Store/Actions/indexerActions';
import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector';
import translate from 'Utilities/String/translate';
import styles from './DeleteIndexerModalContent.css';
interface DeleteIndexerModalContentProps {
@@ -19,21 +21,21 @@ interface DeleteIndexerModalContentProps {
function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
const { indexerIds, onModalClose } = props;
const allIndexer = useSelector(createAllIndexersSelector());
const allIndexers: Indexer[] = useSelector(createAllIndexersSelector());
const dispatch = useDispatch();
const indexers = useMemo(() => {
const indexers = indexerIds.map((id) => {
return allIndexer.find((s) => s.id === id);
});
const indexers = useMemo((): Indexer[] => {
const indexerList = indexerIds.map((id) => {
return allIndexers.find((s) => s.id === id);
}) as Indexer[];
return orderBy(indexers, ['sortTitle']);
}, [indexerIds, allIndexer]);
return orderBy(indexerList, ['sortName']);
}, [indexerIds, allIndexers]);
const onDeleteIndexerConfirmed = useCallback(() => {
dispatch(
bulkDeleteIndexers({
indexerIds,
ids: indexerIds,
})
);
@@ -42,17 +44,17 @@ function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Delete Selected Indexer</ModalHeader>
<ModalHeader>{translate('DeleteSelectedIndexers')}</ModalHeader>
<ModalBody>
<div className={styles.message}>
{`Are you sure you want to delete ${indexers.length} selected indexers?`}
{translate('DeleteSelectedIndexersMessageText', [indexers.length])}
</div>
<ul>
{indexers.map((s) => {
return (
<li key={s.name}>
<li key={s.id}>
<span>{s.name}</span>
</li>
);
@@ -61,10 +63,10 @@ function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.DANGER} onPress={onDeleteIndexerConfirmed}>
Delete
{translate('Delete')}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -7,13 +7,18 @@ 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 { inputTypes, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './EditIndexerModalContent.css';
interface SavePayload {
enable?: boolean;
appProfileId?: number;
priority?: number;
minimumSeeders?: number;
seedRatio?: number;
seedTime?: number;
packSeedTime?: number;
}
interface EditIndexerModalContentProps {
@@ -35,6 +40,15 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
const [enable, setEnable] = useState(NO_CHANGE);
const [appProfileId, setAppProfileId] = useState<string | number>(NO_CHANGE);
const [priority, setPriority] = useState<null | string | number>(null);
const [minimumSeeders, setMinimumSeeders] = useState<null | string | number>(
null
);
const [seedRatio, setSeedRatio] = useState<null | string | number>(null);
const [seedTime, setSeedTime] = useState<null | string | number>(null);
const [packSeedTime, setPackSeedTime] = useState<null | string | number>(
null
);
const save = useCallback(() => {
let hasChanges = false;
@@ -50,15 +64,50 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
payload.appProfileId = appProfileId as number;
}
if (priority !== null) {
hasChanges = true;
payload.priority = priority as number;
}
if (minimumSeeders !== null) {
hasChanges = true;
payload.minimumSeeders = minimumSeeders as number;
}
if (seedRatio !== null) {
hasChanges = true;
payload.seedRatio = seedRatio as number;
}
if (seedTime !== null) {
hasChanges = true;
payload.seedTime = seedTime as number;
}
if (packSeedTime !== null) {
hasChanges = true;
payload.packSeedTime = packSeedTime as number;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [enable, appProfileId, onSavePress, onModalClose]);
}, [
enable,
appProfileId,
priority,
minimumSeeders,
seedRatio,
seedTime,
packSeedTime,
onSavePress,
onModalClose,
]);
const onInputChange = useCallback(
({ name, value }) => {
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'enable':
setEnable(value);
@@ -66,8 +115,23 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
case 'appProfileId':
setAppProfileId(value);
break;
case 'priority':
setPriority(value);
break;
case 'minimumSeeders':
setMinimumSeeders(value);
break;
case 'seedRatio':
setSeedRatio(value);
break;
case 'seedTime':
setSeedTime(value);
break;
case 'packSeedTime':
setPackSeedTime(value);
break;
default:
console.warn('EditIndexerModalContent Unknown Input');
console.warn(`EditIndexersModalContent Unknown Input: '${name}'`);
}
},
[setEnable]
@@ -81,10 +145,10 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('Edit Selected Indexer')}</ModalHeader>
<ModalHeader>{translate('EditSelectedIndexers')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('Enable')}</FormLabel>
<FormInputGroup
@@ -96,30 +160,95 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
/>
</FormGroup>
<FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('SyncProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.APP_PROFILE_SELECT}
name="appProfileId"
value={appProfileId}
helpText={translate('AppProfileSelectHelpText')}
includeNoChange={true}
includeNoChangeDisabled={false}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('IndexerPriority')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
value={priority}
min={1}
max={50}
helpText={translate('IndexerPriorityHelpText')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('AppsMinimumSeeders')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minimumSeeders"
value={minimumSeeders}
helpText={translate('AppsMinimumSeedersHelpText')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('SeedRatio')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="seedRatio"
value={seedRatio}
helpText={translate('SeedRatioHelpText')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('SeedTime')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="seedTime"
value={seedTime}
unit={translate('minutes')}
helpText={translate('SeedTimeHelpText')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('PackSeedTime')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="packSeedTime"
value={packSeedTime}
unit={translate('minutes')}
helpText={translate('PackSeedTimeHelpText')}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('{0} indexers selected', selectedCount.toString())}
{translate('CountIndexersSelected', [selectedCount])}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={onSavePressWrapper}>
{translate('Apply Changes')}
{translate('ApplyChanges')}
</Button>
</div>
</ModalFooter>

View File

@@ -7,7 +7,7 @@ import translate from 'Utilities/String/translate';
interface IndexerIndexSelectAllButtonProps {
label: string;
isSelectMode: boolean;
overflowComponent: React.FunctionComponent;
overflowComponent: React.FunctionComponent<never>;
}
function IndexerIndexSelectAllButton(props: IndexerIndexSelectAllButtonProps) {

View File

@@ -2,10 +2,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import { saveIndexerEditor } from 'Store/Actions/indexerIndexActions';
import { bulkEditIndexers } from 'Store/Actions/indexerActions';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import DeleteIndexerModal from './Delete/DeleteIndexerModal';
@@ -13,8 +15,18 @@ import EditIndexerModal from './Edit/EditIndexerModal';
import TagsModal from './Tags/TagsModal';
import styles from './IndexerIndexSelectFooter.css';
const seriesEditorSelector = createSelector(
(state) => state.indexers,
interface SavePayload {
enable?: boolean;
appProfileId?: number;
priority?: number;
minimumSeeders?: number;
seedRatio?: number;
seedTime?: number;
packSeedTime?: number;
}
const indexersEditorSelector = createSelector(
(state: AppState) => state.indexers,
(indexers) => {
const { isSaving, isDeleting, deleteError } = indexers;
@@ -27,8 +39,9 @@ const seriesEditorSelector = createSelector(
);
function IndexerIndexSelectFooter() {
const { isSaving, isDeleting, deleteError } =
useSelector(seriesEditorSelector);
const { isSaving, isDeleting, deleteError } = useSelector(
indexersEditorSelector
);
const dispatch = useDispatch();
@@ -37,6 +50,7 @@ function IndexerIndexSelectFooter() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isSavingIndexer, setIsSavingIndexer] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const previousIsDeleting = usePrevious(isDeleting);
const [selectState, selectDispatch] = useSelect();
const { selectedState } = selectState;
@@ -56,14 +70,14 @@ function IndexerIndexSelectFooter() {
}, [setIsEditModalOpen]);
const onSavePress = useCallback(
(payload) => {
(payload: SavePayload) => {
setIsSavingIndexer(true);
setIsEditModalOpen(false);
dispatch(
saveIndexerEditor({
bulkEditIndexers({
...payload,
indexerIds,
ids: indexerIds,
})
);
},
@@ -79,13 +93,13 @@ function IndexerIndexSelectFooter() {
}, [setIsTagsModalOpen]);
const onApplyTagsPress = useCallback(
(tags, applyTags) => {
(tags: number[], applyTags: string) => {
setIsSavingTags(true);
setIsTagsModalOpen(false);
dispatch(
saveIndexerEditor({
indexerIds,
bulkEditIndexers({
ids: indexerIds,
tags,
applyTags,
})
@@ -110,10 +124,10 @@ function IndexerIndexSelectFooter() {
}, [isSaving]);
useEffect(() => {
if (!isDeleting && !deleteError) {
if (previousIsDeleting && !isDeleting && !deleteError) {
selectDispatch({ type: 'unselectAll' });
}
}, [isDeleting, deleteError, selectDispatch]);
}, [previousIsDeleting, isDeleting, deleteError, selectDispatch]);
const anySelected = selectedCount > 0;
@@ -134,7 +148,7 @@ function IndexerIndexSelectFooter() {
isDisabled={!anySelected}
onPress={onTagsPress}
>
{translate('Set Tags')}
{translate('SetTags')}
</SpinnerButton>
</div>
@@ -151,7 +165,7 @@ function IndexerIndexSelectFooter() {
</div>
<div className={styles.selected}>
{translate('{0} indexers selected', selectedCount.toString())}
{translate('CountIndexersSelected', [selectedCount])}
</div>
<EditIndexerModal

View File

@@ -7,7 +7,7 @@ interface IndexerIndexSelectModeButtonProps {
label: string;
iconName: IconDefinition;
isSelectMode: boolean;
overflowComponent: React.FunctionComponent;
overflowComponent: React.FunctionComponent<never>;
onPress: () => void;
}

View File

@@ -1,6 +1,7 @@
import { concat, uniq } from 'lodash';
import { uniq } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@@ -12,6 +13,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Indexer from 'Indexer/Indexer';
import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import translate from 'Utilities/String/translate';
@@ -26,29 +28,35 @@ interface TagsModalContentProps {
function TagsModalContent(props: TagsModalContentProps) {
const { indexerIds, onModalClose, onApplyTagsPress } = props;
const allIndexers = useSelector(createAllIndexersSelector());
const tagList = useSelector(createTagsSelector());
const allIndexers: Indexer[] = useSelector(createAllIndexersSelector());
const tagList: Tag[] = useSelector(createTagsSelector());
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const indexerTags = useMemo(() => {
const indexers = indexerIds.map((id) => {
return allIndexers.find((s) => s.id === id);
});
const tags = indexerIds.reduce((acc: number[], id) => {
const s = allIndexers.find((s) => s.id === id);
return uniq(concat(...indexers.map((s) => s.tags)));
if (s) {
acc.push(...s.tags);
}
return acc;
}, []);
return uniq(tags);
}, [indexerIds, allIndexers]);
const onTagsChange = useCallback(
({ value }) => {
({ value }: { value: number[] }) => {
setTags(value);
},
[setTags]
);
const onApplyTagsChange = useCallback(
({ value }) => {
({ value }: { value: string }) => {
setApplyTags(value);
},
[setApplyTags]
@@ -59,14 +67,14 @@ function TagsModalContent(props: TagsModalContentProps) {
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{ key: 'add', value: 'Add' },
{ key: 'remove', value: 'Remove' },
{ key: 'replace', value: 'Replace' },
{ key: 'add', value: translate('Add') },
{ key: 'remove', value: translate('Remove') },
{ key: 'replace', value: translate('Replace') },
];
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Tags</ModalHeader>
<ModalHeader>{translate('Tags')}</ModalHeader>
<ModalBody>
<Form>
@@ -90,10 +98,10 @@ function TagsModalContent(props: TagsModalContentProps) {
value={applyTags}
values={applyTagsOptions}
helpTexts={[
translate('ApplyTagsHelpTexts1'),
translate('ApplyTagsHelpTexts2'),
translate('ApplyTagsHelpTexts3'),
translate('ApplyTagsHelpTexts4'),
translate('ApplyTagsHelpTextHowToApplyIndexers'),
translate('ApplyTagsHelpTextAdd'),
translate('ApplyTagsHelpTextRemove'),
translate('ApplyTagsHelpTextReplace'),
]}
onChange={onApplyTagsChange}
/>
@@ -119,8 +127,8 @@ function TagsModalContent(props: TagsModalContentProps) {
key={tag.id}
title={
removeTag
? translate('RemoveTagRemovingTag')
: translate('RemoveTagExistingTag')
? translate('RemovingTag')
: translate('ExistingTag')
}
kind={removeTag ? kinds.INVERSE : kinds.INFO}
size={sizes.LARGE}
@@ -159,10 +167,10 @@ function TagsModalContent(props: TagsModalContentProps) {
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
Apply
{translate('Apply')}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -19,7 +19,11 @@
.priority,
.protocol,
.privacy {
.privacy,
.minimumSeeders,
.seedRatio,
.seedTime,
.packSeedTime {
composes: cell;
flex: 0 0 90px;

View File

@@ -8,9 +8,13 @@ interface CssExports {
'cell': string;
'checkInput': string;
'externalLink': string;
'minimumSeeders': string;
'packSeedTime': string;
'priority': string;
'privacy': string;
'protocol': string;
'seedRatio': string;
'seedTime': string;
'sortName': string;
'status': string;
'tags': string;

View File

@@ -13,6 +13,7 @@ import DeleteIndexerModal from 'Indexer/Delete/DeleteIndexerModal';
import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector';
import createIndexerIndexItemSelector from 'Indexer/Index/createIndexerIndexItemSelector';
import IndexerTitleLink from 'Indexer/IndexerTitleLink';
import { SelectStateInputProps } from 'typings/props';
import firstCharToUpper from 'Utilities/String/firstCharToUpper';
import translate from 'Utilities/String/translate';
import CapabilitiesLabel from './CapabilitiesLabel';
@@ -55,6 +56,23 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
const vipExpiration =
fields.find((field) => field.name === 'vipExpiration')?.value ?? '';
const minimumSeeders =
fields.find(
(field) => field.name === 'torrentBaseSettings.appMinimumSeeders'
)?.value ?? undefined;
const seedRatio =
fields.find((field) => field.name === 'torrentBaseSettings.seedRatio')
?.value ?? undefined;
const seedTime =
fields.find((field) => field.name === 'torrentBaseSettings.seedTime')
?.value ?? undefined;
const packSeedTime =
fields.find((field) => field.name === 'torrentBaseSettings.packSeedTime')
?.value ?? undefined;
const rssUrl = `${window.location.origin}${
window.Prowlarr.urlBase
}/${id}/api?apikey=${encodeURIComponent(
@@ -83,12 +101,8 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
setIsDeleteIndexerModalOpen(false);
}, [setIsDeleteIndexerModalOpen]);
const checkInputCallback = useCallback(() => {
// Mock handler to satisfy `onChange` being required for `CheckInput`.
}, []);
const onSelectedChange = useCallback(
({ id, value, shiftKey }) => {
({ id, value, shiftKey }: SelectStateInputProps) => {
selectDispatch({
type: 'toggleSelected',
id,
@@ -185,6 +199,8 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
if (name === 'added') {
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(2739)
<RelativeDateCellConnector
key={name}
className={styles[name]}
@@ -196,6 +212,8 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
if (name === 'vipExpiration') {
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(2739)
<RelativeDateCellConnector
key={name}
className={styles[name]}
@@ -213,10 +231,44 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
);
}
if (name === 'minimumSeeders') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{minimumSeeders}
</VirtualTableRowCell>
);
}
if (name === 'seedRatio') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{seedRatio}
</VirtualTableRowCell>
);
}
if (name === 'seedTime') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{seedTime}
</VirtualTableRowCell>
);
}
if (name === 'packSeedTime') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{packSeedTime}
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell
key={column.name}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(2739)
className={styles[column.name]}
>
<IconButton

View File

@@ -1,3 +1,11 @@
.tableScroller {
position: relative;
}
.row {
transition: background-color 500ms;
&:hover {
background-color: var(--tableRowHoverBackgroundColor);
}
}

View File

@@ -1,6 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'row': string;
'tableScroller': string;
}
export const cssExports: CssExports;

View File

@@ -1,8 +1,9 @@
import { throttle } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { RefObject, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Scroller from 'Components/Scroller/Scroller';
import Column from 'Components/Table/Column';
import useMeasure from 'Helpers/Hooks/useMeasure';
@@ -13,7 +14,6 @@ import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import IndexerIndexRow from './IndexerIndexRow';
import IndexerIndexTableHeader from './IndexerIndexTableHeader';
import selectTableOptions from './selectTableOptions';
import styles from './IndexerIndexTable.css';
const bodyPadding = parseInt(dimensions.pageContentBodyPadding);
@@ -30,17 +30,17 @@ interface RowItemData {
interface IndexerIndexTableProps {
items: Indexer[];
sortKey?: string;
sortKey: string;
sortDirection?: SortDirection;
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
scrollerRef: RefObject<HTMLElement>;
isSelectMode: boolean;
isSmallScreen: boolean;
}
const columnsSelector = createSelector(
(state) => state.indexerIndex.columns,
(state: AppState) => state.indexerIndex.columns,
(columns) => columns
);
@@ -64,6 +64,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
justifyContent: 'space-between',
...style,
}}
className={styles.row}
>
<IndexerIndexRow
indexerId={indexer.id}
@@ -91,19 +92,16 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
} = props;
const columns = useSelector(columnsSelector);
const { showBanners } = useSelector(selectTableOptions);
const listRef: React.MutableRefObject<List> = useRef();
const listRef = useRef<List<RowItemData>>(null);
const [measureRef, bounds] = useMeasure();
const [size, setSize] = useState({ width: 0, height: 0 });
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const rowHeight = useMemo(() => {
return showBanners ? 70 : 38;
}, [showBanners]);
const rowHeight = 38;
useEffect(() => {
const current = scrollerRef.current as HTMLElement;
const current = scrollerRef?.current as HTMLElement;
if (isSmallScreen) {
setSize({
@@ -127,8 +125,8 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
}, [isSmallScreen, windowWidth, windowHeight, scrollerRef, bounds]);
useEffect(() => {
const currentScrollListener = isSmallScreen ? window : scrollerRef.current;
const currentScrollerRef = scrollerRef.current;
const currentScrollerRef = scrollerRef.current as HTMLElement;
const currentScrollListener = isSmallScreen ? window : currentScrollerRef;
const handleScroll = throttle(() => {
const { offsetTop = 0 } = currentScrollerRef;
@@ -137,7 +135,7 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
? getWindowScrollTopPosition()
: currentScrollerRef.scrollTop) - offsetTop;
listRef.current.scrollTo(scrollTop);
listRef.current?.scrollTo(scrollTop);
}, 10);
currentScrollListener.addEventListener('scroll', handleScroll);
@@ -166,8 +164,8 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
scrollTop += offset;
}
listRef.current.scrollTo(scrollTop);
scrollerRef.current.scrollTo(0, scrollTop);
listRef.current?.scrollTo(scrollTop);
scrollerRef?.current?.scrollTo(0, scrollTop);
}
}
}, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]);
@@ -179,7 +177,6 @@ function IndexerIndexTable(props: IndexerIndexTableProps) {
scrollDirection={ScrollDirection.Horizontal}
>
<IndexerIndexTableHeader
showBanners={showBanners}
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}

View File

@@ -12,7 +12,11 @@
.priority,
.privacy,
.protocol {
.protocol,
.minimumSeeders,
.seedRatio,
.seedTime,
.packSeedTime {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 90px;

View File

@@ -5,9 +5,13 @@ interface CssExports {
'added': string;
'appProfileId': string;
'capabilities': string;
'minimumSeeders': string;
'packSeedTime': string;
'priority': string;
'privacy': string;
'protocol': string;
'seedRatio': string;
'seedTime': string;
'sortName': string;
'status': string;
'tags': string;

View File

@@ -14,11 +14,11 @@ import {
setIndexerSort,
setIndexerTableOption,
} from 'Store/Actions/indexerIndexActions';
import { CheckInputChanged } from 'typings/inputs';
import IndexerIndexTableOptions from './IndexerIndexTableOptions';
import styles from './IndexerIndexTableHeader.css';
interface IndexerIndexTableHeaderProps {
showBanners: boolean;
columns: Column[];
sortKey?: string;
sortDirection?: SortDirection;
@@ -31,21 +31,21 @@ function IndexerIndexTableHeader(props: IndexerIndexTableHeaderProps) {
const [selectState, selectDispatch] = useSelect();
const onSortPress = useCallback(
(value) => {
(value: string) => {
dispatch(setIndexerSort({ sortKey: value }));
},
[dispatch]
);
const onTableOptionChange = useCallback(
(payload) => {
(payload: unknown) => {
dispatch(setIndexerTableOption(payload));
},
[dispatch]
);
const onSelectAllChange = useCallback(
({ value }) => {
({ value }: CheckInputChanged) => {
selectDispatch({
type: value ? 'selectAll' : 'unselectAll',
});
@@ -92,7 +92,11 @@ function IndexerIndexTableHeader(props: IndexerIndexTableHeaderProps) {
return (
<VirtualTableHeaderCell
key={name}
className={classNames(styles[name])}
className={classNames(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
styles[name]
)}
name={name}
sortKey={sortKey}
sortDirection={sortDirection}

View File

@@ -4,6 +4,7 @@ import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props';
import { CheckInputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import selectTableOptions from './selectTableOptions';
@@ -19,7 +20,7 @@ function IndexerIndexTableOptions(props: IndexerIndexTableOptionsProps) {
const { showSearchAction } = tableOptions;
const onTableOptionChangeWrapper = useCallback(
({ name, value }) => {
({ name, value }: CheckInputChanged) => {
onTableOptionChange({
tableOptions: {
...tableOptions,

View File

@@ -11,6 +11,8 @@ function ProtocolLabel(props: ProtocolLabelProps) {
const protocolName = protocol === 'usenet' ? 'nzb' : protocol;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts(7053)
return <Label className={styles[protocol]}>{protocolName}</Label>;
}

View File

@@ -1,7 +1,8 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
const selectTableOptions = createSelector(
(state) => state.indexerIndex.tableOptions,
(state: AppState) => state.indexerIndex.tableOptions,
(tableOptions) => tableOptions
);

View File

@@ -1,26 +1,17 @@
import { createSelector } from 'reselect';
import Indexer from 'Indexer/Indexer';
import createIndexerAppProfileSelector from 'Store/Selectors/createIndexerAppProfileSelector';
import createIndexerSelector from 'Store/Selectors/createIndexerSelector';
import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector';
import createIndexerStatusSelector from 'Store/Selectors/createIndexerStatusSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
function createIndexerIndexItemSelector(indexerId: number) {
return createSelector(
createIndexerSelector(indexerId),
createIndexerSelectorForHook(indexerId),
createIndexerAppProfileSelector(indexerId),
createIndexerStatusSelector(indexerId),
createUISettingsSelector(),
(indexer: Indexer, appProfile, status, uiSettings) => {
// If a series is deleted this selector may fire before the parent
// selectors, which will result in an undefined series, if that happens
// we want to return early here and again in the render function to avoid
// trying to show a series that has no information available.
if (!indexer) {
return {};
}
return {
indexer,
appProfile,

View File

@@ -45,6 +45,7 @@ interface Indexer extends ModelBase {
priority: number;
fields: IndexerField[];
tags: number[];
sortName: string;
status: IndexerStatus;
capabilities: IndexerCapabilities;
indexerUrls: string[];

View File

@@ -22,13 +22,13 @@ import { kinds } from 'Helpers/Props';
import DeleteIndexerModal from 'Indexer/Delete/DeleteIndexerModal';
import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector';
import Indexer from 'Indexer/Indexer';
import createIndexerSelector from 'Store/Selectors/createIndexerSelector';
import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector';
import translate from 'Utilities/String/translate';
import styles from './IndexerInfoModalContent.css';
function createIndexerInfoItemSelector(indexerId: number) {
return createSelector(
createIndexerSelector(indexerId),
createIndexerSelectorForHook(indexerId),
(indexer: Indexer) => {
return {
indexer,
@@ -130,13 +130,19 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) {
{translate('IndexerSite')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to={baseUrl}>
{baseUrl.replace(/(:\/\/)api\./, '$1')}
</Link>
{baseUrl ? (
<Link to={baseUrl}>
{baseUrl.replace(/(:\/\/)api\./, '$1')}
</Link>
) : (
'-'
)}
</DescriptionListItemDescription>
<DescriptionListItemTitle>{`${
protocol === 'usenet' ? 'Newznab' : 'Torznab'
} Url`}</DescriptionListItemTitle>
<DescriptionListItemTitle>
{protocol === 'usenet'
? translate('NewznabUrl')
: translate('TorznabUrl')}
</DescriptionListItemTitle>
<DescriptionListItemDescription>
{`${window.location.origin}${window.Prowlarr.urlBase}/${id}/api`}
</DescriptionListItemDescription>

View File

@@ -253,7 +253,6 @@ Stats.propTypes = {
isPopulated: PropTypes.bool.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.string.isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
onFilterSelect: PropTypes.func.isRequired,
error: PropTypes.object,
data: PropTypes.object

View File

@@ -1,10 +1,18 @@
import PropTypes from 'prop-types';
import React from 'react';
import { CustomFilter } from 'App/State/AppState';
import FilterMenu from 'Components/Menu/FilterMenu';
import { align } from 'Helpers/Props';
import SearchIndexFilterModalConnector from 'Search/SearchIndexFilterModalConnector';
function SearchIndexFilterMenu(props) {
interface SearchIndexFilterMenuProps {
selectedFilterKey: string | number;
filters: object[];
customFilters: CustomFilter[];
isDisabled: boolean;
onFilterSelect(filterName: string): unknown;
}
function SearchIndexFilterMenu(props: SearchIndexFilterMenuProps) {
const {
selectedFilterKey,
filters,
@@ -26,15 +34,6 @@ function SearchIndexFilterMenu(props) {
);
}
SearchIndexFilterMenu.propTypes = {
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired,
};
SearchIndexFilterMenu.defaultProps = {
showCustomFilters: false,
};

View File

@@ -1,12 +1,19 @@
import PropTypes from 'prop-types';
import React from 'react';
import MenuContent from 'Components/Menu/MenuContent';
import SortMenu from 'Components/Menu/SortMenu';
import SortMenuItem from 'Components/Menu/SortMenuItem';
import { align, sortDirections } from 'Helpers/Props';
import { align } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import translate from 'Utilities/String/translate';
function SearchIndexSortMenu(props) {
interface SearchIndexSortMenuProps {
sortKey?: string;
sortDirection?: SortDirection;
isDisabled: boolean;
onSortSelect(sortKey: string): unknown;
}
function SearchIndexSortMenu(props: SearchIndexSortMenuProps) {
const { sortKey, sortDirection, isDisabled, onSortSelect } = props;
return (
@@ -97,11 +104,4 @@ function SearchIndexSortMenu(props) {
);
}
SearchIndexSortMenu.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
isDisabled: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired,
};
export default SearchIndexSortMenu;

View File

@@ -12,6 +12,7 @@ import Peers from 'Search/Table/Peers';
import dimensions from 'Styles/Variables/dimensions';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import styles from './SearchIndexOverview.css';
@@ -78,6 +79,7 @@ class SearchIndexOverview extends Component {
categories,
seeders,
leechers,
indexerFlags,
size,
age,
ageHours,
@@ -107,7 +109,6 @@ class SearchIndexOverview extends Component {
text={title}
/>
</Link>
</div>
<div className={styles.actions}>
@@ -155,6 +156,20 @@ class SearchIndexOverview extends Component {
<CategoryLabel
categories={categories}
/>
{
indexerFlags.length ?
indexerFlags
.sort((a, b) => a.localeCompare(b))
.map((flag, index) => {
return (
<Label key={index} kind={kinds.INFO}>
{titleCase(flag)}
</Label>
);
}) :
null
}
</div>
</div>
</div>

View File

@@ -1,9 +1,12 @@
import PropTypes from 'prop-types';
import React from 'react';
import translate from 'Utilities/String/translate';
import styles from './NoSearchResults.css';
function NoSearchResults(props) {
interface NoSearchResultsProps {
totalItems: number;
}
function NoSearchResults(props: NoSearchResultsProps) {
const { totalItems } = props;
if (totalItems > 0) {
@@ -18,15 +21,9 @@ function NoSearchResults(props) {
return (
<div>
<div className={styles.message}>
{translate('NoSearchResultsFound')}
</div>
<div className={styles.message}>{translate('NoSearchResultsFound')}</div>
</div>
);
}
NoSearchResults.propTypes = {
totalItems: PropTypes.number.isRequired
};
export default NoSearchResults;

View File

@@ -14,11 +14,11 @@ import QueryParameterOption from './QueryParameterOption';
import styles from './QueryParameterModal.css';
const searchOptions = [
{ key: 'search', value: 'Basic Search' },
{ key: 'tvsearch', value: 'TV Search' },
{ key: 'movie', value: 'Movie Search' },
{ key: 'music', value: 'Audio Search' },
{ key: 'book', value: 'Book Search' }
{ key: 'search', value: translate('BasicSearch') },
{ key: 'tvsearch', value: translate('TvSearch') },
{ key: 'movie', value: translate('MovieSearch') },
{ key: 'music', value: translate( 'AudioSearch') },
{ key: 'book', value: translate('BookSearch') }
];
const seriesTokens = [
@@ -94,8 +94,8 @@ class QueryParameterModal extends Component {
const newValue = `${start}${tokenValue}${end}`;
onSearchInputChange({ name, value: newValue });
this._selectionStart = newValue.length - 1;
this._selectionEnd = newValue.length - 1;
this._selectionStart = newValue.length;
this._selectionEnd = newValue.length;
}
};

View File

@@ -24,7 +24,8 @@
flex-grow: 1;
}
.searchButton {
.searchButton,
.grabReleasesButton {
composes: button from '~Components/Link/SpinnerButton.css';
margin-left: 25px;
@@ -32,18 +33,20 @@
}
.selectedReleasesLabel {
margin-bottom: 3px;
margin-bottom: 5px;
text-align: right;
font-weight: bold;
}
@media only screen and (max-width: $breakpointSmall) {
.inputContainer {
.inputContainer,
.indexerContainer {
margin-right: 0;
}
.buttonContainer {
justify-content: flex-start;
margin-top: 10px;
}
.buttonContainerContent {
@@ -52,5 +55,20 @@
.buttons {
justify-content: space-between;
flex-direction: column;
gap: 10px;
}
.grabReleasesButton,
.searchButton {
margin-left: 0;
}
.grabReleasesButton {
display: none;
}
.selectedReleasesLabel {
text-align: center;
}
}

View File

@@ -4,6 +4,7 @@ interface CssExports {
'buttonContainer': string;
'buttonContainerContent': string;
'buttons': string;
'grabReleasesButton': string;
'indexerContainer': string;
'inputContainer': string;
'searchButton': string;

View File

@@ -27,7 +27,9 @@ class SearchFooter extends Component {
defaultIndexerIds,
defaultCategories,
defaultSearchQuery,
defaultSearchType
defaultSearchType,
defaultSearchLimit,
defaultSearchOffset
} = props;
this.state = {
@@ -38,8 +40,8 @@ class SearchFooter extends Component {
searchQuery: defaultSearchQuery || '',
searchIndexerIds: defaultIndexerIds,
searchCategories: defaultCategories,
searchLimit: 100,
searchOffset: 0,
searchLimit: defaultSearchLimit,
searchOffset: defaultSearchOffset,
newSearch: true
};
}
@@ -55,7 +57,9 @@ class SearchFooter extends Component {
this.onSearchPress();
}
this.props.bindShortcut('enter', this.onSearchPress, { isGlobal: true });
setTimeout(() => {
this.props.bindShortcut('enter', this.onSearchPress, { isGlobal: true });
});
}
componentDidUpdate(prevProps) {
@@ -120,7 +124,6 @@ class SearchFooter extends Component {
};
onSearchPress = () => {
const {
searchLimit,
searchOffset,
@@ -188,10 +191,10 @@ class SearchFooter extends Component {
icon = icons.SEARCH;
}
let footerLabel = `Search ${searchIndexerIds.length === 0 ? 'all' : searchIndexerIds.length} Indexers`;
let footerLabel = searchIndexerIds.length === 0 ? translate('SearchAllIndexers') : translate('SearchCountIndexers', [searchIndexerIds.length]);
if (isPopulated) {
footerLabel = selectedCount === 0 ? `Found ${itemCount} releases` : `Selected ${selectedCount} of ${itemCount} releases`;
footerLabel = selectedCount === 0 ? translate('FoundCountReleases', [itemCount]) : translate('SelectedCountOfCountReleases', [selectedCount, itemCount]);
}
return (
@@ -256,11 +259,10 @@ class SearchFooter extends Component {
/>
<div className={styles.buttons}>
{
isPopulated &&
<SpinnerButton
className={styles.searchButton}
className={styles.grabReleasesButton}
kind={kinds.SUCCESS}
isSpinning={isGrabbing}
isDisabled={isFetching || !hasIndexers || selectedCount === 0}
@@ -302,6 +304,8 @@ SearchFooter.propTypes = {
defaultCategories: PropTypes.arrayOf(PropTypes.number).isRequired,
defaultSearchQuery: PropTypes.string.isRequired,
defaultSearchType: PropTypes.string.isRequired,
defaultSearchLimit: PropTypes.number.isRequired,
defaultSearchOffset: PropTypes.number.isRequired,
selectedCount: PropTypes.number.isRequired,
itemCount: PropTypes.number.isRequired,
isFetching: PropTypes.bool.isRequired,

View File

@@ -3,24 +3,58 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setSearchDefault } from 'Store/Actions/releaseActions';
import parseUrl from 'Utilities/String/parseUrl';
import SearchFooter from './SearchFooter';
function createMapStateToProps() {
return createSelector(
(state) => state.releases,
(releases) => {
(state) => state.router.location,
(releases, location) => {
const {
searchQuery: defaultSearchQuery,
searchIndexerIds: defaultIndexerIds,
searchCategories: defaultCategories,
searchType: defaultSearchType
searchType: defaultSearchType,
searchLimit: defaultSearchLimit,
searchOffset: defaultSearchOffset
} = releases.defaults;
const { params } = parseUrl(location.search);
const defaultSearchQueryParams = {};
if (params.query && !defaultSearchQuery) {
defaultSearchQueryParams.searchQuery = params.query;
}
if (params.indexerIds && !defaultIndexerIds.length) {
defaultSearchQueryParams.searchIndexerIds = params.indexerIds.split(',').map((id) => Number(id)).filter(Boolean);
}
if (params.categories && !defaultCategories.length) {
defaultSearchQueryParams.searchCategories = params.categories.split(',').map((id) => Number(id)).filter(Boolean);
}
if (params.type && defaultSearchType === 'search') {
defaultSearchQueryParams.searchType = params.type;
}
if (params.limit && defaultSearchLimit === 100 && !isNaN(params.limit)) {
defaultSearchQueryParams.searchLimit = Number(params.limit);
}
if (params.offset && !defaultSearchOffset && !isNaN(params.offset)) {
defaultSearchQueryParams.searchOffset = Number(params.offset);
}
return {
defaultSearchQuery,
defaultIndexerIds,
defaultCategories,
defaultSearchType
defaultSearchQueryParams,
defaultSearchQuery: defaultSearchQueryParams.searchQuery ?? defaultSearchQuery,
defaultIndexerIds: defaultSearchQueryParams.searchIndexerIds ?? defaultIndexerIds,
defaultCategories: defaultSearchQueryParams.searchCategories ?? defaultCategories,
defaultSearchType: defaultSearchQueryParams.searchType ?? defaultSearchType,
defaultSearchLimit: defaultSearchQueryParams.searchLimit ?? defaultSearchLimit,
defaultSearchOffset: defaultSearchQueryParams.searchOffset ?? defaultSearchOffset
};
}
);
@@ -32,6 +66,16 @@ const mapDispatchToProps = {
class SearchFooterConnector extends Component {
//
// Lifecycle
componentDidMount() {
// Set defaults from query parameters
Object.entries(this.props.defaultSearchQueryParams).forEach(([name, value]) => {
this.onInputChange({ name, value });
});
}
//
// Listeners
@@ -43,9 +87,14 @@ class SearchFooterConnector extends Component {
// Render
render() {
const {
defaultSearchQueryParams,
...otherProps
} = this.props;
return (
<SearchFooter
{...this.props}
{...otherProps}
onInputChange={this.onInputChange}
/>
);
@@ -53,6 +102,7 @@ class SearchFooterConnector extends Component {
}
SearchFooterConnector.propTypes = {
defaultSearchQueryParams: PropTypes.object.isRequired,
setSearchDefault: PropTypes.func.isRequired
};

View File

@@ -30,13 +30,7 @@ import SearchFooterConnector from './SearchFooterConnector';
import SearchIndexTableConnector from './Table/SearchIndexTableConnector';
import styles from './SearchIndex.css';
function getViewComponent(isSmallScreen) {
if (isSmallScreen) {
return SearchIndexOverviewsConnector;
}
return SearchIndexTableConnector;
}
const getViewComponent = (isSmallScreen) => (isSmallScreen ? SearchIndexOverviewsConnector : SearchIndexTableConnector);
class SearchIndex extends Component {
@@ -78,7 +72,7 @@ class SearchIndex extends Component {
if (sortKey !== prevProps.sortKey ||
sortDirection !== prevProps.sortDirection ||
hasDifferentItemsOrOrder(prevProps.items, items)
hasDifferentItemsOrOrder(prevProps.items, items, 'guid')
) {
this.setJumpBarItems();
this.setSelectedState();
@@ -100,7 +94,14 @@ class SearchIndex extends Component {
if (this.state.allUnselected) {
return [];
}
return getSelectedIds(this.state.selectedState, { parseIds: false });
return _.reduce(this.state.selectedState, (result, value, id) => {
if (value) {
result.push(id);
}
return result;
}, []);
};
setSelectedState() {
@@ -146,7 +147,7 @@ class SearchIndex extends Component {
} = this.props;
// Reset if not sorting by sortTitle
if (sortKey !== 'title') {
if (sortKey !== 'sortTitle') {
this.setState({ jumpBarItems: { order: [] } });
return;
}
@@ -154,7 +155,7 @@ class SearchIndex extends Component {
const characters = _.reduce(items, (acc, item) => {
let char = item.sortTitle.charAt(0);
if (!isNaN(char)) {
if (!isNaN(Number(char))) {
char = '#';
}
@@ -326,15 +327,17 @@ class SearchIndex extends Component {
innerClassName={styles.tableInnerContentBody}
>
{
isFetching && !isPopulated &&
<LoadingIndicator />
isFetching && !isPopulated ?
<LoadingIndicator /> :
null
}
{
!isFetching && !!error &&
!isFetching && !!error ?
<Alert kind={kinds.DANGER}>
{getErrorMessage(error, 'Failed to load search results from API')}
</Alert>
</Alert> :
null
}
{
@@ -359,16 +362,18 @@ class SearchIndex extends Component {
}
{
!error && !isFetching && !hasIndexers &&
!error && !isFetching && !hasIndexers ?
<NoIndexer
totalItems={0}
onAddIndexerPress={this.onAddIndexerPress}
/>
/> :
null
}
{
!error && !isFetching && hasIndexers && !items.length &&
<NoSearchResults totalItems={totalItems} />
!error && !isFetching && isPopulated && hasIndexers && !items.length ?
<NoSearchResults totalItems={totalItems} /> :
null
}
<AddIndexerModal
@@ -384,11 +389,12 @@ class SearchIndex extends Component {
</PageContentBody>
{
isLoaded && !!jumpBarItems.order.length &&
isLoaded && !!jumpBarItems.order.length ?
<PageJumpBar
items={jumpBarItems}
onItemPress={this.onJumpBarItemPress}
/>
/> :
null
}
</div>

View File

@@ -337,7 +337,8 @@ SearchIndexRow.propTypes = {
title: PropTypes.string.isRequired,
fileName: PropTypes.string.isRequired,
infoUrl: PropTypes.string.isRequired,
downloadUrl: PropTypes.string.isRequired,
downloadUrl: PropTypes.string,
magnetUrl: PropTypes.string,
indexerId: PropTypes.number.isRequired,
indexer: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,

View File

@@ -17,7 +17,7 @@ function AdvancedSettingsButton(props) {
return (
<Link
className={styles.button}
title={advancedSettings ? translate('ShownClickToHide') : translate('HiddenClickToShow')}
title={advancedSettings ? translate('AdvancedSettingsShownClickToHide') : translate('AdvancedSettingsHiddenClickToShow')}
onPress={onAdvancedSettingsPress}
>
<Icon

View File

@@ -9,8 +9,35 @@ import AppProfilesConnector from 'Settings/Profiles/App/AppProfilesConnector';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import ApplicationsConnector from './Applications/ApplicationsConnector';
import ManageApplicationsModal from './Applications/Manage/ManageApplicationsModal';
class ApplicationSettings extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isManageApplicationsOpen: false
};
}
//
// Listeners
onManageApplicationsPress = () => {
this.setState({ isManageApplicationsOpen: true });
};
onManageApplicationsModalClose = () => {
this.setState({ isManageApplicationsOpen: false });
};
//
// Render
render() {
const {
isTestingAll,
@@ -19,6 +46,8 @@ class ApplicationSettings extends Component {
onAppIndexerSyncPress
} = this.props;
const { isManageApplicationsOpen } = this.state;
return (
<PageContent title={translate('Applications')}>
<SettingsToolbarConnector
@@ -40,6 +69,12 @@ class ApplicationSettings extends Component {
isSpinning={isTestingAll}
onPress={onTestAllPress}
/>
<PageToolbarButton
label={translate('ManageApplications')}
iconName={icons.MANAGE}
onPress={this.onManageApplicationsPress}
/>
</Fragment>
}
/>
@@ -47,6 +82,11 @@ class ApplicationSettings extends Component {
<PageContentBody>
<ApplicationsConnector />
<AppProfilesConnector />
<ManageApplicationsModal
isOpen={isManageApplicationsOpen}
onModalClose={this.onManageApplicationsModalClose}
/>
</PageContentBody>
</PageContent>
);

View File

@@ -4,6 +4,11 @@
width: 290px;
}
.nameContainer {
display: flex;
justify-content: space-between;
}
.name {
@add-mixin truncate;
@@ -12,6 +17,12 @@
font-size: 24px;
}
.externalLink {
composes: button from '~Components/Link/IconButton.css';
height: 36px;
}
.enabled {
display: flex;
flex-wrap: wrap;

View File

@@ -3,7 +3,9 @@
interface CssExports {
'application': string;
'enabled': string;
'externalLink': string;
'name': string;
'nameContainer': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -2,9 +2,10 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { kinds } from 'Helpers/Props';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditApplicationModalConnector from './EditApplicationModalConnector';
import styles from './Application.css';
@@ -57,18 +58,33 @@ class Application extends Component {
id,
name,
syncLevel,
fields,
tags,
tagList
} = this.props;
const applicationUrl = fields.find((field) => field.name === 'baseUrl')?.value;
return (
<Card
className={styles.application}
overlayContent={true}
onPress={this.onEditApplicationPress}
>
<div className={styles.name}>
{name}
<div className={styles.nameContainer}>
<div className={styles.name}>
{name}
</div>
{
applicationUrl ?
<IconButton
className={styles.externalLink}
name={icons.EXTERNAL_LINK}
title={translate('GoToApplication')}
to={`${applicationUrl}`}
/> : null
}
</div>
{
@@ -125,6 +141,7 @@ Application.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
syncLevel: PropTypes.string.isRequired,
fields: PropTypes.arrayOf(PropTypes.object).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteApplication: PropTypes.func

View File

@@ -14,6 +14,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import translate from 'Utilities/String/translate';
import styles from './EditApplicationModalContent.css';
@@ -38,11 +39,13 @@ function EditApplicationModalContent(props) {
onSavePress,
onTestPress,
onDeleteApplicationPress,
onAdvancedSettingsPress,
...otherProps
} = props;
const {
id,
implementationName,
name,
syncLevel,
tags,
@@ -53,7 +56,7 @@ function EditApplicationModalContent(props) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{`${id ? translate('Edit') : translate('Add')} ${translate('Application')}`}
{`${id ? translate('Edit') : translate('Add')} ${translate('Application')} - ${implementationName}`}
</ModalHeader>
<ModalBody>
@@ -100,7 +103,10 @@ function EditApplicationModalContent(props) {
type={inputTypes.SELECT}
values={syncLevelOptions}
name="syncLevel"
helpText={`${translate('SyncLevelAddRemove')}<br>${translate('SyncLevelFull')}`}
helpTexts={[
translate('SyncLevelAddRemove'),
translate('SyncLevelFull')
]}
{...syncLevel}
onChange={onInputChange}
/>
@@ -149,6 +155,12 @@ function EditApplicationModalContent(props) {
</Button>
}
<AdvancedSettingsButton
advancedSettings={advancedSettings}
onAdvancedSettingsPress={onAdvancedSettingsPress}
showLabel={false}
/>
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
@@ -188,7 +200,8 @@ EditApplicationModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired,
onDeleteApplicationPress: PropTypes.func
onDeleteApplicationPress: PropTypes.func,
onAdvancedSettingsPress: PropTypes.func.isRequired
};
export default EditApplicationModalContent;

View File

@@ -2,7 +2,13 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveApplication, setApplicationFieldValue, setApplicationValue, testApplication } from 'Store/Actions/settingsActions';
import {
saveApplication,
setApplicationFieldValue,
setApplicationValue,
testApplication,
toggleAdvancedSettings
} from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditApplicationModalContent from './EditApplicationModalContent';
@@ -23,7 +29,8 @@ const mapDispatchToProps = {
setApplicationValue,
setApplicationFieldValue,
saveApplication,
testApplication
testApplication,
toggleAdvancedSettings
};
class EditApplicationModalContentConnector extends Component {
@@ -56,6 +63,10 @@ class EditApplicationModalContentConnector extends Component {
this.props.testApplication({ id: this.props.id });
};
onAdvancedSettingsPress = () => {
this.props.toggleAdvancedSettings();
};
//
// Render
@@ -67,6 +78,7 @@ class EditApplicationModalContentConnector extends Component {
onTestPress={this.onTestPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
onAdvancedSettingsPress={this.onAdvancedSettingsPress}
/>
);
}
@@ -82,7 +94,8 @@ EditApplicationModalContentConnector.propTypes = {
setApplicationFieldValue: PropTypes.func,
saveApplication: PropTypes.func,
testApplication: PropTypes.func,
onModalClose: PropTypes.func.isRequired
onModalClose: PropTypes.func.isRequired,
toggleAdvancedSettings: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditApplicationModalContentConnector);

View File

@@ -0,0 +1,26 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageApplicationsEditModalContent from './ManageApplicationsEditModalContent';
interface ManageApplicationsEditModalProps {
isOpen: boolean;
applicationIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
function ManageApplicationsEditModal(props: ManageApplicationsEditModalProps) {
const { isOpen, applicationIds, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageApplicationsEditModalContent
applicationIds={applicationIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default ManageApplicationsEditModal;

View File

@@ -0,0 +1,16 @@
.modalFooter {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
justify-content: space-between;
}
.selected {
font-weight: bold;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.modalFooter {
flex-direction: column;
gap: 10px;
}
}

View File

@@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'modalFooter': string;
'selected': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,109 @@
import React, { useCallback, useState } from 'react';
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 { ApplicationSyncLevel } from 'typings/Application';
import translate from 'Utilities/String/translate';
import styles from './ManageApplicationsEditModalContent.css';
interface SavePayload {
syncLevel?: ApplicationSyncLevel;
}
interface ManageApplicationsEditModalContentProps {
applicationIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const syncLevelOptions = [
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: ApplicationSyncLevel.Disabled, value: translate('Disabled') },
{ key: ApplicationSyncLevel.AddOnly, value: translate('AddOnly') },
{ key: ApplicationSyncLevel.FullSync, value: translate('FullSync') },
];
function ManageApplicationsEditModalContent(
props: ManageApplicationsEditModalContentProps
) {
const { applicationIds, onSavePress, onModalClose } = props;
const [syncLevel, setSyncLevel] = useState(NO_CHANGE);
const save = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (syncLevel !== NO_CHANGE) {
hasChanges = true;
payload.syncLevel = syncLevel as ApplicationSyncLevel;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [syncLevel, onSavePress, onModalClose]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'syncLevel':
setSyncLevel(value);
break;
default:
console.warn(`EditApplicationsModalContent Unknown Input: '${name}'`);
}
},
[]
);
const selectedCount = applicationIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedApplications')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('SyncLevel')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="syncLevel"
value={syncLevel}
values={syncLevelOptions}
helpTexts={[
translate('SyncLevelAddRemove'),
translate('SyncLevelFull'),
]}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountApplicationsSelected', [selectedCount])}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('ApplyChanges')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ManageApplicationsEditModalContent;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageApplicationsModalContent from './ManageApplicationsModalContent';
interface ManageApplicationsModalProps {
isOpen: boolean;
onModalClose(): void;
}
function ManageApplicationsModal(props: ManageApplicationsModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageApplicationsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ManageApplicationsModal;

View File

@@ -0,0 +1,16 @@
.leftButtons,
.rightButtons {
display: flex;
flex: 1 0 50%;
flex-wrap: wrap;
}
.rightButtons {
justify-content: flex-end;
}
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}

View File

@@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'deleteButton': string;
'leftButtons': string;
'rightButtons': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,282 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { ApplicationAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ConfirmModal from 'Components/Modal/ConfirmModal';
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 Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import {
bulkDeleteApplications,
bulkEditApplications,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageApplicationsEditModal from './Edit/ManageApplicationsEditModal';
import ManageApplicationsModalRow from './ManageApplicationsModalRow';
import TagsModal from './Tags/TagsModal';
import styles from './ManageApplicationsModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageApplicationsModalRow
>['onSelectedChange'];
const COLUMNS = [
{
name: 'name',
label: translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: translate('Implementation'),
isSortable: true,
isVisible: true,
},
{
name: 'syncLevel',
label: translate('SyncLevel'),
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: translate('Tags'),
isSortable: true,
isVisible: true,
},
];
interface ManageApplicationsModalContentProps {
onModalClose(): void;
}
function ManageApplicationsModalContent(
props: ManageApplicationsModalContentProps
) {
const { onModalClose } = props;
const {
isFetching,
isPopulated,
isDeleting,
isSaving,
error,
items,
}: ApplicationAppState = useSelector(
createClientSideCollectionSelector('settings.applications')
);
const dispatch = useDispatch();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds: number[] = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = selectedIds.length;
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback(() => {
dispatch(bulkDeleteApplications({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditApplications({
ids: selectedIds,
...payload,
})
);
},
[selectedIds, dispatch]
);
const onTagsPress = useCallback(() => {
setIsTagsModalOpen(true);
}, [setIsTagsModalOpen]);
const onTagsModalClose = useCallback(() => {
setIsTagsModalOpen(false);
}, [setIsTagsModalOpen]);
const onApplyTagsPress = useCallback(
(tags: number[], applyTags: string) => {
setIsSavingTags(true);
setIsTagsModalOpen(false);
dispatch(
bulkEditApplications({
ids: selectedIds,
tags,
applyTags,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const errorMessage = getErrorMessage(
error,
'Unable to load download clients.'
);
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('ManageApplications')}</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && (
<Alert kind={kinds.INFO}>{translate('NoApplicationsFound')}</Alert>
)}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return (
<ManageApplicationsModalRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
columns={COLUMNS}
onSelectedChange={onSelectedChange}
/>
);
})}
</TableBody>
</Table>
) : null}
</ModalBody>
<ModalFooter>
<div className={styles.leftButtons}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!anySelected}
onPress={onDeletePress}
>
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving}
isDisabled={!anySelected}
onPress={onEditPress}
>
{translate('Edit')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving && isSavingTags}
isDisabled={!anySelected}
onPress={onTagsPress}
>
{translate('SetTags')}
</SpinnerButton>
</div>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
<ManageApplicationsEditModal
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
applicationIds={selectedIds}
/>
<TagsModal
isOpen={isTagsModalOpen}
ids={selectedIds}
onApplyTagsPress={onApplyTagsPress}
onModalClose={onTagsModalClose}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedApplications')}
message={translate('DeleteSelectedApplicationsMessageText', [
selectedIds.length,
])}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</ModalContent>
);
}
export default ManageApplicationsModalContent;

View File

@@ -0,0 +1,8 @@
.name,
.syncLevel,
.tags,
.implementation {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}

View File

@@ -0,0 +1,10 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'implementation': string;
'name': string;
'syncLevel': string;
'tags': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,82 @@
import React, { useCallback } from 'react';
import Label from 'Components/Label';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import TagListConnector from 'Components/TagListConnector';
import { kinds } from 'Helpers/Props';
import { ApplicationSyncLevel } from 'typings/Application';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import styles from './ManageApplicationsModalRow.css';
interface ManageApplicationsModalRowProps {
id: number;
name: string;
syncLevel: string;
implementation: string;
tags: number[];
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function ManageApplicationsModalRow(props: ManageApplicationsModalRowProps) {
const {
id,
isSelected,
name,
syncLevel,
implementation,
tags,
onSelectedChange,
} = props;
const onSelectedChangeWrapper = useCallback(
(result: SelectStateInputProps) => {
onSelectedChange({
...result,
});
},
[onSelectedChange]
);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.name}>{name}</TableRowCell>
<TableRowCell className={styles.implementation}>
{implementation}
</TableRowCell>
<TableRowCell className={styles.syncLevel}>
{syncLevel === ApplicationSyncLevel.AddOnly && (
<Label kind={kinds.WARNING}>{translate('AddRemoveOnly')}</Label>
)}
{syncLevel === ApplicationSyncLevel.FullSync && (
<Label kind={kinds.SUCCESS}>{translate('FullSync')}</Label>
)}
{syncLevel === ApplicationSyncLevel.Disabled && (
<Label kind={kinds.DISABLED} outline={true}>
{translate('Disabled')}
</Label>
)}
</TableRowCell>
<TableRowCell className={styles.tags}>
<TagListConnector tags={tags} />
</TableRowCell>
</TableRow>
);
}
export default ManageApplicationsModalRow;

View File

@@ -0,0 +1,22 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import TagsModalContent from './TagsModalContent';
interface TagsModalProps {
isOpen: boolean;
ids: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModal(props: TagsModalProps) {
const { isOpen, onModalClose, ...otherProps } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<TagsModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default TagsModal;

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