From 739019498ff23e65d13b9df9ab8c706897de585a Mon Sep 17 00:00:00 2001 From: Gauthier Date: Fri, 15 Nov 2024 04:01:05 +0100 Subject: [PATCH] New: Add headers setting in webhook connection (cherry picked from commit 78fb20282de73c0ea47375895a807235385d90e3) Closes #5242 --- .../src/Components/Form/FormInputGroup.js | 6 +- .../src/Components/Form/KeyValueListInput.js | 156 ------------------ .../src/Components/Form/KeyValueListInput.tsx | 104 ++++++++++++ .../Components/Form/KeyValueListInputItem.css | 14 +- .../Form/KeyValueListInputItem.css.d.ts | 3 +- .../Components/Form/KeyValueListInputItem.js | 124 -------------- .../Components/Form/KeyValueListInputItem.tsx | 89 ++++++++++ .../Components/Form/ProviderFieldFormGroup.js | 2 + frontend/src/Helpers/Props/inputTypes.js | 4 +- frontend/src/typings/inputs.ts | 7 + .../Reflection/ReflectionExtensions.cs | 3 +- .../Annotations/FieldDefinitionAttribute.cs | 3 +- src/NzbDrone.Core/Localization/Core/en.json | 1 + .../Notifications/Webhook/WebhookProxy.cs | 5 + .../Notifications/Webhook/WebhookSettings.cs | 7 +- 15 files changed, 237 insertions(+), 291 deletions(-) delete mode 100644 frontend/src/Components/Form/KeyValueListInput.js create mode 100644 frontend/src/Components/Form/KeyValueListInput.tsx delete mode 100644 frontend/src/Components/Form/KeyValueListInputItem.js create mode 100644 frontend/src/Components/Form/KeyValueListInputItem.tsx 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));