Category Selection on Search Page

pull/6/head
Qstick 4 years ago
parent d2a8acb1a6
commit eb697b630e

@ -2,34 +2,22 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { clearOptions, fetchOptions } from 'Store/Actions/providerOptionActions'; import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
import DeviceInput from './DeviceInput'; import DeviceInput from './DeviceInput';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state, { value }) => value, (state, { value }) => value,
(state, { name }) => name, (state) => state.providerOptions.devices || defaultState,
(state) => state.providerOptions, (value, devices) => {
(value, name, devices) => {
const {
isFetching,
isPopulated,
error,
items
} = devices;
return { return {
isFetching, ...devices,
isPopulated,
error,
items: items[name] || [],
selectedDevices: value.map((valueDevice) => { selectedDevices: value.map((valueDevice) => {
const sectionItems = items[name] || [];
// Disable equality ESLint rule so we don't need to worry about // Disable equality ESLint rule so we don't need to worry about
// a type mismatch between the value items and the device ID. // a type mismatch between the value items and the device ID.
// eslint-disable-next-line eqeqeq // eslint-disable-next-line eqeqeq
const device = sectionItems.find((d) => d.id == valueDevice); const device = devices.items.find((d) => d.id == valueDevice);
if (device) { if (device) {
return { return {
@ -63,7 +51,7 @@ class DeviceInputConnector extends Component {
} }
componentWillUnmount = () => { componentWillUnmount = () => {
this.props.dispatchClearOptions(); this.props.dispatchClearOptions({ section: 'devices' });
} }
// //
@ -73,14 +61,12 @@ class DeviceInputConnector extends Component {
const { const {
provider, provider,
providerData, providerData,
dispatchFetchOptions, dispatchFetchOptions
requestAction,
name
} = this.props; } = this.props;
dispatchFetchOptions({ dispatchFetchOptions({
action: requestAction, section: 'devices',
itemSection: name, action: 'getDevices',
provider, provider,
providerData providerData
}); });
@ -109,7 +95,6 @@ class DeviceInputConnector extends Component {
DeviceInputConnector.propTypes = { DeviceInputConnector.propTypes = {
provider: PropTypes.string.isRequired, provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired, providerData: PropTypes.object.isRequired,
requestAction: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
dispatchFetchOptions: PropTypes.func.isRequired, dispatchFetchOptions: PropTypes.func.isRequired,

@ -5,6 +5,10 @@
align-items: center; align-items: center;
} }
.editableContainer {
width: 100%;
}
.hasError { .hasError {
composes: hasError from '~Components/Form/Input.css'; composes: hasError from '~Components/Form/Input.css';
} }
@ -22,6 +26,16 @@
margin-left: 12px; margin-left: 12px;
} }
.dropdownArrowContainerEditable {
position: absolute;
top: 0;
right: 0;
padding-right: 17px;
width: 30%;
height: 35px;
text-align: right;
}
.dropdownArrowContainerDisabled { .dropdownArrowContainerDisabled {
composes: dropdownArrowContainer; composes: dropdownArrowContainer;
@ -66,3 +80,8 @@
border-radius: 4px; border-radius: 4px;
background-color: $white; background-color: $white;
} }
.loading {
display: inline-block;
margin: 5px -5px 5px 0;
}

@ -5,6 +5,7 @@ import React, { Component } from 'react';
import { Manager, Popper, Reference } from 'react-popper'; import { Manager, Popper, Reference } from 'react-popper';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Measure from 'Components/Measure'; import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal'; import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
@ -16,6 +17,7 @@ import getUniqueElememtId from 'Utilities/getUniqueElementId';
import { isMobile as isMobileUtil } from 'Utilities/mobile'; import { isMobile as isMobileUtil } from 'Utilities/mobile';
import HintedSelectInputOption from './HintedSelectInputOption'; import HintedSelectInputOption from './HintedSelectInputOption';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import TextInput from './TextInput';
import styles from './EnhancedSelectInput.css'; import styles from './EnhancedSelectInput.css';
function isArrowKey(keyCode) { function isArrowKey(keyCode) {
@ -168,11 +170,21 @@ class EnhancedSelectInput extends Component {
} }
} }
onFocus = () => {
if (this.state.isOpen) {
this._removeListener();
this.setState({ isOpen: false });
}
}
onBlur = () => { onBlur = () => {
// Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) if (!this.props.isEditable) {
const origIndex = getSelectedIndex(this.props); // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
if (origIndex !== this.state.selectedIndex) { const origIndex = getSelectedIndex(this.props);
this.setState({ selectedIndex: origIndex });
if (origIndex !== this.state.selectedIndex) {
this.setState({ selectedIndex: origIndex });
}
} }
} }
@ -250,6 +262,10 @@ class EnhancedSelectInput extends Component {
this._addListener(); this._addListener();
} }
if (!this.state.isOpen && this.props.onOpen) {
this.props.onOpen();
}
this.setState({ isOpen: !this.state.isOpen }); this.setState({ isOpen: !this.state.isOpen });
} }
@ -292,15 +308,19 @@ class EnhancedSelectInput extends Component {
const { const {
className, className,
disabledClassName, disabledClassName,
name,
value, value,
values, values,
isDisabled, isDisabled,
isEditable,
isFetching,
hasError, hasError,
hasWarning, hasWarning,
valueOptions, valueOptions,
selectedValueOptions, selectedValueOptions,
selectedValueComponent: SelectedValueComponent, selectedValueComponent: SelectedValueComponent,
optionComponent: OptionComponent optionComponent: OptionComponent,
onChange
} = this.props; } = this.props;
const { const {
@ -326,40 +346,94 @@ class EnhancedSelectInput extends Component {
whitelist={['width']} whitelist={['width']}
onMeasure={this.onMeasure} onMeasure={this.onMeasure}
> >
<Link {
className={classNames( isEditable ?
className, <div
hasError && styles.hasError, className={styles.editableContainer}
hasWarning && styles.hasWarning, >
isDisabled && disabledClassName <TextInput
)} className={className}
isDisabled={isDisabled} name={name}
onBlur={this.onBlur} value={value}
onKeyDown={this.onKeyDown} readOnly={isDisabled}
onPress={this.onPress} hasError={hasError}
> hasWarning={hasWarning}
<SelectedValueComponent onFocus={this.onFocus}
value={value} onBlur={this.onBlur}
values={values} onChange={onChange}
{...selectedValueOptions} />
{...selectedOption} <Link
isDisabled={isDisabled} className={classNames(
isMultiSelect={isMultiSelect} styles.dropdownArrowContainerEditable,
> isDisabled ?
{selectedOption ? selectedOption.value : null} styles.dropdownArrowContainerDisabled :
</SelectedValueComponent> styles.dropdownArrowContainer)
}
<div onPress={this.onPress}
className={isDisabled ? >
styles.dropdownArrowContainerDisabled : {
styles.dropdownArrowContainer isFetching &&
} <LoadingIndicator
> className={styles.loading}
<Icon size={20}
name={icons.CARET_DOWN} />
/> }
</div>
</Link> {
!isFetching &&
<Icon
name={icons.CARET_DOWN}
/>
}
</Link>
</div> :
<Link
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
>
<SelectedValueComponent
value={value}
values={values}
{...selectedValueOptions}
{...selectedOption}
isDisabled={isDisabled}
isMultiSelect={isMultiSelect}
>
{selectedOption ? selectedOption.value : null}
</SelectedValueComponent>
<div
className={isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer
}
>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
{
!isFetching &&
<Icon
name={icons.CARET_DOWN}
/>
}
</div>
</Link>
}
</Measure> </Measure>
</div> </div>
)} )}
@ -483,12 +557,15 @@ EnhancedSelectInput.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired, values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isEditable: PropTypes.bool.isRequired,
hasError: PropTypes.bool, hasError: PropTypes.bool,
hasWarning: PropTypes.bool, hasWarning: PropTypes.bool,
valueOptions: PropTypes.object.isRequired, valueOptions: PropTypes.object.isRequired,
selectedValueOptions: PropTypes.object.isRequired, selectedValueOptions: PropTypes.object.isRequired,
selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
optionComponent: PropTypes.elementType, optionComponent: PropTypes.elementType,
onOpen: PropTypes.func,
onChange: PropTypes.func.isRequired onChange: PropTypes.func.isRequired
}; };
@ -496,6 +573,8 @@ EnhancedSelectInput.defaultProps = {
className: styles.enhancedSelect, className: styles.enhancedSelect,
disabledClassName: styles.isDisabled, disabledClassName: styles.isDisabled,
isDisabled: false, isDisabled: false,
isFetching: false,
isEditable: false,
valueOptions: {}, valueOptions: {},
selectedValueOptions: {}, selectedValueOptions: {},
selectedValueComponent: HintedSelectInputSelectedValue, selectedValueComponent: HintedSelectInputSelectedValue,

@ -0,0 +1,159 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
import EnhancedSelectInput from './EnhancedSelectInput';
const importantFieldNames = [
'baseUrl',
'apiPath',
'apiKey'
];
function getProviderDataKey(providerData) {
if (!providerData || !providerData.fields) {
return null;
}
const fields = providerData.fields
.filter((f) => importantFieldNames.includes(f.name))
.map((f) => f.value);
return fields;
}
function getSelectOptions(items) {
if (!items) {
return [];
}
return items.map((option) => {
return {
key: option.value,
value: option.name,
hint: option.hint,
parentKey: option.parentValue
};
});
}
function createMapStateToProps() {
return createSelector(
(state, { selectOptionsProviderAction }) => state.providerOptions[selectOptionsProviderAction] || defaultState,
(options) => {
if (options) {
return {
isFetching: options.isFetching,
values: getSelectOptions(options.items)
};
}
}
);
}
const mapDispatchToProps = {
dispatchFetchOptions: fetchOptions,
dispatchClearOptions: clearOptions
};
class EnhancedSelectInputConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
refetchRequired: false
};
}
componentDidMount = () => {
this._populate();
}
componentDidUpdate = (prevProps) => {
const prevKey = getProviderDataKey(prevProps.providerData);
const nextKey = getProviderDataKey(this.props.providerData);
if (!_.isEqual(prevKey, nextKey)) {
this.setState({ refetchRequired: true });
}
}
componentWillUnmount = () => {
this._cleanup();
}
//
// Listeners
onOpen = () => {
if (this.state.refetchRequired) {
this._populate();
}
}
//
// Control
_populate() {
const {
provider,
providerData,
selectOptionsProviderAction,
dispatchFetchOptions
} = this.props;
if (selectOptionsProviderAction) {
this.setState({ refetchRequired: false });
dispatchFetchOptions({
section: selectOptionsProviderAction,
action: selectOptionsProviderAction,
provider,
providerData
});
}
}
_cleanup() {
const {
selectOptionsProviderAction,
dispatchClearOptions
} = this.props;
if (selectOptionsProviderAction) {
dispatchClearOptions({ section: selectOptionsProviderAction });
}
}
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onOpen={this.onOpen}
/>
);
}
}
EnhancedSelectInputConnector.propTypes = {
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
selectOptionsProviderAction: PropTypes.string,
onChange: PropTypes.func.isRequired,
isFetching: PropTypes.bool.isRequired,
dispatchFetchOptions: PropTypes.func.isRequired,
dispatchClearOptions: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EnhancedSelectInputConnector);

@ -32,6 +32,7 @@ class EnhancedSelectInputOption extends Component {
const { const {
className, className,
id, id,
depth,
isSelected, isSelected,
isDisabled, isDisabled,
isHidden, isHidden,
@ -54,6 +55,11 @@ class EnhancedSelectInputOption extends Component {
onPress={this.onPress} onPress={this.onPress}
> >
{
depth !== 0 &&
<div style={{ width: `${depth * 20}px` }} />
}
{ {
isMultiSelect && isMultiSelect &&
<CheckInput <CheckInput
@ -84,6 +90,7 @@ class EnhancedSelectInputOption extends Component {
EnhancedSelectInputOption.propTypes = { EnhancedSelectInputOption.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
depth: PropTypes.number.isRequired,
isSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isHidden: PropTypes.bool.isRequired, isHidden: PropTypes.bool.isRequired,
@ -95,6 +102,7 @@ EnhancedSelectInputOption.propTypes = {
EnhancedSelectInputOption.defaultProps = { EnhancedSelectInputOption.defaultProps = {
className: styles.option, className: styles.option,
depth: 0,
isDisabled: false, isDisabled: false,
isHidden: false, isHidden: false,
isMultiSelect: false isMultiSelect: false

@ -9,6 +9,7 @@ import CaptchaInputConnector from './CaptchaInputConnector';
import CheckInput from './CheckInput'; import CheckInput from './CheckInput';
import DeviceInputConnector from './DeviceInputConnector'; import DeviceInputConnector from './DeviceInputConnector';
import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
import FormInputHelpText from './FormInputHelpText'; import FormInputHelpText from './FormInputHelpText';
import IndexerFlagsSelectInputConnector from './IndexerFlagsSelectInputConnector'; import IndexerFlagsSelectInputConnector from './IndexerFlagsSelectInputConnector';
import KeyValueListInput from './KeyValueListInput'; import KeyValueListInput from './KeyValueListInput';
@ -65,6 +66,9 @@ function getComponent(type) {
case inputTypes.SELECT: case inputTypes.SELECT:
return EnhancedSelectInput; return EnhancedSelectInput;
case inputTypes.DYNAMIC_SELECT:
return EnhancedSelectInputConnector;
case inputTypes.TAG: case inputTypes.TAG:
return TagInputConnector; return TagInputConnector;

@ -9,6 +9,7 @@ function HintedSelectInputOption(props) {
id, id,
value, value,
hint, hint,
depth,
isSelected, isSelected,
isDisabled, isDisabled,
isMultiSelect, isMultiSelect,
@ -19,6 +20,7 @@ function HintedSelectInputOption(props) {
return ( return (
<EnhancedSelectInputOption <EnhancedSelectInputOption
id={id} id={id}
depth={depth}
isSelected={isSelected} isSelected={isSelected}
isDisabled={isDisabled} isDisabled={isDisabled}
isHidden={isDisabled} isHidden={isDisabled}
@ -48,6 +50,7 @@ HintedSelectInputOption.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
hint: PropTypes.node, hint: PropTypes.node,
depth: PropTypes.number,
isSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired, isDisabled: PropTypes.bool.isRequired,
isMultiSelect: PropTypes.bool.isRequired, isMultiSelect: PropTypes.bool.isRequired,

@ -0,0 +1,71 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
(state) => state.settings.indexerCategories,
(value, categories) => {
const values = [];
categories.items.forEach((element) => {
values.push({
key: element.id,
value: element.name,
hint: `(${element.id})`
});
if (element.subCategories && element.subCategories.length > 0) {
element.subCategories.forEach((subCat) => {
values.push({
key: subCat.id,
value: subCat.name,
hint: `(${subCat.id})`,
parentKey: element.id
});
});
}
});
console.log(values);
return {
value,
values
};
}
);
}
class IndexersSelectInputConnector extends Component {
onChange = ({ name, value }) => {
this.props.onChange({ name, value });
}
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
IndexersSelectInputConnector.propTypes = {
name: PropTypes.string.isRequired,
indexerIds: PropTypes.number,
value: PropTypes.arrayOf(PropTypes.number).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
onChange: PropTypes.func.isRequired
};
export default connect(createMapStateToProps)(IndexersSelectInputConnector);

@ -6,7 +6,7 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import { inputTypes } from 'Helpers/Props'; import { inputTypes } from 'Helpers/Props';
function getType(type, value) { function getType({ type, selectOptionsProviderAction }) {
switch (type) { switch (type) {
case 'captcha': case 'captcha':
return inputTypes.CAPTCHA; return inputTypes.CAPTCHA;
@ -23,6 +23,9 @@ function getType(type, value) {
case 'filePath': case 'filePath':
return inputTypes.PATH; return inputTypes.PATH;
case 'select': case 'select':
if (selectOptionsProviderAction) {
return inputTypes.DYNAMIC_SELECT;
}
return inputTypes.SELECT; return inputTypes.SELECT;
case 'tag': case 'tag':
return inputTypes.TEXT_TAG; return inputTypes.TEXT_TAG;
@ -63,7 +66,6 @@ function ProviderFieldFormGroup(props) {
value, value,
type, type,
advanced, advanced,
requestAction,
hidden, hidden,
pending, pending,
errors, errors,
@ -88,7 +90,7 @@ function ProviderFieldFormGroup(props) {
<FormLabel>{label}</FormLabel> <FormLabel>{label}</FormLabel>
<FormInputGroup <FormInputGroup
type={getType(type, value)} type={getType(props)}
name={name} name={name}
label={label} label={label}
helpText={helpText} helpText={helpText}
@ -100,7 +102,6 @@ function ProviderFieldFormGroup(props) {
pending={pending} pending={pending}
includeFiles={type === 'filePath' ? true : undefined} includeFiles={type === 'filePath' ? true : undefined}
onChange={onChange} onChange={onChange}
requestAction={requestAction}
{...otherProps} {...otherProps}
/> />
</FormGroup> </FormGroup>
@ -109,7 +110,8 @@ function ProviderFieldFormGroup(props) {
const selectOptionsShape = { const selectOptionsShape = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
value: PropTypes.number.isRequired value: PropTypes.number.isRequired,
hint: PropTypes.string
}; };
ProviderFieldFormGroup.propTypes = { ProviderFieldFormGroup.propTypes = {
@ -121,12 +123,12 @@ ProviderFieldFormGroup.propTypes = {
value: PropTypes.any, value: PropTypes.any,
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
advanced: PropTypes.bool.isRequired, advanced: PropTypes.bool.isRequired,
requestAction: PropTypes.string,
hidden: PropTypes.string, hidden: PropTypes.string,
pending: PropTypes.bool.isRequired, pending: PropTypes.bool.isRequired,
errors: PropTypes.arrayOf(PropTypes.object).isRequired, errors: PropTypes.arrayOf(PropTypes.object).isRequired,
warnings: PropTypes.arrayOf(PropTypes.object).isRequired, warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)), selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)),
selectOptionsProviderAction: PropTypes.string,
onChange: PropTypes.func.isRequired onChange: PropTypes.func.isRequired
}; };

@ -6,7 +6,7 @@ import { createSelector } from 'reselect';
import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
import { fetchIndexers } from 'Store/Actions/indexerActions'; import { fetchIndexers } from 'Store/Actions/indexerActions';
import { fetchIndexerFlags, fetchLanguages, fetchUISettings } from 'Store/Actions/settingsActions'; import { fetchIndexerCategories, fetchIndexerFlags, fetchLanguages, fetchUISettings } from 'Store/Actions/settingsActions';
import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchStatus } from 'Store/Actions/systemActions';
import { fetchTags } from 'Store/Actions/tagActions'; import { fetchTags } from 'Store/Actions/tagActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
@ -48,6 +48,7 @@ const selectIsPopulated = createSelector(
(state) => state.settings.ui.isPopulated, (state) => state.settings.ui.isPopulated,
(state) => state.settings.languages.isPopulated, (state) => state.settings.languages.isPopulated,
(state) => state.indexers.isPopulated, (state) => state.indexers.isPopulated,
(state) => state.settings.indexerCategories.isPopulated,
(state) => state.settings.indexerFlags.isPopulated, (state) => state.settings.indexerFlags.isPopulated,
(state) => state.system.status.isPopulated, (state) => state.system.status.isPopulated,
( (
@ -56,6 +57,7 @@ const selectIsPopulated = createSelector(
uiSettingsIsPopulated, uiSettingsIsPopulated,
languagesIsPopulated, languagesIsPopulated,
indexersIsPopulated, indexersIsPopulated,
indexerCategoriesIsPopulated,
indexerFlagsIsPopulated, indexerFlagsIsPopulated,
systemStatusIsPopulated systemStatusIsPopulated
) => { ) => {
@ -65,6 +67,7 @@ const selectIsPopulated = createSelector(
uiSettingsIsPopulated && uiSettingsIsPopulated &&
languagesIsPopulated && languagesIsPopulated &&
indexersIsPopulated && indexersIsPopulated &&
indexerCategoriesIsPopulated &&
indexerFlagsIsPopulated && indexerFlagsIsPopulated &&
systemStatusIsPopulated systemStatusIsPopulated
); );
@ -77,6 +80,7 @@ const selectErrors = createSelector(
(state) => state.settings.ui.error, (state) => state.settings.ui.error,
(state) => state.settings.languages.error, (state) => state.settings.languages.error,
(state) => state.indexers.error, (state) => state.indexers.error,
(state) => state.settings.indexerCategories.error,
(state) => state.settings.indexerFlags.error, (state) => state.settings.indexerFlags.error,
(state) => state.system.status.error, (state) => state.system.status.error,
( (
@ -85,6 +89,7 @@ const selectErrors = createSelector(
uiSettingsError, uiSettingsError,
languagesError, languagesError,
indexersError, indexersError,
indexerCategoriesError,
indexerFlagsError, indexerFlagsError,
systemStatusError systemStatusError
) => { ) => {
@ -94,6 +99,7 @@ const selectErrors = createSelector(
uiSettingsError || uiSettingsError ||
languagesError || languagesError ||
indexersError || indexersError ||
indexerCategoriesError ||
indexerFlagsError || indexerFlagsError ||
systemStatusError systemStatusError
); );
@ -105,6 +111,7 @@ const selectErrors = createSelector(
uiSettingsError, uiSettingsError,
languagesError, languagesError,
indexersError, indexersError,
indexerCategoriesError,
indexerFlagsError, indexerFlagsError,
systemStatusError systemStatusError
}; };
@ -150,6 +157,9 @@ function createMapDispatchToProps(dispatch, props) {
dispatchFetchIndexers() { dispatchFetchIndexers() {
dispatch(fetchIndexers()); dispatch(fetchIndexers());
}, },
dispatchFetchIndexerCategories() {
dispatch(fetchIndexerCategories());
},
dispatchFetchIndexerFlags() { dispatchFetchIndexerFlags() {
dispatch(fetchIndexerFlags()); dispatch(fetchIndexerFlags());
}, },
@ -187,6 +197,7 @@ class PageConnector extends Component {
this.props.dispatchFetchTags(); this.props.dispatchFetchTags();
this.props.dispatchFetchLanguages(); this.props.dispatchFetchLanguages();
this.props.dispatchFetchIndexers(); this.props.dispatchFetchIndexers();
this.props.dispatchFetchIndexerCategories();
this.props.dispatchFetchIndexerFlags(); this.props.dispatchFetchIndexerFlags();
this.props.dispatchFetchUISettings(); this.props.dispatchFetchUISettings();
this.props.dispatchFetchStatus(); this.props.dispatchFetchStatus();
@ -210,6 +221,7 @@ class PageConnector extends Component {
dispatchFetchTags, dispatchFetchTags,
dispatchFetchLanguages, dispatchFetchLanguages,
dispatchFetchIndexers, dispatchFetchIndexers,
dispatchFetchIndexerCategories,
dispatchFetchIndexerFlags, dispatchFetchIndexerFlags,
dispatchFetchUISettings, dispatchFetchUISettings,
dispatchFetchStatus, dispatchFetchStatus,
@ -248,6 +260,7 @@ PageConnector.propTypes = {
dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchTags: PropTypes.func.isRequired,
dispatchFetchLanguages: PropTypes.func.isRequired, dispatchFetchLanguages: PropTypes.func.isRequired,
dispatchFetchIndexers: PropTypes.func.isRequired, dispatchFetchIndexers: PropTypes.func.isRequired,
dispatchFetchIndexerCategories: PropTypes.func.isRequired,
dispatchFetchIndexerFlags: PropTypes.func.isRequired, dispatchFetchIndexerFlags: PropTypes.func.isRequired,
dispatchFetchUISettings: PropTypes.func.isRequired, dispatchFetchUISettings: PropTypes.func.isRequired,
dispatchFetchStatus: PropTypes.func.isRequired, dispatchFetchStatus: PropTypes.func.isRequired,

@ -1,6 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import IndexersSelectInputConnector from 'Components/Form/IndexersSelectInputConnector'; import IndexersSelectInputConnector from 'Components/Form/IndexersSelectInputConnector';
import NewznabCategorySelectInputConnector from 'Components/Form/NewznabCategorySelectInputConnector';
import TextInput from 'Components/Form/TextInput'; import TextInput from 'Components/Form/TextInput';
import SpinnerButton from 'Components/Link/SpinnerButton'; import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter'; import PageContentFooter from 'Components/Page/PageContentFooter';
@ -18,7 +19,8 @@ class SearchFooter extends Component {
this.state = { this.state = {
searchingReleases: false, searchingReleases: false,
searchQuery: '', searchQuery: '',
indexerIds: [] indexerIds: [],
categories: []
}; };
} }
@ -43,7 +45,7 @@ class SearchFooter extends Component {
} }
onSearchPress = () => { onSearchPress = () => {
this.props.onSearchPress(this.state.searchQuery, this.state.indexerIds); this.props.onSearchPress(this.state.searchQuery, this.state.indexerIds, this.state.categories);
} }
// //
@ -57,7 +59,8 @@ class SearchFooter extends Component {
const { const {
searchQuery, searchQuery,
indexerIds indexerIds,
categories
} = this.state; } = this.state;
return ( return (
@ -84,13 +87,26 @@ class SearchFooter extends Component {
<IndexersSelectInputConnector <IndexersSelectInputConnector
name='indexerIds' name='indexerIds'
placeholder='Indexers'
value={indexerIds} value={indexerIds}
isDisabled={isFetching} isDisabled={isFetching}
onChange={this.onInputChange} onChange={this.onInputChange}
/> />
</div> </div>
<div className={styles.indexerContainer}>
<SearchFooterLabel
label={'Categories'}
isSaving={false}
/>
<NewznabCategorySelectInputConnector
name='categories'
value={categories}
isDisabled={isFetching}
onChange={this.onInputChange}
/>
</div>
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
<div className={styles.buttonContainerContent}> <div className={styles.buttonContainerContent}>
<div className={styles.buttons}> <div className={styles.buttons}>

@ -146,8 +146,8 @@ class SearchIndex extends Component {
this.setState({ jumpToCharacter }); this.setState({ jumpToCharacter });
} }
onSearchPress = (query, indexerIds) => { onSearchPress = (query, indexerIds, categories) => {
this.props.onSearchPress({ query, indexerIds }); this.props.onSearchPress({ query, indexerIds, categories });
} }
onKeyUp = (event) => { onKeyUp = (event) => {

@ -0,0 +1,48 @@
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import { createThunk } from 'Store/thunks';
//
// Variables
const section = 'settings.indexerCategories';
//
// Actions Types
export const FETCH_INDEXER_CATEGORIES = 'settings/indexerFlags/fetchIndexerCategories';
//
// Action Creators
export const fetchIndexerCategories = createThunk(FETCH_INDEXER_CATEGORIES);
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
items: []
},
//
// Action Handlers
actionHandlers: {
[FETCH_INDEXER_CATEGORIES]: createFetchHandler(section, '/indexer/categories')
},
//
// Reducers
reducers: {
}
};

@ -1,3 +1,4 @@
import _ from 'lodash';
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { createThunk, handleThunks } from 'Store/thunks'; import { createThunk, handleThunks } from 'Store/thunks';
import requestAction from 'Utilities/requestAction'; import requestAction from 'Utilities/requestAction';
@ -10,11 +11,14 @@ import createHandleActions from './Creators/createHandleActions';
export const section = 'providerOptions'; export const section = 'providerOptions';
const lastActions = {};
let lastActionId = 0;
// //
// State // State
export const defaultState = { export const defaultState = {
items: {}, items: [],
isFetching: false, isFetching: false,
isPopulated: false, isPopulated: false,
error: false error: false
@ -23,8 +27,8 @@ export const defaultState = {
// //
// Actions Types // Actions Types
export const FETCH_OPTIONS = 'devices/fetchOptions'; export const FETCH_OPTIONS = 'providers/fetchOptions';
export const CLEAR_OPTIONS = 'devices/clearOptions'; export const CLEAR_OPTIONS = 'providers/clearOptions';
// //
// Action Creators // Action Creators
@ -38,35 +42,55 @@ export const clearOptions = createAction(CLEAR_OPTIONS);
export const actionHandlers = handleThunks({ export const actionHandlers = handleThunks({
[FETCH_OPTIONS]: function(getState, payload, dispatch) { [FETCH_OPTIONS]: function(getState, payload, dispatch) {
const subsection = `${section}.${payload.section}`;
if (lastActions[payload.section] && _.isEqual(payload, lastActions[payload.section].payload)) {
return;
}
const actionId = ++lastActionId;
lastActions[payload.section] = {
actionId,
payload
};
dispatch(set({ dispatch(set({
section, section: subsection,
isFetching: true isFetching: true
})); }));
const oldItems = getState().providerOptions.items;
const itemSection = payload.itemSection;
const promise = requestAction(payload); const promise = requestAction(payload);
promise.done((data) => { promise.done((data) => {
oldItems[itemSection] = data.options || []; if (lastActions[payload.section]) {
if (lastActions[payload.section].actionId === actionId) {
dispatch(set({ lastActions[payload.section] = null;
section, }
isFetching: false,
isPopulated: true, dispatch(set({
error: null, section: subsection,
items: oldItems isFetching: false,
})); isPopulated: true,
error: null,
items: data.options || []
}));
}
}); });
promise.fail((xhr) => { promise.fail((xhr) => {
dispatch(set({ if (lastActions[payload.section]) {
section, if (lastActions[payload.section].actionId === actionId) {
isFetching: false, lastActions[payload.section] = null;
isPopulated: false, }
error: xhr
})); dispatch(set({
section: subsection,
isFetching: false,
isPopulated: false,
error: xhr
}));
}
}); });
} }
}); });
@ -76,8 +100,12 @@ export const actionHandlers = handleThunks({
export const reducers = createHandleActions({ export const reducers = createHandleActions({
[CLEAR_OPTIONS]: function(state) { [CLEAR_OPTIONS]: function(state, { payload }) {
return updateSectionState(state, section, defaultState); const subsection = `${section}.${payload.section}`;
lastActions[payload.section] = null;
return updateSectionState(state, subsection, defaultState);
} }
}, defaultState, section); }, {}, section);

@ -3,6 +3,7 @@ import { handleThunks } from 'Store/thunks';
import createHandleActions from './Creators/createHandleActions'; import createHandleActions from './Creators/createHandleActions';
import applications from './Settings/applications'; import applications from './Settings/applications';
import general from './Settings/general'; import general from './Settings/general';
import indexerCategories from './Settings/indexerCategories';
import indexerFlags from './Settings/indexerFlags'; import indexerFlags from './Settings/indexerFlags';
import indexerOptions from './Settings/indexerOptions'; import indexerOptions from './Settings/indexerOptions';
import languages from './Settings/languages'; import languages from './Settings/languages';
@ -10,6 +11,7 @@ import notifications from './Settings/notifications';
import ui from './Settings/ui'; import ui from './Settings/ui';
export * from './Settings/general'; export * from './Settings/general';
export * from './Settings/indexerCategories';
export * from './Settings/indexerFlags'; export * from './Settings/indexerFlags';
export * from './Settings/indexerOptions'; export * from './Settings/indexerOptions';
export * from './Settings/languages'; export * from './Settings/languages';
@ -29,6 +31,7 @@ export const defaultState = {
advancedSettings: false, advancedSettings: false,
general: general.defaultState, general: general.defaultState,
indexerCategories: indexerCategories.defaultState,
indexerFlags: indexerFlags.defaultState, indexerFlags: indexerFlags.defaultState,
indexerOptions: indexerOptions.defaultState, indexerOptions: indexerOptions.defaultState,
languages: languages.defaultState, languages: languages.defaultState,
@ -56,6 +59,7 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
export const actionHandlers = handleThunks({ export const actionHandlers = handleThunks({
...general.actionHandlers, ...general.actionHandlers,
...indexerCategories.actionHandlers,
...indexerFlags.actionHandlers, ...indexerFlags.actionHandlers,
...indexerOptions.actionHandlers, ...indexerOptions.actionHandlers,
...languages.actionHandlers, ...languages.actionHandlers,
@ -74,6 +78,7 @@ export const reducers = createHandleActions({
}, },
...general.reducers, ...general.reducers,
...indexerCategories.reducers,
...indexerFlags.reducers, ...indexerFlags.reducers,
...indexerOptions.reducers, ...indexerOptions.reducers,
...languages.reducers, ...languages.reducers,

@ -19,10 +19,10 @@ namespace NzbDrone.Core.Annotations
public FieldType Type { get; set; } public FieldType Type { get; set; }
public bool Advanced { get; set; } public bool Advanced { get; set; }
public Type SelectOptions { get; set; } public Type SelectOptions { get; set; }
public string SelectOptionsProviderAction { get; set; }
public string Section { get; set; } public string Section { get; set; }
public HiddenType Hidden { get; set; } public HiddenType Hidden { get; set; }
public PrivacyLevel Privacy { get; set; } public PrivacyLevel Privacy { get; set; }
public string RequestAction { get; set; }
} }
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
@ -39,6 +39,15 @@ namespace NzbDrone.Core.Annotations
public string Hint { get; set; } public string Hint { get; set; }
} }
public class FieldSelectOption
{
public int Value { get; set; }
public string Name { get; set; }
public int Order { get; set; }
public string Hint { get; set; }
public int? ParentValue { get; set; }
}
public enum FieldType public enum FieldType
{ {
Textbox, Textbox,

@ -48,6 +48,7 @@ namespace NzbDrone.Core.Indexers.Newznab
} }
var request = new HttpRequest(url, HttpAccept.Rss); var request = new HttpRequest(url, HttpAccept.Rss);
request.AllowAutoRedirect = true;
HttpResponse response; HttpResponse response;

@ -133,7 +133,7 @@ namespace NzbDrone.Core.Indexers.Newznab
if (categories != null && categories.Any()) if (categories != null && categories.Any())
{ {
var categoriesQuery = string.Join(",", categories.Distinct()); var categoriesQuery = string.Join(",", categories.Distinct());
baseUrl += string.Format("&cats={0}", categoriesQuery); baseUrl += string.Format("&cat={0}", categoriesQuery);
} }
if (Settings.ApiKey.IsNotNullOrWhiteSpace()) if (Settings.ApiKey.IsNotNullOrWhiteSpace())
@ -151,14 +151,7 @@ namespace NzbDrone.Core.Indexers.Newznab
parameters += string.Format("&offset={0}", searchCriteria.Offset); parameters += string.Format("&offset={0}", searchCriteria.Offset);
} }
if (PageSize == 0) yield return new IndexerRequest(string.Format("{0}{1}", baseUrl, parameters), HttpAccept.Rss);
{
yield return new IndexerRequest(string.Format("{0}{1}", baseUrl, parameters), HttpAccept.Rss);
}
else
{
yield return new IndexerRequest(string.Format("{0}&offset={1}&limit={2}{3}", baseUrl, searchCriteria.Offset, searchCriteria.Limit, parameters), HttpAccept.Rss);
}
} }
private static string NewsnabifyTitle(string title) private static string NewsnabifyTitle(string title)

@ -358,7 +358,14 @@ namespace NzbDrone.Core.Indexers
_indexerStatusService.UpdateCookies(Definition.Id, cookies, expiration); _indexerStatusService.UpdateCookies(Definition.Id, cookies, expiration);
}; };
var generator = GetRequestGenerator(); var generator = GetRequestGenerator();
var releases = FetchPage(generator.GetSearchRequests(new MovieSearchCriteria()).GetAllTiers().First().First(), parser); var firstRequest = generator.GetSearchRequests(new BasicSearchCriteria()).GetAllTiers().FirstOrDefault()?.FirstOrDefault();
if (firstRequest == null)
{
return new ValidationFailure(string.Empty, "No rss feed query available. This may be an issue with the indexer or your indexer category settings.");
}
var releases = FetchPage(firstRequest, parser);
if (releases.Empty()) if (releases.Empty())
{ {

@ -28,7 +28,7 @@ namespace NzbDrone.Core.Notifications.PushBullet
[FieldDefinition(0, Label = "Access Token", Privacy = PrivacyLevel.ApiKey, HelpLink = "https://www.pushbullet.com/#settings/account")] [FieldDefinition(0, Label = "Access Token", Privacy = PrivacyLevel.ApiKey, HelpLink = "https://www.pushbullet.com/#settings/account")]
public string ApiKey { get; set; } public string ApiKey { get; set; }
[FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs (leave blank to send to all devices)", Type = FieldType.Device, RequestAction = "getDevices")] [FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs (leave blank to send to all devices)", Type = FieldType.Device)]
public IEnumerable<string> DeviceIds { get; set; } public IEnumerable<string> DeviceIds { get; set; }
[FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)] [FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)]

@ -0,0 +1,19 @@
using NzbDrone.Core.Indexers;
using Prowlarr.Api.V1;
namespace NzbDrone.Api.V1.Indexers
{
public class IndexerDefaultCategoriesModule : ProwlarrV1Module
{
public IndexerDefaultCategoriesModule()
: base("/indexer/categories")
{
Get("/", movie => GetAll());
}
private IndexerCategory[] GetAll()
{
return NewznabStandardCategory.ParentCats;
}
}
}

@ -28,7 +28,7 @@ namespace Prowlarr.Api.V1
Get("schema", x => GetTemplates()); Get("schema", x => GetTemplates());
Post("test", x => Test(ReadResourceFromRequest(true))); Post("test", x => Test(ReadResourceFromRequest(true)));
Post("testall", x => TestAll()); Post("testall", x => TestAll());
Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true))); Post("action/{action}", x => RequestAction(x.action, ReadResourceFromRequest(true, true)));
GetResourceAll = GetAll; GetResourceAll = GetAll;
GetResourceById = GetProviderById; GetResourceById = GetProviderById;

@ -35,22 +35,22 @@ namespace Prowlarr.Api.V1.Search
if (indexerIds.Count > 0) if (indexerIds.Count > 0)
{ {
return GetSearchReleases(request.Query, indexerIds); return GetSearchReleases(request.Query, indexerIds, request.Categories);
} }
else else
{ {
return GetSearchReleases(request.Query, null); return GetSearchReleases(request.Query, null, request.Categories);
} }
} }
return new List<SearchResource>(); return new List<SearchResource>();
} }
private List<SearchResource> GetSearchReleases(string query, List<int> indexerIds) private List<SearchResource> GetSearchReleases(string query, List<int> indexerIds, int[] categories)
{ {
try try
{ {
var decisions = _nzbSearhService.Search(new NewznabRequest { q = query, t = "search" }, indexerIds, true).Releases; var decisions = _nzbSearhService.Search(new NewznabRequest { q = query, t = "search", cat = string.Join(",", categories) }, indexerIds, true).Releases;
return MapDecisions(decisions); return MapDecisions(decisions);
} }

@ -15,9 +15,10 @@ namespace Prowlarr.Http.ClientSchema
public string Type { get; set; } public string Type { get; set; }
public bool Advanced { get; set; } public bool Advanced { get; set; }
public List<SelectOption> SelectOptions { get; set; } public List<SelectOption> SelectOptions { get; set; }
public string SelectOptionsProviderAction { get; set; }
public string Section { get; set; } public string Section { get; set; }
public string Hidden { get; set; } public string Hidden { get; set; }
public string RequestAction { get; set; }
public Field Clone() public Field Clone()
{ {

@ -100,13 +100,19 @@ namespace Prowlarr.Http.ClientSchema
Order = fieldAttribute.Order, Order = fieldAttribute.Order,
Advanced = fieldAttribute.Advanced, Advanced = fieldAttribute.Advanced,
Type = fieldAttribute.Type.ToString().FirstCharToLower(), Type = fieldAttribute.Type.ToString().FirstCharToLower(),
Section = fieldAttribute.Section, Section = fieldAttribute.Section
RequestAction = fieldAttribute.RequestAction
}; };
if (fieldAttribute.Type == FieldType.Select || fieldAttribute.Type == FieldType.TagSelect) if (fieldAttribute.Type == FieldType.Select || fieldAttribute.Type == FieldType.TagSelect)
{ {
field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions); if (fieldAttribute.SelectOptionsProviderAction.IsNotNullOrWhiteSpace())
{
field.SelectOptionsProviderAction = fieldAttribute.SelectOptionsProviderAction;
}
else
{
field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions);
}
} }
if (fieldAttribute.Hidden != HiddenType.Visible) if (fieldAttribute.Hidden != HiddenType.Visible)

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using FluentValidation; using FluentValidation;
using FluentValidation.Results;
using Nancy; using Nancy;
using Nancy.Responses.Negotiation; using Nancy.Responses.Negotiation;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -224,7 +225,7 @@ namespace Prowlarr.Http.REST
return Negotiate.WithModel(model).WithStatusCode(statusCode); return Negotiate.WithModel(model).WithStatusCode(statusCode);
} }
protected TResource ReadResourceFromRequest(bool skipValidate = false) protected TResource ReadResourceFromRequest(bool skipValidate = false, bool skipSharedValidate = false)
{ {
TResource resource; TResource resource;
@ -242,7 +243,12 @@ namespace Prowlarr.Http.REST
throw new BadRequestException("Request body can't be empty"); throw new BadRequestException("Request body can't be empty");
} }
var errors = SharedValidator.Validate(resource).Errors.ToList(); var errors = new List<ValidationFailure>();
if (!skipSharedValidate)
{
errors.AddRange(SharedValidator.Validate(resource).Errors);
}
if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase)) if (Request.Method.Equals("POST", StringComparison.InvariantCultureIgnoreCase) && !skipValidate && !Request.Url.Path.EndsWith("/test", StringComparison.InvariantCultureIgnoreCase))
{ {

Loading…
Cancel
Save