mirror of
https://github.com/Prowlarr/Prowlarr.git
synced 2026-03-05 13:40:08 -05:00
Compare commits
86 Commits
v1.7.0.362
...
v1.7.4.376
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4528d03931 | ||
|
|
e0b30d34b1 | ||
|
|
8edf483e69 | ||
|
|
cea6aae9e1 | ||
|
|
1697cee680 | ||
|
|
ce8c90a125 | ||
|
|
c8ad3d6edd | ||
|
|
ebe01913c2 | ||
|
|
07cb19f9f3 | ||
|
|
7f51c44829 | ||
|
|
07f816f9fd | ||
|
|
a4a50b880c | ||
|
|
79361d92cb | ||
|
|
ecda75152e | ||
|
|
37a4e7c228 | ||
|
|
1a66d23bfe | ||
|
|
a26aa4bd1e | ||
|
|
a5d83459e9 | ||
|
|
4bfaab4b21 | ||
|
|
5764950b10 | ||
|
|
470b57316a | ||
|
|
f546b9a3b0 | ||
|
|
cc28c90e39 | ||
|
|
6e21e892bc | ||
|
|
62d868f0e9 | ||
|
|
27b36fe501 | ||
|
|
fc80efd15f | ||
|
|
9b75ba6ca0 | ||
|
|
d42649c4df | ||
|
|
53adfb750c | ||
|
|
ac487f9b40 | ||
|
|
6dd354bf1a | ||
|
|
b747d0a321 | ||
|
|
0e6cec6f54 | ||
|
|
65cf7c1009 | ||
|
|
5f9c3585f4 | ||
|
|
a9d1d4be90 | ||
|
|
a94ed11b21 | ||
|
|
3fab8fb0db | ||
|
|
5e52627799 | ||
|
|
b9a28f243e | ||
|
|
146e7ca7b6 | ||
|
|
1488fb7570 | ||
|
|
0fc52ae16f | ||
|
|
5218bea705 | ||
|
|
ac33330c7c | ||
|
|
041a7c571f | ||
|
|
5d73c6aa91 | ||
|
|
ef9a3a4f2a | ||
|
|
3ce3f8acdd | ||
|
|
9bac2992b5 | ||
|
|
4a88b70f40 | ||
|
|
c9b1d0d958 | ||
|
|
a5b5e7a3a5 | ||
|
|
376202e2af | ||
|
|
6b698b33be | ||
|
|
1706728230 | ||
|
|
cb520b2264 | ||
|
|
193335e2a8 | ||
|
|
1c98727cf3 | ||
|
|
ab5b321385 | ||
|
|
96340909f1 | ||
|
|
bd6a37dc8c | ||
|
|
a663cebada | ||
|
|
2ce5618499 | ||
|
|
94c91d4c3f | ||
|
|
79fbb2d0d7 | ||
|
|
e2e52746bb | ||
|
|
21cc96d683 | ||
|
|
e68b45636e | ||
|
|
ce68fe4105 | ||
|
|
712404ddca | ||
|
|
826828e8ec | ||
|
|
252740519f | ||
|
|
062fd77e1b | ||
|
|
6769055b6b | ||
|
|
90e92c0b66 | ||
|
|
7eac11f57a | ||
|
|
02a3c1b224 | ||
|
|
57efa6d0b1 | ||
|
|
cee52147bc | ||
|
|
a1abcd6c93 | ||
|
|
18e2757d37 | ||
|
|
8790a6f06a | ||
|
|
4fafdb2cd2 | ||
|
|
bfc06fc8bc |
@@ -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}"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
48
frontend/src/App/State/AppSectionState.ts
Normal file
48
frontend/src/App/State/AppSectionState.ts
Normal 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;
|
||||
44
frontend/src/App/State/AppState.ts
Normal file
44
frontend/src/App/State/AppState.ts
Normal 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;
|
||||
8
frontend/src/App/State/ClientSideCollectionAppState.ts
Normal file
8
frontend/src/App/State/ClientSideCollectionAppState.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { CustomFilter } from './AppState';
|
||||
|
||||
interface ClientSideCollectionAppState {
|
||||
totalItems: number;
|
||||
customFilters: CustomFilter[];
|
||||
}
|
||||
|
||||
export default ClientSideCollectionAppState;
|
||||
33
frontend/src/App/State/IndexerAppState.ts
Normal file
33
frontend/src/App/State/IndexerAppState.ts
Normal 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;
|
||||
33
frontend/src/App/State/SettingsAppState.ts
Normal file
33
frontend/src/App/State/SettingsAppState.ts
Normal 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;
|
||||
12
frontend/src/App/State/TagsAppState.ts
Normal file
12
frontend/src/App/State/TagsAppState.ts
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -37,7 +37,7 @@ function HintedSelectInputOption(props) {
|
||||
|
||||
{
|
||||
hint != null &&
|
||||
<div className={styles.hintText}>
|
||||
<div className={styles.hintText} title={hint}>
|
||||
{hint}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -10,7 +10,7 @@ function parseValue(props, value) {
|
||||
} = props;
|
||||
|
||||
if (value == null || value === '') {
|
||||
return min;
|
||||
return null;
|
||||
}
|
||||
|
||||
let newValue = isFloat ? parseFloat(value) : parseInt(value);
|
||||
|
||||
@@ -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;
|
||||
96
frontend/src/Components/Link/Link.tsx
Normal file
96
frontend/src/Components/Link/Link.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.jumpBar {
|
||||
z-index: $pageJumpBarZIndex;
|
||||
display: flex;
|
||||
align-content: stretch;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -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(
|
||||
() => {
|
||||
|
||||
8
frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts
vendored
Normal file
8
frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts
vendored
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ function Table(props) {
|
||||
}
|
||||
|
||||
Table.propTypes = {
|
||||
...TableHeaderCell.props,
|
||||
className: PropTypes.string,
|
||||
horizontalScroll: PropTypes.bool.isRequired,
|
||||
selectAll: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
11
frontend/src/Helpers/Hooks/usePrevious.tsx
Normal file
11
frontend/src/Helpers/Hooks/usePrevious.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@ interface IndexerIndexSelectModeButtonProps {
|
||||
label: string;
|
||||
iconName: IconDefinition;
|
||||
isSelectMode: boolean;
|
||||
overflowComponent: React.FunctionComponent;
|
||||
overflowComponent: React.FunctionComponent<never>;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,7 +19,11 @@
|
||||
|
||||
.priority,
|
||||
.protocol,
|
||||
.privacy {
|
||||
.privacy,
|
||||
.minimumSeeders,
|
||||
.seedRatio,
|
||||
.seedTime,
|
||||
.packSeedTime {
|
||||
composes: cell;
|
||||
|
||||
flex: 0 0 90px;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
.tableScroller {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.row {
|
||||
transition: background-color 500ms;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--tableRowHoverBackgroundColor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
|
||||
.priority,
|
||||
.privacy,
|
||||
.protocol {
|
||||
.protocol,
|
||||
.minimumSeeders,
|
||||
.seedRatio,
|
||||
.seedTime,
|
||||
.packSeedTime {
|
||||
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
|
||||
|
||||
flex: 0 0 90px;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,6 +45,7 @@ interface Indexer extends ModelBase {
|
||||
priority: number;
|
||||
fields: IndexerField[];
|
||||
tags: number[];
|
||||
sortName: string;
|
||||
status: IndexerStatus;
|
||||
capabilities: IndexerCapabilities;
|
||||
indexerUrls: string[];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
1
frontend/src/Search/SearchFooter.css.d.ts
vendored
1
frontend/src/Search/SearchFooter.css.d.ts
vendored
@@ -4,6 +4,7 @@ interface CssExports {
|
||||
'buttonContainer': string;
|
||||
'buttonContainerContent': string;
|
||||
'buttons': string;
|
||||
'grabReleasesButton': string;
|
||||
'indexerContainer': string;
|
||||
'inputContainer': string;
|
||||
'searchButton': string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
interface CssExports {
|
||||
'application': string;
|
||||
'enabled': string;
|
||||
'externalLink': string;
|
||||
'name': string;
|
||||
'nameContainer': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
.name,
|
||||
.syncLevel,
|
||||
.tags,
|
||||
.implementation {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
word-break: break-all;
|
||||
}
|
||||
10
frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts
vendored
Normal file
10
frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts
vendored
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user