diff --git a/frontend/src/Components/Form/FormInputGroup.tsx b/frontend/src/Components/Form/FormInputGroup.tsx index 897f19bbd..fe860a7ca 100644 --- a/frontend/src/Components/Form/FormInputGroup.tsx +++ b/frontend/src/Components/Form/FormInputGroup.tsx @@ -10,6 +10,7 @@ import CaptchaInput from './CaptchaInput'; import CheckInput from './CheckInput'; import { FormInputButtonProps } from './FormInputButton'; import FormInputHelpText from './FormInputHelpText'; +import KeyValueListInput from './KeyValueListInput'; import NumberInput from './NumberInput'; import OAuthInput from './OAuthInput'; import PasswordInput from './PasswordInput'; @@ -47,6 +48,9 @@ function getComponent(type: InputType) { case inputTypes.DEVICE: return DeviceInput; + case inputTypes.KEY_VALUE_LIST: + return KeyValueListInput; + case inputTypes.MONITOR_EPISODES_SELECT: return MonitorEpisodesSelectInput; diff --git a/frontend/src/Components/Form/KeyValueListInput.css b/frontend/src/Components/Form/KeyValueListInput.css new file mode 100644 index 000000000..d86e6a512 --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInput.css @@ -0,0 +1,21 @@ +.inputContainer { + composes: input from '~Components/Form/Input.css'; + + position: relative; + min-height: 35px; + height: auto; + + &.isFocused { + outline: 0; + border-color: var(--inputFocusBorderColor); + box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), 0 0 8px var(--inputFocusBoxShadowColor); + } +} + +.hasError { + composes: hasError from '~Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from '~Components/Form/Input.css'; +} diff --git a/frontend/src/Components/Form/KeyValueListInput.css.d.ts b/frontend/src/Components/Form/KeyValueListInput.css.d.ts new file mode 100644 index 000000000..972f108c9 --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInput.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'hasError': string; + 'hasWarning': string; + 'inputContainer': string; + 'isFocused': string; +} +export const cssExports: CssExports; +export default cssExports; 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 new file mode 100644 index 000000000..ed82db459 --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInputItem.css @@ -0,0 +1,35 @@ +.itemContainer { + display: flex; + margin-bottom: 3px; + border-bottom: 1px solid var(--inputBorderColor); + + &:last-child { + margin-bottom: 0; + border-bottom: 0; + } +} + +.keyInputWrapper { + flex: 1 0 0; +} + +.valueInputWrapper { + flex: 1 0 0; + min-width: 40px; +} + +.buttonWrapper { + flex: 0 0 22px; +} + +.keyInput, +.valueInput { + width: 100%; + border: none; + 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 new file mode 100644 index 000000000..aa0c1be13 --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'buttonWrapper': 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.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 4fcf99cc0..a4f13dbd1 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 'password': return inputTypes.PASSWORD; case 'number': diff --git a/frontend/src/Helpers/Props/inputTypes.ts b/frontend/src/Helpers/Props/inputTypes.ts index d0ecc3553..a0c4c817c 100644 --- a/frontend/src/Helpers/Props/inputTypes.ts +++ b/frontend/src/Helpers/Props/inputTypes.ts @@ -2,6 +2,7 @@ export const AUTO_COMPLETE = 'autoComplete'; export const CAPTCHA = 'captcha'; export const CHECK = 'check'; export const DEVICE = 'device'; +export const KEY_VALUE_LIST = 'keyValueList'; export const MONITOR_EPISODES_SELECT = 'monitorEpisodesSelect'; export const MONITOR_NEW_ITEMS_SELECT = 'monitorNewItemsSelect'; export const FLOAT = 'float'; @@ -31,6 +32,7 @@ export const all = [ CAPTCHA, CHECK, DEVICE, + KEY_VALUE_LIST, MONITOR_EPISODES_SELECT, MONITOR_NEW_ITEMS_SELECT, FLOAT, diff --git a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs index d832e0f27..54371a2bb 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 22088b01f..cc480337d 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -101,7 +101,8 @@ namespace NzbDrone.Core.Annotations TagSelect, RootFolder, QualityProfile, - SeriesTag + SeriesTag, + KeyValueList, } public enum HiddenType diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index aceda5965..d70350e67 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1436,6 +1436,7 @@ "NotificationsSettingsWebhookMethod": "Method", "NotificationsSettingsWebhookMethodHelpText": "Which HTTP method to use submit to the Webservice", "NotificationsSettingsWebhookUrl": "Webhook URL", + "NotificationsSettingsWebhookHeaders": "Headers", "NotificationsSignalSettingsGroupIdPhoneNumber": "Group ID / Phone Number", "NotificationsSignalSettingsGroupIdPhoneNumberHelpText": "Group ID / Phone Number of the receiver", "NotificationsSignalSettingsPasswordHelpText": "Password used to authenticate requests toward signal-api", 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 565f454e2..51d91f7db 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.Validation; @@ -20,6 +21,7 @@ namespace NzbDrone.Core.Notifications.Webhook public WebhookSettings() { Method = Convert.ToInt32(WebhookMethod.POST); + Headers = new List>(); } [FieldDefinition(0, Label = "NotificationsSettingsWebhookUrl", Type = FieldType.Url)] @@ -34,6 +36,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 override NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this));