diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js
index 79f5aaf0e..3173b493d 100644
--- a/frontend/src/Components/Form/FormInputGroup.js
+++ b/frontend/src/Components/Form/FormInputGroup.js
@@ -49,12 +49,12 @@ function getComponent(type) {
case inputTypes.DEVICE:
return DeviceInputConnector;
- case inputTypes.PLAYLIST:
- return PlaylistInputConnector;
-
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
+ case inputTypes.PLAYLIST:
+ return PlaylistInputConnector;
+
case inputTypes.MONITOR_ALBUMS_SELECT:
return MonitorAlbumsSelectInput;
diff --git a/frontend/src/Components/Form/KeyValueListInput.js b/frontend/src/Components/Form/KeyValueListInput.js
deleted file mode 100644
index 3e73d74f3..000000000
--- a/frontend/src/Components/Form/KeyValueListInput.js
+++ /dev/null
@@ -1,156 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import KeyValueListInputItem from './KeyValueListInputItem';
-import styles from './KeyValueListInput.css';
-
-class KeyValueListInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isFocused: false
- };
- }
-
- //
- // Listeners
-
- onItemChange = (index, itemValue) => {
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const newValue = [...value];
-
- if (index == null) {
- newValue.push(itemValue);
- } else {
- newValue.splice(index, 1, itemValue);
- }
-
- onChange({
- name,
- value: newValue
- });
- };
-
- onRemoveItem = (index) => {
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const newValue = [...value];
- newValue.splice(index, 1);
-
- onChange({
- name,
- value: newValue
- });
- };
-
- onFocus = () => {
- this.setState({
- isFocused: true
- });
- };
-
- onBlur = () => {
- this.setState({
- isFocused: false
- });
-
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const newValue = value.reduce((acc, v) => {
- if (v.key || v.value) {
- acc.push(v);
- }
-
- return acc;
- }, []);
-
- if (newValue.length !== value.length) {
- onChange({
- name,
- value: newValue
- });
- }
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- value,
- keyPlaceholder,
- valuePlaceholder,
- hasError,
- hasWarning
- } = this.props;
-
- const { isFocused } = this.state;
-
- return (
-
- {
- [...value, { key: '', value: '' }].map((v, index) => {
- return (
-
- );
- })
- }
-
- );
- }
-}
-
-KeyValueListInput.propTypes = {
- className: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- value: PropTypes.arrayOf(PropTypes.object).isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- keyPlaceholder: PropTypes.string,
- valuePlaceholder: PropTypes.string,
- onChange: PropTypes.func.isRequired
-};
-
-KeyValueListInput.defaultProps = {
- className: styles.inputContainer,
- value: []
-};
-
-export default KeyValueListInput;
diff --git a/frontend/src/Components/Form/KeyValueListInput.tsx b/frontend/src/Components/Form/KeyValueListInput.tsx
new file mode 100644
index 000000000..f5c6ac19b
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInput.tsx
@@ -0,0 +1,104 @@
+import classNames from 'classnames';
+import React, { useCallback, useState } from 'react';
+import { InputOnChange } from 'typings/inputs';
+import KeyValueListInputItem from './KeyValueListInputItem';
+import styles from './KeyValueListInput.css';
+
+interface KeyValue {
+ key: string;
+ value: string;
+}
+
+export interface KeyValueListInputProps {
+ className?: string;
+ name: string;
+ value: KeyValue[];
+ hasError?: boolean;
+ hasWarning?: boolean;
+ keyPlaceholder?: string;
+ valuePlaceholder?: string;
+ onChange: InputOnChange;
+}
+
+function KeyValueListInput({
+ className = styles.inputContainer,
+ name,
+ value = [],
+ hasError = false,
+ hasWarning = false,
+ keyPlaceholder,
+ valuePlaceholder,
+ onChange,
+}: KeyValueListInputProps): JSX.Element {
+ const [isFocused, setIsFocused] = useState(false);
+
+ const handleItemChange = useCallback(
+ (index: number | null, itemValue: KeyValue) => {
+ const newValue = [...value];
+
+ if (index === null) {
+ newValue.push(itemValue);
+ } else {
+ newValue.splice(index, 1, itemValue);
+ }
+
+ onChange({ name, value: newValue });
+ },
+ [value, name, onChange]
+ );
+
+ const handleRemoveItem = useCallback(
+ (index: number) => {
+ const newValue = [...value];
+ newValue.splice(index, 1);
+ onChange({ name, value: newValue });
+ },
+ [value, name, onChange]
+ );
+
+ const onFocus = useCallback(() => setIsFocused(true), []);
+
+ const onBlur = useCallback(() => {
+ setIsFocused(false);
+
+ const newValue = value.reduce((acc: KeyValue[], v) => {
+ if (v.key || v.value) {
+ acc.push(v);
+ }
+ return acc;
+ }, []);
+
+ if (newValue.length !== value.length) {
+ onChange({ name, value: newValue });
+ }
+ }, [value, name, onChange]);
+
+ return (
+
+ {[...value, { key: '', value: '' }].map((v, index) => (
+
+ ))}
+
+ );
+}
+
+export default KeyValueListInput;
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css b/frontend/src/Components/Form/KeyValueListInputItem.css
index dca2882a8..ed82db459 100644
--- a/frontend/src/Components/Form/KeyValueListInputItem.css
+++ b/frontend/src/Components/Form/KeyValueListInputItem.css
@@ -5,13 +5,19 @@
&:last-child {
margin-bottom: 0;
+ border-bottom: 0;
}
}
-.inputWrapper {
+.keyInputWrapper {
flex: 1 0 0;
}
+.valueInputWrapper {
+ flex: 1 0 0;
+ min-width: 40px;
+}
+
.buttonWrapper {
flex: 0 0 22px;
}
@@ -20,6 +26,10 @@
.valueInput {
width: 100%;
border: none;
- background-color: var(--inputBackgroundColor);
+ background-color: transparent;
color: var(--textColor);
+
+ &::placeholder {
+ color: var(--helpTextColor);
+ }
}
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts b/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts
index 35baf55cd..aa0c1be13 100644
--- a/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts
+++ b/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts
@@ -2,10 +2,11 @@
// Please do not change this file!
interface CssExports {
'buttonWrapper': string;
- 'inputWrapper': string;
'itemContainer': string;
'keyInput': string;
+ 'keyInputWrapper': string;
'valueInput': string;
+ 'valueInputWrapper': string;
}
export const cssExports: CssExports;
export default cssExports;
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.js b/frontend/src/Components/Form/KeyValueListInputItem.js
deleted file mode 100644
index 5379c2129..000000000
--- a/frontend/src/Components/Form/KeyValueListInputItem.js
+++ /dev/null
@@ -1,124 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import IconButton from 'Components/Link/IconButton';
-import { icons } from 'Helpers/Props';
-import TextInput from './TextInput';
-import styles from './KeyValueListInputItem.css';
-
-class KeyValueListInputItem extends Component {
-
- //
- // Listeners
-
- onKeyChange = ({ value: keyValue }) => {
- const {
- index,
- value,
- onChange
- } = this.props;
-
- onChange(index, { key: keyValue, value });
- };
-
- onValueChange = ({ value }) => {
- // TODO: Validate here or validate at a lower level component
-
- const {
- index,
- keyValue,
- onChange
- } = this.props;
-
- onChange(index, { key: keyValue, value });
- };
-
- onRemovePress = () => {
- const {
- index,
- onRemove
- } = this.props;
-
- onRemove(index);
- };
-
- onFocus = () => {
- this.props.onFocus();
- };
-
- onBlur = () => {
- this.props.onBlur();
- };
-
- //
- // Render
-
- render() {
- const {
- keyValue,
- value,
- keyPlaceholder,
- valuePlaceholder,
- isNew
- } = this.props;
-
- return (
-
-
-
-
-
-
-
-
-
-
- {
- isNew ?
- null :
-
- }
-
-
- );
- }
-}
-
-KeyValueListInputItem.propTypes = {
- index: PropTypes.number,
- keyValue: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
- keyPlaceholder: PropTypes.string.isRequired,
- valuePlaceholder: PropTypes.string.isRequired,
- isNew: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired,
- onRemove: PropTypes.func.isRequired,
- onFocus: PropTypes.func.isRequired,
- onBlur: PropTypes.func.isRequired
-};
-
-KeyValueListInputItem.defaultProps = {
- keyPlaceholder: 'Key',
- valuePlaceholder: 'Value'
-};
-
-export default KeyValueListInputItem;
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.tsx b/frontend/src/Components/Form/KeyValueListInputItem.tsx
new file mode 100644
index 000000000..c63ad50a9
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInputItem.tsx
@@ -0,0 +1,89 @@
+import React, { useCallback } from 'react';
+import IconButton from 'Components/Link/IconButton';
+import { icons } from 'Helpers/Props';
+import TextInput from './TextInput';
+import styles from './KeyValueListInputItem.css';
+
+interface KeyValueListInputItemProps {
+ index: number;
+ keyValue: string;
+ value: string;
+ keyPlaceholder?: string;
+ valuePlaceholder?: string;
+ isNew: boolean;
+ onChange: (index: number, itemValue: { key: string; value: string }) => void;
+ onRemove: (index: number) => void;
+ onFocus: () => void;
+ onBlur: () => void;
+}
+
+function KeyValueListInputItem({
+ index,
+ keyValue,
+ value,
+ keyPlaceholder = 'Key',
+ valuePlaceholder = 'Value',
+ isNew,
+ onChange,
+ onRemove,
+ onFocus,
+ onBlur,
+}: KeyValueListInputItemProps): JSX.Element {
+ const handleKeyChange = useCallback(
+ ({ value: keyValue }: { value: string }) => {
+ onChange(index, { key: keyValue, value });
+ },
+ [index, value, onChange]
+ );
+
+ const handleValueChange = useCallback(
+ ({ value }: { value: string }) => {
+ onChange(index, { key: keyValue, value });
+ },
+ [index, keyValue, onChange]
+ );
+
+ const handleRemovePress = useCallback(() => {
+ onRemove(index);
+ }, [index, onRemove]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {isNew ? null : (
+
+ )}
+
+
+ );
+}
+
+export default KeyValueListInputItem;
diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js
index fcdd4f2bc..311f1bbbd 100644
--- a/frontend/src/Components/Form/ProviderFieldFormGroup.js
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -14,6 +14,8 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.CHECK;
case 'device':
return inputTypes.DEVICE;
+ case 'keyValueList':
+ return inputTypes.KEY_VALUE_LIST;
case 'playlist':
return inputTypes.PLAYLIST;
case 'password':
diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js
index 1d08c762f..44115c787 100644
--- a/frontend/src/Helpers/Props/inputTypes.js
+++ b/frontend/src/Helpers/Props/inputTypes.js
@@ -2,8 +2,8 @@ export const AUTO_COMPLETE = 'autoComplete';
export const CAPTCHA = 'captcha';
export const CHECK = 'check';
export const DEVICE = 'device';
-export const PLAYLIST = 'playlist';
export const KEY_VALUE_LIST = 'keyValueList';
+export const PLAYLIST = 'playlist';
export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect';
export const MONITOR_NEW_ITEMS_SELECT = 'monitorNewItemsSelect';
export const FLOAT = 'float';
@@ -34,8 +34,8 @@ export const all = [
CAPTCHA,
CHECK,
DEVICE,
- PLAYLIST,
KEY_VALUE_LIST,
+ PLAYLIST,
MONITOR_ALBUMS_SELECT,
MONITOR_NEW_ITEMS_SELECT,
FLOAT,
diff --git a/frontend/src/typings/inputs.ts b/frontend/src/typings/inputs.ts
index c0fda305c..7d202cd44 100644
--- a/frontend/src/typings/inputs.ts
+++ b/frontend/src/typings/inputs.ts
@@ -1,3 +1,10 @@
+export type InputChanged = {
+ name: string;
+ value: T;
+};
+
+export type InputOnChange = (change: InputChanged) => void;
+
export type CheckInputChanged = {
name: string;
value: boolean;
diff --git a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs
index 8f016450d..a47137bfd 100644
--- a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs
+++ b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs
@@ -34,7 +34,8 @@ namespace NzbDrone.Common.Reflection
|| type == typeof(string)
|| type == typeof(DateTime)
|| type == typeof(Version)
- || type == typeof(decimal);
+ || type == typeof(decimal)
+ || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>));
}
public static bool IsReadable(this PropertyInfo propertyInfo)
diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs
index ed5879e5e..c87449003 100644
--- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs
+++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs
@@ -87,7 +87,8 @@ namespace NzbDrone.Core.Annotations
RootFolder,
QualityProfile,
MetadataProfile,
- ArtistTag
+ ArtistTag,
+ KeyValueList,
}
public enum HiddenType
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index 06cfeefcc..1738d25fb 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -844,6 +844,7 @@
"NotificationsSettingsUpdateMapPathsTo": "Map Paths To",
"NotificationsSettingsUpdateMapPathsToHelpText": "{serviceName} path, used to modify series paths when {serviceName} sees library path location differently from {appName} (Requires 'Update Library')",
"NotificationsSettingsUseSslHelpText": "Connect to {serviceName} over HTTPS instead of HTTP",
+ "NotificationsSettingsWebhookHeaders": "Headers",
"NotificationsTagsArtistHelpText": "Only send notifications for artists with at least one matching tag",
"NotificationsTelegramSettingsIncludeAppName": "Include {appName} in Title",
"NotificationsTelegramSettingsIncludeAppNameHelpText": "Optionally prefix message title with {appName} to differentiate notifications from different applications",
diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs
index 23a7fbdc8..a7a7025e7 100644
--- a/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs
+++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs
@@ -43,6 +43,11 @@ namespace NzbDrone.Core.Notifications.Webhook
request.Credentials = new BasicNetworkCredential(settings.Username, settings.Password);
}
+ foreach (var header in settings.Headers)
+ {
+ request.Headers.Add(header.Key, header.Value);
+ }
+
_httpClient.Execute(request);
}
catch (HttpException ex)
diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs
index dd850e5d5..0d7560142 100644
--- a/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs
+++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs
@@ -1,4 +1,5 @@
-using System;
+using System;
+using System.Collections.Generic;
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.ThingiProvider;
@@ -21,6 +22,7 @@ namespace NzbDrone.Core.Notifications.Webhook
public WebhookSettings()
{
Method = Convert.ToInt32(WebhookMethod.POST);
+ Headers = new List>();
}
[FieldDefinition(0, Label = "URL", Type = FieldType.Url)]
@@ -35,6 +37,9 @@ namespace NzbDrone.Core.Notifications.Webhook
[FieldDefinition(3, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
public string Password { get; set; }
+ [FieldDefinition(4, Label = "NotificationsSettingsWebhookHeaders", Type = FieldType.KeyValueList, Advanced = true)]
+ public IEnumerable> Headers { get; set; }
+
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));