diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index ddc7300fd1..77b933a8f7 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -210,7 +210,6 @@ module.exports = { 'no-undef-init': 'off', 'no-undefined': 'off', 'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }], - 'no-use-before-define': 'error', // Node.js and CommonJS diff --git a/frontend/src/App/State/AppSectionState.ts b/frontend/src/App/State/AppSectionState.ts index 27dc78aa18..de6f4d90c7 100644 --- a/frontend/src/App/State/AppSectionState.ts +++ b/frontend/src/App/State/AppSectionState.ts @@ -1,11 +1,16 @@ import Column from 'Components/Table/Column'; import { SortDirection } from 'Helpers/Props/sortDirections'; +import { ValidationFailure } from 'typings/pending'; import { FilterBuilderProp, PropertyFilter } from './AppState'; export interface Error { - responseJSON: { - message: string; - }; + status?: number; + responseJSON: + | { + message: string | undefined; + } + | ValidationFailure[] + | undefined; } export interface AppSectionDeleteState { @@ -51,6 +56,16 @@ export interface AppSectionItemState { item: T; } +export interface AppSectionProviderState + extends AppSectionDeleteState, + AppSectionSaveState { + isFetching: boolean; + isPopulated: boolean; + error: Error; + items: T[]; + pendingChanges: Partial; +} + interface AppSectionState { isFetching: boolean; isPopulated: boolean; diff --git a/frontend/src/App/State/MetadataAppState.ts b/frontend/src/App/State/MetadataAppState.ts new file mode 100644 index 0000000000..60d5c434cb --- /dev/null +++ b/frontend/src/App/State/MetadataAppState.ts @@ -0,0 +1,6 @@ +import { AppSectionProviderState } from 'App/State/AppSectionState'; +import Metadata from 'typings/Metadata'; + +interface MetadataAppState extends AppSectionProviderState {} + +export default MetadataAppState; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 40dd2656d1..b9f8e64b48 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -20,6 +20,7 @@ import NamingConfig from 'typings/Settings/NamingConfig'; import NamingExample from 'typings/Settings/NamingExample'; import ReleaseProfile from 'typings/Settings/ReleaseProfile'; import UiSettings from 'typings/Settings/UiSettings'; +import MetadataAppState from './MetadataAppState'; export interface DownloadClientAppState extends AppSectionState, @@ -97,6 +98,7 @@ interface SettingsAppState { indexerFlags: IndexerFlagSettingsAppState; indexers: IndexerAppState; languages: LanguageSettingsAppState; + metadata: MetadataAppState; naming: NamingAppState; namingExamples: NamingExamplesAppState; notifications: NotificationAppState; diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index 0cd23298e5..72f4ccef84 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -139,6 +139,8 @@ ProviderFieldFormGroup.propTypes = { type: PropTypes.string.isRequired, advanced: PropTypes.bool.isRequired, hidden: PropTypes.string, + isDisabled: PropTypes.bool, + provider: PropTypes.string, pending: PropTypes.bool.isRequired, errors: PropTypes.arrayOf(PropTypes.object).isRequired, warnings: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.tsx b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.tsx index 766f883c93..8ff410c71f 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.tsx +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.tsx @@ -40,7 +40,7 @@ function createImportListExclusionSelector(id?: number) { importListExclusions; const mapping = id - ? items.find((i) => i.id === id) + ? items.find((i) => i.id === id)! : newImportListExclusion; const settings = selectSettings(mapping, pendingChanges, saveError); diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js deleted file mode 100644 index 4b33df5286..0000000000 --- a/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js +++ /dev/null @@ -1,27 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import { sizes } from 'Helpers/Props'; -import EditMetadataModalContentConnector from './EditMetadataModalContentConnector'; - -function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -EditMetadataModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default EditMetadataModal; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.tsx b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.tsx new file mode 100644 index 0000000000..6dd30ca785 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.tsx @@ -0,0 +1,36 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditMetadataModalContent, { + EditMetadataModalContentProps, +} from './EditMetadataModalContent'; + +interface EditMetadataModalProps extends EditMetadataModalContentProps { + isOpen: boolean; +} + +function EditMetadataModal({ + isOpen, + onModalClose, + ...otherProps +}: EditMetadataModalProps) { + const dispatch = useDispatch(); + + const handleModalClose = useCallback(() => { + dispatch(clearPendingChanges({ section: 'metadata' })); + onModalClose(); + }, [dispatch, onModalClose]); + + return ( + + + + ); +} + +export default EditMetadataModal; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js deleted file mode 100644 index 7513bb82c9..0000000000 --- a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js +++ /dev/null @@ -1,44 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { clearPendingChanges } from 'Store/Actions/baseActions'; -import EditMetadataModal from './EditMetadataModal'; - -function createMapDispatchToProps(dispatch, props) { - const section = 'settings.metadata'; - - return { - dispatchClearPendingChanges() { - dispatch(clearPendingChanges({ section })); - } - }; -} - -class EditMetadataModalConnector extends Component { - // - // Listeners - - onModalClose = () => { - this.props.dispatchClearPendingChanges({ section: 'metadata' }); - this.props.onModalClose(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -EditMetadataModalConnector.propTypes = { - onModalClose: PropTypes.func.isRequired, - dispatchClearPendingChanges: PropTypes.func.isRequired -}; - -export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector); diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.css b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.css new file mode 100644 index 0000000000..7393b9c355 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.css @@ -0,0 +1,5 @@ +.message { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.css.d.ts b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.css.d.ts new file mode 100644 index 0000000000..65c237dff7 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js deleted file mode 100644 index 221c6bcaf1..0000000000 --- a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js +++ /dev/null @@ -1,105 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -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 ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; -import Button from 'Components/Link/Button'; -import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; -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'; - -function EditMetadataModalContent(props) { - const { - advancedSettings, - isSaving, - saveError, - item, - onInputChange, - onFieldChange, - onModalClose, - onSavePress, - ...otherProps - } = props; - - const { - name, - enable, - fields - } = item; - - return ( - - - {translate('EditMetadata', { metadataType: name.value })} - - - -
- - {translate('Enable')} - - - - - { - fields.map((field) => { - return ( - - ); - }) - } - - -
- - - - - - {translate('Save')} - - -
- ); -} - -EditMetadataModalContent.propTypes = { - advancedSettings: PropTypes.bool.isRequired, - isSaving: PropTypes.bool.isRequired, - saveError: PropTypes.object, - item: PropTypes.object.isRequired, - onInputChange: PropTypes.func.isRequired, - onFieldChange: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired, - onSavePress: PropTypes.func.isRequired, - onDeleteMetadataPress: PropTypes.func -}; - -export default EditMetadataModalContent; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.tsx b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.tsx new file mode 100644 index 0000000000..997a4c39cc --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.tsx @@ -0,0 +1,128 @@ +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Alert from 'Components/Alert'; +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 ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +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 { + saveMetadata, + setMetadataFieldValue, + setMetadataValue, +} from 'Store/Actions/settingsActions'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import styles from './EditMetadataModalContent.css'; + +export interface EditMetadataModalContentProps { + id: number; + advancedSettings: boolean; + onModalClose: () => void; +} + +function EditMetadataModalContent({ + id, + advancedSettings, + onModalClose, +}: EditMetadataModalContentProps) { + const dispatch = useDispatch(); + + const { isSaving, saveError, pendingChanges, items } = useSelector( + (state: AppState) => state.settings.metadata + ); + + const { settings, ...otherSettings } = useMemo(() => { + const item = items.find((item) => item.id === id)!; + + return selectSettings(item, pendingChanges, saveError); + }, [id, items, pendingChanges, saveError]); + + const { name, enable, fields, message } = settings; + + const handleInputChange = useCallback( + ({ name, value }: InputChanged) => { + // @ts-expect-error not typed + dispatch(setMetadataValue({ name, value })); + }, + [dispatch] + ); + + const handleFieldChange = useCallback( + ({ name, value }: InputChanged) => { + // @ts-expect-error not typed + dispatch(setMetadataFieldValue({ name, value })); + }, + [dispatch] + ); + + const handleSavePress = useCallback(() => { + dispatch(saveMetadata({ id })); + }, [id, dispatch]); + + return ( + + + {translate('EditMetadata', { metadataType: name.value })} + + + +
+ {message ? ( + + {message.value.message} + + ) : null} + + + {translate('Enable')} + + + + + {fields.map((field) => { + return ( + + ); + })} + +
+ + + + + + {translate('Save')} + + +
+ ); +} + +export default EditMetadataModalContent; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js deleted file mode 100644 index 62dae94f6c..0000000000 --- a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js +++ /dev/null @@ -1,95 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { saveMetadata, setMetadataFieldValue, setMetadataValue } from 'Store/Actions/settingsActions'; -import selectSettings from 'Store/Selectors/selectSettings'; -import EditMetadataModalContent from './EditMetadataModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.advancedSettings, - (state, { id }) => id, - (state) => state.settings.metadata, - (advancedSettings, id, metadata) => { - const { - isSaving, - saveError, - pendingChanges, - items - } = metadata; - - const settings = selectSettings(_.find(items, { id }), pendingChanges, saveError); - - return { - advancedSettings, - id, - isSaving, - saveError, - item: settings.settings, - ...settings - }; - } - ); -} - -const mapDispatchToProps = { - setMetadataValue, - setMetadataFieldValue, - saveMetadata -}; - -class EditMetadataModalContentConnector extends Component { - - // - // Lifecycle - - componentDidUpdate(prevProps, prevState) { - if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { - this.props.onModalClose(); - } - } - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.props.setMetadataValue({ name, value }); - }; - - onFieldChange = ({ name, value }) => { - this.props.setMetadataFieldValue({ name, value }); - }; - - onSavePress = () => { - this.props.saveMetadata({ id: this.props.id }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -EditMetadataModalContentConnector.propTypes = { - id: PropTypes.number, - isSaving: PropTypes.bool.isRequired, - saveError: PropTypes.object, - item: PropTypes.object.isRequired, - setMetadataValue: PropTypes.func.isRequired, - setMetadataFieldValue: PropTypes.func.isRequired, - saveMetadata: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataModalContentConnector); diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.js b/frontend/src/Settings/Metadata/Metadata/Metadata.js deleted file mode 100644 index ffb0ab967f..0000000000 --- a/frontend/src/Settings/Metadata/Metadata/Metadata.js +++ /dev/null @@ -1,150 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Card from 'Components/Card'; -import Label from 'Components/Label'; -import { kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import EditMetadataModalConnector from './EditMetadataModalConnector'; -import styles from './Metadata.css'; - -class Metadata extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditMetadataModalOpen: false - }; - } - - // - // Listeners - - onEditMetadataPress = () => { - this.setState({ isEditMetadataModalOpen: true }); - }; - - onEditMetadataModalClose = () => { - this.setState({ isEditMetadataModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - name, - enable, - fields - } = this.props; - - const metadataFields = []; - const imageFields = []; - - fields.forEach((field) => { - if (field.section === 'metadata') { - metadataFields.push(field); - } else { - imageFields.push(field); - } - }); - - return ( - -
- {name} -
- -
- { - enable ? - : - - } -
- - { - enable && !!metadataFields.length && -
-
- {translate('Metadata')} -
- - { - metadataFields.map((field) => { - if (!field.value) { - return null; - } - - return ( - - ); - }) - } -
- } - - { - enable && !!imageFields.length && -
-
- {translate('Images')} -
- - { - imageFields.map((field) => { - if (!field.value) { - return null; - } - - return ( - - ); - }) - } -
- } - - -
- ); - } -} - -Metadata.propTypes = { - id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, - enable: PropTypes.bool.isRequired, - fields: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -export default Metadata; diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.tsx b/frontend/src/Settings/Metadata/Metadata/Metadata.tsx new file mode 100644 index 0000000000..52797218df --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadata.tsx @@ -0,0 +1,107 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import { kinds } from 'Helpers/Props'; +import Field from 'typings/Field'; +import translate from 'Utilities/String/translate'; +import EditMetadataModal from './EditMetadataModal'; +import styles from './Metadata.css'; + +interface MetadataProps { + id: number; + name: string; + enable: boolean; + fields: Field[]; +} + +function Metadata({ id, name, enable, fields }: MetadataProps) { + const [isEditMetadataModalOpen, setIsEditMetadataModalOpen] = useState(false); + + const { metadataFields, imageFields } = useMemo(() => { + return fields.reduce<{ metadataFields: Field[]; imageFields: Field[] }>( + (acc, field) => { + if (field.section === 'metadata') { + acc.metadataFields.push(field); + } else { + acc.imageFields.push(field); + } + + return acc; + }, + { metadataFields: [], imageFields: [] } + ); + }, [fields]); + + const handleOpenPress = useCallback(() => { + setIsEditMetadataModalOpen(true); + }, []); + + const handleModalClose = useCallback(() => { + setIsEditMetadataModalOpen(false); + }, []); + + return ( + +
{name}
+ +
+ {enable ? ( + + ) : ( + + )} +
+ + {enable && metadataFields.length ? ( +
+
{translate('Metadata')}
+ + {metadataFields.map((field) => { + if (!field.value) { + return null; + } + + return ( + + ); + })} +
+ ) : null} + + {enable && imageFields.length ? ( +
+
{translate('Images')}
+ + {imageFields.map((field) => { + if (!field.value) { + return null; + } + + return ( + + ); + })} +
+ ) : null} + + +
+ ); +} + +export default Metadata; diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.js b/frontend/src/Settings/Metadata/Metadata/Metadatas.js deleted file mode 100644 index a52275bcc3..0000000000 --- a/frontend/src/Settings/Metadata/Metadata/Metadatas.js +++ /dev/null @@ -1,44 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import FieldSet from 'Components/FieldSet'; -import PageSectionContent from 'Components/Page/PageSectionContent'; -import translate from 'Utilities/String/translate'; -import Metadata from './Metadata'; -import styles from './Metadatas.css'; - -function Metadatas(props) { - const { - items, - ...otherProps - } = props; - - return ( -
- -
- { - items.map((item) => { - return ( - - ); - }) - } -
-
-
- ); -} - -Metadatas.propTypes = { - isFetching: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -export default Metadatas; diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.tsx b/frontend/src/Settings/Metadata/Metadata/Metadatas.tsx new file mode 100644 index 0000000000..befe207d82 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.tsx @@ -0,0 +1,52 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import MetadataAppState from 'App/State/MetadataAppState'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import { fetchMetadata } from 'Store/Actions/settingsActions'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import MetadataType from 'typings/Metadata'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; +import Metadata from './Metadata'; +import styles from './Metadatas.css'; + +function createMetadatasSelector() { + return createSelector( + createSortedSectionSelector( + 'settings.metadata', + sortByProp('name') + ), + (metadata: MetadataAppState) => metadata + ); +} + +function Metadatas() { + const dispatch = useDispatch(); + const { isFetching, error, items, ...otherProps } = useSelector( + createMetadatasSelector() + ); + + useEffect(() => { + dispatch(fetchMetadata()); + }, [dispatch]); + + return ( +
+ +
+ {items.map((item) => { + return ; + })} +
+
+
+ ); +} + +export default Metadatas; diff --git a/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js deleted file mode 100644 index 8675f4742d..0000000000 --- a/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js +++ /dev/null @@ -1,47 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchMetadata } from 'Store/Actions/settingsActions'; -import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByProp from 'Utilities/Array/sortByProp'; -import Metadatas from './Metadatas'; - -function createMapStateToProps() { - return createSelector( - createSortedSectionSelector('settings.metadata', sortByProp('name')), - (metadata) => metadata - ); -} - -const mapDispatchToProps = { - fetchMetadata -}; - -class MetadatasConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.fetchMetadata(); - } - - // - // Render - - render() { - return ( - - ); - } -} - -MetadatasConnector.propTypes = { - fetchMetadata: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/frontend/src/Settings/Metadata/MetadataSettings.js b/frontend/src/Settings/Metadata/MetadataSettings.js index e0a256cb19..5c92f0ace9 100644 --- a/frontend/src/Settings/Metadata/MetadataSettings.js +++ b/frontend/src/Settings/Metadata/MetadataSettings.js @@ -3,7 +3,7 @@ import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; -import MetadatasConnector from './Metadata/MetadatasConnector'; +import Metadatas from './Metadata/Metadatas'; import MetadataOptionsConnector from './Options/MetadataOptionsConnector'; class MetadataSettings extends Component { @@ -62,7 +62,7 @@ class MetadataSettings extends Component { onChildStateChange={this.onChildStateChange} /> - + ); diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx index 5b2b4289cc..ceb1d6a43d 100644 --- a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.tsx @@ -19,14 +19,15 @@ import { setReleaseProfileValue, } from 'Store/Actions/Settings/releaseProfiles'; import selectSettings from 'Store/Selectors/selectSettings'; -import { PendingSection } from 'typings/pending'; import ReleaseProfile from 'typings/Settings/ReleaseProfile'; import translate from 'Utilities/String/translate'; import styles from './EditReleaseProfileModalContent.css'; const tagInputDelimiters = ['Tab', 'Enter']; -const newReleaseProfile = { +const newReleaseProfile: ReleaseProfile = { + id: 0, + name: '', enabled: true, required: [], ignored: [], @@ -41,8 +42,12 @@ function createReleaseProfileSelector(id?: number) { const { items, isFetching, error, isSaving, saveError, pendingChanges } = releaseProfiles; - const mapping = id ? items.find((i) => i.id === id) : newReleaseProfile; - const settings = selectSettings(mapping, pendingChanges, saveError); + const mapping = id ? items.find((i) => i.id === id)! : newReleaseProfile; + const settings = selectSettings( + mapping, + pendingChanges, + saveError + ); return { id, @@ -50,7 +55,7 @@ function createReleaseProfileSelector(id?: number) { error, isSaving, saveError, - item: settings.settings as PendingSection, + item: settings.settings, ...settings, }; } diff --git a/frontend/src/Store/Selectors/selectSettings.js b/frontend/src/Store/Selectors/selectSettings.js deleted file mode 100644 index 3e30478b7b..0000000000 --- a/frontend/src/Store/Selectors/selectSettings.js +++ /dev/null @@ -1,104 +0,0 @@ -import _ from 'lodash'; - -function getValidationFailures(saveError) { - if (!saveError || saveError.status !== 400) { - return []; - } - - return _.cloneDeep(saveError.responseJSON); -} - -function mapFailure(failure) { - return { - message: failure.errorMessage, - link: failure.infoLink, - detailedMessage: failure.detailedDescription - }; -} - -function selectSettings(item, pendingChanges, saveError) { - const validationFailures = getValidationFailures(saveError); - - // Merge all settings from the item along with pending - // changes to ensure any settings that were not included - // with the item are included. - const allSettings = Object.assign({}, item, pendingChanges); - - const settings = _.reduce(allSettings, (result, value, key) => { - if (key === 'fields') { - return result; - } - - // Return a flattened value - if (key === 'implementationName') { - result.implementationName = item[key]; - - return result; - } - - const setting = { - value: item[key], - errors: _.map(_.remove(validationFailures, (failure) => { - return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning; - }), mapFailure), - - warnings: _.map(_.remove(validationFailures, (failure) => { - return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning; - }), mapFailure) - }; - - if (pendingChanges.hasOwnProperty(key)) { - setting.previousValue = setting.value; - setting.value = pendingChanges[key]; - setting.pending = true; - } - - result[key] = setting; - return result; - }, {}); - - const fields = _.reduce(item.fields, (result, f) => { - const field = Object.assign({ pending: false }, f); - const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name); - - if (hasPendingFieldChange) { - field.previousValue = field.value; - field.value = pendingChanges.fields[field.name]; - field.pending = true; - } - - field.errors = _.map(_.remove(validationFailures, (failure) => { - return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning; - }), mapFailure); - - field.warnings = _.map(_.remove(validationFailures, (failure) => { - return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning; - }), mapFailure); - - result.push(field); - return result; - }, []); - - if (fields.length) { - settings.fields = fields; - } - - const validationErrors = _.filter(validationFailures, (failure) => { - return !failure.isWarning; - }); - - const validationWarnings = _.filter(validationFailures, (failure) => { - return failure.isWarning; - }); - - return { - settings, - validationErrors, - validationWarnings, - hasPendingChanges: !_.isEmpty(pendingChanges), - hasSettings: !_.isEmpty(settings), - pendingChanges - }; -} - -export default selectSettings; diff --git a/frontend/src/Store/Selectors/selectSettings.ts b/frontend/src/Store/Selectors/selectSettings.ts new file mode 100644 index 0000000000..75665d73b1 --- /dev/null +++ b/frontend/src/Store/Selectors/selectSettings.ts @@ -0,0 +1,168 @@ +import { cloneDeep, isEmpty } from 'lodash'; +import { Error } from 'App/State/AppSectionState'; +import Field from 'typings/Field'; +import { + Failure, + Pending, + PendingField, + PendingSection, + ValidationError, + ValidationFailure, + ValidationWarning, +} from 'typings/pending'; + +interface ValidationFailures { + errors: ValidationError[]; + warnings: ValidationWarning[]; +} + +function getValidationFailures(saveError?: Error): ValidationFailures { + if (!saveError || saveError.status !== 400) { + return { + errors: [], + warnings: [], + }; + } + + return cloneDeep(saveError.responseJSON as ValidationFailure[]).reduce( + (acc: ValidationFailures, failure: ValidationFailure) => { + if (failure.isWarning) { + acc.warnings.push(failure as ValidationWarning); + } else { + acc.errors.push(failure as ValidationError); + } + + return acc; + }, + { + errors: [], + warnings: [], + } + ); +} + +function getFailures(failures: ValidationFailure[], key: string) { + const result = []; + + for (let i = failures.length - 1; i >= 0; i--) { + if (failures[i].propertyName.toLowerCase() === key.toLowerCase()) { + result.unshift(mapFailure(failures[i])); + + failures.splice(i, 1); + } + } + + return result; +} + +function mapFailure(failure: ValidationFailure): Failure { + return { + errorMessage: failure.errorMessage, + infoLink: failure.infoLink, + detailedDescription: failure.detailedDescription, + + // TODO: Remove these renamed properties + message: failure.errorMessage, + link: failure.infoLink, + detailedMessage: failure.detailedDescription, + }; +} + +interface ModelBaseSetting { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [id: string]: any; +} + +function selectSettings( + item: T, + pendingChanges: Partial, + saveError?: Error +) { + const { errors, warnings } = getValidationFailures(saveError); + + // Merge all settings from the item along with pending + // changes to ensure any settings that were not included + // with the item are included. + const allSettings = Object.assign({}, item, pendingChanges); + + const settings = Object.keys(allSettings).reduce( + (acc: PendingSection, key) => { + if (key === 'fields') { + return acc; + } + + // Return a flattened value + if (key === 'implementationName') { + acc.implementationName = item[key]; + + return acc; + } + + const setting: Pending = { + value: item[key], + pending: false, + errors: getFailures(errors, key), + warnings: getFailures(warnings, key), + }; + + if (pendingChanges.hasOwnProperty(key)) { + setting.previousValue = setting.value; + setting.value = pendingChanges[key]; + setting.pending = true; + } + + // @ts-expect-error - This is a valid key + acc[key] = setting; + return acc; + }, + {} as PendingSection + ); + + if ('fields' in item) { + const fields = + (item.fields as Field[]).reduce((acc: PendingField[], f) => { + const field: PendingField = Object.assign( + { pending: false, errors: [], warnings: [] }, + f + ); + + if ('fields' in pendingChanges) { + const pendingChangesFields = pendingChanges.fields as Record< + string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >; + + if (pendingChangesFields.hasOwnProperty(field.name)) { + field.previousValue = field.value; + field.value = pendingChangesFields[field.name]; + field.pending = true; + } + } + + field.errors = getFailures(errors, field.name); + field.warnings = getFailures(warnings, field.name); + + acc.push(field); + return acc; + }, []) ?? []; + + if (fields.length) { + settings.fields = fields; + } + } + + const validationErrors = errors; + const validationWarnings = warnings; + + return { + settings, + validationErrors, + validationWarnings, + hasPendingChanges: !isEmpty(pendingChanges), + hasSettings: !isEmpty(settings), + pendingChanges, + }; +} + +export default selectSettings; diff --git a/frontend/src/typings/DownloadClient.ts b/frontend/src/typings/DownloadClient.ts index 2c032c22a8..417d74b0b4 100644 --- a/frontend/src/typings/DownloadClient.ts +++ b/frontend/src/typings/DownloadClient.ts @@ -1,27 +1,13 @@ -import ModelBase from 'App/ModelBase'; +import Provider from './Provider'; -export interface Field { - order: number; - name: string; - label: string; - value: boolean | number | string; - type: string; - advanced: boolean; - privacy: string; -} +export type Protocol = 'torrent' | 'usenet' | 'unknown'; -interface DownloadClient extends ModelBase { +interface DownloadClient extends Provider { enable: boolean; - protocol: string; + protocol: Protocol; priority: number; removeCompletedDownloads: boolean; removeFailedDownloads: boolean; - name: string; - fields: Field[]; - implementationName: string; - implementation: string; - configContract: string; - infoLink: string; tags: number[]; } diff --git a/frontend/src/typings/Field.ts b/frontend/src/typings/Field.ts new file mode 100644 index 0000000000..404c436ef4 --- /dev/null +++ b/frontend/src/typings/Field.ts @@ -0,0 +1,23 @@ +export interface FieldSelectOption { + value: T; + name: string; + order: number; + hint?: string; + parentValue?: T; + isDisabled?: boolean; + additionalProperties?: Record; +} + +interface Field { + order: number; + name: string; + label: string; + value: boolean | number | string | number[]; + section: string; + hidden: 'hidden' | 'hiddenIfNotSet' | 'visible'; + type: string; + advanced: boolean; + privacy: string; +} + +export default Field; diff --git a/frontend/src/typings/ImportList.ts b/frontend/src/typings/ImportList.ts index 849b20be01..be7b72b3d3 100644 --- a/frontend/src/typings/ImportList.ts +++ b/frontend/src/typings/ImportList.ts @@ -1,28 +1,12 @@ -import ModelBase from 'App/ModelBase'; +import Provider from './Provider'; -export interface Field { - order: number; - name: string; - label: string; - value: boolean | number | string; - type: string; - advanced: boolean; - privacy: string; -} - -interface ImportList extends ModelBase { +interface ImportList extends Provider { enable: boolean; enabled: boolean; enableAuto: boolean; qualityProfileId: number; minimumAvailability: string; rootFolderPath: string; - name: string; - fields: Field[]; - implementationName: string; - implementation: string; - configContract: string; - infoLink: string; tags: number[]; } diff --git a/frontend/src/typings/Indexer.ts b/frontend/src/typings/Indexer.ts index e6c23eda2e..ea38651f45 100644 --- a/frontend/src/typings/Indexer.ts +++ b/frontend/src/typings/Indexer.ts @@ -1,27 +1,11 @@ -import ModelBase from 'App/ModelBase'; +import Provider from './Provider'; -export interface Field { - order: number; - name: string; - label: string; - value: boolean | number | string; - type: string; - advanced: boolean; - privacy: string; -} - -interface Indexer extends ModelBase { +interface Indexer extends Provider { enableRss: boolean; enableAutomaticSearch: boolean; enableInteractiveSearch: boolean; protocol: string; priority: number; - name: string; - fields: Field[]; - implementationName: string; - implementation: string; - configContract: string; - infoLink: string; tags: number[]; } diff --git a/frontend/src/typings/Metadata.ts b/frontend/src/typings/Metadata.ts new file mode 100644 index 0000000000..b7d0cfb710 --- /dev/null +++ b/frontend/src/typings/Metadata.ts @@ -0,0 +1,7 @@ +import Provider from './Provider'; + +interface Metadata extends Provider { + enable: boolean; +} + +export default Metadata; diff --git a/frontend/src/typings/Notification.ts b/frontend/src/typings/Notification.ts index e2b5ad7eb1..12057015b4 100644 --- a/frontend/src/typings/Notification.ts +++ b/frontend/src/typings/Notification.ts @@ -1,23 +1,7 @@ -import ModelBase from 'App/ModelBase'; +import Provider from './Provider'; -export interface Field { - order: number; - name: string; - label: string; - value: boolean | number | string; - type: string; - advanced: boolean; - privacy: string; -} - -interface Notification extends ModelBase { +interface Notification extends Provider { enable: boolean; - name: string; - fields: Field[]; - implementationName: string; - implementation: string; - configContract: string; - infoLink: string; tags: number[]; } diff --git a/frontend/src/typings/Provider.ts b/frontend/src/typings/Provider.ts new file mode 100644 index 0000000000..e9eabba0b6 --- /dev/null +++ b/frontend/src/typings/Provider.ts @@ -0,0 +1,20 @@ +import ModelBase from 'App/ModelBase'; +import { Kind } from 'Helpers/Props/kinds'; +import Field from './Field'; + +export interface ProviderMessage { + message: string; + type: Extract; +} + +interface Provider extends ModelBase { + name: string; + fields: Field[]; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; + message: ProviderMessage; +} + +export default Provider; diff --git a/frontend/src/typings/pending.ts b/frontend/src/typings/pending.ts index 5cdcbc003d..480c356235 100644 --- a/frontend/src/typings/pending.ts +++ b/frontend/src/typings/pending.ts @@ -1,6 +1,11 @@ +import Field from './Field'; + export interface ValidationFailure { + isWarning: boolean; propertyName: string; errorMessage: string; + infoLink?: string; + detailedDescription?: string; severity: 'error' | 'warning'; } @@ -12,12 +17,47 @@ export interface ValidationWarning extends ValidationFailure { isWarning: true; } -export interface Pending { - value: T; - errors: ValidationError[]; - warnings: ValidationWarning[]; +export interface Failure { + errorMessage: ValidationFailure['errorMessage']; + infoLink: ValidationFailure['infoLink']; + detailedDescription: ValidationFailure['detailedDescription']; + + // TODO: Remove these renamed properties + + message: ValidationFailure['errorMessage']; + link: ValidationFailure['infoLink']; + detailedMessage: ValidationFailure['detailedDescription']; } -export type PendingSection = { - [K in keyof T]: Pending; +export interface Pending { + value: T; + errors: Failure[]; + warnings: Failure[]; + pending: boolean; + previousValue?: T; +} + +export interface PendingField + extends Field, + Omit, 'previousValue' | 'value'> { + previousValue?: Field['value']; +} + +// export type PendingSection = { +// [K in keyof T]: Pending; +// }; + +type Mapped = { + [Prop in keyof T]: { + value: T[Prop]; + errors: Failure[]; + warnings: Failure[]; + pending?: boolean; + previousValue?: T[Prop]; + }; +}; + +export type PendingSection = Mapped & { + implementationName?: string; + fields?: PendingField[]; }; diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Kometa/KometaMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Kometa/KometaMetadata.cs index 5749d45292..32b3c969c8 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Kometa/KometaMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Kometa/KometaMetadata.cs @@ -1,13 +1,12 @@ -using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text.RegularExpressions; using NzbDrone.Common.Extensions; using NzbDrone.Core.Extras.Metadata.Files; -using NzbDrone.Core.MediaCover; +using NzbDrone.Core.Localization; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Movies; +using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa { @@ -15,13 +14,15 @@ public class KometaMetadata : MetadataBase { private static readonly Regex MovieImagesRegex = new (@"^(?:poster|background)\.(?:png|jpe?g)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private readonly IMapCoversToLocal _mediaCoverService; + private readonly ILocalizationService _localizationService; public override string Name => "Kometa"; - public KometaMetadata(IMapCoversToLocal mediaCoverService) + public override ProviderMessage Message => new (_localizationService.GetLocalizedString("MetadataKometaDeprecated"), ProviderMessageType.Warning); + + public KometaMetadata(ILocalizationService localizationService) { - _mediaCoverService = mediaCoverService; + _localizationService = localizationService; } public override MetadataFile FindMetadataFile(Movie movie, string path) @@ -56,31 +57,7 @@ public override MetadataFileResult MovieMetadata(Movie movie, MovieFile movieFil public override List MovieImages(Movie movie) { - if (!Settings.MovieImages) - { - return new List(); - } - - return ProcessMovieImages(movie).ToList(); - } - - private IEnumerable ProcessMovieImages(Movie movie) - { - foreach (var image in movie.MovieMetadata.Value.Images.Where(i => i.CoverType is MediaCoverTypes.Poster or MediaCoverTypes.Fanart)) - { - var source = _mediaCoverService.GetCoverPath(movie.Id, image.CoverType); - - var filename = image.CoverType switch - { - MediaCoverTypes.Poster => "poster", - MediaCoverTypes.Fanart => "background", - _ => throw new ArgumentOutOfRangeException($"{image.CoverType} is not supported") - }; - - var destination = filename + Path.GetExtension(source); - - yield return new ImageFileResult(destination, source); - } + return new List(); } } } diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Kometa/KometaMetadataSettings.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Kometa/KometaMetadataSettings.cs index 6eae8c23a7..2ca2ee4fa3 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Kometa/KometaMetadataSettings.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Kometa/KometaMetadataSettings.cs @@ -15,11 +15,11 @@ public class KometaMetadataSettings : IProviderConfig public KometaMetadataSettings() { - MovieImages = true; + Deprecated = true; } - [FieldDefinition(0, Label = "MetadataSettingsMovieImages", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "poster.jpg, background.jpg")] - public bool MovieImages { get; set; } + [FieldDefinition(0, Label = "MetadataKometaDeprecatedSetting", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, Hidden = HiddenType.Hidden)] + public bool Deprecated { get; set; } public NzbDroneValidationResult Validate() { diff --git a/src/NzbDrone.Core/HealthCheck/Checks/MetadataCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/MetadataCheck.cs new file mode 100644 index 0000000000..48fa924d9f --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/MetadataCheck.cs @@ -0,0 +1,34 @@ +using System.Linq; +using NzbDrone.Core.Extras.Metadata; +using NzbDrone.Core.Extras.Metadata.Consumers.Kometa; +using NzbDrone.Core.Localization; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ProviderUpdatedEvent))] + public class MetadataCheck : HealthCheckBase + { + private readonly IMetadataFactory _metadataFactory; + + public MetadataCheck(IMetadataFactory metadataFactory, ILocalizationService localizationService) + : base(localizationService) + { + _metadataFactory = metadataFactory; + } + + public override HealthCheck Check() + { + var enabled = _metadataFactory.Enabled(); + + if (enabled.Any(m => m.Definition.Implementation == nameof(KometaMetadata))) + { + return new HealthCheck(GetType(), + HealthCheckResult.Warning, + $"{_localizationService.GetLocalizedString("MetadataKometaDeprecated")}"); + } + + return new HealthCheck(GetType()); + } + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 6732f48284..7b6c81d77b 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -919,6 +919,8 @@ "Menu": "Menu", "Message": "Message", "Metadata": "Metadata", + "MetadataKometaDeprecated": "Kometa files will no longer be created, support will be removed completely in v6", + "MetadataKometaDeprecatedSetting": "Deprecated", "MetadataLoadError": "Unable to load Metadata", "MetadataSettings": "Metadata Settings", "MetadataSettingsMovieImages": "Movie Images",