mirror of
https://github.com/Prowlarr/Prowlarr.git
synced 2025-04-24 05:47:22 -04:00
New: Bulk Manage Applications, Download Clients
Co-authored-by: Qstick <qstick@gmail.com>
This commit is contained in:
parent
cb520b2264
commit
1706728230
85 changed files with 2366 additions and 255 deletions
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;
|
43
frontend/src/App/State/AppState.ts
Normal file
43
frontend/src/App/State/AppState.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import IndexerAppState 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 {
|
||||
indexers: IndexerAppState;
|
||||
settings: SettingsAppState;
|
||||
tags: TagsAppState;
|
||||
}
|
||||
|
||||
export default AppState;
|
12
frontend/src/App/State/IndexerAppState.ts
Normal file
12
frontend/src/App/State/IndexerAppState.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import Indexer from 'typings/Indexer';
|
||||
import AppSectionState, {
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
} from './AppSectionState';
|
||||
|
||||
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;
|
|
@ -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 {
|
||||
|
|
|
@ -10,7 +10,7 @@ function parseValue(props, value) {
|
|||
} = props;
|
||||
|
||||
if (value == null || value === '') {
|
||||
return min;
|
||||
return null;
|
||||
}
|
||||
|
||||
let newValue = isFloat ? parseFloat(value) : parseInt(value);
|
||||
|
|
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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -190,7 +190,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
|
|||
|
||||
return (
|
||||
<SelectProvider items={items}>
|
||||
<PageContent>
|
||||
<PageContent title={translate('Indexers')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
|
|
|
@ -7,7 +7,7 @@ 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 { bulkDeleteIndexers } from 'Store/Actions/indexerActions';
|
||||
import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './DeleteIndexerModalContent.css';
|
||||
|
@ -34,7 +34,7 @@ function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
|
|||
const onDeleteIndexerConfirmed = useCallback(() => {
|
||||
dispatch(
|
||||
bulkDeleteIndexers({
|
||||
indexerIds,
|
||||
ids: indexerIds,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ 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';
|
||||
|
||||
|
@ -15,6 +15,10 @@ interface SavePayload {
|
|||
enable?: boolean;
|
||||
appProfileId?: number;
|
||||
priority?: number;
|
||||
minimumSeeders?: number;
|
||||
seedRatio?: number;
|
||||
seedTime?: number;
|
||||
packSeedTime?: number;
|
||||
}
|
||||
|
||||
interface EditIndexerModalContentProps {
|
||||
|
@ -37,6 +41,14 @@ 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;
|
||||
|
@ -57,12 +69,42 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
|||
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, priority, onSavePress, onModalClose]);
|
||||
}, [
|
||||
enable,
|
||||
appProfileId,
|
||||
priority,
|
||||
minimumSeeders,
|
||||
seedRatio,
|
||||
seedTime,
|
||||
packSeedTime,
|
||||
onSavePress,
|
||||
onModalClose,
|
||||
]);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
({ name, value }) => {
|
||||
|
@ -76,6 +118,18 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
|||
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(`EditIndexersModalContent Unknown Input: '${name}'`);
|
||||
}
|
||||
|
@ -94,7 +148,7 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
|||
<ModalHeader>{translate('EditSelectedIndexers')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<FormGroup>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('Enable')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
|
@ -106,21 +160,22 @@ 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>
|
||||
<FormLabel>{translate('Priority')}</FormLabel>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('IndexerPriority')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
|
@ -128,6 +183,57 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
|
|||
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>
|
||||
|
|
|
@ -2,11 +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';
|
||||
|
@ -15,7 +16,7 @@ import TagsModal from './Tags/TagsModal';
|
|||
import styles from './IndexerIndexSelectFooter.css';
|
||||
|
||||
const indexersEditorSelector = createSelector(
|
||||
(state) => state.indexers,
|
||||
(state: AppState) => state.indexers,
|
||||
(indexers) => {
|
||||
const { isSaving, isDeleting, deleteError } = indexers;
|
||||
|
||||
|
@ -64,9 +65,9 @@ function IndexerIndexSelectFooter() {
|
|||
setIsEditModalOpen(false);
|
||||
|
||||
dispatch(
|
||||
saveIndexerEditor({
|
||||
bulkEditIndexers({
|
||||
...payload,
|
||||
indexerIds,
|
||||
ids: indexerIds,
|
||||
})
|
||||
);
|
||||
},
|
||||
|
@ -87,8 +88,8 @@ function IndexerIndexSelectFooter() {
|
|||
setIsTagsModalOpen(false);
|
||||
|
||||
dispatch(
|
||||
saveIndexerEditor({
|
||||
indexerIds,
|
||||
bulkEditIndexers({
|
||||
ids: indexerIds,
|
||||
tags,
|
||||
applyTags,
|
||||
})
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -55,6 +55,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(
|
||||
|
@ -213,6 +230,38 @@ 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
|
||||
|
|
|
@ -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,6 +14,7 @@ import {
|
|||
setIndexerSort,
|
||||
setIndexerTableOption,
|
||||
} from 'Store/Actions/indexerIndexActions';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import IndexerIndexTableOptions from './IndexerIndexTableOptions';
|
||||
import styles from './IndexerIndexTableHeader.css';
|
||||
|
||||
|
@ -45,7 +46,7 @@ function IndexerIndexTableHeader(props: IndexerIndexTableHeaderProps) {
|
|||
);
|
||||
|
||||
const onSelectAllChange = useCallback(
|
||||
({ value }) => {
|
||||
({ value }: SelectStateInputProps) => {
|
||||
selectDispatch({
|
||||
type: value ? 'selectAll' : 'unselectAll',
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,108 @@
|
|||
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}
|
||||
helpText={`${translate('SyncLevelAddRemove')}<br>${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;
|
|
@ -0,0 +1,12 @@
|
|||
.renameIcon {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.result {
|
||||
padding-top: 4px;
|
||||
}
|
9
frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css.d.ts
vendored
Normal file
9
frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'message': string;
|
||||
'renameIcon': string;
|
||||
'result': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
|
@ -0,0 +1,183 @@
|
|||
import { uniq } from 'lodash';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { ApplicationAppState } from 'App/State/SettingsAppState';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Label from 'Components/Label';
|
||||
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, kinds, sizes } from 'Helpers/Props';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import Application from 'typings/Application';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './TagsModalContent.css';
|
||||
|
||||
interface TagsModalContentProps {
|
||||
ids: number[];
|
||||
onApplyTagsPress: (tags: number[], applyTags: string) => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function TagsModalContent(props: TagsModalContentProps) {
|
||||
const { ids, onModalClose, onApplyTagsPress } = props;
|
||||
|
||||
const allApplications: ApplicationAppState = useSelector(
|
||||
(state: AppState) => state.settings.applications
|
||||
);
|
||||
const tagList: Tag[] = useSelector(createTagsSelector());
|
||||
|
||||
const [tags, setTags] = useState<number[]>([]);
|
||||
const [applyTags, setApplyTags] = useState('add');
|
||||
|
||||
const applicationsTags = useMemo(() => {
|
||||
const tags = ids.reduce((acc: number[], id) => {
|
||||
const s = allApplications.items.find((s: Application) => s.id === id);
|
||||
|
||||
if (s) {
|
||||
acc.push(...s.tags);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return uniq(tags);
|
||||
}, [ids, allApplications]);
|
||||
|
||||
const onTagsChange = useCallback(
|
||||
({ value }: { value: number[] }) => {
|
||||
setTags(value);
|
||||
},
|
||||
[setTags]
|
||||
);
|
||||
|
||||
const onApplyTagsChange = useCallback(
|
||||
({ value }: { value: string }) => {
|
||||
setApplyTags(value);
|
||||
},
|
||||
[setApplyTags]
|
||||
);
|
||||
|
||||
const onApplyPress = useCallback(() => {
|
||||
onApplyTagsPress(tags, applyTags);
|
||||
}, [tags, applyTags, onApplyTagsPress]);
|
||||
|
||||
const applyTagsOptions = [
|
||||
{ key: 'add', value: translate('Add') },
|
||||
{ key: 'remove', value: translate('Remove') },
|
||||
{ key: 'replace', value: translate('Replace') },
|
||||
];
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('Tags')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
value={tags}
|
||||
onChange={onTagsChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ApplyTags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="applyTags"
|
||||
value={applyTags}
|
||||
values={applyTagsOptions}
|
||||
helpTexts={[
|
||||
translate('ApplyTagsHelpTexts1'),
|
||||
translate('ApplyTagsHelpTexts2'),
|
||||
translate('ApplyTagsHelpTexts3'),
|
||||
translate('ApplyTagsHelpTexts4'),
|
||||
]}
|
||||
onChange={onApplyTagsChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Result')}</FormLabel>
|
||||
|
||||
<div className={styles.result}>
|
||||
{applicationsTags.map((id) => {
|
||||
const tag = tagList.find((t) => t.id === id);
|
||||
|
||||
if (!tag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const removeTag =
|
||||
(applyTags === 'remove' && tags.indexOf(id) > -1) ||
|
||||
(applyTags === 'replace' && tags.indexOf(id) === -1);
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={tag.id}
|
||||
title={
|
||||
removeTag
|
||||
? translate('RemovingTag')
|
||||
: translate('ExistingTag')
|
||||
}
|
||||
kind={removeTag ? kinds.INVERSE : kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
{tag.label}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
|
||||
{(applyTags === 'add' || applyTags === 'replace') &&
|
||||
tags.map((id) => {
|
||||
const tag = tagList.find((t) => t.id === id);
|
||||
|
||||
if (!tag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (applicationsTags.indexOf(id) > -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={tag.id}
|
||||
title={translate('AddingTag')}
|
||||
kind={kinds.SUCCESS}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
{tag.label}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
|
||||
{translate('Apply')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagsModalContent;
|
|
@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props';
|
|||
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
|
||||
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
|
||||
|
||||
class DownloadClientSettings extends Component {
|
||||
|
||||
|
@ -21,7 +22,8 @@ class DownloadClientSettings extends Component {
|
|||
|
||||
this.state = {
|
||||
isSaving: false,
|
||||
hasPendingChanges: false
|
||||
hasPendingChanges: false,
|
||||
isManageDownloadClientsOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -36,6 +38,14 @@ class DownloadClientSettings extends Component {
|
|||
this.setState(payload);
|
||||
};
|
||||
|
||||
onManageDownloadClientsPress = () => {
|
||||
this.setState({ isManageDownloadClientsOpen: true });
|
||||
};
|
||||
|
||||
onManageDownloadClientsModalClose = () => {
|
||||
this.setState({ isManageDownloadClientsOpen: false });
|
||||
};
|
||||
|
||||
onSavePress = () => {
|
||||
if (this._saveCallback) {
|
||||
this._saveCallback();
|
||||
|
@ -53,7 +63,8 @@ class DownloadClientSettings extends Component {
|
|||
|
||||
const {
|
||||
isSaving,
|
||||
hasPendingChanges
|
||||
hasPendingChanges,
|
||||
isManageDownloadClientsOpen
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
|
@ -71,6 +82,12 @@ class DownloadClientSettings extends Component {
|
|||
isSpinning={isTestingAll}
|
||||
onPress={dispatchTestAllDownloadClients}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('ManageDownloadClients')}
|
||||
iconName={icons.MANAGE}
|
||||
onPress={this.onManageDownloadClientsPress}
|
||||
/>
|
||||
</Fragment>
|
||||
}
|
||||
onSavePress={this.onSavePress}
|
||||
|
@ -78,6 +95,11 @@ class DownloadClientSettings extends Component {
|
|||
|
||||
<PageContentBody>
|
||||
<DownloadClientsConnector />
|
||||
|
||||
<ManageDownloadClientsModal
|
||||
isOpen={isManageDownloadClientsOpen}
|
||||
onModalClose={this.onManageDownloadClientsModalClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
|
|
|
@ -89,7 +89,7 @@ class DownloadClient extends Component {
|
|||
kind={kinds.DISABLED}
|
||||
outline={true}
|
||||
>
|
||||
{translate('PrioritySettings', [priority])}
|
||||
{translate('Priority')}: {priority}
|
||||
</Label>
|
||||
}
|
||||
</div>
|
||||
|
|
|
@ -159,7 +159,7 @@ class EditDownloadClientModalContent extends Component {
|
|||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="priority"
|
||||
helpText={translate('PriorityHelpText')}
|
||||
helpText={translate('DownloadClientPriorityHelpText')}
|
||||
min={1}
|
||||
max={50}
|
||||
{...priority}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ManageDownloadClientsEditModalContent from './ManageDownloadClientsEditModalContent';
|
||||
|
||||
interface ManageDownloadClientsEditModalProps {
|
||||
isOpen: boolean;
|
||||
downloadClientIds: number[];
|
||||
onSavePress(payload: object): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function ManageDownloadClientsEditModal(
|
||||
props: ManageDownloadClientsEditModalProps
|
||||
) {
|
||||
const { isOpen, downloadClientIds, onSavePress, onModalClose } = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<ManageDownloadClientsEditModalContent
|
||||
downloadClientIds={downloadClientIds}
|
||||
onSavePress={onSavePress}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageDownloadClientsEditModal;
|
|
@ -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,129 @@
|
|||
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 translate from 'Utilities/String/translate';
|
||||
import styles from './ManageDownloadClientsEditModalContent.css';
|
||||
|
||||
interface SavePayload {
|
||||
enable?: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
interface ManageDownloadClientsEditModalContentProps {
|
||||
downloadClientIds: number[];
|
||||
onSavePress(payload: object): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
const NO_CHANGE = 'noChange';
|
||||
|
||||
const enableOptions = [
|
||||
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
|
||||
{ key: 'enabled', value: translate('Enabled') },
|
||||
{ key: 'disabled', value: translate('Disabled') },
|
||||
];
|
||||
|
||||
function ManageDownloadClientsEditModalContent(
|
||||
props: ManageDownloadClientsEditModalContentProps
|
||||
) {
|
||||
const { downloadClientIds, onSavePress, onModalClose } = props;
|
||||
|
||||
const [enable, setEnable] = useState(NO_CHANGE);
|
||||
const [priority, setPriority] = useState<null | string | number>(null);
|
||||
|
||||
const save = useCallback(() => {
|
||||
let hasChanges = false;
|
||||
const payload: SavePayload = {};
|
||||
|
||||
if (enable !== NO_CHANGE) {
|
||||
hasChanges = true;
|
||||
payload.enable = enable === 'enabled';
|
||||
}
|
||||
|
||||
if (priority !== null) {
|
||||
hasChanges = true;
|
||||
payload.priority = priority as number;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
onSavePress(payload);
|
||||
}
|
||||
|
||||
onModalClose();
|
||||
}, [enable, priority, onSavePress, onModalClose]);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
({ name, value }: { name: string; value: string }) => {
|
||||
switch (name) {
|
||||
case 'enable':
|
||||
setEnable(value);
|
||||
break;
|
||||
case 'priority':
|
||||
setPriority(value);
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
`EditDownloadClientsModalContent Unknown Input: '${name}'`
|
||||
);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const selectedCount = downloadClientIds.length;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('EditSelectedDownloadClients')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Enabled')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="enable"
|
||||
value={enable}
|
||||
values={enableOptions}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ClientPriority')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="priority"
|
||||
value={priority}
|
||||
min={1}
|
||||
max={50}
|
||||
helpText={translate('DownloadClientPriorityHelpText')}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<div className={styles.selected}>
|
||||
{translate('CountDownloadClientsSelected', [selectedCount])}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button onPress={save}>{translate('ApplyChanges')}</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageDownloadClientsEditModalContent;
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ManageDownloadClientsModalContent from './ManageDownloadClientsModalContent';
|
||||
|
||||
interface ManageDownloadClientsModalProps {
|
||||
isOpen: boolean;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function ManageDownloadClientsModal(props: ManageDownloadClientsModalProps) {
|
||||
const { isOpen, onModalClose } = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<ManageDownloadClientsModalContent onModalClose={onModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageDownloadClientsModal;
|
|
@ -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,240 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { DownloadClientAppState } 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 {
|
||||
bulkDeleteDownloadClients,
|
||||
bulkEditDownloadClients,
|
||||
} 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 ManageDownloadClientsEditModal from './Edit/ManageDownloadClientsEditModal';
|
||||
import ManageDownloadClientsModalRow from './ManageDownloadClientsModalRow';
|
||||
import styles from './ManageDownloadClientsModalContent.css';
|
||||
|
||||
// TODO: This feels janky to do, but not sure of a better way currently
|
||||
type OnSelectedChangeCallback = React.ComponentProps<
|
||||
typeof ManageDownloadClientsModalRow
|
||||
>['onSelectedChange'];
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
name: 'name',
|
||||
label: translate('Name'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'implementation',
|
||||
label: translate('Implementation'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'enable',
|
||||
label: translate('Enabled'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'priority',
|
||||
label: translate('ClientPriority'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
interface ManageDownloadClientsModalContentProps {
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function ManageDownloadClientsModalContent(
|
||||
props: ManageDownloadClientsModalContentProps
|
||||
) {
|
||||
const { onModalClose } = props;
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
isDeleting,
|
||||
isSaving,
|
||||
error,
|
||||
items,
|
||||
}: DownloadClientAppState = useSelector(
|
||||
createClientSideCollectionSelector('settings.downloadClients')
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = 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(bulkDeleteDownloadClients({ ids: selectedIds }));
|
||||
setIsDeleteModalOpen(false);
|
||||
}, [selectedIds, dispatch]);
|
||||
|
||||
const onSavePress = useCallback(
|
||||
(payload: object) => {
|
||||
setIsEditModalOpen(false);
|
||||
|
||||
dispatch(
|
||||
bulkEditDownloadClients({
|
||||
ids: selectedIds,
|
||||
...payload,
|
||||
})
|
||||
);
|
||||
},
|
||||
[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('ManageDownloadClients')}</ModalHeader>
|
||||
<ModalBody>
|
||||
{isFetching ? <LoadingIndicator /> : null}
|
||||
|
||||
{error ? <div>{errorMessage}</div> : null}
|
||||
|
||||
{isPopulated && !error && !items.length && (
|
||||
<Alert kind={kinds.INFO}>{translate('NoDownloadClientsFound')}</Alert>
|
||||
)}
|
||||
|
||||
{isPopulated && !!items.length && !isFetching && !isFetching ? (
|
||||
<Table
|
||||
columns={COLUMNS}
|
||||
horizontalScroll={true}
|
||||
selectAll={true}
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
onSelectAllChange={onSelectAllChange}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<ManageDownloadClientsModalRow
|
||||
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>
|
||||
</div>
|
||||
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
|
||||
<ManageDownloadClientsEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onModalClose={onEditModalClose}
|
||||
onSavePress={onSavePress}
|
||||
downloadClientIds={selectedIds}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteSelectedDownloadClients')}
|
||||
message={translate('DeleteSelectedDownloadClientsMessageText', [
|
||||
selectedIds.length,
|
||||
])}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={onDeleteModalClose}
|
||||
/>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageDownloadClientsModalContent;
|
|
@ -0,0 +1,8 @@
|
|||
.name,
|
||||
.enable,
|
||||
.priority,
|
||||
.implementation {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
word-break: break-all;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'enable': string;
|
||||
'implementation': string;
|
||||
'name': string;
|
||||
'priority': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
|
@ -0,0 +1,70 @@
|
|||
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 { kinds } from 'Helpers/Props';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ManageDownloadClientsModalRow.css';
|
||||
|
||||
interface ManageDownloadClientsModalRowProps {
|
||||
id: number;
|
||||
name: string;
|
||||
enable: boolean;
|
||||
priority: number;
|
||||
implementation: string;
|
||||
columns: Column[];
|
||||
isSelected?: boolean;
|
||||
onSelectedChange(result: SelectStateInputProps): void;
|
||||
}
|
||||
|
||||
function ManageDownloadClientsModalRow(
|
||||
props: ManageDownloadClientsModalRowProps
|
||||
) {
|
||||
const {
|
||||
id,
|
||||
isSelected,
|
||||
name,
|
||||
enable,
|
||||
priority,
|
||||
implementation,
|
||||
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.enable}>
|
||||
<Label kind={enable ? kinds.SUCCESS : kinds.DISABLED} outline={!enable}>
|
||||
{enable ? translate('Yes') : translate('No')}
|
||||
</Label>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.priority}>{priority}</TableRowCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageDownloadClientsModalRow;
|
|
@ -0,0 +1,54 @@
|
|||
import { batchActions } from 'redux-batched-actions';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import { set, updateItem } from '../baseActions';
|
||||
|
||||
function createBulkEditItemHandler(section, url) {
|
||||
return function(getState, payload, dispatch) {
|
||||
|
||||
dispatch(set({ section, isSaving: true }));
|
||||
|
||||
const ajaxOptions = {
|
||||
url: `${url}`,
|
||||
method: 'PUT',
|
||||
data: JSON.stringify(payload),
|
||||
dataType: 'json'
|
||||
};
|
||||
|
||||
const promise = createAjaxRequest(ajaxOptions).request;
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(batchActions([
|
||||
set({
|
||||
section,
|
||||
isSaving: false,
|
||||
saveError: null
|
||||
}),
|
||||
|
||||
...data.map((provider) => {
|
||||
|
||||
const {
|
||||
...propsToUpdate
|
||||
} = provider;
|
||||
|
||||
return updateItem({
|
||||
id: provider.id,
|
||||
section,
|
||||
...propsToUpdate
|
||||
});
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section,
|
||||
isSaving: false,
|
||||
saveError: xhr
|
||||
}));
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
export default createBulkEditItemHandler;
|
|
@ -0,0 +1,48 @@
|
|||
import { batchActions } from 'redux-batched-actions';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import { removeItem, set } from '../baseActions';
|
||||
|
||||
function createBulkRemoveItemHandler(section, url) {
|
||||
return function(getState, payload, dispatch) {
|
||||
const {
|
||||
ids
|
||||
} = payload;
|
||||
|
||||
dispatch(set({ section, isDeleting: true }));
|
||||
|
||||
const ajaxOptions = {
|
||||
url: `${url}`,
|
||||
method: 'DELETE',
|
||||
data: JSON.stringify(payload),
|
||||
dataType: 'json'
|
||||
};
|
||||
|
||||
const promise = createAjaxRequest(ajaxOptions).request;
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(batchActions([
|
||||
set({
|
||||
section,
|
||||
isDeleting: false,
|
||||
deleteError: null
|
||||
}),
|
||||
|
||||
...ids.map((id) => {
|
||||
return removeItem({ section, id });
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section,
|
||||
isDeleting: false,
|
||||
deleteError: xhr
|
||||
}));
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
export default createBulkRemoveItemHandler;
|
|
@ -52,14 +52,14 @@ export default {
|
|||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
isDeleting: false,
|
||||
deleteError: null,
|
||||
isSchemaFetching: false,
|
||||
isSchemaPopulated: false,
|
||||
schemaError: null,
|
||||
schema: {},
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
isDeleting: false,
|
||||
deleteError: null,
|
||||
items: [],
|
||||
pendingChanges: {}
|
||||
},
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
|
||||
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
|
||||
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
|
||||
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
|
||||
|
@ -29,6 +31,8 @@ export const DELETE_APPLICATION = 'settings/applications/deleteApplication';
|
|||
export const TEST_APPLICATION = 'settings/applications/testApplication';
|
||||
export const CANCEL_TEST_APPLICATION = 'settings/applications/cancelTestApplication';
|
||||
export const TEST_ALL_APPLICATIONS = 'indexers/testAllApplications';
|
||||
export const BULK_EDIT_APPLICATIONS = 'settings/applications/bulkEditApplications';
|
||||
export const BULK_DELETE_APPLICATIONS = 'settings/applications/bulkDeleteApplications';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
@ -43,6 +47,8 @@ export const deleteApplication = createThunk(DELETE_APPLICATION);
|
|||
export const testApplication = createThunk(TEST_APPLICATION);
|
||||
export const cancelTestApplication = createThunk(CANCEL_TEST_APPLICATION);
|
||||
export const testAllApplications = createThunk(TEST_ALL_APPLICATIONS);
|
||||
export const bulkEditApplications = createThunk(BULK_EDIT_APPLICATIONS);
|
||||
export const bulkDeleteApplications = createThunk(BULK_DELETE_APPLICATIONS);
|
||||
|
||||
export const setApplicationValue = createAction(SET_APPLICATION_VALUE, (payload) => {
|
||||
return {
|
||||
|
@ -77,6 +83,8 @@ export default {
|
|||
selectedSchema: {},
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
isDeleting: false,
|
||||
deleteError: null,
|
||||
isTesting: false,
|
||||
isTestingAll: false,
|
||||
items: [],
|
||||
|
@ -95,7 +103,9 @@ export default {
|
|||
[DELETE_APPLICATION]: createRemoveItemHandler(section, '/applications'),
|
||||
[TEST_APPLICATION]: createTestProviderHandler(section, '/applications'),
|
||||
[CANCEL_TEST_APPLICATION]: createCancelTestProviderHandler(section),
|
||||
[TEST_ALL_APPLICATIONS]: createTestAllProvidersHandler(section, '/applications')
|
||||
[TEST_ALL_APPLICATIONS]: createTestAllProvidersHandler(section, '/applications'),
|
||||
[BULK_EDIT_APPLICATIONS]: createBulkEditItemHandler(section, '/applications/bulk'),
|
||||
[BULK_DELETE_APPLICATIONS]: createBulkRemoveItemHandler(section, '/applications/bulk')
|
||||
},
|
||||
|
||||
//
|
||||
|
|
|
@ -75,6 +75,8 @@ export default {
|
|||
selectedSchema: {},
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
isDeleting: false,
|
||||
deleteError: null,
|
||||
items: [],
|
||||
pendingChanges: {}
|
||||
},
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
|
||||
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
|
||||
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
|
||||
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
|
||||
|
@ -30,6 +32,8 @@ export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadCl
|
|||
export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient';
|
||||
export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient';
|
||||
export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients';
|
||||
export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients';
|
||||
export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
@ -44,6 +48,8 @@ export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT);
|
|||
export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT);
|
||||
export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT);
|
||||
export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS);
|
||||
export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS);
|
||||
export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS);
|
||||
|
||||
export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
|
||||
return {
|
||||
|
@ -78,6 +84,8 @@ export default {
|
|||
selectedSchema: {},
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
isDeleting: false,
|
||||
deleteError: null,
|
||||
isTesting: false,
|
||||
isTestingAll: false,
|
||||
items: [],
|
||||
|
@ -120,7 +128,9 @@ export default {
|
|||
},
|
||||
|
||||
[CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section),
|
||||
[TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient')
|
||||
[TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'),
|
||||
[BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk'),
|
||||
[BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk')
|
||||
},
|
||||
|
||||
//
|
||||
|
|
|
@ -74,6 +74,8 @@ export default {
|
|||
selectedSchema: {},
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
isDeleting: false,
|
||||
deleteError: null,
|
||||
isTesting: false,
|
||||
items: [],
|
||||
pendingChanges: {}
|
||||
|
|
|
@ -74,6 +74,8 @@ export default {
|
|||
selectedSchema: {},
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
isDeleting: false,
|
||||
deleteError: null,
|
||||
isTesting: false,
|
||||
items: [],
|
||||
pendingChanges: {}
|
||||
|
|
|
@ -13,6 +13,8 @@ import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate';
|
|||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
import updateSectionState from 'Utilities/State/updateSectionState';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import createBulkEditItemHandler from './Creators/createBulkEditItemHandler';
|
||||
import createBulkRemoveItemHandler from './Creators/createBulkRemoveItemHandler';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
|
||||
|
||||
|
@ -95,6 +97,8 @@ export const DELETE_INDEXER = 'indexers/deleteIndexer';
|
|||
export const TEST_INDEXER = 'indexers/testIndexer';
|
||||
export const CANCEL_TEST_INDEXER = 'indexers/cancelTestIndexer';
|
||||
export const TEST_ALL_INDEXERS = 'indexers/testAllIndexers';
|
||||
export const BULK_EDIT_INDEXERS = 'indexers/bulkEditIndexers';
|
||||
export const BULK_DELETE_INDEXERS = 'indexers/bulkDeleteIndexers';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
@ -111,6 +115,8 @@ export const deleteIndexer = createThunk(DELETE_INDEXER);
|
|||
export const testIndexer = createThunk(TEST_INDEXER);
|
||||
export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
|
||||
export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
|
||||
export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS);
|
||||
export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
|
||||
|
||||
export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
|
||||
return {
|
||||
|
@ -163,7 +169,9 @@ export const actionHandlers = handleThunks({
|
|||
[DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'),
|
||||
[TEST_INDEXER]: createTestProviderHandler(section, '/indexer'),
|
||||
[CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section),
|
||||
[TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer')
|
||||
[TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'),
|
||||
[BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk'),
|
||||
[BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk')
|
||||
});
|
||||
|
||||
//
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
import { batchActions } from 'redux-batched-actions';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { removeItem, set, updateItem } from './baseActions';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
|
||||
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
|
||||
|
@ -90,6 +86,30 @@ export const defaultState = {
|
|||
isSortable: false,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'minimumSeeders',
|
||||
label: translate('MinimumSeeders'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'seedRatio',
|
||||
label: translate('SeedRatio'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'seedTime',
|
||||
label: translate('SeedTime'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'packSeedTime',
|
||||
label: translate('PackSeedTime'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: translate('Tags'),
|
||||
|
@ -186,8 +206,6 @@ export const SET_INDEXER_SORT = 'indexerIndex/setIndexerSort';
|
|||
export const SET_INDEXER_FILTER = 'indexerIndex/setIndexerFilter';
|
||||
export const SET_INDEXER_VIEW = 'indexerIndex/setIndexerView';
|
||||
export const SET_INDEXER_TABLE_OPTION = 'indexerIndex/setIndexerTableOption';
|
||||
export const SAVE_INDEXER_EDITOR = 'indexerIndex/saveIndexerEditor';
|
||||
export const BULK_DELETE_INDEXERS = 'indexerIndex/bulkDeleteIndexers';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
@ -196,89 +214,6 @@ export const setIndexerSort = createAction(SET_INDEXER_SORT);
|
|||
export const setIndexerFilter = createAction(SET_INDEXER_FILTER);
|
||||
export const setIndexerView = createAction(SET_INDEXER_VIEW);
|
||||
export const setIndexerTableOption = createAction(SET_INDEXER_TABLE_OPTION);
|
||||
export const saveIndexerEditor = createThunk(SAVE_INDEXER_EDITOR);
|
||||
export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
|
||||
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
export const actionHandlers = handleThunks({
|
||||
[SAVE_INDEXER_EDITOR]: function(getState, payload, dispatch) {
|
||||
dispatch(set({
|
||||
section,
|
||||
isSaving: true
|
||||
}));
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: '/indexer/editor',
|
||||
method: 'PUT',
|
||||
data: JSON.stringify(payload),
|
||||
dataType: 'json'
|
||||
}).request;
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(batchActions([
|
||||
...data.map((indexer) => {
|
||||
return updateItem({
|
||||
id: indexer.id,
|
||||
section: 'indexers',
|
||||
...indexer
|
||||
});
|
||||
}),
|
||||
|
||||
set({
|
||||
section,
|
||||
isSaving: false,
|
||||
saveError: null
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section,
|
||||
isSaving: false,
|
||||
saveError: xhr
|
||||
}));
|
||||
});
|
||||
},
|
||||
|
||||
[BULK_DELETE_INDEXERS]: function(getState, payload, dispatch) {
|
||||
dispatch(set({
|
||||
section,
|
||||
isDeleting: true
|
||||
}));
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: '/indexer/editor',
|
||||
method: 'DELETE',
|
||||
data: JSON.stringify(payload),
|
||||
dataType: 'json'
|
||||
}).request;
|
||||
|
||||
promise.done(() => {
|
||||
dispatch(batchActions([
|
||||
...payload.indexerIds.map((id) => {
|
||||
return removeItem({ section: 'indexers', id });
|
||||
}),
|
||||
|
||||
set({
|
||||
section,
|
||||
isDeleting: false,
|
||||
deleteError: null
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(set({
|
||||
section,
|
||||
isDeleting: false,
|
||||
deleteError: xhr
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// Reducers
|
||||
|
|
19
frontend/src/typings/Application.ts
Normal file
19
frontend/src/typings/Application.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
export enum ApplicationSyncLevel {
|
||||
Disabled = 'disabled',
|
||||
AddOnly = 'addOnly',
|
||||
FullSync = 'fullSync',
|
||||
}
|
||||
|
||||
interface Application extends ModelBase {
|
||||
name: string;
|
||||
syncLevel: ApplicationSyncLevel;
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
export default Application;
|
26
frontend/src/typings/DownloadClient.ts
Normal file
26
frontend/src/typings/DownloadClient.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
export interface Field {
|
||||
order: number;
|
||||
name: string;
|
||||
label: string;
|
||||
value: boolean | number | string;
|
||||
type: string;
|
||||
advanced: boolean;
|
||||
privacy: string;
|
||||
}
|
||||
|
||||
interface DownloadClient extends ModelBase {
|
||||
enable: boolean;
|
||||
protocol: string;
|
||||
priority: number;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
export default DownloadClient;
|
27
frontend/src/typings/Indexer.ts
Normal file
27
frontend/src/typings/Indexer.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
export interface Field {
|
||||
order: number;
|
||||
name: string;
|
||||
label: string;
|
||||
value: boolean | number | string;
|
||||
type: string;
|
||||
advanced: boolean;
|
||||
privacy: string;
|
||||
}
|
||||
|
||||
interface Indexer extends ModelBase {
|
||||
enable: boolean;
|
||||
appProfileId: number;
|
||||
protocol: string;
|
||||
priority: number;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
export default Indexer;
|
24
frontend/src/typings/Notification.ts
Normal file
24
frontend/src/typings/Notification.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
export interface Field {
|
||||
order: number;
|
||||
name: string;
|
||||
label: string;
|
||||
value: boolean | number | string;
|
||||
type: string;
|
||||
advanced: boolean;
|
||||
privacy: string;
|
||||
}
|
||||
|
||||
interface Notification extends ModelBase {
|
||||
enable: boolean;
|
||||
name: string;
|
||||
fields: Field[];
|
||||
implementationName: string;
|
||||
implementation: string;
|
||||
configContract: string;
|
||||
infoLink: string;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
export default Notification;
|
6
frontend/src/typings/UiSettings.ts
Normal file
6
frontend/src/typings/UiSettings.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export interface UiSettings {
|
||||
showRelativeDates: boolean;
|
||||
shortDateFormat: string;
|
||||
longDateFormat: string;
|
||||
timeFormat: string;
|
||||
}
|
5
frontend/src/typings/props.ts
Normal file
5
frontend/src/typings/props.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface SelectStateInputProps {
|
||||
id: number;
|
||||
value: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
|
@ -48,6 +48,8 @@
|
|||
"ApplyTagsHelpTexts3": "Remove: Remove the entered tags",
|
||||
"ApplyTagsHelpTexts4": "Replace: Replace the tags with the entered tags (enter no tags to clear all tags)",
|
||||
"Apps": "Apps",
|
||||
"AppsMinimumSeeders": "Apps Minimum Seeders",
|
||||
"AppsMinimumSeedersHelpText": "Minimum seeders required by the Applications for the indexer to grab, empty is Sync profile's default",
|
||||
"AreYouSureYouWantToDeleteCategory": "Are you sure you want to delete mapped category?",
|
||||
"AreYouSureYouWantToResetYourAPIKey": "Are you sure you want to reset your API Key?",
|
||||
"Artist": "Artist",
|
||||
|
@ -102,7 +104,9 @@
|
|||
"ConnectionLostMessage": "Prowlarr has lost its connection to the backend and will need to be reloaded to restore functionality.",
|
||||
"Connections": "Connections",
|
||||
"CouldNotConnectSignalR": "Could not connect to SignalR, UI won't update",
|
||||
"CountIndexersSelected": "{0} indexers selected",
|
||||
"CountApplicationsSelected": "{0} application(s) selected",
|
||||
"CountDownloadClientsSelected": "{0} download client(s) selected",
|
||||
"CountIndexersSelected": "{0} indexer(s) selected",
|
||||
"Custom": "Custom",
|
||||
"CustomFilters": "Custom Filters",
|
||||
"DBMigration": "DB Migration",
|
||||
|
@ -122,6 +126,10 @@
|
|||
"DeleteIndexerProxyMessageText": "Are you sure you want to delete the proxy '{0}'?",
|
||||
"DeleteNotification": "Delete Notification",
|
||||
"DeleteNotificationMessageText": "Are you sure you want to delete the notification '{0}'?",
|
||||
"DeleteSelectedApplications": "Delete Selected Applications",
|
||||
"DeleteSelectedApplicationsMessageText": "Are you sure you want to delete {0} selected application(s)?",
|
||||
"DeleteSelectedDownloadClients": "Delete Download Client(s)",
|
||||
"DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {0} selected download client(s)?",
|
||||
"DeleteSelectedIndexer": "Delete Selected Indexer",
|
||||
"DeleteSelectedIndexers": "Delete Selected Indexers",
|
||||
"DeleteSelectedIndexersMessageText": "Are you sure you want to delete {0} selected indexer(s)?",
|
||||
|
@ -137,6 +145,7 @@
|
|||
"Donations": "Donations",
|
||||
"DownloadClient": "Download Client",
|
||||
"DownloadClientCategory": "Download Client Category",
|
||||
"DownloadClientPriorityHelpText": "Prioritize multiple Download Clients. Round-Robin is used for clients with the same priority.",
|
||||
"DownloadClientSettings": "Download Client Settings",
|
||||
"DownloadClientStatusCheckAllClientMessage": "All download clients are unavailable due to failures",
|
||||
"DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}",
|
||||
|
@ -145,6 +154,7 @@
|
|||
"Duration": "Duration",
|
||||
"Edit": "Edit",
|
||||
"EditIndexer": "Edit Indexer",
|
||||
"EditSelectedDownloadClients": "Edit Selected Download Clients",
|
||||
"EditSelectedIndexers": "Edit Selected Indexers",
|
||||
"EditSyncProfile": "Edit Sync Profile",
|
||||
"ElapsedTime": "Elapsed Time",
|
||||
|
@ -204,6 +214,7 @@
|
|||
"Id": "Id",
|
||||
"IgnoredAddresses": "Ignored Addresses",
|
||||
"IllRestartLater": "I'll restart later",
|
||||
"Implementation": "Implementation",
|
||||
"IncludeHealthWarningsHelpText": "Include Health Warnings",
|
||||
"IncludeManualGrabsHelpText": "Include Manual Grabs made within Prowlarr",
|
||||
"Indexer": "Indexer",
|
||||
|
@ -263,6 +274,8 @@
|
|||
"Logs": "Logs",
|
||||
"MIA": "MIA",
|
||||
"MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details",
|
||||
"ManageApplications": "Manage Applications",
|
||||
"ManageDownloadClients": "Manage Download Clients",
|
||||
"Manual": "Manual",
|
||||
"MappedCategories": "Mapped Categories",
|
||||
"MappedDrivesRunningAsService": "Mapped network drives are not available when running as a Windows Service. Please see the FAQ for more information",
|
||||
|
@ -287,6 +300,7 @@
|
|||
"NoBackupsAreAvailable": "No backups are available",
|
||||
"NoChange": "No Change",
|
||||
"NoChanges": "No Changes",
|
||||
"NoDownloadClientsFound": "No download clients found",
|
||||
"NoHistoryFound": "No history found",
|
||||
"NoIndexersFound": "No indexers found",
|
||||
"NoLeaveIt": "No, Leave It",
|
||||
|
@ -313,6 +327,8 @@
|
|||
"OpenBrowserOnStart": "Open browser on start",
|
||||
"OpenThisModal": "Open This Modal",
|
||||
"Options": "Options",
|
||||
"PackSeedTime": "Pack Seed Time",
|
||||
"PackSeedTimeHelpText": "The time a pack (season or discography) torrent should be seeded before stopping, empty is app's default",
|
||||
"PackageVersion": "Package Version",
|
||||
"PageSize": "Page Size",
|
||||
"PageSizeHelpText": "Number of items to show on each page",
|
||||
|
@ -326,8 +342,6 @@
|
|||
"PortNumber": "Port Number",
|
||||
"Presets": "Presets",
|
||||
"Priority": "Priority",
|
||||
"PriorityHelpText": "Prioritize multiple Download Clients. Round-Robin is used for clients with the same priority.",
|
||||
"PrioritySettings": "Priority",
|
||||
"Privacy": "Privacy",
|
||||
"Private": "Private",
|
||||
"Protocol": "Protocol",
|
||||
|
@ -398,6 +412,10 @@
|
|||
"SearchTypes": "Search Types",
|
||||
"Season": "Season",
|
||||
"Security": "Security",
|
||||
"SeedRatio": "Seed Ratio",
|
||||
"SeedRatioHelpText": "The ratio a torrent should reach before stopping, empty is app's default",
|
||||
"SeedTime": "Seed Time",
|
||||
"SeedTimeHelpText": "The time a torrent should be seeded before stopping, empty is app's default",
|
||||
"Seeders": "Seeders",
|
||||
"SelectAll": "Select All",
|
||||
"SelectIndexers": "Select Indexers",
|
||||
|
|
|
@ -12,9 +12,10 @@ namespace NzbDrone.Core.ThingiProvider
|
|||
bool Exists(int id);
|
||||
TProviderDefinition Find(int id);
|
||||
TProviderDefinition Get(int id);
|
||||
IEnumerable<TProviderDefinition> Get(IEnumerable<int> ids);
|
||||
TProviderDefinition Create(TProviderDefinition definition);
|
||||
void Update(TProviderDefinition definition);
|
||||
void Update(IEnumerable<TProviderDefinition> definitions);
|
||||
IEnumerable<TProviderDefinition> Update(IEnumerable<TProviderDefinition> definitions);
|
||||
void Delete(int id);
|
||||
void Delete(IEnumerable<int> ids);
|
||||
IEnumerable<TProviderDefinition> GetDefaultDefinitions();
|
||||
|
|
|
@ -101,6 +101,11 @@ namespace NzbDrone.Core.ThingiProvider
|
|||
return _providerRepository.Get(id);
|
||||
}
|
||||
|
||||
public IEnumerable<TProviderDefinition> Get(IEnumerable<int> ids)
|
||||
{
|
||||
return _providerRepository.Get(ids);
|
||||
}
|
||||
|
||||
public TProviderDefinition Find(int id)
|
||||
{
|
||||
return _providerRepository.Find(id);
|
||||
|
@ -120,10 +125,12 @@ namespace NzbDrone.Core.ThingiProvider
|
|||
_eventAggregator.PublishEvent(new ProviderUpdatedEvent<TProvider>(updatedDef));
|
||||
}
|
||||
|
||||
public virtual void Update(IEnumerable<TProviderDefinition> definitions)
|
||||
public virtual IEnumerable<TProviderDefinition> Update(IEnumerable<TProviderDefinition> definitions)
|
||||
{
|
||||
_providerRepository.UpdateMany(definitions.ToList());
|
||||
_eventAggregator.PublishEvent(new ProviderBulkUpdatedEvent<TProvider>(definitions));
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
public void Delete(int id)
|
||||
|
|
28
src/Prowlarr.Api.V1/Applications/ApplicationBulkResource.cs
Normal file
28
src/Prowlarr.Api.V1/Applications/ApplicationBulkResource.cs
Normal file
|
@ -0,0 +1,28 @@
|
|||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Applications;
|
||||
|
||||
namespace Prowlarr.Api.V1.Applications
|
||||
{
|
||||
public class ApplicationBulkResource : ProviderBulkResource<ApplicationBulkResource>
|
||||
{
|
||||
public ApplicationSyncLevel? SyncLevel { get; set; }
|
||||
}
|
||||
|
||||
public class ApplicationBulkResourceMapper : ProviderBulkResourceMapper<ApplicationBulkResource, ApplicationDefinition>
|
||||
{
|
||||
public override List<ApplicationDefinition> UpdateModel(ApplicationBulkResource resource, List<ApplicationDefinition> existingDefinitions)
|
||||
{
|
||||
if (resource == null)
|
||||
{
|
||||
return new List<ApplicationDefinition>();
|
||||
}
|
||||
|
||||
existingDefinitions.ForEach(existing =>
|
||||
{
|
||||
existing.SyncLevel = resource.SyncLevel ?? existing.SyncLevel;
|
||||
});
|
||||
|
||||
return existingDefinitions;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,15 +1,16 @@
|
|||
using NzbDrone.Core.Applications;
|
||||
using Prowlarr.Http;
|
||||
|
||||
namespace Prowlarr.Api.V1.Application
|
||||
namespace Prowlarr.Api.V1.Applications
|
||||
{
|
||||
[V1ApiController("applications")]
|
||||
public class ApplicationController : ProviderControllerBase<ApplicationResource, IApplication, ApplicationDefinition>
|
||||
public class ApplicationController : ProviderControllerBase<ApplicationResource, ApplicationBulkResource, IApplication, ApplicationDefinition>
|
||||
{
|
||||
public static readonly ApplicationResourceMapper ResourceMapper = new ApplicationResourceMapper();
|
||||
public static readonly ApplicationResourceMapper ResourceMapper = new ();
|
||||
public static readonly ApplicationBulkResourceMapper BulkResourceMapper = new ();
|
||||
|
||||
public ApplicationController(ApplicationFactory applicationsFactory)
|
||||
: base(applicationsFactory, "applications", ResourceMapper)
|
||||
: base(applicationsFactory, "applications", ResourceMapper, BulkResourceMapper)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
using NzbDrone.Core.Applications;
|
||||
|
||||
namespace Prowlarr.Api.V1.Application
|
||||
namespace Prowlarr.Api.V1.Applications
|
||||
{
|
||||
public class ApplicationResource : ProviderResource<ApplicationResource>
|
||||
{
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Download;
|
||||
|
||||
namespace Prowlarr.Api.V1.DownloadClient
|
||||
{
|
||||
public class DownloadClientBulkResource : ProviderBulkResource<DownloadClientBulkResource>
|
||||
{
|
||||
public bool? Enable { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
}
|
||||
|
||||
public class DownloadClientBulkResourceMapper : ProviderBulkResourceMapper<DownloadClientBulkResource, DownloadClientDefinition>
|
||||
{
|
||||
public override List<DownloadClientDefinition> UpdateModel(DownloadClientBulkResource resource, List<DownloadClientDefinition> existingDefinitions)
|
||||
{
|
||||
if (resource == null)
|
||||
{
|
||||
return new List<DownloadClientDefinition>();
|
||||
}
|
||||
|
||||
existingDefinitions.ForEach(existing =>
|
||||
{
|
||||
existing.Enable = resource.Enable ?? existing.Enable;
|
||||
existing.Priority = resource.Priority ?? existing.Priority;
|
||||
});
|
||||
|
||||
return existingDefinitions;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,12 +4,13 @@ using Prowlarr.Http;
|
|||
namespace Prowlarr.Api.V1.DownloadClient
|
||||
{
|
||||
[V1ApiController]
|
||||
public class DownloadClientController : ProviderControllerBase<DownloadClientResource, IDownloadClient, DownloadClientDefinition>
|
||||
public class DownloadClientController : ProviderControllerBase<DownloadClientResource, DownloadClientBulkResource, IDownloadClient, DownloadClientDefinition>
|
||||
{
|
||||
public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper();
|
||||
public static readonly DownloadClientResourceMapper ResourceMapper = new ();
|
||||
public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new ();
|
||||
|
||||
public DownloadClientController(IDownloadClientFactory downloadClientFactory)
|
||||
: base(downloadClientFactory, "downloadclient", ResourceMapper)
|
||||
: base(downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
using NzbDrone.Core.IndexerProxies;
|
||||
|
||||
namespace Prowlarr.Api.V1.IndexerProxies
|
||||
{
|
||||
public class IndexerProxyBulkResource : ProviderBulkResource<IndexerProxyBulkResource>
|
||||
{
|
||||
}
|
||||
|
||||
public class IndexerProxyBulkResourceMapper : ProviderBulkResourceMapper<IndexerProxyBulkResource, IndexerProxyDefinition>
|
||||
{
|
||||
}
|
||||
}
|
|
@ -1,16 +1,31 @@
|
|||
using System;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Core.IndexerProxies;
|
||||
using Prowlarr.Http;
|
||||
|
||||
namespace Prowlarr.Api.V1.IndexerProxies
|
||||
{
|
||||
[V1ApiController]
|
||||
public class IndexerProxyController : ProviderControllerBase<IndexerProxyResource, IIndexerProxy, IndexerProxyDefinition>
|
||||
public class IndexerProxyController : ProviderControllerBase<IndexerProxyResource, IndexerProxyBulkResource, IIndexerProxy, IndexerProxyDefinition>
|
||||
{
|
||||
public static readonly IndexerProxyResourceMapper ResourceMapper = new IndexerProxyResourceMapper();
|
||||
public static readonly IndexerProxyResourceMapper ResourceMapper = new ();
|
||||
public static readonly IndexerProxyBulkResourceMapper BulkResourceMapper = new ();
|
||||
|
||||
public IndexerProxyController(IndexerProxyFactory notificationFactory)
|
||||
: base(notificationFactory, "indexerProxy", ResourceMapper)
|
||||
: base(notificationFactory, "indexerProxy", ResourceMapper, BulkResourceMapper)
|
||||
{
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public override ActionResult<IndexerProxyResource> UpdateProvider([FromBody] IndexerProxyBulkResource providerResource)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public override object DeleteProviders([FromBody] IndexerProxyBulkResource resource)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
44
src/Prowlarr.Api.V1/Indexers/IndexerBulkResource.cs
Normal file
44
src/Prowlarr.Api.V1/Indexers/IndexerBulkResource.cs
Normal file
|
@ -0,0 +1,44 @@
|
|||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Indexers;
|
||||
|
||||
namespace Prowlarr.Api.V1.Indexers
|
||||
{
|
||||
public class IndexerBulkResource : ProviderBulkResource<IndexerBulkResource>
|
||||
{
|
||||
public bool? Enable { get; set; }
|
||||
public int? AppProfileId { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
public int? MinimumSeeders { get; set; }
|
||||
public double? SeedRatio { get; set; }
|
||||
public int? SeedTime { get; set; }
|
||||
public int? PackSeedTime { get; set; }
|
||||
}
|
||||
|
||||
public class IndexerBulkResourceMapper : ProviderBulkResourceMapper<IndexerBulkResource, IndexerDefinition>
|
||||
{
|
||||
public override List<IndexerDefinition> UpdateModel(IndexerBulkResource resource, List<IndexerDefinition> existingDefinitions)
|
||||
{
|
||||
if (resource == null)
|
||||
{
|
||||
return new List<IndexerDefinition>();
|
||||
}
|
||||
|
||||
existingDefinitions.ForEach(existing =>
|
||||
{
|
||||
existing.Enable = resource.Enable ?? existing.Enable;
|
||||
existing.AppProfileId = resource.AppProfileId ?? existing.AppProfileId;
|
||||
existing.Priority = resource.Priority ?? existing.Priority;
|
||||
|
||||
if (existing.Protocol == DownloadProtocol.Torrent)
|
||||
{
|
||||
((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.AppMinimumSeeders = resource.MinimumSeeders ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.AppMinimumSeeders;
|
||||
((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedRatio = resource.SeedRatio ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedRatio;
|
||||
((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedTime = resource.SeedTime ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedTime;
|
||||
((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.PackSeedTime = resource.PackSeedTime ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.PackSeedTime;
|
||||
}
|
||||
});
|
||||
|
||||
return existingDefinitions;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,10 +4,10 @@ using Prowlarr.Http;
|
|||
namespace Prowlarr.Api.V1.Indexers
|
||||
{
|
||||
[V1ApiController]
|
||||
public class IndexerController : ProviderControllerBase<IndexerResource, IIndexer, IndexerDefinition>
|
||||
public class IndexerController : ProviderControllerBase<IndexerResource, IndexerBulkResource, IIndexer, IndexerDefinition>
|
||||
{
|
||||
public IndexerController(IndexerFactory indexerFactory, IndexerResourceMapper resourceMapper)
|
||||
: base(indexerFactory, "indexer", resourceMapper)
|
||||
public IndexerController(IndexerFactory indexerFactory, IndexerResourceMapper resourceMapper, IndexerBulkResourceMapper bulkResourceMapper)
|
||||
: base(indexerFactory, "indexer", resourceMapper, bulkResourceMapper)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,88 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Messaging.Commands;
|
||||
using Prowlarr.Http;
|
||||
|
||||
namespace Prowlarr.Api.V1.Indexers
|
||||
{
|
||||
[V1ApiController("indexer/editor")]
|
||||
public class IndexerEditorController : Controller
|
||||
{
|
||||
private readonly IIndexerFactory _indexerFactory;
|
||||
private readonly IManageCommandQueue _commandQueueManager;
|
||||
private readonly IndexerResourceMapper _resourceMapper;
|
||||
|
||||
public IndexerEditorController(IIndexerFactory indexerFactory, IManageCommandQueue commandQueueManager, IndexerResourceMapper resourceMapper)
|
||||
{
|
||||
_indexerFactory = indexerFactory;
|
||||
_commandQueueManager = commandQueueManager;
|
||||
_resourceMapper = resourceMapper;
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[Consumes("application/json")]
|
||||
public IActionResult SaveAll(IndexerEditorResource resource)
|
||||
{
|
||||
var indexersToUpdate = _indexerFactory.AllProviders(false).Select(x => (IndexerDefinition)x.Definition).Where(d => resource.IndexerIds.Contains(d.Id));
|
||||
|
||||
foreach (var indexer in indexersToUpdate)
|
||||
{
|
||||
if (resource.Enable.HasValue)
|
||||
{
|
||||
indexer.Enable = resource.Enable.Value;
|
||||
}
|
||||
|
||||
if (resource.AppProfileId.HasValue)
|
||||
{
|
||||
indexer.AppProfileId = resource.AppProfileId.Value;
|
||||
}
|
||||
|
||||
if (resource.Priority.HasValue)
|
||||
{
|
||||
indexer.Priority = resource.Priority.Value;
|
||||
}
|
||||
|
||||
if (resource.Tags != null)
|
||||
{
|
||||
var newTags = resource.Tags;
|
||||
var applyTags = resource.ApplyTags;
|
||||
|
||||
switch (applyTags)
|
||||
{
|
||||
case ApplyTags.Add:
|
||||
newTags.ForEach(t => indexer.Tags.Add(t));
|
||||
break;
|
||||
case ApplyTags.Remove:
|
||||
newTags.ForEach(t => indexer.Tags.Remove(t));
|
||||
break;
|
||||
case ApplyTags.Replace:
|
||||
indexer.Tags = new HashSet<int>(newTags);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_indexerFactory.Update(indexersToUpdate);
|
||||
|
||||
var indexers = _indexerFactory.All();
|
||||
|
||||
foreach (var definition in indexers)
|
||||
{
|
||||
_indexerFactory.SetProviderCharacteristics(definition);
|
||||
}
|
||||
|
||||
return Accepted(_resourceMapper.ToResource(indexers));
|
||||
}
|
||||
|
||||
[HttpDelete]
|
||||
[Consumes("application/json")]
|
||||
public object DeleteIndexers([FromBody] IndexerEditorResource resource)
|
||||
{
|
||||
_indexerFactory.Delete(resource.IndexerIds);
|
||||
|
||||
return new { };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace Prowlarr.Api.V1.Indexers
|
||||
{
|
||||
public class IndexerEditorResource
|
||||
{
|
||||
public List<int> IndexerIds { get; set; }
|
||||
public bool? Enable { get; set; }
|
||||
public int? AppProfileId { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
public List<int> Tags { get; set; }
|
||||
public ApplyTags ApplyTags { get; set; }
|
||||
}
|
||||
|
||||
public enum ApplyTags
|
||||
{
|
||||
Add,
|
||||
Remove,
|
||||
Replace
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
using NzbDrone.Core.Notifications;
|
||||
|
||||
namespace Prowlarr.Api.V1.Notifications
|
||||
{
|
||||
public class NotificationBulkResource : ProviderBulkResource<NotificationBulkResource>
|
||||
{
|
||||
}
|
||||
|
||||
public class NotificationBulkResourceMapper : ProviderBulkResourceMapper<NotificationBulkResource, NotificationDefinition>
|
||||
{
|
||||
}
|
||||
}
|
|
@ -1,16 +1,31 @@
|
|||
using System;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Core.Notifications;
|
||||
using Prowlarr.Http;
|
||||
|
||||
namespace Prowlarr.Api.V1.Notifications
|
||||
{
|
||||
[V1ApiController]
|
||||
public class NotificationController : ProviderControllerBase<NotificationResource, INotification, NotificationDefinition>
|
||||
public class NotificationController : ProviderControllerBase<NotificationResource, NotificationBulkResource, INotification, NotificationDefinition>
|
||||
{
|
||||
public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper();
|
||||
public static readonly NotificationResourceMapper ResourceMapper = new ();
|
||||
public static readonly NotificationBulkResourceMapper BulkResourceMapper = new ();
|
||||
|
||||
public NotificationController(NotificationFactory notificationFactory)
|
||||
: base(notificationFactory, "notification", ResourceMapper)
|
||||
: base(notificationFactory, "notification", ResourceMapper, BulkResourceMapper)
|
||||
{
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public override ActionResult<NotificationResource> UpdateProvider([FromBody] NotificationBulkResource providerResource)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public override object DeleteProviders([FromBody] NotificationBulkResource resource)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
39
src/Prowlarr.Api.V1/ProviderBulkResource.cs
Normal file
39
src/Prowlarr.Api.V1/ProviderBulkResource.cs
Normal file
|
@ -0,0 +1,39 @@
|
|||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
|
||||
namespace Prowlarr.Api.V1
|
||||
{
|
||||
public class ProviderBulkResource<T>
|
||||
{
|
||||
public List<int> Ids { get; set; }
|
||||
public List<int> Tags { get; set; }
|
||||
public ApplyTags ApplyTags { get; set; }
|
||||
|
||||
public ProviderBulkResource()
|
||||
{
|
||||
Ids = new List<int>();
|
||||
}
|
||||
}
|
||||
|
||||
public enum ApplyTags
|
||||
{
|
||||
Add,
|
||||
Remove,
|
||||
Replace
|
||||
}
|
||||
|
||||
public class ProviderBulkResourceMapper<TProviderBulkResource, TProviderDefinition>
|
||||
where TProviderBulkResource : ProviderBulkResource<TProviderBulkResource>, new()
|
||||
where TProviderDefinition : ProviderDefinition, new()
|
||||
{
|
||||
public virtual List<TProviderDefinition> UpdateModel(TProviderBulkResource resource, List<TProviderDefinition> existingDefinitions)
|
||||
{
|
||||
if (resource == null)
|
||||
{
|
||||
return new List<TProviderDefinition>();
|
||||
}
|
||||
|
||||
return existingDefinitions;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,18 +10,25 @@ using Prowlarr.Http.REST;
|
|||
|
||||
namespace Prowlarr.Api.V1
|
||||
{
|
||||
public abstract class ProviderControllerBase<TProviderResource, TProvider, TProviderDefinition> : RestController<TProviderResource>
|
||||
public abstract class ProviderControllerBase<TProviderResource, TBulkProviderResource, TProvider, TProviderDefinition> : RestController<TProviderResource>
|
||||
where TProviderDefinition : ProviderDefinition, new()
|
||||
where TBulkProviderResource : ProviderBulkResource<TBulkProviderResource>, new()
|
||||
where TProvider : IProvider
|
||||
where TProviderResource : ProviderResource<TProviderResource>, new()
|
||||
{
|
||||
protected readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory;
|
||||
protected readonly ProviderResourceMapper<TProviderResource, TProviderDefinition> _resourceMapper;
|
||||
private readonly ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> _bulkResourceMapper;
|
||||
|
||||
protected ProviderControllerBase(IProviderFactory<TProvider, TProviderDefinition> providerFactory, string resource, ProviderResourceMapper<TProviderResource, TProviderDefinition> resourceMapper)
|
||||
protected ProviderControllerBase(IProviderFactory<TProvider,
|
||||
TProviderDefinition> providerFactory,
|
||||
string resource,
|
||||
ProviderResourceMapper<TProviderResource, TProviderDefinition> resourceMapper,
|
||||
ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> bulkResourceMapper)
|
||||
{
|
||||
_providerFactory = providerFactory;
|
||||
_resourceMapper = resourceMapper;
|
||||
_bulkResourceMapper = bulkResourceMapper;
|
||||
|
||||
SharedValidator.RuleFor(c => c.Name).NotEmpty();
|
||||
SharedValidator.RuleFor(c => c.Name).Must((v, c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique");
|
||||
|
@ -92,6 +99,47 @@ namespace Prowlarr.Api.V1
|
|||
return Accepted(providerResource.Id);
|
||||
}
|
||||
|
||||
[HttpPut("bulk")]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public virtual ActionResult<TProviderResource> UpdateProvider([FromBody] TBulkProviderResource providerResource)
|
||||
{
|
||||
if (!providerResource.Ids.Any())
|
||||
{
|
||||
throw new BadRequestException("ids must be provided");
|
||||
}
|
||||
|
||||
var definitionsToUpdate = _providerFactory.Get(providerResource.Ids).ToList();
|
||||
|
||||
foreach (var definition in definitionsToUpdate)
|
||||
{
|
||||
_providerFactory.SetProviderCharacteristics(definition);
|
||||
|
||||
if (providerResource.Tags != null)
|
||||
{
|
||||
var newTags = providerResource.Tags;
|
||||
var applyTags = providerResource.ApplyTags;
|
||||
|
||||
switch (applyTags)
|
||||
{
|
||||
case ApplyTags.Add:
|
||||
newTags.ForEach(t => definition.Tags.Add(t));
|
||||
break;
|
||||
case ApplyTags.Remove:
|
||||
newTags.ForEach(t => definition.Tags.Remove(t));
|
||||
break;
|
||||
case ApplyTags.Replace:
|
||||
definition.Tags = new HashSet<int>(newTags);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_bulkResourceMapper.UpdateModel(providerResource, definitionsToUpdate);
|
||||
|
||||
return Accepted(_providerFactory.Update(definitionsToUpdate).Select(x => _resourceMapper.ToResource(x)));
|
||||
}
|
||||
|
||||
private TProviderDefinition GetDefinition(TProviderResource providerResource, bool validate, bool includeWarnings, bool forceValidate)
|
||||
{
|
||||
var definition = _resourceMapper.ToModel(providerResource);
|
||||
|
@ -112,6 +160,16 @@ namespace Prowlarr.Api.V1
|
|||
return new { };
|
||||
}
|
||||
|
||||
[HttpDelete("bulk")]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public virtual object DeleteProviders([FromBody] TBulkProviderResource resource)
|
||||
{
|
||||
_providerFactory.Delete(resource.Ids);
|
||||
|
||||
return new { };
|
||||
}
|
||||
|
||||
[HttpGet("schema")]
|
||||
[Produces("application/json")]
|
||||
public virtual List<TProviderResource> GetTemplates()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue