From 6d49b41dd26ca9bc61bda119698debcb960612e1 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Tue, 30 Dec 2025 15:00:46 -0800 Subject: [PATCH] Use react-query for Connections --- .../ImportSeries/Import/importSeriesStore.ts | 2 - frontend/src/App/State/SettingsAppState.ts | 8 - .../Helpers/Hooks/usePendingFieldsStore.ts | 91 ++++++++ .../Notifications/AddNotificationItem.tsx | 21 +- .../AddNotificationModalContent.tsx | 23 +- .../AddNotificationPresetMenuItem.tsx | 19 +- .../Notifications/EditNotificationModal.tsx | 6 - .../EditNotificationModalContent.tsx | 161 ++++++-------- .../Notifications/Notification.tsx | 10 +- .../Notifications/NotificationEventItems.tsx | 4 +- .../Notifications/Notifications.tsx | 32 ++- .../Settings/Notifications/useConnections.ts | 133 ++++++++++++ .../Tags/Details/TagDetailsModalContent.tsx | 9 +- frontend/src/Settings/Tags/Tags.tsx | 7 +- frontend/src/Settings/useProviderSchema.ts | 75 +++++++ frontend/src/Settings/useProviderSettings.ts | 204 ++++++++++++++++-- .../Store/Actions/Settings/notifications.js | 133 ------------ frontend/src/Store/Actions/settingsActions.js | 11 +- frontend/src/typings/Notification.ts | 35 --- 19 files changed, 606 insertions(+), 378 deletions(-) create mode 100644 frontend/src/Helpers/Hooks/usePendingFieldsStore.ts create mode 100644 frontend/src/Settings/Notifications/useConnections.ts create mode 100644 frontend/src/Settings/useProviderSchema.ts delete mode 100644 frontend/src/Store/Actions/Settings/notifications.js delete mode 100644 frontend/src/typings/Notification.ts diff --git a/frontend/src/AddSeries/ImportSeries/Import/importSeriesStore.ts b/frontend/src/AddSeries/ImportSeries/Import/importSeriesStore.ts index 8705928c1..f0f420ec2 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/importSeriesStore.ts +++ b/frontend/src/AddSeries/ImportSeries/Import/importSeriesStore.ts @@ -73,8 +73,6 @@ export const useEnsureImportSeriesItems = ( export const updateImportSeriesItem = ( itemData: Partial & Pick ) => { - console.info('\x1b[36m[MarkTest] updating item\x1b[0m', itemData); - importSeriesStore.setState((state) => { const existingItem = state.items[itemData.id]; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index da206ac4a..5c03d6d79 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -17,7 +17,6 @@ import ImportListExclusion from 'typings/ImportListExclusion'; import ImportListOptionsSettings from 'typings/ImportListOptionsSettings'; import Indexer from 'typings/Indexer'; import IndexerFlag from 'typings/IndexerFlag'; -import Notification from 'typings/Notification'; import DownloadClientOptions from 'typings/Settings/DownloadClientOptions'; import General from 'typings/Settings/General'; import IndexerOptions from 'typings/Settings/IndexerOptions'; @@ -92,12 +91,6 @@ export interface IndexerAppState isTestingAll: boolean; } -export interface NotificationAppState - extends AppSectionState, - AppSectionDeleteState, - AppSectionSaveState, - AppSectionSchemaState> {} - export interface CustomFormatAppState extends AppSectionState, AppSectionDeleteState, @@ -144,7 +137,6 @@ interface SettingsAppState { metadata: MetadataAppState; naming: NamingAppState; namingExamples: NamingExamplesAppState; - notifications: NotificationAppState; } export default SettingsAppState; diff --git a/frontend/src/Helpers/Hooks/usePendingFieldsStore.ts b/frontend/src/Helpers/Hooks/usePendingFieldsStore.ts new file mode 100644 index 000000000..63f4631de --- /dev/null +++ b/frontend/src/Helpers/Hooks/usePendingFieldsStore.ts @@ -0,0 +1,91 @@ +import { useCallback, useMemo, useState } from 'react'; +import { create, useStore } from 'zustand'; +import { useShallow } from 'zustand/react/shallow'; + +interface PendingFieldsStore { + pendingFields: Map; +} + +export const usePendingFieldsStore = () => { + // eslint-disable-next-line react/hook-use-state + const [store] = useState(() => { + return create()((_set) => { + return { + pendingFields: new Map(), + }; + }); + }); + + const setPendingField = useCallback( + (name: string, value: unknown) => { + store.setState((state) => { + const newPendingFields = new Map(state.pendingFields); + newPendingFields.set(name, value); + + return { + ...state, + pendingFields: newPendingFields, + }; + }); + }, + [store] + ); + + const setPendingFields = useCallback( + (fieldProperties: Record) => { + store.setState((state) => { + const newPendingFields = new Map(state.pendingFields); + Object.entries(fieldProperties).forEach(([key, value]) => { + newPendingFields.set(key, value); + }); + return { + ...state, + pendingFields: newPendingFields, + }; + }); + }, + [store] + ); + + const unsetPendingField = useCallback( + (name: string) => { + store.setState((state) => { + const newPendingFields = new Map(state.pendingFields); + newPendingFields.delete(name); + return { + ...state, + pendingFields: newPendingFields, + }; + }); + }, + [store] + ); + + const clearPendingFields = useCallback(() => { + store.setState((state) => ({ + ...state, + pendingFields: new Map(), + })); + }, [store]); + + const pendingFields = useStore( + store, + useShallow((state) => { + return state.pendingFields; + }) + ); + + const hasPendingFields = useMemo(() => { + return pendingFields.size > 0; + }, [pendingFields]); + + return { + store, + pendingFields, + setPendingField, + setPendingFields, + unsetPendingField, + clearPendingFields, + hasPendingFields, + }; +}; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.tsx b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.tsx index a9bf61fc4..e126a285e 100644 --- a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.tsx +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.tsx @@ -1,13 +1,12 @@ import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; import Button from 'Components/Link/Button'; import Link from 'Components/Link/Link'; import Menu from 'Components/Menu/Menu'; import MenuContent from 'Components/Menu/MenuContent'; import { sizes } from 'Helpers/Props'; -import { selectNotificationSchema } from 'Store/Actions/settingsActions'; -import Notification from 'typings/Notification'; +import { SelectedSchema } from 'Settings/useProviderSchema'; import translate from 'Utilities/String/translate'; +import { NotificationModel } from '../useConnections'; import AddNotificationPresetMenuItem from './AddNotificationPresetMenuItem'; import styles from './AddNotificationItem.css'; @@ -15,8 +14,8 @@ interface AddNotificationItemProps { implementation: string; implementationName: string; infoLink: string; - presets?: Notification[]; - onNotificationSelect: () => void; + presets?: NotificationModel[]; + onNotificationSelect: (selectedScehema: SelectedSchema) => void; } function AddNotificationItem({ @@ -26,19 +25,11 @@ function AddNotificationItem({ presets, onNotificationSelect, }: AddNotificationItemProps) { - const dispatch = useDispatch(); const hasPresets = !!presets && !!presets.length; const handleNotificationSelect = useCallback(() => { - dispatch( - selectNotificationSchema({ - implementation, - implementationName, - }) - ); - - onNotificationSelect(); - }, [implementation, implementationName, dispatch, onNotificationSelect]); + onNotificationSelect({ implementation, implementationName }); + }, [implementation, implementationName, onNotificationSelect]); return (
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.tsx b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.tsx index 35761ee46..764eda74b 100644 --- a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.tsx +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.tsx @@ -1,6 +1,4 @@ -import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; +import React from 'react'; import Alert from 'Components/Alert'; import FieldSet from 'Components/FieldSet'; import Button from 'Components/Link/Button'; @@ -10,13 +8,14 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { kinds } from 'Helpers/Props'; -import { fetchNotificationSchema } from 'Store/Actions/settingsActions'; +import { SelectedSchema } from 'Settings/useProviderSchema'; import translate from 'Utilities/String/translate'; +import { useConnectionSchema } from '../useConnections'; import AddNotificationItem from './AddNotificationItem'; import styles from './AddNotificationModalContent.css'; export interface AddNotificationModalContentProps { - onNotificationSelect: () => void; + onNotificationSelect: (selectedScehema: SelectedSchema) => void; onModalClose: () => void; } @@ -24,27 +23,21 @@ function AddNotificationModalContent({ onNotificationSelect, onModalClose, }: AddNotificationModalContentProps) { - const dispatch = useDispatch(); - - const { isSchemaFetching, isSchemaPopulated, schemaError, schema } = - useSelector((state: AppState) => state.settings.notifications); - - useEffect(() => { - dispatch(fetchNotificationSchema()); - }, [dispatch]); + const { isSchemaFetching, isSchemaFetched, schemaError, schema } = + useConnectionSchema(); return ( {translate('AddNotification')} - {isSchemaFetching ? : null} + {isSchemaFetching && !isSchemaFetched ? : null} {!isSchemaFetching && !!schemaError ? ( {translate('AddNotificationError')} ) : null} - {isSchemaPopulated && !schemaError ? ( + {isSchemaFetched && !schemaError ? (
{translate('SupportedNotifications')}
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.tsx b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.tsx index 27e39582c..a94b24247 100644 --- a/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.tsx +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.tsx @@ -1,13 +1,12 @@ import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; import MenuItem from 'Components/Menu/MenuItem'; -import { selectNotificationSchema } from 'Store/Actions/settingsActions'; +import { SelectedSchema } from 'Settings/useProviderSchema'; interface AddNotificationPresetMenuItemProps { name: string; implementation: string; implementationName: string; - onPress: () => void; + onPress: (selectedScehema: SelectedSchema) => void; } function AddNotificationPresetMenuItem({ @@ -17,19 +16,9 @@ function AddNotificationPresetMenuItem({ onPress, ...otherProps }: AddNotificationPresetMenuItemProps) { - const dispatch = useDispatch(); - const handlePress = useCallback(() => { - dispatch( - selectNotificationSchema({ - implementation, - implementationName, - presetName: name, - }) - ); - - onPress(); - }, [name, implementation, implementationName, dispatch, onPress]); + onPress({ implementation, implementationName, presetName: name }); + }, [name, implementation, implementationName, onPress]); return ( diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.tsx b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.tsx index cee73925f..00752acad 100644 --- a/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.tsx +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.tsx @@ -3,10 +3,6 @@ import { useDispatch } from 'react-redux'; import Modal from 'Components/Modal/Modal'; import { sizes } from 'Helpers/Props'; import { clearPendingChanges } from 'Store/Actions/baseActions'; -import { - cancelSaveNotification, - cancelTestNotification, -} from 'Store/Actions/settingsActions'; import EditNotificationModalContent, { EditNotificationModalContentProps, } from './EditNotificationModalContent'; @@ -26,8 +22,6 @@ function EditNotificationModal({ const handleModalClose = useCallback(() => { dispatch(clearPendingChanges({ section })); - dispatch(cancelTestNotification({ section })); - dispatch(cancelSaveNotification({ section })); onModalClose(); }, [dispatch, onModalClose]); diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.tsx b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.tsx index 4a0b2ba81..73bf7b756 100644 --- a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.tsx +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.tsx @@ -1,6 +1,4 @@ import React, { useCallback, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { NotificationAppState } from 'App/State/SettingsAppState'; import Alert from 'Components/Alert'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; @@ -9,7 +7,6 @@ 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 LoadingIndicator from 'Components/Loading/LoadingIndicator'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; @@ -18,48 +15,45 @@ import usePrevious from 'Helpers/Hooks/usePrevious'; import { inputTypes, kinds } from 'Helpers/Props'; import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore'; -import { - saveNotification, - setNotificationFieldValues, - setNotificationValue, - testNotification, -} from 'Store/Actions/settingsActions'; -import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector'; +import { useManageConnection } from 'Settings/Notifications/useConnections'; +import { SelectedSchema } from 'Settings/useProviderSchema'; import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs'; -import Notification from 'typings/Notification'; import translate from 'Utilities/String/translate'; import NotificationEventItems from './NotificationEventItems'; import styles from './EditNotificationModalContent.css'; export interface EditNotificationModalContentProps { id?: number; + selectedSchema?: SelectedSchema; onModalClose: () => void; onDeleteNotificationPress?: () => void; } function EditNotificationModalContent({ id, + selectedSchema, onModalClose, onDeleteNotificationPress, }: EditNotificationModalContentProps) { - const dispatch = useDispatch(); const showAdvancedSettings = useShowAdvancedSettings(); + const result = useManageConnection(id, selectedSchema); const { - isFetching, - error, - isSaving, - isTesting = false, - saveError, item, + updateValue, + saveProvider, + isSaving, + saveError, + testProvider, + isTesting, validationErrors, validationWarnings, - } = useSelector( - createProviderSettingsSelectorHook( - 'notifications', - id - ) - ); + } = result; + + // updateFieldValue is guaranteed to exist for NotificationModel since it extends Provider + const { updateFieldValue } = result as typeof result & { + updateFieldValue: (fieldProperties: Record) => void; + }; const wasSaving = usePrevious(isSaving); @@ -67,10 +61,10 @@ function EditNotificationModalContent({ const handleInputChange = useCallback( (change: InputChanged) => { - // @ts-expect-error - actions are not typed - dispatch(setNotificationValue(change)); + // @ts-expect-error - change is not yet typed + updateValue(change.name, change.value); }, - [dispatch] + [updateValue] ); const handleFieldChange = useCallback( @@ -79,23 +73,18 @@ function EditNotificationModalContent({ value, additionalProperties, }: EnhancedSelectInputChanged) => { - dispatch( - // @ts-expect-error - actions are not typed - setNotificationFieldValues({ - properties: { [name]: value, ...additionalProperties }, - }) - ); + updateFieldValue({ [name]: value, ...additionalProperties }); }, - [dispatch] + [updateFieldValue] ); const handleTestPress = useCallback(() => { - dispatch(testNotification({ id })); - }, [id, dispatch]); + testProvider(); + }, [testProvider]); const handleSavePress = useCallback(() => { - dispatch(saveNotification({ id })); - }, [id, dispatch]); + saveProvider(); + }, [saveProvider]); useEffect(() => { if (wasSaving && !isSaving && !saveError) { @@ -112,65 +101,57 @@ function EditNotificationModalContent({ - {isFetching ? : null} +
+ {message ? ( + + {message.value.message} + + ) : null} - {!isFetching && !!error ? ( - {translate('AddNotificationError')} - ) : null} + + {translate('Name')} - {!isFetching && !error ? ( - - {message ? ( - - {message.value.message} - - ) : null} - - - {translate('Name')} - - - - - + - - {translate('Tags')} + - + {translate('Tags')} + + + + + {fields.map((field) => { + return ( + - - - {fields.map((field) => { - return ( - - ); - })} - - ) : null} + ); + })} +
diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.tsx b/frontend/src/Settings/Notifications/Notifications/Notification.tsx index 1b99c074b..0d53dc322 100644 --- a/frontend/src/Settings/Notifications/Notifications/Notification.tsx +++ b/frontend/src/Settings/Notifications/Notifications/Notification.tsx @@ -1,14 +1,12 @@ import React, { useCallback, useState } from 'react'; -import { useDispatch } from 'react-redux'; import Card from 'Components/Card'; import Label from 'Components/Label'; import ConfirmModal from 'Components/Modal/ConfirmModal'; import TagList from 'Components/TagList'; import { kinds } from 'Helpers/Props'; -import { deleteNotification } from 'Store/Actions/settingsActions'; import { useTagList } from 'Tags/useTags'; -import NotificationModel from 'typings/Notification'; import translate from 'Utilities/String/translate'; +import { NotificationModel, useDeleteConnection } from '../useConnections'; import EditNotificationModal from './EditNotificationModal'; import styles from './Notification.css'; @@ -43,8 +41,8 @@ function Notification({ supportsOnManualInteractionRequired, tags, }: NotificationModel) { - const dispatch = useDispatch(); const tagList = useTagList(); + const { deleteConnection } = useDeleteConnection(id); const [isEditNotificationModalOpen, setIsEditNotificationModalOpen] = useState(false); @@ -69,8 +67,8 @@ function Notification({ }, []); const handleConfirmDeleteNotification = useCallback(() => { - dispatch(deleteNotification({ id })); - }, [id, dispatch]); + deleteConnection(); + }, [deleteConnection]); return ( ; + item: PendingSection; onInputChange: (change: CheckInputChanged) => void; } diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.tsx b/frontend/src/Settings/Notifications/Notifications/Notifications.tsx index b77ab8034..8dcc5e66d 100644 --- a/frontend/src/Settings/Notifications/Notifications/Notifications.tsx +++ b/frontend/src/Settings/Notifications/Notifications/Notifications.tsx @@ -1,30 +1,24 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { NotificationAppState } from 'App/State/SettingsAppState'; +import React, { useCallback, useState } from 'react'; import Card from 'Components/Card'; import FieldSet from 'Components/FieldSet'; import Icon from 'Components/Icon'; import PageSectionContent from 'Components/Page/PageSectionContent'; import { icons } from 'Helpers/Props'; -import { fetchNotifications } from 'Store/Actions/settingsActions'; -import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import NotificationModel from 'typings/Notification'; -import sortByProp from 'Utilities/Array/sortByProp'; +import { SelectedSchema } from 'Settings/useProviderSchema'; import translate from 'Utilities/String/translate'; +import { useConnections, useSortedConnections } from '../useConnections'; import AddNotificationModal from './AddNotificationModal'; import EditNotificationModal from './EditNotificationModal'; import Notification from './Notification'; import styles from './Notifications.css'; function Notifications() { - const dispatch = useDispatch(); + const { error, isFetching, isFetched } = useConnections(); + const items = useSortedConnections(); - const { error, isFetching, isPopulated, items } = useSelector( - createSortedSectionSelector( - 'settings.notifications', - sortByProp('name') - ) - ); + const [selectedSchema, setSelectedSchema] = useState< + SelectedSchema | undefined + >(undefined); const [isAddNotificationModalOpen, setIsAddNotificationModalOpen] = useState(false); @@ -36,7 +30,8 @@ function Notifications() { setIsAddNotificationModalOpen(true); }, []); - const handleNotificationSelect = useCallback(() => { + const handleNotificationSelect = useCallback((selected: SelectedSchema) => { + setSelectedSchema(selected); setIsAddNotificationModalOpen(false); setIsEditNotificationModalOpen(true); }, []); @@ -49,17 +44,13 @@ function Notifications() { setIsEditNotificationModalOpen(false); }, []); - useEffect(() => { - dispatch(fetchNotifications()); - }, [dispatch]); - return (
{items.map((item) => ( @@ -84,6 +75,7 @@ function Notifications() { diff --git a/frontend/src/Settings/Notifications/useConnections.ts b/frontend/src/Settings/Notifications/useConnections.ts new file mode 100644 index 000000000..f677301b9 --- /dev/null +++ b/frontend/src/Settings/Notifications/useConnections.ts @@ -0,0 +1,133 @@ +import { useMemo } from 'react'; +import { + SelectedSchema, + useProviderSchema, + useSelectedSchema, +} from 'Settings/useProviderSchema'; +import { + useDeleteProvider, + useManageProviderSettings, + useProviderSettings, +} from 'Settings/useProviderSettings'; +import Provider from 'typings/Provider'; +import { sortByProp } from 'Utilities/Array/sortByProp'; + +export interface NotificationModel extends Provider { + enable: boolean; + onGrab: boolean; + onDownload: boolean; + onUpgrade: boolean; + onImportComplete: boolean; + onRename: boolean; + onSeriesAdd: boolean; + onSeriesDelete: boolean; + onEpisodeFileDelete: boolean; + onEpisodeFileDeleteForUpgrade: boolean; + onHealthIssue: boolean; + includeHealthWarnings: boolean; + onHealthRestored: boolean; + onApplicationUpdate: boolean; + onManualInteractionRequired: boolean; + supportsOnGrab: boolean; + supportsOnDownload: boolean; + supportsOnUpgrade: boolean; + supportsOnImportComplete: boolean; + supportsOnRename: boolean; + supportsOnSeriesAdd: boolean; + supportsOnSeriesDelete: boolean; + supportsOnEpisodeFileDelete: boolean; + supportsOnEpisodeFileDeleteForUpgrade: boolean; + supportsOnHealthIssue: boolean; + supportsOnHealthRestored: boolean; + supportsOnApplicationUpdate: boolean; + supportsOnManualInteractionRequired: boolean; + tags: number[]; +} + +const PATH = '/connection'; + +export const useConnectionsWithIds = (ids: number[]) => { + const allNotifications = useConnectionsData(); + + return allNotifications.filter((notification) => + ids.includes(notification.id) + ); +}; + +export const useConnection = (id: number | undefined) => { + const { data } = useConnections(); + + if (id === undefined) { + return undefined; + } + + return data.find((notification) => notification.id === id); +}; + +export const useConnectionsData = () => { + const { data } = useConnections(); + + return data; +}; + +export const useSortedConnections = () => { + const { data } = useConnections(); + + return useMemo(() => data.sort(sortByProp('name')), [data]); +}; + +export const useConnections = () => { + return useProviderSettings({ + path: PATH, + }); +}; + +export const useManageConnection = ( + id: number | undefined, + selectedSchema?: SelectedSchema +) => { + const schema = useSelectedSchema(PATH, selectedSchema); + + if (selectedSchema && !schema) { + throw new Error('A selected schema is required to manage a notification'); + } + + const manage = useManageProviderSettings( + id, + selectedSchema && schema + ? { + ...schema, + name: schema.implementationName || '', + onGrab: schema.supportsOnGrab || false, + onDownload: schema.supportsOnDownload || false, + onUpgrade: schema.supportsOnUpgrade || false, + onImportComplete: schema.supportsOnImportComplete || false, + onRename: schema.supportsOnRename || false, + onSeriesAdd: schema.supportsOnSeriesAdd || false, + onSeriesDelete: schema.supportsOnSeriesDelete || false, + onEpisodeFileDelete: schema.supportsOnEpisodeFileDelete || false, + onEpisodeFileDeleteForUpgrade: + schema.supportsOnEpisodeFileDeleteForUpgrade || false, + onApplicationUpdate: schema.supportsOnApplicationUpdate || false, + onManualInteractionRequired: + schema.supportsOnManualInteractionRequired || false, + } + : ({} as NotificationModel), + PATH + ); + + return manage; +}; + +export const useDeleteConnection = (id: number) => { + const result = useDeleteProvider(id, PATH); + + return { + ...result, + deleteConnection: result.deleteProvider, + }; +}; + +export const useConnectionSchema = (enabled: boolean = true) => { + return useProviderSchema(PATH, enabled); +}; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx index 12a7f06de..1bc9c2cb7 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx @@ -12,6 +12,7 @@ import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { kinds } from 'Helpers/Props'; import useSeries from 'Series/useSeries'; +import { useConnectionsWithIds } from 'Settings/Notifications/useConnections'; import { useReleaseProfilesWithIds } from 'Settings/Profiles/Release/useReleaseProfiles'; import translate from 'Utilities/String/translate'; import TagDetailsDelayProfile from './TagDetailsDelayProfile'; @@ -96,14 +97,8 @@ function TagDetailsModalContent({ ) ); - const notifications = useSelector( - createMatchingItemSelector( - notificationIds, - (state: AppState) => state.settings.notifications.items - ) - ); - const releaseProfiles = useReleaseProfilesWithIds(releaseProfileIds); + const notifications = useConnectionsWithIds(notificationIds); const indexers = useSelector( createMatchingItemSelector( diff --git a/frontend/src/Settings/Tags/Tags.tsx b/frontend/src/Settings/Tags/Tags.tsx index a4b052384..73c89bcc0 100644 --- a/frontend/src/Settings/Tags/Tags.tsx +++ b/frontend/src/Settings/Tags/Tags.tsx @@ -5,12 +5,13 @@ import Alert from 'Components/Alert'; import FieldSet from 'Components/FieldSet'; import PageSectionContent from 'Components/Page/PageSectionContent'; import { kinds } from 'Helpers/Props'; +import { useConnections } from 'Settings/Notifications/useConnections'; +import { useReleaseProfiles } from 'Settings/Profiles/Release/useReleaseProfiles'; import { fetchDelayProfiles, fetchDownloadClients, fetchImportLists, fetchIndexers, - fetchNotifications, } from 'Store/Actions/settingsActions'; import useTagDetails from 'Tags/useTagDetails'; import useTags, { useSortedTagList } from 'Tags/useTags'; @@ -30,10 +31,12 @@ function Tags() { error: detailsError, } = useTagDetails(); + useReleaseProfiles(); + useConnections(); + useEffect(() => { dispatch(fetchDelayProfiles()); dispatch(fetchImportLists()); - dispatch(fetchNotifications()); dispatch(fetchIndexers()); dispatch(fetchDownloadClients()); diff --git a/frontend/src/Settings/useProviderSchema.ts b/frontend/src/Settings/useProviderSchema.ts new file mode 100644 index 000000000..69f955bba --- /dev/null +++ b/frontend/src/Settings/useProviderSchema.ts @@ -0,0 +1,75 @@ +import { useMemo } from 'react'; +import ModelBase from 'App/ModelBase'; +import useApiQuery from 'Helpers/Hooks/useApiQuery'; +import Provider from 'typings/Provider'; + +type ProviderWithPresets = T & { + presets: T[]; +}; + +export interface SelectedSchema { + implementation: string; + implementationName: string; + presetName?: string; +} + +export const useProviderSchema = ( + path: string, + enabled: boolean = true +) => { + const { isFetching, isFetched, error, data } = useApiQuery({ + path: `${path}/schema`, + queryOptions: { + enabled, + }, + }); + + return { + isSchemaFetching: isFetching, + isSchemaFetched: isFetched, + schemaError: error, + schema: data ?? ([] as T[]), + }; +}; + +export const useSelectedSchema = ( + path: string, + selectedSchema: SelectedSchema | undefined +) => { + const { schema } = useProviderSchema(path, selectedSchema != null); + + return useMemo(() => { + if (!selectedSchema) { + return undefined; + } + + const selected = schema.find( + (s: T) => s.implementation === selectedSchema.implementation + ); + + if (!selected) { + throw new Error( + `Schema with implementation ${selectedSchema.implementation} not found` + ); + } + + if (selectedSchema.presetName == null) { + return selected; + } + + const preset = + 'presets' in selected + ? (selected as ProviderWithPresets).presets?.find( + (p: T & { name: string }) => p.name === selectedSchema.presetName + ) + : undefined; + + if (!preset) { + throw new Error( + `Preset with name ${selectedSchema.presetName} not found for implementation ${selectedSchema.implementation}` + ); + } + + return preset; + }, [schema, selectedSchema]); +}; diff --git a/frontend/src/Settings/useProviderSettings.ts b/frontend/src/Settings/useProviderSettings.ts index fac373ce9..0756f9300 100644 --- a/frontend/src/Settings/useProviderSettings.ts +++ b/frontend/src/Settings/useProviderSettings.ts @@ -1,12 +1,37 @@ import { useQueryClient } from '@tanstack/react-query'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import ModelBase from 'App/ModelBase'; import useApiMutation from 'Helpers/Hooks/useApiMutation'; import useApiQuery, { QueryOptions } from 'Helpers/Hooks/useApiQuery'; import { usePendingChangesStore } from 'Helpers/Hooks/usePendingChangesStore'; +import { usePendingFieldsStore } from 'Helpers/Hooks/usePendingFieldsStore'; import selectSettings from 'Store/Selectors/selectSettings'; +import { PendingSection } from 'typings/pending'; +import Provider from 'typings/Provider'; +import { ApiError } from 'Utilities/Fetch/fetchJson'; -export const useProvider = ( +interface ManageProviderSettings + extends Omit>, 'settings'> { + item: PendingSection; + updateValue: (key: K, value: T[K]) => void; + saveProvider: () => void; + isSaving: boolean; + saveError: ApiError | null; + testProvider: () => void; + isTesting: boolean; + updateFieldValue?: (fieldProperties: Record) => void; +} + +const isProviderWithFields = (provider: unknown): provider is Provider => { + return ( + typeof provider === 'object' && + provider !== null && + 'fields' in provider && + Array.isArray((provider as Provider).fields) + ); +}; + +export const useProviderWithDefault = ( id: number | undefined, defaultProvider: T, path: string @@ -42,7 +67,8 @@ export const useProviderSettings = ( export const useSaveProviderSettings = ( id: number, path: string, - onSuccess?: () => void + onSuccess?: (updatedSettings: T) => void, + onError?: (error: ApiError) => void ) => { const queryClient = useQueryClient(); @@ -60,8 +86,9 @@ export const useSaveProviderSettings = ( return [...oldData, updatedSettings]; }); - onSuccess?.(); + onSuccess?.(updatedSettings); }, + onError, }, }); @@ -72,12 +99,34 @@ export const useSaveProviderSettings = ( }; }; +export const useTestProvider = ( + path: string, + onSuccess?: () => void, + onError?: (error: ApiError) => void +) => { + const { mutate, isPending, error } = useApiMutation({ + path: `${path}/test`, + method: 'POST', + mutationOptions: { + onSuccess, + onError, + }, + }); + + return { + test: mutate, + isTesting: isPending, + testError: error, + }; +}; + export const useManageProviderSettings = ( id: number | undefined, defaultProvider: T, path: string -) => { - const provider = useProvider(id, defaultProvider, path); +): ManageProviderSettings => { + const provider = useProviderWithDefault(id, defaultProvider, path); + const [mutationError, setMutationError] = useState(null); const { pendingChanges, @@ -86,24 +135,111 @@ export const useManageProviderSettings = ( clearPendingChanges, } = usePendingChangesStore({}); - const { save, isSaving, saveError } = useSaveProviderSettings( + const { + pendingFields, + setPendingFields, + clearPendingFields, + hasPendingFields, + } = usePendingFieldsStore(); + + const handleSaveSuccess = useCallback(() => { + setMutationError(null); + clearPendingChanges(); + clearPendingFields(); + }, [clearPendingChanges, clearPendingFields]); + + const handleTestSuccess = useCallback(() => { + setMutationError(null); + }, []); + + const { save, isSaving } = useSaveProviderSettings( provider.id, path, - clearPendingChanges + handleSaveSuccess, + setMutationError + ); + + const { test, isTesting } = useTestProvider( + path, + handleTestSuccess, + setMutationError ); const { settings: item, ...settings } = useMemo(() => { - return selectSettings(provider, pendingChanges, saveError); - }, [provider, pendingChanges, saveError]); + // Create a combined pending changes object that includes fields + const combinedPendingChanges = hasPendingFields + ? { + ...pendingChanges, + fields: Object.fromEntries(pendingFields), + } + : pendingChanges; + + return selectSettings(provider, combinedPendingChanges, mutationError); + }, [ + provider, + pendingChanges, + pendingFields, + hasPendingFields, + mutationError, + ]); const saveProvider = useCallback(() => { - const updatedSettings = { + let updatedSettings: T = { ...provider, ...pendingChanges, }; + // If there are pending field changes and the provider has fields + if (isProviderWithFields(provider)) { + const fields = provider.fields.map((field) => { + if (pendingFields.has(field.name)) { + return { + name: field.name, + value: pendingFields.get(field.name), + }; + } + + return { + name: field.name, + value: field.value, + }; + }); + + updatedSettings = { + ...updatedSettings, + fields, + } as T; + } + save(updatedSettings); - }, [provider, pendingChanges, save]); + }, [provider, pendingChanges, pendingFields, save]); + + const testProvider = useCallback(() => { + let updatedSettings: T = { + ...provider, + ...pendingChanges, + }; + + // If there are pending field changes and the provider has fields + if (isProviderWithFields(provider)) { + const fields = provider.fields.map((field) => { + if (pendingFields.has(field.name)) { + return { + ...field, + value: pendingFields.get(field.name), + }; + } + return field; + }); + + updatedSettings = { + ...updatedSettings, + fields, + } as T; + } + + test(updatedSettings); + }, [provider, pendingChanges, pendingFields, test]); const updateValue = useCallback( (key: K, value: T[K]) => { @@ -116,14 +252,54 @@ export const useManageProviderSettings = ( [provider, setPendingChange, unsetPendingChange] ); - return { + const hasFields = useMemo(() => { + return 'fields' in provider && Array.isArray(provider.fields); + }, [provider]); + + const updateFieldValue = useCallback( + (fieldProperties: Record) => { + if (!isProviderWithFields(provider)) { + throw new Error('updateFieldValue called on provider without fields'); + } + + const providerFields = provider.fields; + const currentFields = pendingFields; + const newFields = { ...currentFields, ...fieldProperties }; + + // Check if the new fields are different from the provider's current fields + const hasChanges = Object.entries(newFields).some(([key, value]) => { + const currentField = providerFields.find((f) => f.name === key); + return currentField?.value !== value; + }); + + if (hasChanges) { + setPendingFields(newFields); + } else { + clearPendingFields(); + } + }, + [pendingFields, provider, setPendingFields, clearPendingFields] + ); + + const baseReturn = { ...settings, item, updateValue, saveProvider, isSaving, - saveError, + saveError: mutationError, + testProvider, + isTesting, }; + + if (hasFields) { + return { + ...baseReturn, + updateFieldValue, + }; + } + + return baseReturn; }; export const useDeleteProvider = ( diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js deleted file mode 100644 index ad0943f2b..000000000 --- a/frontend/src/Store/Actions/Settings/notifications.js +++ /dev/null @@ -1,133 +0,0 @@ -import { createAction } from 'redux-actions'; -import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; -import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; -import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; -import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; -import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; -import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; -import createSetProviderFieldValuesReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValuesReducer'; -import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; -import { createThunk } from 'Store/thunks'; -import selectProviderSchema from 'Utilities/State/selectProviderSchema'; - -// -// Variables - -const section = 'settings.notifications'; - -// -// Actions Types - -export const FETCH_NOTIFICATIONS = 'settings/notifications/fetchNotifications'; -export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificationSchema'; -export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema'; -export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue'; -export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue'; -export const SET_NOTIFICATION_FIELD_VALUES = 'settings/notifications/setNotificationFieldValues'; -export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification'; -export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification'; -export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification'; -export const TEST_NOTIFICATION = 'settings/notifications/testNotification'; -export const CANCEL_TEST_NOTIFICATION = 'settings/notifications/cancelTestNotification'; - -// -// Action Creators - -export const fetchNotifications = createThunk(FETCH_NOTIFICATIONS); -export const fetchNotificationSchema = createThunk(FETCH_NOTIFICATION_SCHEMA); -export const selectNotificationSchema = createAction(SELECT_NOTIFICATION_SCHEMA); - -export const saveNotification = createThunk(SAVE_NOTIFICATION); -export const cancelSaveNotification = createThunk(CANCEL_SAVE_NOTIFICATION); -export const deleteNotification = createThunk(DELETE_NOTIFICATION); -export const testNotification = createThunk(TEST_NOTIFICATION); -export const cancelTestNotification = createThunk(CANCEL_TEST_NOTIFICATION); - -export const setNotificationValue = createAction(SET_NOTIFICATION_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -export const setNotificationFieldValues = createAction(SET_NOTIFICATION_FIELD_VALUES, (payload) => { - return { - section, - ...payload - }; -}); - -// -// Details - -export default { - - // - // State - - defaultState: { - isFetching: false, - isPopulated: false, - error: null, - isSchemaFetching: false, - isSchemaPopulated: false, - schemaError: null, - schema: [], - selectedSchema: {}, - isSaving: false, - saveError: null, - isTesting: false, - items: [], - pendingChanges: {} - }, - - // - // Action Handlers - - actionHandlers: { - [FETCH_NOTIFICATIONS]: createFetchHandler(section, '/notification'), - [FETCH_NOTIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/notification/schema'), - - [SAVE_NOTIFICATION]: createSaveProviderHandler(section, '/notification'), - [CANCEL_SAVE_NOTIFICATION]: createCancelSaveProviderHandler(section), - [DELETE_NOTIFICATION]: createRemoveItemHandler(section, '/notification'), - [TEST_NOTIFICATION]: createTestProviderHandler(section, '/notification'), - [CANCEL_TEST_NOTIFICATION]: createCancelTestProviderHandler(section) - }, - - // - // Reducers - - reducers: { - [SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section), - [SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section), - [SET_NOTIFICATION_FIELD_VALUES]: createSetProviderFieldValuesReducer(section), - - [SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => { - return selectProviderSchema(state, section, payload, (selectedSchema) => { - selectedSchema.name = selectedSchema.implementationName; - selectedSchema.onGrab = selectedSchema.supportsOnGrab; - selectedSchema.onDownload = selectedSchema.supportsOnDownload; - selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade; - selectedSchema.onImportComplete = selectedSchema.supportsOnImportComplete; - selectedSchema.onRename = selectedSchema.supportsOnRename; - selectedSchema.onSeriesAdd = selectedSchema.supportsOnSeriesAdd; - selectedSchema.onSeriesDelete = selectedSchema.supportsOnSeriesDelete; - selectedSchema.onEpisodeFileDelete = selectedSchema.supportsOnEpisodeFileDelete; - selectedSchema.onEpisodeFileDeleteForUpgrade = selectedSchema.supportsOnEpisodeFileDeleteForUpgrade; - selectedSchema.onApplicationUpdate = selectedSchema.supportsOnApplicationUpdate; - selectedSchema.onManualInteractionRequired = selectedSchema.supportsOnManualInteractionRequired; - - return selectedSchema; - }); - } - } - -}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 1b3c9c3b2..adb9ffb36 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -19,7 +19,6 @@ import mediaManagement from './Settings/mediaManagement'; import metadata from './Settings/metadata'; import naming from './Settings/naming'; import namingExamples from './Settings/namingExamples'; -import notifications from './Settings/notifications'; export * from './Settings/autoTaggingSpecifications'; export * from './Settings/autoTaggings'; @@ -40,7 +39,6 @@ export * from './Settings/mediaManagement'; export * from './Settings/metadata'; export * from './Settings/naming'; export * from './Settings/namingExamples'; -export * from './Settings/notifications'; // // Variables @@ -70,8 +68,7 @@ export const defaultState = { mediaManagement: mediaManagement.defaultState, metadata: metadata.defaultState, naming: naming.defaultState, - namingExamples: namingExamples.defaultState, - notifications: notifications.defaultState + namingExamples: namingExamples.defaultState }; export const persistState = [ @@ -100,8 +97,7 @@ export const actionHandlers = handleThunks({ ...mediaManagement.actionHandlers, ...metadata.actionHandlers, ...naming.actionHandlers, - ...namingExamples.actionHandlers, - ...notifications.actionHandlers + ...namingExamples.actionHandlers }); // @@ -126,7 +122,6 @@ export const reducers = createHandleActions({ ...mediaManagement.reducers, ...metadata.reducers, ...naming.reducers, - ...namingExamples.reducers, - ...notifications.reducers + ...namingExamples.reducers }, defaultState, section); diff --git a/frontend/src/typings/Notification.ts b/frontend/src/typings/Notification.ts deleted file mode 100644 index e7dec26c4..000000000 --- a/frontend/src/typings/Notification.ts +++ /dev/null @@ -1,35 +0,0 @@ -import Provider from './Provider'; - -interface Notification extends Provider { - enable: boolean; - onGrab: boolean; - onDownload: boolean; - onUpgrade: boolean; - onImportComplete: boolean; - onRename: boolean; - onSeriesAdd: boolean; - onSeriesDelete: boolean; - onEpisodeFileDelete: boolean; - onEpisodeFileDeleteForUpgrade: boolean; - onHealthIssue: boolean; - includeHealthWarnings: boolean; - onHealthRestored: boolean; - onApplicationUpdate: boolean; - onManualInteractionRequired: boolean; - supportsOnGrab: boolean; - supportsOnDownload: boolean; - supportsOnUpgrade: boolean; - supportsOnImportComplete: boolean; - supportsOnRename: boolean; - supportsOnSeriesAdd: boolean; - supportsOnSeriesDelete: boolean; - supportsOnEpisodeFileDelete: boolean; - supportsOnEpisodeFileDeleteForUpgrade: boolean; - supportsOnHealthIssue: boolean; - supportsOnHealthRestored: boolean; - supportsOnApplicationUpdate: boolean; - supportsOnManualInteractionRequired: boolean; - tags: number[]; -} - -export default Notification;